Unified · Split
| @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ · 16 unmodified lines | ||
| 17 | 17 | {pingClassName ? ( |
| 18 | 18 | <span |
| 19 | 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 | 21 | pingClassName, |
| 22 | 22 | )} |
| 23 | 23 | /> |
| @@ -771,7 +771,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr · 770 unmodified lines | ||
| 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 | 777 | </TooltipTrigger> |
| 776 | 778 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 777 | 779 | </Tooltip> |
| @@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec · 1448 unmodified lines | ||
| 2226 | 2228 | <span className="absolute inset-0 flex items-center justify-center transition-opacity duration-150 group-hover/project-header:opacity-0"> |
| 2227 | 2229 | <span |
| 2228 | 2230 | className={`size-[9px] rounded-full ${projectStatus.dotClass} ${ |
| 2229 | − projectStatus.pulse ? "animate-pulse" : "" | |
| 2231 | + projectStatus.pulse ? "animate-status-pulse" : "" | |
| 2230 | 2232 | }`} |
| 2231 | 2233 | /> |
| 2232 | 2234 | </span> |
| @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ · 148 unmodified lines | ||
| 149 | 149 | > |
| 150 | 150 | <span |
| 151 | 151 | className={`size-[9px] rounded-full ${status.dotClass} ${ |
| 152 | − status.pulse ? "animate-pulse" : "" | |
| 152 | + status.pulse ? "animate-status-pulse" : "" | |
| 153 | 153 | }`} |
| 154 | 154 | /> |
| 155 | 155 | </TooltipTrigger> |
| @@ -170,7 +170,7 @@ export function ThreadStatusLabel({ · 14 unmodified lines | ||
| 170 | 170 | > |
| 171 | 171 | <span |
| 172 | 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 | 176 | <span className="hidden md:inline">{status.label}</span> |
| @@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma · 99 unmodified lines | ||
| 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 | 282 | </TooltipTrigger> |
| 281 | 283 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 282 | 284 | </Tooltip> |
| @@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin · 1054 unmodified lines | ||
| 1055 | 1055 | <div className="py-0.5 pl-1.5"> |
| 1056 | 1056 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums"> |
| 1057 | 1057 | <span className="inline-flex items-center gap-[3px]"> |
| 1058 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 1059 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 1060 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 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-status-pulse [animation-delay:200ms]" /> | |
| 1060 | + <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" /> | |
| 1061 | 1061 | </span> |
| 1062 | 1062 | <span> |
| 1063 | 1063 | {row.createdAt ? ( |
| @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { · 65 unmodified lines | ||
| 66 | 66 | {event.phase === "click" ? ( |
| 67 | 67 | <span |
| 68 | 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 | 71 | ) : null} |
| 72 | 72 | <MousePointer2 |
| @@ -266,7 +266,7 @@ export function PreviewChromeRow({ · 265 unmodified lines | ||
| 266 | 266 | > |
| 267 | 267 | <Camera className={cn(recording && "text-destructive")} /> |
| 268 | 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 | 270 | ) : null} |
| 271 | 271 | </TooltipTrigger> |
| 272 | 272 | <TooltipPopup> |
| @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { · 35 unmodified lines | ||
| 36 | 36 | function PulsingDot() { |
| 37 | 37 | return ( |
| 38 | 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 | 40 | <span className="relative inline-flex size-2 rounded-full bg-success" /> |
| 41 | 41 | </span> |
| 42 | 42 | ); |
| @@ -216,7 +216,7 @@ function Sidebar({ · 215 unmodified lines | ||
| 216 | 216 | <SidebarInstanceContext value={instanceContextValue}> |
| 217 | 217 | <div |
| 218 | 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 | 220 | className, |
| 221 | 221 | )} |
| 222 | 222 | data-slot="sidebar" |
| @@ -234,7 +234,7 @@ function Sidebar({ · 11 unmodified lines | ||
| 234 | 234 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> |
| 235 | 235 | <SheetPopup |
| 236 | 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 | 238 | className, |
| 239 | 239 | )} |
| 240 | 240 | data-mobile="true" |
| @@ -304,7 +304,7 @@ function Sidebar({ · 63 unmodified lines | ||
| 304 | 304 | {...props} |
| 305 | 305 | > |
| 306 | 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 | 308 | data-sidebar="sidebar" |
| 309 | 309 | data-slot="sidebar-inner" |
| 310 | 310 | > |
| @@ -622,7 +622,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { · 311 unmodified lines | ||
| 622 | 622 | return ( |
| 623 | 623 | <main |
| 624 | 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 | 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 | 627 | className, |
| 628 | 628 | )} |
| @@ -36,6 +36,11 @@ · 35 unmodified lines | ||
| 36 | 36 | |
| 37 | 37 | @theme inline { |
| 38 | 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 | 44 | --font-sans: |
| 40 | 45 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, |
| 41 | 46 | sans-serif; |
| @@ -78,6 +83,36 @@ · 36 unmodified lines | ||
| 78 | 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 | 118 | @layer base { |
| @@ -151,11 +186,18 @@ · 67 unmodified lines | ||
| 151 | 186 | padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); |
| 152 | 187 | } |
| 153 | 188 | |
| 154 | − .chat-composer-glass, | |
| 155 | − .chat-composer-lower-chrome { | |
| 189 | + .chat-composer-glass { | |
| 156 | 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 | 201 | .chat-composer-glass { |
| 160 | 202 | box-shadow: |
| 161 | 203 | 0 18px 48px -20px rgb(0 0 0 / 28%), |
| @@ -167,11 +209,14 @@ · 5 unmodified lines | ||
| 167 | 209 | backdrop-filter: blur(16px); |
| 168 | 210 | } |
| 169 | 211 | |
| 170 | − .dark .chat-composer-glass, | |
| 171 | − .dark .chat-composer-lower-chrome { | |
| 212 | + .dark .chat-composer-glass { | |
| 172 | 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 | 220 | .dark .chat-composer-glass { |
| 176 | 221 | box-shadow: |
| 177 | 222 | 0 18px 48px -20px rgb(0 0 0 / 60%), |
| @@ -192,10 +237,13 @@ · 14 unmodified lines | ||
| 192 | 237 | } |
| 193 | 238 | |
| 194 | 239 | @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { |
| 195 | − .chat-composer-glass, | |
| 196 | − .chat-composer-lower-chrome { | |
| 240 | + .chat-composer-glass { | |
| 197 | 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 @@ · 13 unmodified lines | ||
| 215 | 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 | 275 | /* Suppress all transitions during theme changes */ |
| 219 | 276 | .no-transitions, |
| 220 | 277 | .no-transitions *, |
| @@ -320,13 +377,19 @@ body { · 99 unmodified lines | ||
| 320 | 377 | padding-top: max(env(safe-area-inset-top), 0px); |
| 321 | 378 | } |
| 322 | 379 | |
| 323 | −body::after { | |
| 324 | − content: ""; | |
| 325 | − position: fixed; | |
| 326 | − inset: 0; | |
| 327 | − pointer-events: none; | |
| 328 | − opacity: 0.035; | |
| 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"); | |
| 380 | +/* App-chrome grain. Baked into each surface's own background (behind | |
| 381 | + content) rather than a fixed overlay on top: a full-viewport overlay | |
| 382 | + forces the compositor to re-blend every frame any animation produces, | |
| 383 | + which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the | |
| 384 | + SVG rect instead. Surfaces that float over the body (e.g. the inset main | |
| 385 | + card) must opt in via the surface-grain utility, or they lose the | |
| 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 | 393 | background-repeat: repeat; |
| 331 | 394 | background-size: 256px 256px; |
| 332 | 395 | } |