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