| author | |
| committer | |
| log | 2fdc704bbb2c6b9e0ba01edbc21f3aa2c956d698 |
| tree | 293e9ff402198af735e85707afb1370d49b95a31 |
| parent | 3513fa04fbf12c1d4fa2b8d07cfc7f0905714d31 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 96 insertions(+), 29 deletions(-)
apps/web/src/components/ConnectionStatusDot.tsx+1-1| ... | @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ | ... | @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ |
| 17 | {pingClassName ? ( | 17 | {pingClassName ? ( |
| 18 | <span | 18 | <span |
| 19 | className={cn( | 19 | className={cn( |
| 20 | "absolute inline-flex h-full w-full animate-ping rounded-full", | 20 | "absolute inline-flex h-full w-full animate-status-ping rounded-full", |
| 21 | pingClassName, | 21 | pingClassName, |
| 22 | )} | 22 | )} |
| 23 | /> | 23 | /> |
apps/web/src/components/Sidebar.tsx+4-2| ... | @@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr | ... | @@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr |
| 771 | /> | 771 | /> |
| 772 | } | 772 | } |
| 773 | > | 773 | > |
| 774 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | 774 | <TerminalIcon |
| 775 | className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`} | ||
| 776 | /> | ||
| 775 | </TooltipTrigger> | 777 | </TooltipTrigger> |
| 776 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | 778 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 777 | </Tooltip> | 779 | </Tooltip> |
| ... | @@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2226 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> | 2228 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> |
| 2227 | <span | 2229 | <span |
| 2228 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ | 2230 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ |
| 2229 | projectStatus.pulse ? "animate-pulse" : "" | 2231 | projectStatus.pulse ? "animate-status-pulse" : "" |
| 2230 | }`} | 2232 | }`} |
| 2231 | /> | 2233 | /> |
| 2232 | </span> | 2234 | </span> |
apps/web/src/components/ThreadStatusIndicators.tsx+5-3| ... | @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ | ... | @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ |
| 149 | > | 149 | > |
| 150 | <span | 150 | <span |
| 151 | className={`size-[9px] rounded-full ${status.dotClass} ${ | 151 | className={`size-[9px] rounded-full ${status.dotClass} ${ |
| 152 | status.pulse ? "animate-pulse" : "" | 152 | status.pulse ? "animate-status-pulse" : "" |
| 153 | }`} | 153 | }`} |
| 154 | /> | 154 | /> |
| 155 | </TooltipTrigger> | 155 | </TooltipTrigger> |
| ... | @@ -170,7 +170,7 @@ export function ThreadStatusLabel({ | ... | @@ -170,7 +170,7 @@ export function ThreadStatusLabel({ |
| 170 | > | 170 | > |
| 171 | <span | 171 | <span |
| 172 | className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ | 172 | className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${ |
| 173 | status.pulse ? "animate-pulse" : "" | 173 | status.pulse ? "animate-status-pulse" : "" |
| 174 | }`} | 174 | }`} |
| 175 | /> | 175 | /> |
| 176 | <span className="hidden md:inline">{status.label}</span> | 176 | <span className="hidden md:inline">{status.label}</span> |
| ... | @@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma | ... | @@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma |
| 276 | /> | 276 | /> |
| 277 | } | 277 | } |
| 278 | > | 278 | > |
| 279 | <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> | 279 | <TerminalIcon |
| 280 | className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`} | ||
| 281 | /> | ||
| 280 | </TooltipTrigger> | 282 | </TooltipTrigger> |
| 281 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | 283 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 282 | </Tooltip> | 284 | </Tooltip> |
apps/web/src/components/chat/MessagesTimeline.tsx+3-3| ... | @@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin | ... | @@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1055 | <div className="py-0.5 pl-1.5"> | 1055 | <div className="py-0.5 pl-1.5"> |
| 1056 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> | 1056 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> |
| 1057 | <span className="inline-flex items-center gap-[3px]"> | 1057 | <span className="inline-flex items-center gap-[3px]"> |
| 1058 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | 1058 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" /> |
| 1059 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | 1059 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" /> |
| 1060 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | 1060 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" /> |
| 1061 | </span> | 1061 | </span> |
| 1062 | <span> | 1062 | <span> |
| 1063 | {row.createdAt ? ( | 1063 | {row.createdAt ? ( |
apps/web/src/components/preview/AgentBrowserCursor.tsx+1-1| ... | @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { | ... | @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { |
| 66 | {event.phase === "click" ? ( | 66 | {event.phase === "click" ? ( |
| 67 | <span | 67 | <span |
| 68 | key={event.sequence} | 68 | key={event.sequence} |
| 69 | className="absolute left-0.5 top-0.5 size-4 animate-ping rounded-full bg-primary/25 motion-reduce:animate-none" | 69 | className="absolute left-0.5 top-0.5 size-4 animate-status-ping rounded-full bg-primary/25 motion-reduce:animate-none" |
| 70 | /> | 70 | /> |
| 71 | ) : null} | 71 | ) : null} |
| 72 | <MousePointer2 | 72 | <MousePointer2 |
apps/web/src/components/preview/PreviewChromeRow.tsx+1-1| ... | @@ -266,7 +266,7 @@ export function PreviewChromeRow({ | ... | @@ -266,7 +266,7 @@ export function PreviewChromeRow({ |
| 266 | > | 266 | > |
| 267 | <Camera className={cn(recording && "text-destructive")} /> | 267 | <Camera className={cn(recording && "text-destructive")} /> |
| 268 | {recording ? ( | 268 | {recording ? ( |
| 269 | <span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" /> | 269 | <span className="absolute right-0.5 top-0.5 size-1.5 animate-status-pulse rounded-full bg-destructive" /> |
| 270 | ) : null} | 270 | ) : null} |
| 271 | </TooltipTrigger> | 271 | </TooltipTrigger> |
| 272 | <TooltipPopup> | 272 | <TooltipPopup> |
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-1| ... | @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { | ... | @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { |
| 36 | function PulsingDot() { | 36 | function PulsingDot() { |
| 37 | return ( | 37 | return ( |
| 38 | <span aria-label="Listening" className="relative inline-flex size-2 shrink-0"> | 38 | <span aria-label="Listening" className="relative inline-flex size-2 shrink-0"> |
| 39 | <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60" /> | 39 | <span className="absolute inset-0 animate-status-ping rounded-full bg-success opacity-60" /> |
| 40 | <span className="relative inline-flex size-2 rounded-full bg-success" /> | 40 | <span className="relative inline-flex size-2 rounded-full bg-success" /> |
| 41 | </span> | 41 | </span> |
| 42 | ); | 42 | ); |
apps/web/src/components/ui/sidebar.tsx+4-4| ... | @@ -216,7 +216,7 @@ function Sidebar({ | ... | @@ -216,7 +216,7 @@ function Sidebar({ |
| 216 | <SidebarInstanceContext value={instanceContextValue}> | 216 | <SidebarInstanceContext value={instanceContextValue}> |
| 217 | <div | 217 | <div |
| 218 | className={cn( | 218 | className={cn( |
| 219 | "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", | 219 | "flex h-full w-(--sidebar-width) flex-col bg-sidebar surface-grain text-sidebar-foreground", |
| 220 | className, | 220 | className, |
| 221 | )} | 221 | )} |
| 222 | data-slot="sidebar" | 222 | data-slot="sidebar" |
| ... | @@ -234,7 +234,7 @@ function Sidebar({ | ... | @@ -234,7 +234,7 @@ function Sidebar({ |
| 234 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> | 234 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> |
| 235 | <SheetPopup | 235 | <SheetPopup |
| 236 | className={cn( | 236 | className={cn( |
| 237 | "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground", | 237 | "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground", |
| 238 | className, | 238 | className, |
| 239 | )} | 239 | )} |
| 240 | data-mobile="true" | 240 | data-mobile="true" |
| ... | @@ -304,7 +304,7 @@ function Sidebar({ | ... | @@ -304,7 +304,7 @@ function Sidebar({ |
| 304 | {...props} | 304 | {...props} |
| 305 | > | 305 | > |
| 306 | <div | 306 | <div |
| 307 | className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5" | 307 | className="flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5" |
| 308 | data-sidebar="sidebar" | 308 | data-sidebar="sidebar" |
| 309 | data-slot="sidebar-inner" | 309 | data-slot="sidebar-inner" |
| 310 | > | 310 | > |
| ... | @@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { | ... | @@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { |
| 622 | return ( | 622 | return ( |
| 623 | <main | 623 | <main |
| 624 | className={cn( | 624 | className={cn( |
| 625 | "relative flex min-w-0 w-full flex-1 flex-col bg-background", | 625 | "relative flex min-w-0 w-full flex-1 flex-col bg-background surface-grain", |
| 626 | "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5", | 626 | "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5", |
| 627 | className, | 627 | className, |
| 628 | )} | 628 | )} |
apps/web/src/index.css+76-13| ... | @@ -36,6 +36,11 @@ | ... | @@ -36,6 +36,11 @@ |
| 36 | 36 | ||
| 37 | @theme inline { | 37 | @theme inline { |
| 38 | --animate-skeleton: skeleton 2s -1s infinite linear; | 38 | --animate-skeleton: skeleton 2s -1s infinite linear; |
| 39 | /* Duty-cycled indicator animations: long opacity holds with short stepped | ||
| 40 | ramps, so the compositor only produces frames while the value changes | ||
| 41 | (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ | ||
| 42 | --animate-status-pulse: status-pulse 2s infinite; | ||
| 43 | --animate-status-ping: status-ping 2s infinite; | ||
| 39 | --font-sans: | 44 | --font-sans: |
| 40 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, | 45 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, |
| 41 | sans-serif; | 46 | sans-serif; |
| ... | @@ -78,6 +83,36 @@ | ... | @@ -78,6 +83,36 @@ |
| 78 | background-position: -200% 0; | 83 | background-position: -200% 0; |
| 79 | } | 84 | } |
| 80 | } | 85 | } |
| 86 | @keyframes status-pulse { | ||
| 87 | 0%, | ||
| 88 | 40% { | ||
| 89 | opacity: 1; | ||
| 90 | animation-timing-function: steps(6); | ||
| 91 | } | ||
| 92 | 50%, | ||
| 93 | 90% { | ||
| 94 | opacity: 0.5; | ||
| 95 | animation-timing-function: steps(6); | ||
| 96 | } | ||
| 97 | 100% { | ||
| 98 | opacity: 1; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | @keyframes status-ping { | ||
| 102 | /* Burst first (immediate feedback for click ripples), then hold | ||
| 103 | invisible for the rest of the cycle. Mirrors animate-ping's | ||
| 104 | 75%-scale start. */ | ||
| 105 | 0% { | ||
| 106 | opacity: 0.9; | ||
| 107 | scale: 0.75; | ||
| 108 | animation-timing-function: steps(8); | ||
| 109 | } | ||
| 110 | 40%, | ||
| 111 | 100% { | ||
| 112 | opacity: 0; | ||
| 113 | scale: 2; | ||
| 114 | } | ||
| 115 | } | ||
| 81 | } | 116 | } |
| 82 | 117 | ||
| 83 | @layer base { | 118 | @layer base { |
| ... | @@ -151,11 +186,18 @@ | ... | @@ -151,11 +186,18 @@ |
| 151 | padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); | 186 | padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); |
| 152 | } | 187 | } |
| 153 | 188 | ||
| 154 | .chat-composer-glass, | 189 | .chat-composer-glass { |
| 155 | .chat-composer-lower-chrome { | ||
| 156 | background: color-mix(in srgb, var(--card) 20%, transparent); | 190 | background: color-mix(in srgb, var(--card) 20%, transparent); |
| 157 | } | 191 | } |
| 158 | 192 | ||
| 193 | /* Mix from --background (not --card): the lower chrome is a full-width | ||
| 194 | strip over the main view, and a card-tinted wash reads as a lighter | ||
| 195 | seam against it when no content is scrolled underneath. The backdrop | ||
| 196 | blur, not the tint, carries the frosted effect. */ | ||
| 197 | .chat-composer-lower-chrome { | ||
| 198 | background: color-mix(in srgb, var(--background) 20%, transparent); | ||
| 199 | } | ||
| 200 | |||
| 159 | .chat-composer-glass { | 201 | .chat-composer-glass { |
| 160 | box-shadow: | 202 | box-shadow: |
| 161 | 0 18px 48px -20px rgb(0 0 0 / 28%), | 203 | 0 18px 48px -20px rgb(0 0 0 / 28%), |
| ... | @@ -167,11 +209,14 @@ | ... | @@ -167,11 +209,14 @@ |
| 167 | backdrop-filter: blur(16px); | 209 | backdrop-filter: blur(16px); |
| 168 | } | 210 | } |
| 169 | 211 | ||
| 170 | .dark .chat-composer-glass, | 212 | .dark .chat-composer-glass { |
| 171 | .dark .chat-composer-lower-chrome { | ||
| 172 | background: color-mix(in srgb, var(--card) 45%, transparent); | 213 | background: color-mix(in srgb, var(--card) 45%, transparent); |
| 173 | } | 214 | } |
| 174 | 215 | ||
| 216 | .dark .chat-composer-lower-chrome { | ||
| 217 | background: color-mix(in srgb, var(--background) 45%, transparent); | ||
| 218 | } | ||
| 219 | |||
| 175 | .dark .chat-composer-glass { | 220 | .dark .chat-composer-glass { |
| 176 | box-shadow: | 221 | box-shadow: |
| 177 | 0 18px 48px -20px rgb(0 0 0 / 60%), | 222 | 0 18px 48px -20px rgb(0 0 0 / 60%), |
| ... | @@ -192,10 +237,13 @@ | ... | @@ -192,10 +237,13 @@ |
| 192 | } | 237 | } |
| 193 | 238 | ||
| 194 | @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { | 239 | @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { |
| 195 | .chat-composer-glass, | 240 | .chat-composer-glass { |
| 196 | .chat-composer-lower-chrome { | ||
| 197 | background: var(--card); | 241 | background: var(--card); |
| 198 | } | 242 | } |
| 243 | |||
| 244 | .chat-composer-lower-chrome { | ||
| 245 | background: var(--background); | ||
| 246 | } | ||
| 199 | } | 247 | } |
| 200 | } | 248 | } |
| 201 | 249 | ||
| ... | @@ -215,6 +263,15 @@ | ... | @@ -215,6 +263,15 @@ |
| 215 | padding-right: max(env(safe-area-inset-right), 0px); | 263 | padding-right: max(env(safe-area-inset-right), 0px); |
| 216 | } | 264 | } |
| 217 | 265 | ||
| 266 | /* Grain texture for chrome surfaces that float above the body (see the | ||
| 267 | --surface-grain note). Layered over the element's background-color, so it | ||
| 268 | composes with bg-* utilities. */ | ||
| 269 | @utility surface-grain { | ||
| 270 | background-image: var(--surface-grain); | ||
| 271 | background-repeat: repeat; | ||
| 272 | background-size: 256px 256px; | ||
| 273 | } | ||
| 274 | |||
| 218 | /* Suppress all transitions during theme changes */ | 275 | /* Suppress all transitions during theme changes */ |
| 219 | .no-transitions, | 276 | .no-transitions, |
| 220 | .no-transitions *, | 277 | .no-transitions *, |
| ... | @@ -320,13 +377,19 @@ body { | ... | @@ -320,13 +377,19 @@ body { |
| 320 | padding-top: max(env(safe-area-inset-top), 0px); | 377 | padding-top: max(env(safe-area-inset-top), 0px); |
| 321 | } | 378 | } |
| 322 | 379 | ||
| 323 | body::after { | 380 | /* App-chrome grain. Baked into each surface's own background (behind |
| 324 | content: ""; | 381 | content) rather than a fixed overlay on top: a full-viewport overlay |
| 325 | position: fixed; | 382 | forces the compositor to re-blend every frame any animation produces, |
| 326 | inset: 0; | 383 | which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the |
| 327 | pointer-events: none; | 384 | SVG rect instead. Surfaces that float over the body (e.g. the inset main |
| 328 | opacity: 0.035; | 385 | card) must opt in via the surface-grain utility, or they lose the |
| 329 | background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | 386 | grain's subtle brightening and stand out against the body. */ |
| 387 | :root { | ||
| 388 | --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); | ||
| 389 | } | ||
| 390 | |||
| 391 | body { | ||
| 392 | background-image: var(--surface-grain); | ||
| 330 | background-repeat: repeat; | 393 | background-repeat: repeat; |
| 331 | background-size: 256px 256px; | 394 | background-size: 256px 256px; |
| 332 | } | 395 | } |