| 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 | 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 | /> |
apps/web/src/components/Sidebar.tsx+4-2| ... | ... | @@ -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 | 777 | </TooltipTrigger> |
| 776 | 778 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
| 777 | 779 | </Tooltip> |
| ... | ... | @@ -2226,7 +2228,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 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> |
apps/web/src/components/ThreadStatusIndicators.tsx+5-3| ... | ... | @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ |
| 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({ |
| 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 |
| 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> |
apps/web/src/components/chat/MessagesTimeline.tsx+3-3| ... | ... | @@ -1055,9 +1055,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 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 ? ( |
apps/web/src/components/preview/AgentBrowserCursor.tsx+1-1| ... | ... | @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { |
| 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 |
apps/web/src/components/preview/PreviewChromeRow.tsx+1-1| ... | ... | @@ -266,7 +266,7 @@ export function PreviewChromeRow({ |
| 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> |
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-1| ... | ... | @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { |
| 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 | ); |
apps/web/src/components/ui/sidebar.tsx+4-4| ... | ... | @@ -216,7 +216,7 @@ function Sidebar({ |
| 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({ |
| 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({ |
| 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">) { |
| 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 | )} |
apps/web/src/index.css+76-13| ... | ... | @@ -36,6 +36,11 @@ |
| 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 @@ |
| 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 @@ |
| 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 @@ |
| 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 @@ |
| 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 @@ |
| 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 { |
| 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 | } |