| author | |
| committer | |
| log | 21b9dda5afb00a33e228a68d2ccc885bba7285dc |
| tree | ad20a620ba7321e0e702f239e4a23e767d615c8d |
| parent | d4ba2a1f11498eae9683f6dc95a08b1c17c29765 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>4 files changed, 39 insertions(+), 45 deletions(-)
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+16-10| ... | ... | @@ -3,11 +3,9 @@ |
| 3 | 3 | * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing |
| 4 | 4 | * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. |
| 5 | 5 | * |
| 6 | * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — | |
| 7 | * compositor-safe, but a layer for every bar on screen — and its white highlight over the | |
| 8 | * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the | |
| 9 | * container is a single opacity animation however many bars sit under it, and the bars take | |
| 10 | * their tone from `muted-foreground` at low alpha, which reads on both themes. | |
| 6 | * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on | |
| 7 | * both themes) and the single `animate-skeleton` pulse, applied once on the container so any | |
| 8 | * number of bars costs one opacity animation. | |
| 11 | 9 | */ |
| 12 | 10 | import { cn } from "~/lib/utils"; |
| 13 | 11 | |
| ... | ... | @@ -32,7 +30,7 @@ export function PullRequestListGhost({ |
| 32 | 30 | <div |
| 33 | 31 | role="status" |
| 34 | 32 | aria-label={caption ?? "Loading pull requests"} |
| 35 | className="animate-ghost-pulse space-y-0.5" | |
| 33 | className="motion-safe:animate-skeleton space-y-0.5" | |
| 36 | 34 | > |
| 37 | 35 | {caption ? ( |
| 38 | 36 | <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p> |
| ... | ... | @@ -67,7 +65,7 @@ export function PullRequestDetailGhost() { |
| 67 | 65 | <div |
| 68 | 66 | role="status" |
| 69 | 67 | aria-label="Loading pull request" |
| 70 | className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background" | |
| 68 | className="motion-safe:animate-skeleton flex h-full min-h-0 flex-col overflow-hidden bg-background" | |
| 71 | 69 | > |
| 72 | 70 | <div className="shrink-0 border-b border-border/60"> |
| 73 | 71 | <div className="flex h-7 items-center justify-between gap-3 px-4"> |
| ... | ... | @@ -160,7 +158,11 @@ export function PullRequestDetailGhost() { |
| 160 | 158 | /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ |
| 161 | 159 | export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { |
| 162 | 160 | return ( |
| 163 | <div role="status" aria-label="Loading people" className="animate-ghost-pulse space-y-1 p-1"> | |
| 161 | <div | |
| 162 | role="status" | |
| 163 | aria-label="Loading people" | |
| 164 | className="motion-safe:animate-skeleton space-y-1 p-1" | |
| 165 | > | |
| 164 | 166 | {Array.from({ length: rows }, (_, index) => ( |
| 165 | 167 | <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2"> |
| 166 | 168 | <GhostBar className="size-4 rounded-full" /> |
| ... | ... | @@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { |
| 174 | 176 | /** The timeline's own shape: dots on the rail, a line and a date to each. */ |
| 175 | 177 | export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { |
| 176 | 178 | return ( |
| 177 | <div role="status" aria-label="Loading timeline" className="animate-ghost-pulse px-4 py-5"> | |
| 179 | <div | |
| 180 | role="status" | |
| 181 | aria-label="Loading timeline" | |
| 182 | className="motion-safe:animate-skeleton px-4 py-5" | |
| 183 | > | |
| 178 | 184 | <div className="relative ml-2 border-l border-border/70 pl-5"> |
| 179 | 185 | {Array.from({ length: rows }, (_, index) => ( |
| 180 | 186 | <div key={index} className="relative pb-5"> |
| ... | ... | @@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) { |
| 194 | 200 | <div |
| 195 | 201 | role="status" |
| 196 | 202 | aria-label="Loading pull request conversation" |
| 197 | className="animate-ghost-pulse space-y-4 py-2" | |
| 203 | className="motion-safe:animate-skeleton space-y-4 py-2" | |
| 198 | 204 | > |
| 199 | 205 | {Array.from({ length: rows }, (_, index) => ( |
| 200 | 206 | <div key={index} className="flex items-start gap-2"> |
apps/web/src/components/ui/skeleton.tsx+1-4| ... | ... | @@ -3,10 +3,7 @@ import { cn } from "~/lib/utils"; |
| 3 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { |
| 4 | 4 | return ( |
| 5 | 5 | <div |
| 6 | className={cn( | |
| 7 | "relative overflow-hidden rounded-sm bg-muted [--skeleton-highlight:--alpha(var(--color-white)/64%)] after:absolute after:inset-0 after:animate-skeleton after:bg-[linear-gradient(120deg,transparent_40%,var(--skeleton-highlight),transparent_60%)] motion-reduce:after:content-none dark:[--skeleton-highlight:--alpha(var(--color-white)/4%)]", | |
| 8 | className, | |
| 9 | )} | |
| 6 | className={cn("rounded-sm bg-muted-foreground/15 motion-safe:animate-skeleton", className)} | |
| 10 | 7 | data-slot="skeleton" |
| 11 | 8 | {...props} |
| 12 | 9 | /> |
apps/web/src/components/usage/UsagePage.tsx+17-15| ... | ... | @@ -23,6 +23,7 @@ import { Button } from "../ui/button"; |
| 23 | 23 | import { ScrollArea } from "../ui/scroll-area"; |
| 24 | 24 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 25 | 25 | import { SidebarInset } from "../ui/sidebar"; |
| 26 | import { Skeleton } from "../ui/skeleton"; | |
| 26 | 27 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 27 | 28 | import { |
| 28 | 29 | WorkspaceBreadcrumb, |
| ... | ... | @@ -585,8 +586,9 @@ function UsageDeviceStrip({ |
| 585 | 586 | } |
| 586 | 587 | |
| 587 | 588 | /** |
| 588 | * Static stand-in with the loaded page's shape. No shimmer; blocks fill in | |
| 589 | * exactly once when the last device answers. | |
| 589 | * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it | |
| 590 | * breathes with the same `animate-skeleton` pulse as every other loading state. | |
| 591 | * Blocks fill in exactly once when the last device answers. | |
| 590 | 592 | */ |
| 591 | 593 | function UsageSkeleton() { |
| 592 | 594 | return ( |
| ... | ... | @@ -594,29 +596,29 @@ function UsageSkeleton() { |
| 594 | 596 | <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]"> |
| 595 | 597 | <div className="flex flex-col gap-5"> |
| 596 | 598 | <div className="flex flex-col gap-1"> |
| 597 | <div className="h-10 w-36 rounded-sm bg-muted" /> | |
| 598 | <div className="h-4 w-32 rounded-sm bg-muted" /> | |
| 599 | <Skeleton className="h-10 w-36" /> | |
| 600 | <Skeleton className="h-4 w-32" /> | |
| 599 | 601 | </div> |
| 600 | 602 | {PROVIDER_ORDER.map((provider) => ( |
| 601 | 603 | <div key={provider} className="flex flex-col gap-1"> |
| 602 | 604 | <div className="flex min-h-5 items-center justify-between gap-4"> |
| 603 | 605 | <span className="flex items-center gap-2"> |
| 604 | <span className="size-2 shrink-0 rounded-full bg-muted" /> | |
| 605 | <span className="size-4 shrink-0 rounded-full bg-muted" /> | |
| 606 | <div className="h-3.5 w-20 rounded-sm bg-muted" /> | |
| 606 | <Skeleton className="size-2 shrink-0 rounded-full" /> | |
| 607 | <Skeleton className="size-4 shrink-0 rounded-full" /> | |
| 608 | <Skeleton className="h-3.5 w-20" /> | |
| 607 | 609 | </span> |
| 608 | <div className="h-3.5 w-14 rounded-sm bg-muted" /> | |
| 610 | <Skeleton className="h-3.5 w-14" /> | |
| 609 | 611 | </div> |
| 610 | <div className="h-4 w-36 rounded-sm bg-muted" /> | |
| 612 | <Skeleton className="h-4 w-36" /> | |
| 611 | 613 | </div> |
| 612 | 614 | ))} |
| 613 | 615 | </div> |
| 614 | 616 | |
| 615 | 617 | <div className="flex flex-col gap-3"> |
| 616 | <div className="h-5 w-24 rounded-sm bg-muted" /> | |
| 618 | <Skeleton className="h-5 w-24" /> | |
| 617 | 619 | <div className="flex flex-col gap-1"> |
| 618 | <div className="ml-16 h-56 rounded-sm bg-muted/35" /> | |
| 619 | <div className="ml-16 h-4 rounded-sm bg-muted/35" /> | |
| 620 | <Skeleton className="ml-16 h-56 bg-muted-foreground/10" /> | |
| 621 | <Skeleton className="ml-16 h-4 bg-muted-foreground/10" /> | |
| 620 | 622 | </div> |
| 621 | 623 | </div> |
| 622 | 624 | </section> |
| ... | ... | @@ -628,7 +630,7 @@ function UsageSkeleton() { |
| 628 | 630 | (label) => ( |
| 629 | 631 | <div key={label} className="flex flex-col gap-0.5"> |
| 630 | 632 | <span className="text-xs text-muted-foreground">{label}</span> |
| 631 | <div className="h-6 w-16 rounded-sm bg-muted" /> | |
| 633 | <Skeleton className="h-6 w-16" /> | |
| 632 | 634 | </div> |
| 633 | 635 | ), |
| 634 | 636 | )} |
| ... | ... | @@ -638,9 +640,9 @@ function UsageSkeleton() { |
| 638 | 640 | <section className="flex flex-col gap-3"> |
| 639 | 641 | <div className="flex items-center justify-between gap-3"> |
| 640 | 642 | <h2 className="text-sm font-medium text-foreground">Breakdown</h2> |
| 641 | <div className="h-7 w-28 rounded-lg bg-input/40" /> | |
| 643 | <Skeleton className="h-7 w-28 rounded-lg" /> | |
| 642 | 644 | </div> |
| 643 | <div className="h-44 rounded-sm bg-muted/35" /> | |
| 645 | <Skeleton className="h-44 bg-muted-foreground/10" /> | |
| 644 | 646 | </section> |
| 645 | 647 | </> |
| 646 | 648 | ); |
apps/web/src/index.css+5-16| ... | ... | @@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 145 | 145 | |
| 146 | 146 | @theme inline { |
| 147 | 147 | --color-zinc-25: oklch(99.2% 0 0); |
| 148 | --animate-skeleton: skeleton 2s infinite linear; | |
| 148 | --animate-skeleton: skeleton 2.4s infinite; | |
| 149 | 149 | /* Duty-cycled indicator animations: long holds with stepped ramps, so the |
| 150 | 150 | compositor updates discrete frames instead of every vsync. */ |
| 151 | 151 | --animate-status-pulse: status-pulse 2s infinite; |
| 152 | --animate-ghost-pulse: ghost-pulse 2.4s infinite; | |
| 153 | 152 | --animate-status-ping: status-ping 2s infinite; |
| 154 | 153 | --color-warning-foreground: var(--warning-foreground); |
| 155 | 154 | --color-warning: var(--warning); |
| ... | ... | @@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 207 | 206 | --radius-2xl: calc(var(--radius) + 8px); |
| 208 | 207 | --radius-3xl: calc(var(--radius) + 12px); |
| 209 | 208 | @keyframes skeleton { |
| 210 | /* Transform-only so the highlight sweep stays on the compositor, then a | |
| 211 | long hold with the band parked off-screen instead of a constant shimmer. */ | |
| 212 | 0% { | |
| 213 | transform: translateX(-100%); | |
| 214 | } | |
| 215 | 60%, | |
| 216 | 100% { | |
| 217 | transform: translateX(100%); | |
| 218 | } | |
| 219 | } | |
| 220 | @keyframes ghost-pulse { | |
| 221 | /* The loading ghosts' breath. Stepped like the status indicators, so however many bars a | |
| 222 | ghost holds, the compositor draws a handful of discrete frames per cycle rather than one | |
| 223 | per vsync — which on a 120Hz display is the difference between ~14 and ~288 updates. */ | |
| 209 | /* The single loading-bar breath used by every skeleton: one opacity pulse | |
| 210 | per container, stepped so however many bars sit under it, the compositor | |
| 211 | draws a handful of discrete frames per cycle rather than one per vsync — | |
| 212 | which on a 120Hz display is the difference between ~14 and ~288 updates. */ | |
| 224 | 213 | 0%, |
| 225 | 214 | 42% { |
| 226 | 215 | opacity: 1; |