| 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,7 +19,10 @@ import LegacyThreadSidebar from "./LegacySidebar"; |
| 19 | import ThreadSidebar from "./Sidebar"; | 19 | import ThreadSidebar from "./Sidebar"; |
| 20 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | 20 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 21 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 21 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 22 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | 22 | import { |
| 23 | resolveSidebarStageFocusRingOffsetClass, | ||
| 24 | useSidebarStageBackdropVariant, | ||
| 25 | } from "./SidebarStageBackdrop"; | ||
| 23 | import { useProjects } from "../state/entities"; | 26 | import { useProjects } from "../state/entities"; |
| 24 | import { | 27 | import { |
| 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"; |
| 4 | import { | 4 | import { |
| 5 | resolveEnvironmentIdentificationPillLabel, | 5 | resolveEnvironmentIdentificationPillLabel, |
| 6 | resolveSidebarStageBackdropVariant, | 6 | resolveSidebarStageBackdropVariant, |
| 7 | resolveSidebarStageFocusRingOffsetClass, | ||
| 7 | StageBackdropArt, | 8 | StageBackdropArt, |
| 9 | StageBackdropButtonArt, | ||
| 8 | } from "./SidebarStageBackdrop"; | 10 | } from "./SidebarStageBackdrop"; |
| 9 | 11 | ||
| 10 | describe("SidebarStageBackdrop", () => { | 12 | describe("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 | }); |
| 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 | 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 | } |
| 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 | export function resolveEnvironmentIdentificationPillLabel( | 34 | export 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 | } |
| 64 | 72 | ||
| 73 | export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { | ||
| 74 | return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />; | ||
| 75 | } | ||
| 76 | |||
| 65 | const NIGHTLY_STARS: ReadonlyArray<{ | 77 | const 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 | ]; |
| 95 | 107 | ||
| 96 | function NightlySkyArt() { | 108 | function 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() { |
| 104 | 116 | ||
| 105 | return ( | 117 | return ( |
| 106 | <svg | 118 | <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 | <radialGradient | 139 | <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 | <circle | 170 | <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 | } |
| 193 | 218 | ||
| 194 | function DevBlueprintArt() { | 219 | function 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() { |
| 205 | 230 | ||
| 206 | return ( | 231 | return ( |
| 207 | <svg | 232 | <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 | <radialGradient | 253 | <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 | <radialGradient | 269 | <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 | <radialGradient | 285 | <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 | <path | 318 | <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> |
| 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 | <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> |
| 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 | <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 @@ |
| 1 | import { createElement } from "react"; | 1 | import { createElement } from "react"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 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 | vi.mock("~/hooks/useSettings", () => ({ | 10 | vi.mock("~/hooks/useSettings", () => ({ |
| 6 | useEnvironmentIdentificationMode: () => "none", | 11 | useEnvironmentIdentificationMode: () => stageArtworkState.mode, |
| 7 | })); | 12 | })); |
| 8 | vi.mock("../SidebarStageBackdrop", () => ({ | 13 | vi.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 | })); |
| 12 | 17 | ||
| 13 | import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; | 18 | import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; |
| ... | @@ -60,6 +65,32 @@ function renderStandaloneStop() { | ... | @@ -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 | describe("formatPendingPrimaryActionLabel", () => { | 94 | describe("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 @@ |
| 1 | import { memo, type PointerEventHandler } from "react"; | 1 | import { memo, type PointerEventHandler } from "react"; |
| 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; | 2 | import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; |
| 3 | import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; | ||
| 3 | import { cn } from "~/lib/utils"; | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; | ||
| 4 | import { Button } from "../ui/button"; | 6 | import { Button } from "../ui/button"; |
| 5 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 6 | import { Spinner } from "../ui/spinner"; | 8 | import { 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 = preserveComposerFocusOnPointerDown | 75 | 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 | ); | ||
| 77 | 83 | ||
| 78 | const renderStopGenerationButton = (insidePendingAction: boolean) => ( | 84 | const renderStopGenerationButton = (insidePendingAction: boolean) => ( |
| 79 | <button | 85 | <button |
| ... | @@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | ... | @@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ |
| 212 | <button | 218 | <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]); |
| 235 | 232 | ||
| 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({ |
| 261 | 258 | ||
| 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; anything | 261 | // Themes saved by the guided editor carry the managed flag; anything |
| 268 | // else (imports, hand-edited files, older saves) opens in advanced mode | 262 | // else (imports, hand-edited files, older saves) opens in advanced mode |
| 269 | // so guided regeneration cannot silently discard hand-tuned colors. A | 263 | // 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]); |
| 352 | 334 | ||
| 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 > 1 | 686 | ...(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 | ); |
| 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 | 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"; |
| 8 | import { | 8 | import { |
| 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"; |
| 35 | import * as Struct from "effect/Struct"; | 36 | import * as Struct from "effect/Struct"; |
| 36 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 37 | import { 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 an | 240 | // 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 | } |
| 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,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 265 | } | 265 | } |
| 266 | 266 | ||
| 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 | } |
| 337 | 370 | ||
| 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"] { |
| 343 | 376 | --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 | } |
| 349 | 502 | ||
| 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 | }); |
| 208 | 209 | ||
| 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 | }); |
| 223 | 218 | ||
| 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 | }); |
| 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 | 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 | }); |
| 239 | 233 | ||
| 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); |
| 243 | 237 | ||
| 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); |
| 330 | 326 | ||
| ... | @@ -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 | }); |
| 363 | 360 | ||
| 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 | ); | ||
| 463 | 462 | ||
| 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 its | 101 | /** 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 | }>; |
| 115 | 114 | ||
| ... | @@ -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 | }; |
| 1303 | 1302 | ||
| 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 | }; |
| 1345 | 1345 | ||
| 1346 | export const OCEAN_THEME: ThemeDefinition = { | 1346 | export 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 | }; |
| 1361 | 1362 | ||
| 1362 | export const EMBER_THEME: ThemeDefinition = { | 1363 | export 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 | }; |
| 1377 | 1379 | ||
| 1378 | export const IRIS_THEME: ThemeDefinition = { | 1380 | export 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 | }; |
| 1393 | 1396 | ||
| 1394 | const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [ | 1397 | const 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 | } |
| 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 | export function getThemeColorsForMode( | 1423 | export 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. Callers | 1670 | * 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 | */ |
| 1663 | export function applyThemeColorPreview( | 1673 | export 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; |
| 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 | 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. |
| 11 | If reordering is unavailable for one environment, update the T3 Code server running in that | 11 | If reordering is unavailable for one environment, update the T3 Code server running in that |
| 12 | environment. Older servers can still pin and unpin threads, but do not understand synced ordering; | 12 | environment. Older servers can still pin and unpin threads, but do not understand synced ordering; |
| 13 | their pinned threads keep the default newest-first order below the ones you have arranged. | 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. |