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";...@@ -19,7 +19,10 @@ import LegacyThreadSidebar from "./LegacySidebar";
19import ThreadSidebar from "./Sidebar";19import ThreadSidebar from "./Sidebar";
20import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";20import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
21import { SidebarChromeHeader } from "./sidebar/SidebarChrome";21import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
22import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";22import {
23 resolveSidebarStageFocusRingOffsetClass,
24 useSidebarStageBackdropVariant,
25} from "./SidebarStageBackdrop";
23import { useProjects } from "../state/entities";26import { useProjects } from "../state/entities";
24import {27import {
25 resolveInitialThreadSidebarWidth,28 resolveInitialThreadSidebarWidth,
...@@ -105,7 +108,10 @@ function SidebarControl() {...@@ -105,7 +108,10 @@ function SidebarControl() {
105 "pointer-events-auto",108 "pointer-events-auto",
106 isSidebarVisible &&109 isSidebarVisible &&
107 stageBackdropVariant &&110 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),
109 )}115 )}
110 aria-label="Toggle main sidebar"116 aria-label="Toggle main sidebar"
111 />117 />
apps/web/src/components/SidebarStageBackdrop.test.tsx+33
...@@ -4,7 +4,9 @@ import { renderToStaticMarkup } from "react-dom/server";...@@ -4,7 +4,9 @@ import { renderToStaticMarkup } from "react-dom/server";
4import {4import {
5 resolveEnvironmentIdentificationPillLabel,5 resolveEnvironmentIdentificationPillLabel,
6 resolveSidebarStageBackdropVariant,6 resolveSidebarStageBackdropVariant,
7 resolveSidebarStageFocusRingOffsetClass,
7 StageBackdropArt,8 StageBackdropArt,
9 StageBackdropButtonArt,
8} from "./SidebarStageBackdrop";10} from "./SidebarStageBackdrop";
911
10describe("SidebarStageBackdrop", () => {12describe("SidebarStageBackdrop", () => {
...@@ -22,6 +24,15 @@ describe("SidebarStageBackdrop", () => {...@@ -22,6 +24,15 @@ describe("SidebarStageBackdrop", () => {
22 expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull();24 expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull();
23 });25 });
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
25 it.each(["nightly", "dev"] as const)(36 it.each(["nightly", "dev"] as const)(
26 "uses unique SVG definition ids when %s artwork is rendered more than once",37 "uses unique SVG definition ids when %s artwork is rendered more than once",
27 (variant) => {38 (variant) => {
...@@ -37,4 +48,26 @@ describe("SidebarStageBackdrop", () => {...@@ -37,4 +48,26 @@ describe("SidebarStageBackdrop", () => {
37 expect(new Set(ids).size).toBe(ids.length);48 expect(new Set(ids).size).toBe(ids.length);
38 },49 },
39 );50 );
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 });
40});73});
apps/web/src/components/SidebarStageBackdrop.tsx+92-35
...@@ -23,6 +23,14 @@ export function resolveSidebarStageBackdropVariant(...@@ -23,6 +23,14 @@ export function resolveSidebarStageBackdropVariant(
23 return null;23 return null;
24}24}
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
26export function resolveEnvironmentIdentificationPillLabel(34export function resolveEnvironmentIdentificationPillLabel(
27 stageLabel: string,35 stageLabel: string,
28): EnvironmentIdentificationPillLabel | null {36): EnvironmentIdentificationPillLabel | null {
...@@ -62,6 +70,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar...@@ -62,6 +70,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar
62 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;70 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
63}71}
6472
73export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) {
74 return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />;
75}
76
65const NIGHTLY_STARS: ReadonlyArray<{77const NIGHTLY_STARS: ReadonlyArray<{
66 cx: number;78 cx: number;
67 cy: number;79 cy: number;
...@@ -93,7 +105,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [...@@ -93,7 +105,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [
93 { x: 246, y: 26 },105 { x: 246, y: 26 },
94];106];
95107
96function NightlySkyArt() {108function NightlySkyArt({ compact = false }: { compact?: boolean }) {
97 const idPrefix = useId().replaceAll(":", "");109 const idPrefix = useId().replaceAll(":", "");
98 const skyId = `${idPrefix}-stage-night-sky`;110 const skyId = `${idPrefix}-stage-night-sky`;
99 const glowId = `${idPrefix}-stage-night-glow`;111 const glowId = `${idPrefix}-stage-night-glow`;
...@@ -104,10 +116,10 @@ function NightlySkyArt() {...@@ -104,10 +116,10 @@ function NightlySkyArt() {
104116
105 return (117 return (
106 <svg118 <svg
107 className="h-full w-full"119 className="stage-art stage-nightly h-full w-full"
108 fill="none"120 fill="none"
109 preserveAspectRatio="xMinYMin slice"121 preserveAspectRatio="xMinYMin slice"
110 viewBox={STAGE_BACKDROP_VIEW_BOX}122 viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}
111 xmlns="http://www.w3.org/2000/svg"123 xmlns="http://www.w3.org/2000/svg"
112 >124 >
113 <defs>125 <defs>
...@@ -120,9 +132,9 @@ function NightlySkyArt() {...@@ -120,9 +132,9 @@ function NightlySkyArt() {
120 gradientUnits="userSpaceOnUse"132 gradientUnits="userSpaceOnUse"
121 spreadMethod="reflect"133 spreadMethod="reflect"
122 >134 >
123 <stop stopColor="#07152F" />135 <stop style={{ stopColor: "var(--stage-night-bottom)" }} />
124 <stop offset="0.5" stopColor="#151443" />136 <stop offset="0.5" style={{ stopColor: "var(--stage-night-mid)" }} />
125 <stop offset="1" stopColor="#32155B" />137 <stop offset="1" style={{ stopColor: "var(--stage-night-top)" }} />
126 </linearGradient>138 </linearGradient>
127 <radialGradient139 <radialGradient
128 id={glowId}140 id={glowId}
...@@ -132,20 +144,28 @@ function NightlySkyArt() {...@@ -132,20 +144,28 @@ function NightlySkyArt() {
132 gradientTransform="translate(216 18) rotate(137) scale(120 84)"144 gradientTransform="translate(216 18) rotate(137) scale(120 84)"
133 gradientUnits="userSpaceOnUse"145 gradientUnits="userSpaceOnUse"
134 >146 >
135 <stop stopColor="#5165D8" stopOpacity="0.4" />147 <stop style={{ stopColor: "var(--stage-night-highlight)" }} stopOpacity="0.4" />
136 <stop offset="0.5" stopColor="#283075" stopOpacity="0.16" />148 <stop
137 <stop offset="1" stopColor="#111635" stopOpacity="0" />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" />
138 </radialGradient>154 </radialGradient>
139 <linearGradient id={cloudId} x1="0" y1="60" x2="288" y2="96" gradientUnits="userSpaceOnUse">155 <linearGradient id={cloudId} x1="0" y1="60" x2="288" y2="96" gradientUnits="userSpaceOnUse">
140 <stop stopColor="#4EA4FF" stopOpacity="0.5" />156 <stop style={{ stopColor: "var(--stage-night-highlight)" }} stopOpacity="0.5" />
141 <stop offset="0.52" stopColor="#696FEA" stopOpacity="0.62" />157 <stop
142 <stop offset="1" stopColor="#A85BEA" stopOpacity="0.5" />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" />
143 </linearGradient>163 </linearGradient>
144 <filter id={softId} x="-24" y="-24" width="336" height="144" filterUnits="userSpaceOnUse">164 <filter id={softId} x="-24" y="-24" width="336" height="144" filterUnits="userSpaceOnUse">
145 <feGaussianBlur stdDeviation="4" />165 <feGaussianBlur stdDeviation="4" />
146 </filter>166 </filter>
147 <pattern id={starsId} width="288" height="96" patternUnits="userSpaceOnUse">167 <pattern id={starsId} width="288" height="96" patternUnits="userSpaceOnUse">
148 <g fill="#E4EAFF">168 <g style={{ fill: "var(--stage-night-line)" }}>
149 {NIGHTLY_STARS.map((star) => (169 {NIGHTLY_STARS.map((star) => (
150 <circle170 <circle
151 key={`${star.cx}-${star.cy}`}171 key={`${star.cx}-${star.cy}`}
...@@ -156,7 +176,12 @@ function NightlySkyArt() {...@@ -156,7 +176,12 @@ function NightlySkyArt() {
156 />176 />
157 ))}177 ))}
158 </g>178 </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 >
160 {NIGHTLY_SPARKLES.map((sparkle) => (185 {NIGHTLY_SPARKLES.map((sparkle) => (
161 <g key={`${sparkle.x}-${sparkle.y}`}>186 <g key={`${sparkle.x}-${sparkle.y}`}>
162 <path d={`M${sparkle.x - 1.5} ${sparkle.y}H${sparkle.x + 1.5}`} />187 <path d={`M${sparkle.x - 1.5} ${sparkle.y}H${sparkle.x + 1.5}`} />
...@@ -191,7 +216,7 @@ function NightlySkyArt() {...@@ -191,7 +216,7 @@ function NightlySkyArt() {
191 );216 );
192}217}
193218
194function DevBlueprintArt() {219function DevBlueprintArt({ compact = false }: { compact?: boolean }) {
195 const idPrefix = useId().replaceAll(":", "");220 const idPrefix = useId().replaceAll(":", "");
196 const paperId = `${idPrefix}-stage-bp-paper`;221 const paperId = `${idPrefix}-stage-bp-paper`;
197 const glowId = `${idPrefix}-stage-bp-glow`;222 const glowId = `${idPrefix}-stage-bp-glow`;
...@@ -205,10 +230,10 @@ function DevBlueprintArt() {...@@ -205,10 +230,10 @@ function DevBlueprintArt() {
205230
206 return (231 return (
207 <svg232 <svg
208 className="stage-blueprint h-full w-full"233 className="stage-art stage-blueprint h-full w-full"
209 fill="none"234 fill="none"
210 preserveAspectRatio="xMinYMin slice"235 preserveAspectRatio="xMinYMin slice"
211 viewBox={STAGE_BACKDROP_VIEW_BOX}236 viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}
212 xmlns="http://www.w3.org/2000/svg"237 xmlns="http://www.w3.org/2000/svg"
213 >238 >
214 <defs>239 <defs>
...@@ -221,9 +246,9 @@ function DevBlueprintArt() {...@@ -221,9 +246,9 @@ function DevBlueprintArt() {
221 gradientUnits="userSpaceOnUse"246 gradientUnits="userSpaceOnUse"
222 spreadMethod="reflect"247 spreadMethod="reflect"
223 >248 >
224 <stop style={{ stopColor: "var(--stage-bp-bottom)" }} />249 <stop style={{ stopColor: "var(--stage-art-bottom)" }} />
225 <stop offset="0.5" style={{ stopColor: "var(--stage-bp-mid)" }} />250 <stop offset="0.5" style={{ stopColor: "var(--stage-art-mid)" }} />
226 <stop offset="1" style={{ stopColor: "var(--stage-bp-top)" }} />251 <stop offset="1" style={{ stopColor: "var(--stage-art-top)" }} />
227 </linearGradient>252 </linearGradient>
228 <radialGradient253 <radialGradient
229 id={glowId}254 id={glowId}
...@@ -233,9 +258,13 @@ function DevBlueprintArt() {...@@ -233,9 +258,13 @@ function DevBlueprintArt() {
233 gradientTransform="translate(216 14) rotate(137) scale(120 84)"258 gradientTransform="translate(216 14) rotate(137) scale(120 84)"
234 gradientUnits="userSpaceOnUse"259 gradientUnits="userSpaceOnUse"
235 >260 >
236 <stop stopColor="#D4F6FF" stopOpacity="0.4" />261 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.4" />
237 <stop offset="0.52" stopColor="#65C8FF" stopOpacity="0.16" />262 <stop
238 <stop offset="1" stopColor="#276AF1" stopOpacity="0" />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" />
239 </radialGradient>268 </radialGradient>
240 <radialGradient269 <radialGradient
241 id={celesteGlowId}270 id={celesteGlowId}
...@@ -245,9 +274,13 @@ function DevBlueprintArt() {...@@ -245,9 +274,13 @@ function DevBlueprintArt() {
245 gradientTransform="translate(474 44) rotate(166) scale(156 92)"274 gradientTransform="translate(474 44) rotate(166) scale(156 92)"
246 gradientUnits="userSpaceOnUse"275 gradientUnits="userSpaceOnUse"
247 >276 >
248 <stop stopColor="#D2FFFF" stopOpacity="0.34" />277 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.34" />
249 <stop offset="0.5" stopColor="#48DCF5" stopOpacity="0.18" />278 <stop
250 <stop offset="1" stopColor="#277EF1" stopOpacity="0" />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" />
251 </radialGradient>284 </radialGradient>
252 <radialGradient285 <radialGradient
253 id={violetGlowId}286 id={violetGlowId}
...@@ -257,20 +290,34 @@ function DevBlueprintArt() {...@@ -257,20 +290,34 @@ function DevBlueprintArt() {
257 gradientTransform="translate(704 18) rotate(145) scale(132 88)"290 gradientTransform="translate(704 18) rotate(145) scale(132 88)"
258 gradientUnits="userSpaceOnUse"291 gradientUnits="userSpaceOnUse"
259 >292 >
260 <stop stopColor="#D9D8FF" stopOpacity="0.3" />293 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.3" />
261 <stop offset="0.52" stopColor="#7C8BFF" stopOpacity="0.14" />294 <stop
262 <stop offset="1" stopColor="#3155DF" stopOpacity="0" />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" />
263 </radialGradient>300 </radialGradient>
264 <pattern id={minorGridId} width="8" height="8" patternUnits="userSpaceOnUse">301 <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 />
266 </pattern>308 </pattern>
267 <pattern id={majorGridId} width="32" height="32" patternUnits="userSpaceOnUse">309 <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 />
269 </pattern>316 </pattern>
270 <pattern id={rulerId} width="32" height="6" patternUnits="userSpaceOnUse">317 <pattern id={rulerId} width="32" height="6" patternUnits="userSpaceOnUse">
271 <path318 <path
272 d="M4 0V2.5M12 0V2.5M20 0V4M28 0V2.5"319 d="M4 0V2.5M12 0V2.5M20 0V4M28 0V2.5"
273 stroke="#DDF7FF"320 style={{ stroke: "var(--stage-art-line)" }}
274 strokeOpacity="0.5"321 strokeOpacity="0.5"
275 strokeWidth="0.5"322 strokeWidth="0.5"
276 />323 />
...@@ -281,7 +328,12 @@ function DevBlueprintArt() {...@@ -281,7 +328,12 @@ function DevBlueprintArt() {
281 <rect width="768" height="96" fill={`url(#${violetGlowId})`} />328 <rect width="768" height="96" fill={`url(#${violetGlowId})`} />
282 </pattern>329 </pattern>
283 <pattern id={annotationsId} width="768" height="96" patternUnits="userSpaceOnUse">330 <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 >
285 <path d="M180 64H264" strokeDasharray="5 4" />337 <path d="M180 64H264" strokeDasharray="5 4" />
286 <path d="M180 61V67M264 61V67" />338 <path d="M180 61V67M264 61V67" />
287 <path d="M276 10V44" strokeDasharray="4 4" strokeOpacity="0.5" />339 <path d="M276 10V44" strokeDasharray="4 4" strokeOpacity="0.5" />
...@@ -294,7 +346,12 @@ function DevBlueprintArt() {...@@ -294,7 +346,12 @@ function DevBlueprintArt() {
294 <path d="M590 67V73M724 67V73" strokeOpacity="0.55" />346 <path d="M590 67V73M724 67V73" strokeOpacity="0.55" />
295 </g>347 </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 >
298 <g>355 <g>
299 <path d="M34 60L38 64M38 60L34 64" />356 <path d="M34 60L38 64M38 60L34 64" />
300 </g>357 </g>
...@@ -318,7 +375,7 @@ function DevBlueprintArt() {...@@ -318,7 +375,7 @@ function DevBlueprintArt() {
318 </g>375 </g>
319 </g>376 </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">
322 <circle cx="196" cy="38" r="13" strokeDasharray="3.5 4" />379 <circle cx="196" cy="38" r="13" strokeDasharray="3.5 4" />
323 <path d="M196 33V43M191 38H201" strokeOpacity="0.6" strokeWidth="0.4" />380 <path d="M196 33V43M191 38H201" strokeOpacity="0.6" strokeWidth="0.4" />
324 <circle cx="414" cy="64" r="10" strokeDasharray="2.5 3.5" />381 <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 @@...@@ -1,13 +1,18 @@
1import { createElement } from "react";1import { createElement } from "react";
2import { renderToStaticMarkup } from "react-dom/server";2import { 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
5vi.mock("~/hooks/useSettings", () => ({10vi.mock("~/hooks/useSettings", () => ({
6 useEnvironmentIdentificationMode: () => "none",11 useEnvironmentIdentificationMode: () => stageArtworkState.mode,
7}));12}));
8vi.mock("../SidebarStageBackdrop", () => ({13vi.mock("../SidebarStageBackdrop", () => ({
9 StageBackdropButtonArt: () => null,14 StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`,
10 useSidebarStageBackdropVariant: () => null,15 useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null),
11}));16}));
1217
13import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";18import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
...@@ -60,6 +65,32 @@ function renderStandaloneStop() {...@@ -60,6 +65,32 @@ function renderStandaloneStop() {
60 );65 );
61}66}
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
63describe("formatPendingPrimaryActionLabel", () => {94describe("formatPendingPrimaryActionLabel", () => {
64 it("returns 'Submitting...' while responding", () => {95 it("returns 'Submitting...' while responding", () => {
65 expect(96 expect(
...@@ -164,4 +195,24 @@ describe("ComposerPrimaryActions", () => {...@@ -164,4 +195,24 @@ describe("ComposerPrimaryActions", () => {
164 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");195 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
165 expect(renderStandaloneStop()).not.toContain("sm:size-7");196 expect(renderStandaloneStop()).not.toContain("sm:size-7");
166 });197 });
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 });
167});218});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+15-2
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { memo, type PointerEventHandler } from "react";1import { memo, type PointerEventHandler } from "react";
2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
4import { Button } from "../ui/button";6import { Button } from "../ui/button";
5import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6import { Spinner } from "../ui/spinner";8import { Spinner } from "../ui/spinner";
...@@ -73,7 +75,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -73,7 +75,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
73 const pointerFocusProps = preserveComposerFocusOnPointerDown75 const pointerFocusProps = preserveComposerFocusOnPointerDown
74 ? { onPointerDown: preventPointerFocus }76 ? { onPointerDown: preventPointerFocus }
75 : undefined;77 : undefined;
78 const environmentIdentificationMode = useEnvironmentIdentificationMode();
76 const isSendDisabled = sendDisabledReason !== null;79 const isSendDisabled = sendDisabledReason !== null;
80 const stageBackdropVariant = useSidebarStageBackdropVariant(
81 environmentIdentificationMode === "artwork",
82 );
7783
78 const renderStopGenerationButton = (insidePendingAction: boolean) => (84 const renderStopGenerationButton = (insidePendingAction: boolean) => (
79 <button85 <button
...@@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
212 <button218 <button
213 type="submit"219 type="submit"
214 className={cn(220 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",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",
216 "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover",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",
217 )}225 )}
218 {...pointerFocusProps}226 {...pointerFocusProps}
219 disabled={227 disabled={
...@@ -237,6 +245,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -237,6 +245,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
237 : "Send message"245 : "Send message"
238 }246 }
239 >247 >
248 {stageBackdropVariant ? (
249 <span className="absolute inset-0 -z-10" aria-hidden="true">
250 <StageBackdropButtonArt variant={stageBackdropVariant} />
251 </span>
252 ) : null}
240 {isConnecting || isSendBusy ? (253 {isConnecting || isSendBusy ? (
241 <Spinner className="size-3.5" aria-hidden="true" />254 <Spinner className="size-3.5" aria-hidden="true" />
242 ) : (255 ) : (
apps/web/src/components/settings/ThemeEditorPanel.tsx+2-40
...@@ -165,7 +165,6 @@ export function ThemeEditorPanel({...@@ -165,7 +165,6 @@ export function ThemeEditorPanel({
165 const isEditing = editingTheme !== null;165 const isEditing = editingTheme !== null;
166 const [name, setName] = useState("");166 const [name, setName] = useState("");
167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
168 const [sidebarArtwork, setSidebarArtwork] = useState(false);
169 const [isAdvanced, setIsAdvanced] = useState(false);168 const [isAdvanced, setIsAdvanced] = useState(false);
170 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>169 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
171 getThemeEditorColorsByAppearance(),170 getThemeEditorColorsByAppearance(),
...@@ -179,7 +178,6 @@ export function ThemeEditorPanel({...@@ -179,7 +178,6 @@ export function ThemeEditorPanel({
179 const [isInspecting, setIsInspecting] = useState(false);178 const [isInspecting, setIsInspecting] = useState(false);
180 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);179 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
181 const [usageCount, setUsageCount] = useState<number | null>(null);180 const [usageCount, setUsageCount] = useState<number | null>(null);
182 const previousMergeTargetIdRef = useRef<string | null>(null);
183 // Null parks the panel at its default corner; a value is a dragged spot,181 // Null parks the panel at its default corner; a value is a dragged spot,
184 // kept clamped so the header can always be grabbed again.182 // kept clamped so the header can always be grabbed again.
185 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);183 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
...@@ -230,7 +228,6 @@ export function ThemeEditorPanel({...@@ -230,7 +228,6 @@ export function ThemeEditorPanel({
230 };228 };
231 window.addEventListener("resize", clamp);229 window.addEventListener("resize", clamp);
232 return () => window.removeEventListener("resize", clamp);230 return () => window.removeEventListener("resize", clamp);
233 // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only.
234 }, [isMinimized, open]);231 }, [isMinimized, open]);
235232
236 // The draft only reaches the live app once this open has been seeded;233 // The draft only reaches the live app once this open has been seeded;
...@@ -261,9 +258,6 @@ export function ThemeEditorPanel({...@@ -261,9 +258,6 @@ export function ThemeEditorPanel({
261258
262 setName(editingTheme?.label ?? seedName ?? "");259 setName(editingTheme?.label ?? seedName ?? "");
263 setActiveAppearance(nextAppearance);260 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);
267 // Themes saved by the guided editor carry the managed flag; anything261 // Themes saved by the guided editor carry the managed flag; anything
268 // else (imports, hand-edited files, older saves) opens in advanced mode262 // else (imports, hand-edited files, older saves) opens in advanced mode
269 // so guided regeneration cannot silently discard hand-tuned colors. A263 // so guided regeneration cannot silently discard hand-tuned colors. A
...@@ -320,18 +314,6 @@ export function ThemeEditorPanel({...@@ -320,18 +314,6 @@ export function ThemeEditorPanel({
320 // an explanation instead.314 // an explanation instead.
321 const mergeTargetId = mergeTarget?.id ?? null;315 const mergeTargetId = mergeTarget?.id ?? null;
322 const takenAppearancesKey = takenAppearances.join(",");316 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
335 useEffect(() => {317 useEffect(() => {
336 if (isEditing || mergeTargetId === null) return;318 if (isEditing || mergeTargetId === null) return;
337 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];319 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];
...@@ -347,8 +329,8 @@ export function ThemeEditorPanel({...@@ -347,8 +329,8 @@ export function ThemeEditorPanel({
347 // comes back when the editor closes, including on cancel.329 // comes back when the editor closes, including on cancel.
348 useEffect(() => {330 useEffect(() => {
349 if (!open || !isDraftSeeded) return;331 if (!open || !isDraftSeeded) return;
350 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork);332 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);
351 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]);333 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);
352334
353 useEffect(() => {335 useEffect(() => {
354 if (!open) return;336 if (!open) return;
...@@ -674,7 +656,6 @@ export function ThemeEditorPanel({...@@ -674,7 +656,6 @@ export function ThemeEditorPanel({
674 ...mergeTarget.variants,656 ...mergeTarget.variants,
675 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),657 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),
676 },658 },
677 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
678 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),659 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
679 }),660 }),
680 );661 );
...@@ -705,7 +686,6 @@ export function ThemeEditorPanel({...@@ -705,7 +686,6 @@ export function ThemeEditorPanel({
705 ...(getThemeModes(editingTheme).length > 1686 ...(getThemeModes(editingTheme).length > 1
706 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }687 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
707 : {}),688 : {}),
708 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
709 ...(isAdvanced ? {} : { managed: true }),689 ...(isAdvanced ? {} : { managed: true }),
710 }),690 }),
711 );691 );
...@@ -732,7 +712,6 @@ export function ThemeEditorPanel({...@@ -732,7 +712,6 @@ export function ThemeEditorPanel({
732 ...mergeTarget.variants,712 ...mergeTarget.variants,
733 [activeAppearance]: colorsForSave[activeAppearance],713 [activeAppearance]: colorsForSave[activeAppearance],
734 },714 },
735 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
736 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),715 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
737 }),716 }),
738 );717 );
...@@ -743,7 +722,6 @@ export function ThemeEditorPanel({...@@ -743,7 +722,6 @@ export function ThemeEditorPanel({
743 name,722 name,
744 appearance: activeAppearance,723 appearance: activeAppearance,
745 colors: colorsForSave[activeAppearance],724 colors: colorsForSave[activeAppearance],
746 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
747 ...(isAdvanced ? {} : { managed: true }),725 ...(isAdvanced ? {} : { managed: true }),
748 }),726 }),
749 );727 );
...@@ -794,7 +772,6 @@ export function ThemeEditorPanel({...@@ -794,7 +772,6 @@ export function ThemeEditorPanel({
794 name,772 name,
795 onOpenChange,773 onOpenChange,
796 onSaved,774 onSaved,
797 sidebarArtwork,
798 simpleColorsDirtyByAppearance,775 simpleColorsDirtyByAppearance,
799 takenAppearances,776 takenAppearances,
800 ]);777 ]);
...@@ -854,20 +831,6 @@ export function ThemeEditorPanel({...@@ -854,20 +831,6 @@ export function ThemeEditorPanel({
854 </div>831 </div>
855 );832 );
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
871 const renderColorsHeader = () => (834 const renderColorsHeader = () => (
872 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">835 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
873 <div>836 <div>
...@@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({...@@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({
1124 </p>1087 </p>
1125 ) : null}1088 ) : null}
1126 {renderAppearanceButtons()}1089 {renderAppearanceButtons()}
1127 {renderSidebarArtworkToggle()}
1128 <div className="space-y-3">1090 <div className="space-y-3">
1129 {renderColorsHeader()}1091 {renderColorsHeader()}
1130 {renderColorFields()}1092 {renderColorFields()}
apps/web/src/components/sidebar/SidebarChrome.tsx+3-1
...@@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments";...@@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments";
8import {8import {
9 resolveEnvironmentIdentificationPillLabel,9 resolveEnvironmentIdentificationPillLabel,
10 resolveSidebarStageBackdropVariant,10 resolveSidebarStageBackdropVariant,
11 resolveSidebarStageFocusRingOffsetClass,
11 SidebarStageBackdrop,12 SidebarStageBackdrop,
12 useEnvironmentStageLabel,13 useEnvironmentStageLabel,
13} from "../SidebarStageBackdrop";14} from "../SidebarStageBackdrop";
...@@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
52 className={cn(53 className={cn(
53 "relative z-10 md:hidden",54 "relative z-10 md:hidden",
54 backdropVariant &&55 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),
56 )}58 )}
57 />59 />
58 <SidebarBrand onBackdrop={backdropVariant !== null} />60 <SidebarBrand onBackdrop={backdropVariant !== null} />
apps/web/src/hooks/useSettings.ts+4-4
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 getThemePreviewSidebarArtwork,31 getThemePreviewSidebarArtwork,
32 resolveThemeHalf,32 resolveThemeHalf,
33 subscribeToThemePreview,33 subscribeToThemePreview,
34 themeAllowsSidebarArtwork,
34} from "~/themePalette";35} from "~/themePalette";
35import * as Struct from "effect/Struct";36import * as Struct from "effect/Struct";
36import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";37import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
...@@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: {...@@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: {
236}): EnvironmentIdentificationMode {237}): EnvironmentIdentificationMode {
237 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.238 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
238 if (!input.settingsHydrated) return "none";239 if (!input.settingsHydrated) return "none";
239 // Stage artwork has fixed colors that can clash with palette themes. Keep an240 // Artwork palettes are maintained for built-ins only. Keep an explicit
240 // explicit "none", but use the theme-aware pill in place of artwork.241 // "none", but use the theme-aware pill for user-controlled palettes.
241 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"242 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"
242 ? "pill"243 ? "pill"
243 : input.mode;244 : input.mode;
...@@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod...@@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod
258 mode,259 mode,
259 settingsHydrated,260 settingsHydrated,
260 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,261 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
261 paletteThemeAllowsArtwork:262 paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),
262 previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true,
263 });263 });
264}264}
265265
apps/web/src/index.css+163-10
...@@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
265}265}
266266
267@layer base {267@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
268 * {301 * {
269 @apply border-border outline-ring/50;302 @apply border-border outline-ring/50;
270 }303 }
...@@ -335,16 +368,136 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -335,16 +368,136 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
335 );368 );
336 }369 }
337370
338 .stage-blueprint {371 /* Each maintainer palette gives the same line art its own material: rose
339 --stage-bp-top: #67c2ff;372 vellum, forest drafting paper, marine cyanotype, copper, and violet ink.
340 --stage-bp-mid: #347ff8;373 These colors stay deliberately deep at the top edge so the white stage
341 --stage-bp-bottom: #1538d0;374 wordmark and controls retain contrast in both appearance modes. */
342 }375 html[data-theme-id="t3-chat"] {
343376 --stage-art-top: #f28ac1;
344 .dark .stage-blueprint {377 --stage-art-mid: #cb3e86;
345 --stage-bp-top: #3a7ad1;378 --stage-art-bottom: #73194f;
346 --stage-bp-mid: #2050ae;379 --stage-art-highlight: #ffe6f6;
347 --stage-bp-bottom: #101f6e;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;
348 }501 }
349502
350 .workspace-topbar {503 .workspace-topbar {
apps/web/src/themePalette.test.ts+15-16
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 serializeThemeFile,20 serializeThemeFile,
21 subscribeToThemePreview,21 subscribeToThemePreview,
22 subscribeToCustomThemes,22 subscribeToCustomThemes,
23 themeAllowsSidebarArtwork,
23 T3_CHAT_THEME,24 T3_CHAT_THEME,
24 EMBER_THEME,25 EMBER_THEME,
25 GROVE_THEME,26 GROVE_THEME,
...@@ -206,14 +207,8 @@ describe("theme files", () => {...@@ -206,14 +207,8 @@ describe("theme files", () => {
206 });207 });
207 });208 });
208209
209 it("keeps sidebar artwork opt-in through theme files", () => {210 it("keeps sidebar artwork disabled for custom theme files", () => {
210 const withoutArtwork = parseThemeFile({211 const theme = parseThemeFile({
211 version: THEME_FILE_VERSION,
212 name: "Plain sidebar",
213 appearance: "light",
214 colors: { accent: "#5b6cff" },
215 });
216 const withArtwork = parseThemeFile({
217 version: THEME_FILE_VERSION,212 version: THEME_FILE_VERSION,
218 name: "Art sidebar",213 name: "Art sidebar",
219 appearance: "light",214 appearance: "light",
...@@ -221,12 +216,11 @@ describe("theme files", () => {...@@ -221,12 +216,11 @@ describe("theme files", () => {
221 sidebarArtwork: true,216 sidebarArtwork: true,
222 });217 });
223218
224 expect(withoutArtwork.sidebarArtwork).toBeUndefined();219 expect(theme.sidebarArtwork).toBeUndefined();
225 expect(withArtwork.sidebarArtwork).toBe(true);220 expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork");
226 expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true);
227 });221 });
228222
229 it("publishes sidebar artwork changes from the live theme preview", () => {223 it("suppresses sidebar artwork during a live custom-theme preview", () => {
230 const listener = vi.fn();224 const listener = vi.fn();
231 const unsubscribe = subscribeToThemePreview(listener);225 const unsubscribe = subscribeToThemePreview(listener);
232 vi.stubGlobal("document", {226 vi.stubGlobal("document", {
...@@ -237,8 +231,8 @@ describe("theme files", () => {...@@ -237,8 +231,8 @@ describe("theme files", () => {
237 },231 },
238 });232 });
239233
240 applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true);234 applyThemeColorPreview(T3_CHAT_THEME.colors, "light");
241 expect(getThemePreviewSidebarArtwork()).toBe(true);235 expect(getThemePreviewSidebarArtwork()).toBe(false);
242 expect(listener).toHaveBeenCalledTimes(1);236 expect(listener).toHaveBeenCalledTimes(1);
243237
244 applyThemePalette("system");238 applyThemePalette("system");
...@@ -325,6 +319,8 @@ describe("theme files", () => {...@@ -325,6 +319,8 @@ describe("theme files", () => {
325 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {319 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
326 expect(getThemeDefinition(theme.id)).toBe(theme);320 expect(getThemeDefinition(theme.id)).toBe(theme);
327 expect(getThemeModes(theme)).toEqual(["light", "dark"]);321 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
322 expect(theme.sidebarArtwork).toBe(true);
323 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
328 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);324 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);
329 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);325 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);
330326
...@@ -359,6 +355,7 @@ describe("theme files", () => {...@@ -359,6 +355,7 @@ describe("theme files", () => {
359 );355 );
360 }356 }
361 }357 }
358 expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false);
362 });359 });
363360
364 it("rejects a variant that repeats the base appearance", () => {361 it("rejects a variant that repeats the base appearance", () => {
...@@ -451,15 +448,17 @@ describe("theme files", () => {...@@ -451,15 +448,17 @@ describe("theme files", () => {
451 expect(updatedTheme).toMatchObject({448 expect(updatedTheme).toMatchObject({
452 id: "aurora",449 id: "aurora",
453 label: "Aurora Night",450 label: "Aurora Night",
454 sidebarArtwork: true,
455 });451 });
452 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
456 invalidateCustomThemes();453 invalidateCustomThemes();
457 expect(getCustomThemes()).toEqual([updatedTheme]);454 expect(getCustomThemes()).toEqual([updatedTheme]);
458 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({455 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
459 id: "aurora",456 id: "aurora",
460 label: "Aurora Night",457 label: "Aurora Night",
461 sidebarArtwork: true,
462 });458 });
459 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty(
460 "sidebarArtwork",
461 );
463462
464 vi.unstubAllGlobals();463 vi.unstubAllGlobals();
465 invalidateCustomThemes();464 invalidateCustomThemes();
apps/web/src/themePalette.ts+19-11
...@@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{...@@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{
96 appearance: ThemeAppearance;96 appearance: ThemeAppearance;
97 colors: ThemeColors;97 colors: ThemeColors;
98 variants?: ThemeVariants;98 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. */
100 sidebarArtwork?: boolean;100 sidebarArtwork?: boolean;
101 /** True when the palette was generated by the guided editor from its101 /** True when the palette was generated by the guided editor from its
102 * canvas and accent; such themes reopen in guided mode. */102 * canvas and accent; such themes reopen in guided mode. */
...@@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{...@@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{
109 appearance: ThemeAppearance;109 appearance: ThemeAppearance;
110 colors: ThemeColorOverrides;110 colors: ThemeColorOverrides;
111 variants?: ThemeVariantOverrides;111 variants?: ThemeVariantOverrides;
112 sidebarArtwork?: boolean;
113 managed?: boolean;112 managed?: boolean;
114}>;113}>;
115114
...@@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {...@@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
223 appearance: value.appearance,222 appearance: value.appearance,
224 colors,223 colors,
225 ...(variants ? { variants } : {}),224 ...(variants ? { variants } : {}),
226 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
227 ...(value.managed === true ? { managed: true } : {}),225 ...(value.managed === true ? { managed: true } : {}),
228 };226 };
229}227}
...@@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = {...@@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = {
1299 variants: {1297 variants: {
1300 dark: T3_CHAT_DARK_COLORS,1298 dark: T3_CHAT_DARK_COLORS,
1301 },1299 },
1300 sidebarArtwork: true,
1302};1301};
13031302
1304/** Theme-file defaults follow the flagship palette for the requested mode. */1303/** Theme-file defaults follow the flagship palette for the requested mode. */
...@@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = {...@@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = {
1341 ...themeActionColors("#e3b34e"),1340 ...themeActionColors("#e3b34e"),
1342 },1341 },
1343 },1342 },
1343 sidebarArtwork: true,
1344};1344};
13451345
1346export const OCEAN_THEME: ThemeDefinition = {1346export const OCEAN_THEME: ThemeDefinition = {
...@@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = {...@@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = {
1357 ...themeActionColors("#5bd0d6"),1357 ...themeActionColors("#5bd0d6"),
1358 },1358 },
1359 },1359 },
1360 sidebarArtwork: true,
1360};1361};
13611362
1362export const EMBER_THEME: ThemeDefinition = {1363export const EMBER_THEME: ThemeDefinition = {
...@@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = {...@@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = {
1373 ...themeActionColors("#f78a7a"),1374 ...themeActionColors("#f78a7a"),
1374 },1375 },
1375 },1376 },
1377 sidebarArtwork: true,
1376};1378};
13771379
1378export const IRIS_THEME: ThemeDefinition = {1380export const IRIS_THEME: ThemeDefinition = {
...@@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = {...@@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = {
1389 ...themeActionColors("#f099d8"),1391 ...themeActionColors("#f099d8"),
1390 },1392 },
1391 },1393 },
1394 sidebarArtwork: true,
1392};1395};
13931396
1394const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [1397const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [
...@@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu...@@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu
1408 );1411 );
1409}1412}
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
1411export function getThemeColorsForMode(1423export function getThemeColorsForMode(
1412 theme: ThemeDefinition,1424 theme: ThemeDefinition,
1413 mode: ThemeAppearance,1425 mode: ThemeAppearance,
...@@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition {...@@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
1569 appearance,1581 appearance,
1570 colors: { ...fallback, ...overrides },1582 colors: { ...fallback, ...overrides },
1571 ...(Object.keys(variants).length > 0 ? { variants } : {}),1583 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1572 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
1573 ...(value.managed === true ? { managed: true } : {}),1584 ...(value.managed === true ? { managed: true } : {}),
1574 };1585 };
1575}1586}
...@@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string {...@@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
1582 appearance: theme.appearance,1593 appearance: theme.appearance,
1583 colors: theme.colors,1594 colors: theme.colors,
1584 ...(theme.variants ? { variants: theme.variants } : {}),1595 ...(theme.variants ? { variants: theme.variants } : {}),
1585 ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}),
1586 ...(theme.managed ? { managed: true } : {}),1596 ...(theme.managed ? { managed: true } : {}),
1587 };1597 };
1588 return `${JSON.stringify(file, null, 2)}\n`;1598 return `${JSON.stringify(file, null, 2)}\n`;
...@@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview";...@@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview";
1660 * can be judged against the real interface instead of a miniature. Callers1670 * can be judged against the real interface instead of a miniature. Callers
1661 * restore the stored theme (refreshTheme) when the draft goes away.1671 * restore the stored theme (refreshTheme) when the draft goes away.
1662 */1672 */
1663export function applyThemeColorPreview(1673export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {
1664 colors: ThemeColors,
1665 appearance: ThemeAppearance,
1666 sidebarArtwork = false,
1667): void {
1668 if (typeof document === "undefined") return;1674 if (typeof document === "undefined") return;
1669 const root = document.documentElement;1675 const root = document.documentElement;
1670 if (!root?.style) return;1676 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);
1673 root.dataset.themeId = THEME_PREVIEW_ID;1681 root.dataset.themeId = THEME_PREVIEW_ID;
1674 root.classList.toggle("dark", appearance === "dark");1682 root.classList.toggle("dark", appearance === "dark");
1675 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {1683 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....@@ -11,3 +11,10 @@ other connected devices.
11If reordering is unavailable for one environment, update the T3 Code server running in that11If reordering is unavailable for one environment, update the T3 Code server running in that
12environment. Older servers can still pin and unpin threads, but do not understand synced ordering;12environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
13their pinned threads keep the default newest-first order below the ones you have arranged.13their 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.