From b736ac320f3550ac2d408323c57e1655c6555445 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 5 Jun 2026 22:13:55 +0200 Subject: [PATCH] feat(ui): add skeleton loading handoffs - Add reusable shadcn skeleton scanner and reveal primitives - Wrap page-level loading states with skeleton content handoffs - Document skeleton usage through a project skill --- AGENTS.md | 2 + skills/prowler-ui-skeletons/SKILL.md | 60 +++++++++++++++++++ ui/AGENTS.md | 60 ++++++++++--------- ui/app/(prowler)/compliance/page.tsx | 6 +- ui/app/(prowler)/findings/page.tsx | 7 +-- ui/app/(prowler)/invitations/page.tsx | 10 ++-- ui/app/(prowler)/mutelist/page.tsx | 10 ++-- ui/app/(prowler)/page.tsx | 48 ++++++++------- ui/app/(prowler)/providers/page.tsx | 12 ++-- ui/app/(prowler)/resources/page.tsx | 7 +-- ui/app/(prowler)/roles/page.tsx | 10 ++-- ui/app/(prowler)/scans/page.tsx | 7 +-- ui/app/(prowler)/users/page.tsx | 10 ++-- ui/components/shadcn/badge/badge.tsx | 2 +- ui/components/shadcn/index.ts | 2 + ui/components/shadcn/progress.test.tsx | 30 ++++++++++ ui/components/shadcn/progress.tsx | 4 +- .../skeleton/skeleton-boundary.test.tsx | 41 +++++++++++++ .../shadcn/skeleton/skeleton-boundary.tsx | 25 ++++++++ .../skeleton/skeleton-content-reveal.test.tsx | 51 ++++++++++++++++ .../skeleton/skeleton-content-reveal.tsx | 28 +++++++++ .../shadcn/skeleton/skeleton.test.tsx | 34 +++++++++++ ui/components/shadcn/skeleton/skeleton.tsx | 16 ++++- .../shadcn/spinner/loading-state.test.tsx | 18 ++++++ .../shadcn/spinner/loading-state.tsx | 9 ++- ui/components/shadcn/spinner/spinner.tsx | 5 +- ui/components/ui/table/status-badge.test.tsx | 19 +++++- ui/components/ui/table/status-badge.tsx | 5 +- ui/styles/globals.css | 15 +++++ 29 files changed, 455 insertions(+), 98 deletions(-) create mode 100644 skills/prowler-ui-skeletons/SKILL.md create mode 100644 ui/components/shadcn/progress.test.tsx create mode 100644 ui/components/shadcn/skeleton/skeleton-boundary.test.tsx create mode 100644 ui/components/shadcn/skeleton/skeleton-boundary.tsx create mode 100644 ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx create mode 100644 ui/components/shadcn/skeleton/skeleton-content-reveal.tsx create mode 100644 ui/components/shadcn/skeleton/skeleton.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 1cf023889c..da707b6033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Use these skills for detailed patterns on-demand: | `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) | | `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) | | `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) | +| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) | | `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) | | `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) | | `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) | @@ -85,6 +86,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating new skills | `skill-creator` | | Creating or reviewing Django migrations | `django-migration-psql` | | Creating/modifying Prowler UI components | `prowler-ui` | +| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` | | Creating/modifying models, views, serializers | `prowler-api` | | Creating/updating compliance frameworks | `prowler-compliance` | | Debug why a GitHub Actions job is failing | `prowler-ci` | diff --git a/skills/prowler-ui-skeletons/SKILL.md b/skills/prowler-ui-skeletons/SKILL.md new file mode 100644 index 0000000000..071af8c00b --- /dev/null +++ b/skills/prowler-ui-skeletons/SKILL.md @@ -0,0 +1,60 @@ +--- +name: prowler-ui-skeletons +description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly." +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: + - "Creating/modifying skeletons" + - "Creating/modifying loading states" + - "Adding Suspense fallbacks" +--- + +## Activation Contract + +Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition. + +## Hard Rules + +- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons. +- Do not mix HeroUI and shadcn inside the same new loading surface. +- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files. +- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired. +- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton. +- Respect `motion-reduce`; every animation must degrade to no transform/transition. +- Preserve layout stability: skeleton dimensions must match the final content as closely as practical. +- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration. + +## Decision Gates + +| Situation | Action | +| --- | --- | +| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. | +| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. | +| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. | +| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. | +| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. | + +## Execution Steps + +1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback. +2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`. +3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content. +4. Verify reduced-motion classes remain present. +5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries. + +## Output Contract + +Report: +- Which loading surfaces changed. +- Whether each surface is Suspense-boundary or client-state loading. +- Which legacy/HeroUI skeletons were intentionally left untouched. +- Test/typecheck evidence when implementation changes are made. + +## References + +- `ui/components/shadcn/skeleton/skeleton.tsx` +- `ui/components/shadcn/skeleton/skeleton-boundary.tsx` +- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx` diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 38af45860f..03b6897701 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -3,6 +3,7 @@ > **Skills Reference**: For detailed patterns, use these skills: > > - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns +> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions > - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive) > - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces > - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler @@ -19,35 +20,36 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: -| Action | Skill | -| -------------------------------------------------------------- | ------------------- | -| Add changelog entry for a PR or feature | `prowler-changelog` | -| App Router / Server Actions | `nextjs-16` | -| Building AI chat features | `ai-sdk-5` | -| Committing changes | `prowler-commit` | -| Create PR that requires changelog entry | `prowler-changelog` | -| Creating Zod schemas | `zod-4` | -| Creating a git commit | `prowler-commit` | -| Creating/modifying Prowler UI components | `prowler-ui` | -| Fixing bug | `tdd` | -| Implementing feature | `tdd` | -| Modifying component | `tdd` | -| Refactoring code | `tdd` | -| Review changelog format and conventions | `prowler-changelog` | -| Testing hooks or utilities | `vitest` | -| Update CHANGELOG.md in any component | `prowler-changelog` | -| Using Zustand stores | `zustand-5` | -| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | -| Working on task | `tdd` | -| Working with Prowler UI test helpers/pages | `prowler-test-ui` | -| Working with Tailwind classes | `tailwind-4` | -| Writing Playwright E2E tests | `playwright` | -| Writing Prowler UI E2E tests | `prowler-test-ui` | -| Writing React component tests | `vitest` | -| Writing React components | `react-19` | -| Writing TypeScript types/interfaces | `typescript` | -| Writing Vitest tests | `vitest` | -| Writing unit tests for UI | `vitest` | +| Action | Skill | +| ------------------------------------------------------------------- | ---------------------- | +| Add changelog entry for a PR or feature | `prowler-changelog` | +| App Router / Server Actions | `nextjs-16` | +| Building AI chat features | `ai-sdk-5` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying Prowler UI components | `prowler-ui` | +| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` | +| Fixing bug | `tdd` | +| Implementing feature | `tdd` | +| Modifying component | `tdd` | +| Refactoring code | `tdd` | +| Review changelog format and conventions | `prowler-changelog` | +| Testing hooks or utilities | `vitest` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Using Zustand stores | `zustand-5` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working on task | `tdd` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing React component tests | `vitest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing Vitest tests | `vitest` | +| Writing unit tests for UI | `vitest` | --- diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index a67509bed1..eedd7d7253 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -1,5 +1,4 @@ import { Info } from "lucide-react"; -import { Suspense } from "react"; import { getComplianceOverviewMetadataInfo, @@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; import { Card, CardContent } from "@/components/shadcn/card/card"; +import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary"; import { ContentLayout } from "@/components/ui"; import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types"; import { @@ -156,7 +156,7 @@ export default async function Compliance({ )} {/* Row 3: Compliance grid with client-side search */} - @@ -169,7 +169,7 @@ export default async function Compliance({ scanId={selectedScanId} selectedScan={selectedScanData} /> - + ) : ( diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 7ff9780292..64b313542b 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -1,5 +1,3 @@ -import { Suspense } from "react"; - import { adaptFindingGroupsResponse, getFindingGroups, @@ -14,6 +12,7 @@ import { FindingsGroupTable, SkeletonTableFindings, } from "@/components/findings/table"; +import { SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { @@ -111,12 +110,12 @@ export default async function Findings({ } /> - }> + }> - + ); diff --git a/ui/app/(prowler)/invitations/page.tsx b/ui/app/(prowler)/invitations/page.tsx index 57cc268354..48980aceb4 100644 --- a/ui/app/(prowler)/invitations/page.tsx +++ b/ui/app/(prowler)/invitations/page.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { Suspense } from "react"; import { getInvitations } from "@/actions/invitations/invitation"; import { getRoles } from "@/actions/roles"; @@ -10,7 +9,7 @@ import { ColumnsInvitation, SkeletonTableInvitation, } from "@/components/invitations/table"; -import { Button } from "@/components/shadcn"; +import { Button, SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { InvitationProps, Role, SearchParamsProps } from "@/types"; @@ -39,9 +38,12 @@ export default async function Invitations({ - }> + } + > - + ); diff --git a/ui/app/(prowler)/mutelist/page.tsx b/ui/app/(prowler)/mutelist/page.tsx index 97fd2733c2..f479fbb2f7 100644 --- a/ui/app/(prowler)/mutelist/page.tsx +++ b/ui/app/(prowler)/mutelist/page.tsx @@ -1,5 +1,4 @@ -import { Suspense } from "react"; - +import { SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { SearchParamsProps } from "@/types/components"; @@ -18,9 +17,12 @@ export default async function MutelistPage({ }> + } + > - + } /> diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 99f152b4e4..e3738f360a 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,7 +1,6 @@ -import { Suspense } from "react"; - import { getAllProviders } from "@/actions/providers"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { SearchParamsProps } from "@/types"; @@ -47,55 +46,64 @@ export default async function Home({
- }> + } + className="w-full lg:max-w-[312px]" + > - + - }> + } + className="min-w-[312px] flex-1 md:min-w-[380px]" + > - + - }> + } + className="min-w-[312px] flex-1 md:min-w-[380px]" + > - +
- }> + }> - +
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
- }> + }> - +
- }> + }> - +
{/* Charts column: Attack Surface on top, Findings Over Time below */}
- }> + }> - - }> + + }> - +
- }> + }> - +
); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index cb2dae2e8b..43aec0f6db 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,8 +1,6 @@ -import { Suspense } from "react"; - import { ProvidersAccountsView } from "@/components/providers"; import { SkeletonTableProviders } from "@/components/providers/table"; -import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { Skeleton, SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { SearchParamsProps } from "@/types"; @@ -30,20 +28,20 @@ export default async function Providers({ } > - + } providerGroupsContent={ - } > - + } /> diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index fb7e5ab63d..feed0a011d 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,5 +1,3 @@ -import { Suspense } from "react"; - import { getAllProviders } from "@/actions/providers"; import { getLatestMetadataInfo, @@ -11,6 +9,7 @@ import { import { ResourcesFilters } from "@/components/resources/resources-filters"; import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; import { ResourcesTableWithSelection } from "@/components/resources/table"; +import { SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { @@ -86,12 +85,12 @@ export default async function Resources({ uniqueGroups={uniqueGroups} /> - }> + }> - + ); diff --git a/ui/app/(prowler)/roles/page.tsx b/ui/app/(prowler)/roles/page.tsx index 259776c5a3..a59338dd6c 100644 --- a/ui/app/(prowler)/roles/page.tsx +++ b/ui/app/(prowler)/roles/page.tsx @@ -1,12 +1,11 @@ import Link from "next/link"; -import { Suspense } from "react"; import { getRoles } from "@/actions/roles"; import { FilterControls } from "@/components/filters"; import { filterRoles } from "@/components/filters/data-filters"; import { AddIcon } from "@/components/icons"; import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table"; -import { Button } from "@/components/shadcn"; +import { Button, SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { SearchParamsProps } from "@/types"; @@ -34,9 +33,12 @@ export default async function Roles({ - }> + } + > - + ); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 62aba551ae..742d536052 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,5 +1,3 @@ -import { Suspense } from "react"; - import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; @@ -12,6 +10,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell"; import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; +import { SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { ProviderProps, @@ -88,7 +87,7 @@ export default async function Scans({ hasManageScansPermission={hasManageScansPermission} activeScanCount={activeScanCount} > - - + )} diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 4b26c0baf2..860db010bf 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -1,12 +1,11 @@ import Link from "next/link"; -import { Suspense } from "react"; import { getRoles } from "@/actions/roles/roles"; import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users"; import { auth } from "@/auth.config"; import { FilterControls } from "@/components/filters"; import { AddIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; +import { Button, SkeletonBoundary } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; import { DataTable } from "@/components/ui/table"; import { ColumnsUser, SkeletonTableUser } from "@/components/users/table"; @@ -35,9 +34,12 @@ export default async function Users({ - }> + } + > - + ); diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index f200fda7e2..35eff00857 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -5,7 +5,7 @@ import { ComponentProps } from "react"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none overflow-hidden", { variants: { variant: { diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index 91c08ef72e..b9743d92cd 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -20,6 +20,8 @@ export * from "./select/multiselect"; export * from "./select/select"; export * from "./separator/separator"; export * from "./skeleton/skeleton"; +export * from "./skeleton/skeleton-boundary"; +export * from "./skeleton/skeleton-content-reveal"; export * from "./tabs/generic-tabs"; export * from "./tabs/tabs"; export * from "./textarea/textarea"; diff --git a/ui/components/shadcn/progress.test.tsx b/ui/components/shadcn/progress.test.tsx new file mode 100644 index 0000000000..5f0bb64874 --- /dev/null +++ b/ui/components/shadcn/progress.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Progress } from "./progress"; + +describe("Progress", () => { + it("animates progress value changes with a transform-only transition", () => { + // Given + render(); + + // When + const root = screen.getByRole("progressbar", { name: /scan progress/i }); + const indicator = root.querySelector("[data-slot='progress-indicator']"); + + // Then + expect(root).toHaveClass( + "transition-colors", + "duration-200", + "ease-out", + "motion-reduce:transition-none", + ); + expect(indicator).toHaveClass( + "transition-transform", + "duration-300", + "ease-out", + "motion-reduce:transition-none", + ); + expect(indicator).toHaveStyle({ transform: "translateX(-60%)" }); + }); +}); diff --git a/ui/components/shadcn/progress.tsx b/ui/components/shadcn/progress.tsx index 3a549f4004..c92d456c1d 100644 --- a/ui/components/shadcn/progress.tsx +++ b/ui/components/shadcn/progress.tsx @@ -22,7 +22,7 @@ function Progress({ data-slot="progress" value={normalizedValue} className={cn( - "border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border", + "border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border transition-colors duration-200 ease-out motion-reduce:transition-none", className, )} {...props} @@ -30,7 +30,7 @@ function Progress({ { + it("wraps resolved content with the shared skeleton reveal", () => { + // Given + render( + }> +
Ready
+
, + ); + + // When + const reveal = screen.getByTestId("skeleton-content-reveal"); + + // Then + expect(screen.getByLabelText("Resolved content")).toBeInTheDocument(); + expect(reveal).toHaveAttribute("data-motion", "skeleton-content-handoff"); + }); + + it("forwards className to the reveal wrapper", () => { + // Given + render( + } + className="custom-boundary" + > + Ready + , + ); + + // When + const reveal = screen.getByTestId("skeleton-content-reveal"); + + // Then + expect(reveal).toHaveClass("custom-boundary"); + }); +}); diff --git a/ui/components/shadcn/skeleton/skeleton-boundary.tsx b/ui/components/shadcn/skeleton/skeleton-boundary.tsx new file mode 100644 index 0000000000..8950ad5e13 --- /dev/null +++ b/ui/components/shadcn/skeleton/skeleton-boundary.tsx @@ -0,0 +1,25 @@ +import { ReactNode, Suspense } from "react"; + +import { SkeletonContentReveal } from "./skeleton-content-reveal"; + +interface SkeletonBoundaryProps { + children: ReactNode; + fallback: ReactNode; + className?: string; +} + +function SkeletonBoundary({ + children, + fallback, + className, +}: SkeletonBoundaryProps) { + return ( + + + {children} + + + ); +} + +export { SkeletonBoundary }; diff --git a/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx b/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx new file mode 100644 index 0000000000..d0722f4859 --- /dev/null +++ b/ui/components/shadcn/skeleton/skeleton-content-reveal.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SkeletonContentReveal } from "./skeleton-content-reveal"; + +describe("SkeletonContentReveal", () => { + it("reveals streamed content with insertion-time CSS motion", () => { + // Given + render( + +
Ready
+
, + ); + + // When + const wrapper = screen.getByTestId("skeleton-content-reveal"); + + // Then + expect(screen.getByLabelText("Loaded content")).toBeInTheDocument(); + expect(wrapper).toHaveAttribute("data-motion", "skeleton-content-handoff"); + expect(wrapper).toHaveClass( + "transition-[opacity,transform]", + "duration-700", + "starting:opacity-0", + "starting:translate-y-3", + "opacity-100", + "translate-y-0", + "motion-reduce:transform-none", + "motion-reduce:transition-none", + ); + }); + + it("merges caller classes without dropping the motion contract", () => { + // Given + render( + + Ready + , + ); + + // When + const wrapper = screen.getByTestId("skeleton-content-reveal"); + + // Then + expect(wrapper).toHaveClass( + "custom-reveal", + "transition-[opacity,transform]", + "starting:opacity-0", + ); + }); +}); diff --git a/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx b/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx new file mode 100644 index 0000000000..2f49efadd1 --- /dev/null +++ b/ui/components/shadcn/skeleton/skeleton-content-reveal.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +interface SkeletonContentRevealProps { + children: ReactNode; + className?: string; +} + +function SkeletonContentReveal({ + children, + className, +}: SkeletonContentRevealProps) { + return ( +
+ {children} +
+ ); +} + +export { SkeletonContentReveal }; diff --git a/ui/components/shadcn/skeleton/skeleton.test.tsx b/ui/components/shadcn/skeleton/skeleton.test.tsx new file mode 100644 index 0000000000..fc86daafc2 --- /dev/null +++ b/ui/components/shadcn/skeleton/skeleton.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Skeleton } from "./skeleton"; + +describe("Skeleton", () => { + it("uses a subtle scanner animation that respects reduced motion", () => { + // Given + render(); + + // When + const skeleton = screen.getByLabelText("Loading providers"); + + // Then + expect(skeleton).toHaveClass( + "relative", + "overflow-hidden", + "bg-border-neutral-tertiary", + "transition-colors", + "duration-500", + "ease-out", + ); + + const scanner = skeleton.querySelector("[data-slot='skeleton-scanner']"); + expect(scanner).toHaveClass( + "animate-skeleton-scan", + "bg-gradient-to-r", + "from-transparent", + "via-white/10", + "to-transparent", + "motion-reduce:hidden", + ); + }); +}); diff --git a/ui/components/shadcn/skeleton/skeleton.tsx b/ui/components/shadcn/skeleton/skeleton.tsx index 34971c0554..e535d552c0 100644 --- a/ui/components/shadcn/skeleton/skeleton.tsx +++ b/ui/components/shadcn/skeleton/skeleton.tsx @@ -1,15 +1,25 @@ import { cn } from "@/lib/utils"; -function Skeleton({ className, ...props }: React.ComponentProps<"div">) { +function Skeleton({ + className, + children, + ...props +}: React.ComponentProps<"div">) { return (
+ > + + {children} +
); } diff --git a/ui/components/shadcn/spinner/loading-state.test.tsx b/ui/components/shadcn/spinner/loading-state.test.tsx index c2cddc59e7..ba1494297b 100644 --- a/ui/components/shadcn/spinner/loading-state.test.tsx +++ b/ui/components/shadcn/spinner/loading-state.test.tsx @@ -33,4 +33,22 @@ describe("LoadingState", () => { const { container } = render(); expect(container.firstChild).toHaveClass("custom-wrapper"); }); + + it("animates the loading state entry and label color subtly", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass( + "animate-in", + "fade-in-0", + "duration-200", + "ease-out", + "motion-reduce:animate-none", + "motion-reduce:transition-none", + ); + expect(screen.getByText("Loading findings...")).toHaveClass( + "transition-colors", + "duration-200", + "ease-out", + "motion-reduce:transition-none", + ); + }); }); diff --git a/ui/components/shadcn/spinner/loading-state.tsx b/ui/components/shadcn/spinner/loading-state.tsx index 77355c840f..b4b9d72a37 100644 --- a/ui/components/shadcn/spinner/loading-state.tsx +++ b/ui/components/shadcn/spinner/loading-state.tsx @@ -17,11 +17,16 @@ export function LoadingState({ }: LoadingStateProps) { return (
{label && ( - {label} + + {label} + )}
); diff --git a/ui/components/shadcn/spinner/spinner.tsx b/ui/components/shadcn/spinner/spinner.tsx index 7ded4c58c7..3ff7654345 100644 --- a/ui/components/shadcn/spinner/spinner.tsx +++ b/ui/components/shadcn/spinner/spinner.tsx @@ -19,7 +19,10 @@ interface SpinnerProps { export function Spinner({ className }: SpinnerProps) { return ( { ); it("renders the executing state with spinner and progress percentage", () => { - render(); + const { container } = render( + , + ); expect(screen.getByText("executing")).toBeInTheDocument(); expect(screen.getByText("42%")).toBeInTheDocument(); + expect(container.querySelector("svg")).toHaveClass( + "animate-spin", + "motion-reduce:animate-none", + ); + }); + + it("animates status color changes without layout motion", () => { + const { container } = render(); + const badge = container.querySelector("[data-slot='badge']"); + expect(badge).toHaveClass( + "transition-[background-color,border-color,color,box-shadow]", + "duration-200", + "ease-out", + "motion-reduce:transition-none", + ); }); it("omits progress when loadingProgress is not provided", () => { diff --git a/ui/components/ui/table/status-badge.tsx b/ui/components/ui/table/status-badge.tsx index dbde046f21..39a57ea096 100644 --- a/ui/components/ui/table/status-badge.tsx +++ b/ui/components/ui/table/status-badge.tsx @@ -68,7 +68,10 @@ export const StatusBadge = ({ > {status === "executing" ? ( - + {loadingProgress !== undefined && ( {loadingProgress}% )} diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 32391a2099..a751cb3b4e 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -409,6 +409,21 @@ transform-box: fill-box; transform-origin: center; } + + @keyframes skeletonScan { + 0% { + transform: translateX(0); + } + + 55%, + 100% { + transform: translateX(300%); + } + } + + .animate-skeleton-scan { + animation: skeletonScan 2.6s ease-in-out infinite; + } } /* ===== BASE LAYER ===== */