| author | |
| committer | |
| log | b30a9bc4143910f12d9c990c221fb9bc20cbc293 |
| tree | c81c519d988e370886050a6beecfccb96c4e1539 |
| parent | ac4780f451f98c10d5b518f2bfa3d035b46645df |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 19 | import ThreadSidebar from "./Sidebar"; |
| 20 | 20 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 21 | 21 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 22 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | |
| 22 | import { | |
| 23 | resolveSidebarStageFocusRingOffsetClass, | |
| 24 | useSidebarStageBackdropVariant, | |
| 25 | } from "./SidebarStageBackdrop"; | |
| 23 | 26 | import { useProjects } from "../state/entities"; |
| 24 | 27 | import { |
| 25 | 28 | resolveInitialThreadSidebarWidth, |
| ... | ... | @@ -105,7 +108,10 @@ function SidebarControl() { |
| 105 | 108 | "pointer-events-auto", |
| 106 | 109 | isSidebarVisible && |
| 107 | 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 | 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 | 4 | import { |
| 5 | 5 | resolveEnvironmentIdentificationPillLabel, |
| 6 | 6 | resolveSidebarStageBackdropVariant, |
| 7 | resolveSidebarStageFocusRingOffsetClass, | |
| 7 | 8 | StageBackdropArt, |
| 9 | StageBackdropButtonArt, | |
| 8 | 10 | } from "./SidebarStageBackdrop"; |
| 9 | 11 | |
| 10 | 12 | describe("SidebarStageBackdrop", () => { |
| ... | ... | @@ -22,6 +24,15 @@ describe("SidebarStageBackdrop", () => { |
| 22 | 24 | expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); |
| 23 | 25 | }); |
| 24 | 26 | |
| 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 | 36 | it.each(["nightly", "dev"] as const)( |
| 26 | 37 | "uses unique SVG definition ids when %s artwork is rendered more than once", |
| 27 | 38 | (variant) => { |
| ... | ... | @@ -37,4 +48,26 @@ describe("SidebarStageBackdrop", () => { |
| 37 | 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 | 23 | return null; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | export 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 | ||
| 26 | 34 | export function resolveEnvironmentIdentificationPillLabel( |
| 27 | 35 | stageLabel: string, |
| 28 | 36 | ): EnvironmentIdentificationPillLabel | null { |
| ... | ... | @@ -62,6 +70,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar |
| 62 | 70 | return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />; |
| 63 | 71 | } |
| 64 | 72 | |
| 73 | export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { | |
| 74 | return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />; | |
| 75 | } | |
| 76 | ||
| 65 | 77 | const NIGHTLY_STARS: ReadonlyArray<{ |
| 66 | 78 | cx: number; |
| 67 | 79 | cy: number; |
| ... | ... | @@ -93,7 +105,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ |
| 93 | 105 | { x: 246, y: 26 }, |
| 94 | 106 | ]; |
| 95 | 107 | |
| 96 | function NightlySkyArt() { | |
| 108 | function NightlySkyArt({ compact = false }: { compact?: boolean }) { | |
| 97 | 109 | const idPrefix = useId().replaceAll(":", ""); |
| 98 | 110 | const skyId = `${idPrefix}-stage-night-sky`; |
| 99 | 111 | const glowId = `${idPrefix}-stage-night-glow`; |
| ... | ... | @@ -104,10 +116,10 @@ function NightlySkyArt() { |
| 104 | 116 | |
| 105 | 117 | return ( |
| 106 | 118 | <svg |
| 107 | className="h-full w-full" | |
| 119 | className="stage-art stage-nightly h-full w-full" | |
| 108 | 120 | fill="none" |
| 109 | 121 | preserveAspectRatio="xMinYMin slice" |
| 110 | viewBox={STAGE_BACKDROP_VIEW_BOX} | |
| 122 | viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | |
| 111 | 123 | xmlns="http://www.w3.org/2000/svg" |
| 112 | 124 | > |
| 113 | 125 | <defs> |
| ... | ... | @@ -120,9 +132,9 @@ function NightlySkyArt() { |
| 120 | 132 | gradientUnits="userSpaceOnUse" |
| 121 | 133 | spreadMethod="reflect" |
| 122 | 134 | > |
| 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)" }} /> | |
| 126 | 138 | </linearGradient> |
| 127 | 139 | <radialGradient |
| 128 | 140 | id={glowId} |
| ... | ... | @@ -132,20 +144,28 @@ function NightlySkyArt() { |
| 132 | 144 | gradientTransform="translate(216 18) rotate(137) scale(120 84)" |
| 133 | 145 | gradientUnits="userSpaceOnUse" |
| 134 | 146 | > |
| 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" /> | |
| 138 | 154 | </radialGradient> |
| 139 | 155 | <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" /> | |
| 143 | 163 | </linearGradient> |
| 144 | 164 | <filter id={softId} x="-24" y="-24" width="336" height="144" filterUnits="userSpaceOnUse"> |
| 145 | 165 | <feGaussianBlur stdDeviation="4" /> |
| 146 | 166 | </filter> |
| 147 | 167 | <pattern id={starsId} width="288" height="96" patternUnits="userSpaceOnUse"> |
| 148 | <g fill="#E4EAFF"> | |
| 168 | <g style={{ fill: "var(--stage-night-line)" }}> | |
| 149 | 169 | {NIGHTLY_STARS.map((star) => ( |
| 150 | 170 | <circle |
| 151 | 171 | key={`${star.cx}-${star.cy}`} |
| ... | ... | @@ -156,7 +176,12 @@ function NightlySkyArt() { |
| 156 | 176 | /> |
| 157 | 177 | ))} |
| 158 | 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 | 185 | {NIGHTLY_SPARKLES.map((sparkle) => ( |
| 161 | 186 | <g key={`${sparkle.x}-${sparkle.y}`}> |
| 162 | 187 | <path d={`M${sparkle.x - 1.5} ${sparkle.y}H${sparkle.x + 1.5}`} /> |
| ... | ... | @@ -191,7 +216,7 @@ function NightlySkyArt() { |
| 191 | 216 | ); |
| 192 | 217 | } |
| 193 | 218 | |
| 194 | function DevBlueprintArt() { | |
| 219 | function DevBlueprintArt({ compact = false }: { compact?: boolean }) { | |
| 195 | 220 | const idPrefix = useId().replaceAll(":", ""); |
| 196 | 221 | const paperId = `${idPrefix}-stage-bp-paper`; |
| 197 | 222 | const glowId = `${idPrefix}-stage-bp-glow`; |
| ... | ... | @@ -205,10 +230,10 @@ function DevBlueprintArt() { |
| 205 | 230 | |
| 206 | 231 | return ( |
| 207 | 232 | <svg |
| 208 | className="stage-blueprint h-full w-full" | |
| 233 | className="stage-art stage-blueprint h-full w-full" | |
| 209 | 234 | fill="none" |
| 210 | 235 | preserveAspectRatio="xMinYMin slice" |
| 211 | viewBox={STAGE_BACKDROP_VIEW_BOX} | |
| 236 | viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} | |
| 212 | 237 | xmlns="http://www.w3.org/2000/svg" |
| 213 | 238 | > |
| 214 | 239 | <defs> |
| ... | ... | @@ -221,9 +246,9 @@ function DevBlueprintArt() { |
| 221 | 246 | gradientUnits="userSpaceOnUse" |
| 222 | 247 | spreadMethod="reflect" |
| 223 | 248 | > |
| 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)" }} /> | |
| 227 | 252 | </linearGradient> |
| 228 | 253 | <radialGradient |
| 229 | 254 | id={glowId} |
| ... | ... | @@ -233,9 +258,13 @@ function DevBlueprintArt() { |
| 233 | 258 | gradientTransform="translate(216 14) rotate(137) scale(120 84)" |
| 234 | 259 | gradientUnits="userSpaceOnUse" |
| 235 | 260 | > |
| 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" /> | |
| 239 | 268 | </radialGradient> |
| 240 | 269 | <radialGradient |
| 241 | 270 | id={celesteGlowId} |
| ... | ... | @@ -245,9 +274,13 @@ function DevBlueprintArt() { |
| 245 | 274 | gradientTransform="translate(474 44) rotate(166) scale(156 92)" |
| 246 | 275 | gradientUnits="userSpaceOnUse" |
| 247 | 276 | > |
| 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" /> | |
| 251 | 284 | </radialGradient> |
| 252 | 285 | <radialGradient |
| 253 | 286 | id={violetGlowId} |
| ... | ... | @@ -257,20 +290,34 @@ function DevBlueprintArt() { |
| 257 | 290 | gradientTransform="translate(704 18) rotate(145) scale(132 88)" |
| 258 | 291 | gradientUnits="userSpaceOnUse" |
| 259 | 292 | > |
| 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" /> | |
| 263 | 300 | </radialGradient> |
| 264 | 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 | 308 | </pattern> |
| 267 | 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 | 316 | </pattern> |
| 270 | 317 | <pattern id={rulerId} width="32" height="6" patternUnits="userSpaceOnUse"> |
| 271 | 318 | <path |
| 272 | 319 | d="M4 0V2.5M12 0V2.5M20 0V4M28 0V2.5" |
| 273 | stroke="#DDF7FF" | |
| 320 | style={{ stroke: "var(--stage-art-line)" }} | |
| 274 | 321 | strokeOpacity="0.5" |
| 275 | 322 | strokeWidth="0.5" |
| 276 | 323 | /> |
| ... | ... | @@ -281,7 +328,12 @@ function DevBlueprintArt() { |
| 281 | 328 | <rect width="768" height="96" fill={`url(#${violetGlowId})`} /> |
| 282 | 329 | </pattern> |
| 283 | 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 | 337 | <path d="M180 64H264" strokeDasharray="5 4" /> |
| 286 | 338 | <path d="M180 61V67M264 61V67" /> |
| 287 | 339 | <path d="M276 10V44" strokeDasharray="4 4" strokeOpacity="0.5" /> |
| ... | ... | @@ -294,7 +346,12 @@ function DevBlueprintArt() { |
| 294 | 346 | <path d="M590 67V73M724 67V73" strokeOpacity="0.55" /> |
| 295 | 347 | </g> |
| 296 | 348 | |
| 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 | 355 | <g> |
| 299 | 356 | <path d="M34 60L38 64M38 60L34 64" /> |
| 300 | 357 | </g> |
| ... | ... | @@ -318,7 +375,7 @@ function DevBlueprintArt() { |
| 318 | 375 | </g> |
| 319 | 376 | </g> |
| 320 | 377 | |
| 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 | 379 | <circle cx="196" cy="38" r="13" strokeDasharray="3.5 4" /> |
| 323 | 380 | <path d="M196 33V43M191 38H201" strokeOpacity="0.6" strokeWidth="0.4" /> |
| 324 | 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 | 1 | import { createElement } from "react"; |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const stageArtworkState = vi.hoisted(() => ({ | |
| 6 | mode: "none" as "artwork" | "none", | |
| 7 | variant: null as "nightly" | "dev" | null, | |
| 8 | })); | |
| 4 | 9 | |
| 5 | 10 | vi.mock("~/hooks/useSettings", () => ({ |
| 6 | useEnvironmentIdentificationMode: () => "none", | |
| 11 | useEnvironmentIdentificationMode: () => stageArtworkState.mode, | |
| 7 | 12 | })); |
| 8 | 13 | vi.mock("../SidebarStageBackdrop", () => ({ |
| 9 | StageBackdropButtonArt: () => null, | |
| 10 | useSidebarStageBackdropVariant: () => null, | |
| 14 | StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, | |
| 15 | useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), | |
| 11 | 16 | })); |
| 12 | 17 | |
| 13 | 18 | import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; |
| ... | ... | @@ -60,6 +65,32 @@ function renderStandaloneStop() { |
| 60 | 65 | ); |
| 61 | 66 | } |
| 62 | 67 | |
| 68 | function 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 | ||
| 89 | afterEach(() => { | |
| 90 | stageArtworkState.mode = "none"; | |
| 91 | stageArtworkState.variant = null; | |
| 92 | }); | |
| 93 | ||
| 63 | 94 | describe("formatPendingPrimaryActionLabel", () => { |
| 64 | 95 | it("returns 'Submitting...' while responding", () => { |
| 65 | 96 | expect( |
| ... | ... | @@ -164,4 +195,24 @@ describe("ComposerPrimaryActions", () => { |
| 164 | 195 | expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); |
| 165 | 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 | 1 | import { memo, type PointerEventHandler } from "react"; |
| 2 | 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; |
| 3 | import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; | |
| 3 | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; | |
| 4 | 6 | import { Button } from "../ui/button"; |
| 5 | 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 6 | 8 | import { Spinner } from "../ui/spinner"; |
| ... | ... | @@ -73,7 +75,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 73 | 75 | const pointerFocusProps = preserveComposerFocusOnPointerDown |
| 74 | 76 | ? { onPointerDown: preventPointerFocus } |
| 75 | 77 | : undefined; |
| 78 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); | |
| 76 | 79 | const isSendDisabled = sendDisabledReason !== null; |
| 80 | const stageBackdropVariant = useSidebarStageBackdropVariant( | |
| 81 | environmentIdentificationMode === "artwork", | |
| 82 | ); | |
| 77 | 83 | |
| 78 | 84 | const renderStopGenerationButton = (insidePendingAction: boolean) => ( |
| 79 | 85 | <button |
| ... | ... | @@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 212 | 218 | <button |
| 213 | 219 | type="submit" |
| 214 | 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", | |
| 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", | |
| 217 | 225 | )} |
| 218 | 226 | {...pointerFocusProps} |
| 219 | 227 | disabled={ |
| ... | ... | @@ -237,6 +245,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 237 | 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 | 253 | {isConnecting || isSendBusy ? ( |
| 241 | 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 | 165 | const isEditing = editingTheme !== null; |
| 166 | 166 | const [name, setName] = useState(""); |
| 167 | 167 | const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance); |
| 168 | const [sidebarArtwork, setSidebarArtwork] = useState(false); | |
| 169 | 168 | const [isAdvanced, setIsAdvanced] = useState(false); |
| 170 | 169 | const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() => |
| 171 | 170 | getThemeEditorColorsByAppearance(), |
| ... | ... | @@ -179,7 +178,6 @@ export function ThemeEditorPanel({ |
| 179 | 178 | const [isInspecting, setIsInspecting] = useState(false); |
| 180 | 179 | const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null); |
| 181 | 180 | const [usageCount, setUsageCount] = useState<number | null>(null); |
| 182 | const previousMergeTargetIdRef = useRef<string | null>(null); | |
| 183 | 181 | // Null parks the panel at its default corner; a value is a dragged spot, |
| 184 | 182 | // kept clamped so the header can always be grabbed again. |
| 185 | 183 | const [position, setPosition] = useState<{ x: number; y: number } | null>(null); |
| ... | ... | @@ -230,7 +228,6 @@ export function ThemeEditorPanel({ |
| 230 | 228 | }; |
| 231 | 229 | window.addEventListener("resize", clamp); |
| 232 | 230 | return () => window.removeEventListener("resize", clamp); |
| 233 | // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only. | |
| 234 | 231 | }, [isMinimized, open]); |
| 235 | 232 | |
| 236 | 233 | // The draft only reaches the live app once this open has been seeded; |
| ... | ... | @@ -261,9 +258,6 @@ export function ThemeEditorPanel({ |
| 261 | 258 | |
| 262 | 259 | setName(editingTheme?.label ?? seedName ?? ""); |
| 263 | 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 | 261 | // Themes saved by the guided editor carry the managed flag; anything |
| 268 | 262 | // else (imports, hand-edited files, older saves) opens in advanced mode |
| 269 | 263 | // so guided regeneration cannot silently discard hand-tuned colors. A |
| ... | ... | @@ -320,18 +314,6 @@ export function ThemeEditorPanel({ |
| 320 | 314 | // an explanation instead. |
| 321 | 315 | const mergeTargetId = mergeTarget?.id ?? null; |
| 322 | 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 | 317 | useEffect(() => { |
| 336 | 318 | if (isEditing || mergeTargetId === null) return; |
| 337 | 319 | const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; |
| ... | ... | @@ -347,8 +329,8 @@ export function ThemeEditorPanel({ |
| 347 | 329 | // comes back when the editor closes, including on cancel. |
| 348 | 330 | useEffect(() => { |
| 349 | 331 | 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]); | |
| 352 | 334 | |
| 353 | 335 | useEffect(() => { |
| 354 | 336 | if (!open) return; |
| ... | ... | @@ -674,7 +656,6 @@ export function ThemeEditorPanel({ |
| 674 | 656 | ...mergeTarget.variants, |
| 675 | 657 | ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), |
| 676 | 658 | }, |
| 677 | ...(sidebarArtwork ? { sidebarArtwork: true } : {}), | |
| 678 | 659 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 679 | 660 | }), |
| 680 | 661 | ); |
| ... | ... | @@ -705,7 +686,6 @@ export function ThemeEditorPanel({ |
| 705 | 686 | ...(getThemeModes(editingTheme).length > 1 |
| 706 | 687 | ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } |
| 707 | 688 | : {}), |
| 708 | ...(sidebarArtwork ? { sidebarArtwork: true } : {}), | |
| 709 | 689 | ...(isAdvanced ? {} : { managed: true }), |
| 710 | 690 | }), |
| 711 | 691 | ); |
| ... | ... | @@ -732,7 +712,6 @@ export function ThemeEditorPanel({ |
| 732 | 712 | ...mergeTarget.variants, |
| 733 | 713 | [activeAppearance]: colorsForSave[activeAppearance], |
| 734 | 714 | }, |
| 735 | ...(sidebarArtwork ? { sidebarArtwork: true } : {}), | |
| 736 | 715 | ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), |
| 737 | 716 | }), |
| 738 | 717 | ); |
| ... | ... | @@ -743,7 +722,6 @@ export function ThemeEditorPanel({ |
| 743 | 722 | name, |
| 744 | 723 | appearance: activeAppearance, |
| 745 | 724 | colors: colorsForSave[activeAppearance], |
| 746 | ...(sidebarArtwork ? { sidebarArtwork: true } : {}), | |
| 747 | 725 | ...(isAdvanced ? {} : { managed: true }), |
| 748 | 726 | }), |
| 749 | 727 | ); |
| ... | ... | @@ -794,7 +772,6 @@ export function ThemeEditorPanel({ |
| 794 | 772 | name, |
| 795 | 773 | onOpenChange, |
| 796 | 774 | onSaved, |
| 797 | sidebarArtwork, | |
| 798 | 775 | simpleColorsDirtyByAppearance, |
| 799 | 776 | takenAppearances, |
| 800 | 777 | ]); |
| ... | ... | @@ -854,20 +831,6 @@ export function ThemeEditorPanel({ |
| 854 | 831 | </div> |
| 855 | 832 | ); |
| 856 | 833 | |
| 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 | 834 | const renderColorsHeader = () => ( |
| 872 | 835 | <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3"> |
| 873 | 836 | <div> |
| ... | ... | @@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({ |
| 1124 | 1087 | </p> |
| 1125 | 1088 | ) : null} |
| 1126 | 1089 | {renderAppearanceButtons()} |
| 1127 | {renderSidebarArtworkToggle()} | |
| 1128 | 1090 | <div className="space-y-3"> |
| 1129 | 1091 | {renderColorsHeader()} |
| 1130 | 1092 | {renderColorFields()} |
apps/web/src/components/sidebar/SidebarChrome.tsx+3-1| ... | ... | @@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments"; |
| 8 | 8 | import { |
| 9 | 9 | resolveEnvironmentIdentificationPillLabel, |
| 10 | 10 | resolveSidebarStageBackdropVariant, |
| 11 | resolveSidebarStageFocusRingOffsetClass, | |
| 11 | 12 | SidebarStageBackdrop, |
| 12 | 13 | useEnvironmentStageLabel, |
| 13 | 14 | } from "../SidebarStageBackdrop"; |
| ... | ... | @@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 52 | 53 | className={cn( |
| 53 | 54 | "relative z-10 md:hidden", |
| 54 | 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 | 60 | <SidebarBrand onBackdrop={backdropVariant !== null} /> |
apps/web/src/hooks/useSettings.ts+4-4| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | getThemePreviewSidebarArtwork, |
| 32 | 32 | resolveThemeHalf, |
| 33 | 33 | subscribeToThemePreview, |
| 34 | themeAllowsSidebarArtwork, | |
| 34 | 35 | } from "~/themePalette"; |
| 35 | 36 | import * as Struct from "effect/Struct"; |
| 36 | 37 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| ... | ... | @@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: { |
| 236 | 237 | }): EnvironmentIdentificationMode { |
| 237 | 238 | // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. |
| 238 | 239 | 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. | |
| 241 | 242 | return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" |
| 242 | 243 | ? "pill" |
| 243 | 244 | : input.mode; |
| ... | ... | @@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod |
| 258 | 259 | mode, |
| 259 | 260 | settingsHydrated, |
| 260 | 261 | paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, |
| 261 | paletteThemeAllowsArtwork: | |
| 262 | previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, | |
| 262 | paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme), | |
| 263 | 263 | }); |
| 264 | 264 | } |
| 265 | 265 |
apps/web/src/index.css+163-10| ... | ... | @@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 265 | 265 | } |
| 266 | 266 | |
| 267 | 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 | 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 | 368 | ); |
| 336 | 369 | } |
| 337 | 370 | |
| 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; | |
| 348 | 501 | } |
| 349 | 502 | |
| 350 | 503 | .workspace-topbar { |
apps/web/src/themePalette.test.ts+15-16| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | serializeThemeFile, |
| 21 | 21 | subscribeToThemePreview, |
| 22 | 22 | subscribeToCustomThemes, |
| 23 | themeAllowsSidebarArtwork, | |
| 23 | 24 | T3_CHAT_THEME, |
| 24 | 25 | EMBER_THEME, |
| 25 | 26 | GROVE_THEME, |
| ... | ... | @@ -206,14 +207,8 @@ describe("theme files", () => { |
| 206 | 207 | }); |
| 207 | 208 | }); |
| 208 | 209 | |
| 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({ | |
| 217 | 212 | version: THEME_FILE_VERSION, |
| 218 | 213 | name: "Art sidebar", |
| 219 | 214 | appearance: "light", |
| ... | ... | @@ -221,12 +216,11 @@ describe("theme files", () => { |
| 221 | 216 | sidebarArtwork: true, |
| 222 | 217 | }); |
| 223 | 218 | |
| 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"); | |
| 227 | 221 | }); |
| 228 | 222 | |
| 229 | it("publishes sidebar artwork changes from the live theme preview", () => { | |
| 223 | it("suppresses sidebar artwork during a live custom-theme preview", () => { | |
| 230 | 224 | const listener = vi.fn(); |
| 231 | 225 | const unsubscribe = subscribeToThemePreview(listener); |
| 232 | 226 | vi.stubGlobal("document", { |
| ... | ... | @@ -237,8 +231,8 @@ describe("theme files", () => { |
| 237 | 231 | }, |
| 238 | 232 | }); |
| 239 | 233 | |
| 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); | |
| 242 | 236 | expect(listener).toHaveBeenCalledTimes(1); |
| 243 | 237 | |
| 244 | 238 | applyThemePalette("system"); |
| ... | ... | @@ -325,6 +319,8 @@ describe("theme files", () => { |
| 325 | 319 | for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { |
| 326 | 320 | expect(getThemeDefinition(theme.id)).toBe(theme); |
| 327 | 321 | expect(getThemeModes(theme)).toEqual(["light", "dark"]); |
| 322 | expect(theme.sidebarArtwork).toBe(true); | |
| 323 | expect(themeAllowsSidebarArtwork(theme.id)).toBe(true); | |
| 328 | 324 | expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i); |
| 329 | 325 | expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i); |
| 330 | 326 | |
| ... | ... | @@ -359,6 +355,7 @@ describe("theme files", () => { |
| 359 | 355 | ); |
| 360 | 356 | } |
| 361 | 357 | } |
| 358 | expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false); | |
| 362 | 359 | }); |
| 363 | 360 | |
| 364 | 361 | it("rejects a variant that repeats the base appearance", () => { |
| ... | ... | @@ -451,15 +448,17 @@ describe("theme files", () => { |
| 451 | 448 | expect(updatedTheme).toMatchObject({ |
| 452 | 449 | id: "aurora", |
| 453 | 450 | label: "Aurora Night", |
| 454 | sidebarArtwork: true, | |
| 455 | 451 | }); |
| 452 | expect(updatedTheme).not.toHaveProperty("sidebarArtwork"); | |
| 456 | 453 | invalidateCustomThemes(); |
| 457 | 454 | expect(getCustomThemes()).toEqual([updatedTheme]); |
| 458 | 455 | expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ |
| 459 | 456 | id: "aurora", |
| 460 | 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 | ); | |
| 463 | 462 | |
| 464 | 463 | vi.unstubAllGlobals(); |
| 465 | 464 | invalidateCustomThemes(); |
apps/web/src/themePalette.ts+19-11| ... | ... | @@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{ |
| 96 | 96 | appearance: ThemeAppearance; |
| 97 | 97 | colors: ThemeColors; |
| 98 | 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 | 100 | sidebarArtwork?: boolean; |
| 101 | 101 | /** True when the palette was generated by the guided editor from its |
| 102 | 102 | * canvas and accent; such themes reopen in guided mode. */ |
| ... | ... | @@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{ |
| 109 | 109 | appearance: ThemeAppearance; |
| 110 | 110 | colors: ThemeColorOverrides; |
| 111 | 111 | variants?: ThemeVariantOverrides; |
| 112 | sidebarArtwork?: boolean; | |
| 113 | 112 | managed?: boolean; |
| 114 | 113 | }>; |
| 115 | 114 | |
| ... | ... | @@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { |
| 223 | 222 | appearance: value.appearance, |
| 224 | 223 | colors, |
| 225 | 224 | ...(variants ? { variants } : {}), |
| 226 | ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), | |
| 227 | 225 | ...(value.managed === true ? { managed: true } : {}), |
| 228 | 226 | }; |
| 229 | 227 | } |
| ... | ... | @@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = { |
| 1299 | 1297 | variants: { |
| 1300 | 1298 | dark: T3_CHAT_DARK_COLORS, |
| 1301 | 1299 | }, |
| 1300 | sidebarArtwork: true, | |
| 1302 | 1301 | }; |
| 1303 | 1302 | |
| 1304 | 1303 | /** Theme-file defaults follow the flagship palette for the requested mode. */ |
| ... | ... | @@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = { |
| 1341 | 1340 | ...themeActionColors("#e3b34e"), |
| 1342 | 1341 | }, |
| 1343 | 1342 | }, |
| 1343 | sidebarArtwork: true, | |
| 1344 | 1344 | }; |
| 1345 | 1345 | |
| 1346 | 1346 | export const OCEAN_THEME: ThemeDefinition = { |
| ... | ... | @@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = { |
| 1357 | 1357 | ...themeActionColors("#5bd0d6"), |
| 1358 | 1358 | }, |
| 1359 | 1359 | }, |
| 1360 | sidebarArtwork: true, | |
| 1360 | 1361 | }; |
| 1361 | 1362 | |
| 1362 | 1363 | export const EMBER_THEME: ThemeDefinition = { |
| ... | ... | @@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = { |
| 1373 | 1374 | ...themeActionColors("#f78a7a"), |
| 1374 | 1375 | }, |
| 1375 | 1376 | }, |
| 1377 | sidebarArtwork: true, | |
| 1376 | 1378 | }; |
| 1377 | 1379 | |
| 1378 | 1380 | export const IRIS_THEME: ThemeDefinition = { |
| ... | ... | @@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = { |
| 1389 | 1391 | ...themeActionColors("#f099d8"), |
| 1390 | 1392 | }, |
| 1391 | 1393 | }, |
| 1394 | sidebarArtwork: true, | |
| 1392 | 1395 | }; |
| 1393 | 1396 | |
| 1394 | 1397 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [ |
| ... | ... | @@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu |
| 1408 | 1411 | ); |
| 1409 | 1412 | } |
| 1410 | 1413 | |
| 1414 | /** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */ | |
| 1415 | export 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 | ||
| 1411 | 1423 | export function getThemeColorsForMode( |
| 1412 | 1424 | theme: ThemeDefinition, |
| 1413 | 1425 | mode: ThemeAppearance, |
| ... | ... | @@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition { |
| 1569 | 1581 | appearance, |
| 1570 | 1582 | colors: { ...fallback, ...overrides }, |
| 1571 | 1583 | ...(Object.keys(variants).length > 0 ? { variants } : {}), |
| 1572 | ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), | |
| 1573 | 1584 | ...(value.managed === true ? { managed: true } : {}), |
| 1574 | 1585 | }; |
| 1575 | 1586 | } |
| ... | ... | @@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string { |
| 1582 | 1593 | appearance: theme.appearance, |
| 1583 | 1594 | colors: theme.colors, |
| 1584 | 1595 | ...(theme.variants ? { variants: theme.variants } : {}), |
| 1585 | ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), | |
| 1586 | 1596 | ...(theme.managed ? { managed: true } : {}), |
| 1587 | 1597 | }; |
| 1588 | 1598 | return `${JSON.stringify(file, null, 2)}\n`; |
| ... | ... | @@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview"; |
| 1660 | 1670 | * can be judged against the real interface instead of a miniature. Callers |
| 1661 | 1671 | * restore the stored theme (refreshTheme) when the draft goes away. |
| 1662 | 1672 | */ |
| 1663 | export function applyThemeColorPreview( | |
| 1664 | colors: ThemeColors, | |
| 1665 | appearance: ThemeAppearance, | |
| 1666 | sidebarArtwork = false, | |
| 1667 | ): void { | |
| 1673 | export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { | |
| 1668 | 1674 | if (typeof document === "undefined") return; |
| 1669 | 1675 | const root = document.documentElement; |
| 1670 | 1676 | if (!root?.style) return; |
| 1671 | 1677 | |
| 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 | 1681 | root.dataset.themeId = THEME_PREVIEW_ID; |
| 1674 | 1682 | root.classList.toggle("dark", appearance === "dark"); |
| 1675 | 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 | 11 | If reordering is unavailable for one environment, update the T3 Code server running in that |
| 12 | 12 | environment. Older servers can still pin and unpin threads, but do not understand synced ordering; |
| 13 | 13 | their pinned threads keep the default newest-first order below the ones you have arranged. |
| 14 | ||
| 15 | ## Environment artwork | |
| 16 | ||
| 17 | Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in | |
| 18 | the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment | |
| 19 | identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version | |
| 20 | pill** fallback because their colors are not controlled by T3 Code. |