From d107236da182e5d34e42adc77bd6e00bc24d9207 Mon Sep 17 00:00:00 2001 From: Great-O Date: Tue, 25 Aug 2026 16:44:58 +0100 Subject: [PATCH 01/55] feat(#280): Implement design tokens mapping in loading_spinner_skeleton - Replace hardcoded gray-* Tailwind classes with semantic design tokens: * bg-gray-900 -> bg-surface-card * bg-gray-800 -> bg-surface-field (content placeholders) * border-gray-800 -> border-border-strong * bg-gray-700 -> bg-border-subtle (nested placeholders / contrast layer) - Add data-testid attributes to all skeleton sections for testability - Add 19 comprehensive test cases covering: * Design token validation (no hardcoded gray-* classes remain) * Correct token-to-element mapping across container/header/stats/milestones * Component layout structure (grid, count, padding, animations) * Accessibility attributes (role=status, aria-live, sr-only, aria-hidden) All 436 tests (33 files) pass, including the 19 new assertions. --- __tests__/loading-skeleton.test.tsx | 161 + app/components/LoadingSkeleton.tsx | 42 +- package-lock.json | 12771 +++++++++++++++++--------- 3 files changed, 8507 insertions(+), 4467 deletions(-) create mode 100644 __tests__/loading-skeleton.test.tsx diff --git a/__tests__/loading-skeleton.test.tsx b/__tests__/loading-skeleton.test.tsx new file mode 100644 index 0000000..df18d18 --- /dev/null +++ b/__tests__/loading-skeleton.test.tsx @@ -0,0 +1,161 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import LoadingSkeleton from "@/app/components/LoadingSkeleton"; + +const DESIGN_TOKEN_CLASSES = { + surfaceCard: "bg-surface-card", + surfaceField: "bg-surface-field", + borderSubtle: "bg-border-subtle", + borderStrong: "border-border-strong", +} as const; + +const DEPRECATED_GRAY_PATTERN = /(bg|border)-gray-\d+/; + +describe("LoadingSkeleton — design tokens mapping", () => { + beforeEach(() => { + render(); + }); + + it("does NOT use any hardcoded gray-* utility classes anywhere", () => { + const root = screen.getByTestId("loading-skeleton"); + const allElements = root.querySelectorAll("*"); + + const offenders: { tag: string; classes: string }[] = []; + const checkElement = (el: HTMLElement) => { + const cls = el.getAttribute("class") || ""; + if (DEPRECATED_GRAY_PATTERN.test(cls)) { + offenders.push({ tag: el.tagName, classes: cls }); + } + }; + + checkElement(root); + allElements.forEach(checkElement); + + expect(offenders).toEqual([]); + }); + + it("uses border-strong design token on outer container border", () => { + const container = screen.getByTestId("skeleton-container"); + expect(container.className).toContain(DESIGN_TOKEN_CLASSES.borderStrong); + }); + + it("uses surface-card design token on outer container background", () => { + const container = screen.getByTestId("skeleton-container"); + expect(container.className).toContain(DESIGN_TOKEN_CLASSES.surfaceCard); + }); + + it("uses surface-field design token on header placeholder bars", () => { + const title = screen.getByTestId("skeleton-header-title"); + const subtitle = screen.getByTestId("skeleton-header-subtitle"); + expect(title.className).toContain(DESIGN_TOKEN_CLASSES.surfaceField); + expect(subtitle.className).toContain(DESIGN_TOKEN_CLASSES.surfaceField); + }); + + it("uses surface-field design token for stat cards background", () => { + for (let i = 0; i < 3; i++) { + const card = screen.getByTestId(`skeleton-stat-card-${i}`); + expect(card.className).toContain(DESIGN_TOKEN_CLASSES.surfaceField); + } + }); + + it("uses border-subtle design token for stat label and value placeholders", () => { + for (let i = 0; i < 3; i++) { + const label = screen.getByTestId(`skeleton-stat-label-${i}`); + const value = screen.getByTestId(`skeleton-stat-value-${i}`); + expect(label.className).toContain(DESIGN_TOKEN_CLASSES.borderSubtle); + expect(value.className).toContain(DESIGN_TOKEN_CLASSES.borderSubtle); + } + }); + + it("uses surface-card for milestone card backgrounds", () => { + for (let i = 0; i < 2; i++) { + const card = screen.getByTestId(`skeleton-milestone-card-${i}`); + expect(card.className).toContain(DESIGN_TOKEN_CLASSES.surfaceCard); + } + }); + + it("uses border-strong for milestone card borders", () => { + for (let i = 0; i < 2; i++) { + const card = screen.getByTestId(`skeleton-milestone-card-${i}`); + expect(card.className).toContain(DESIGN_TOKEN_CLASSES.borderStrong); + } + }); + + it("uses surface-field for milestone placeholder bars", () => { + for (let i = 0; i < 2; i++) { + const title = screen.getByTestId(`skeleton-milestone-title-${i}`); + const amount = screen.getByTestId(`skeleton-milestone-amount-${i}`); + expect(title.className).toContain(DESIGN_TOKEN_CLASSES.surfaceField); + expect(amount.className).toContain(DESIGN_TOKEN_CLASSES.surfaceField); + } + }); +}); + +describe("LoadingSkeleton — component layout & structure", () => { + beforeEach(() => { + render(); + }); + + it("renders the root skeleton wrapper", () => { + expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument(); + }); + + it("renders the outer skeleton container with padding and rounded corners", () => { + const container = screen.getByTestId("skeleton-container"); + expect(container.className).toContain("rounded-xl"); + expect(container.className).toContain("p-6"); + }); + + it("renders the stats grid with 3 columns on md+ breakpoint", () => { + const grid = screen.getByTestId("skeleton-stats-grid"); + expect(grid.className).toContain("grid-cols-1"); + expect(grid.className).toContain("md:grid-cols-3"); + expect(grid.className).toContain("gap-4"); + }); + + it("renders exactly 3 stat cards in the stats grid", () => { + for (let i = 0; i < 3; i++) { + expect(screen.getByTestId(`skeleton-stat-card-${i}`)).toBeInTheDocument(); + } + expect(screen.queryByTestId("skeleton-stat-card-3")).not.toBeInTheDocument(); + }); + + it("renders exactly 2 milestone cards in the milestones section", () => { + for (let i = 0; i < 2; i++) { + expect(screen.getByTestId(`skeleton-milestone-card-${i}`)).toBeInTheDocument(); + } + expect(screen.queryByTestId("skeleton-milestone-card-2")).not.toBeInTheDocument(); + }); + + it("has pulse animation on the wrapper", () => { + const root = screen.getByTestId("loading-skeleton"); + expect(root.className).toContain("animate-pulse"); + }); +}); + +describe("LoadingSkeleton — accessibility attributes", () => { + beforeEach(() => { + render(); + }); + + it("exposes role=status on the wrapper for assistive tech", () => { + const root = screen.getByTestId("loading-skeleton"); + expect(root).toHaveAttribute("role", "status"); + }); + + it("exposes aria-live=polite on the wrapper for live region announcements", () => { + const root = screen.getByTestId("loading-skeleton"); + expect(root).toHaveAttribute("aria-live", "polite"); + }); + + it("includes a screen-reader-only loading label", () => { + expect(screen.getByText(/Loading job data/i)).toBeInTheDocument(); + const srLabel = screen.getByText(/Loading job data/i); + expect(srLabel.className).toContain("sr-only"); + }); + + it("marks the decorative skeleton content as aria-hidden", () => { + const container = screen.getByTestId("skeleton-container"); + expect(container).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/app/components/LoadingSkeleton.tsx b/app/components/LoadingSkeleton.tsx index 4b42b2c..182181b 100644 --- a/app/components/LoadingSkeleton.tsx +++ b/app/components/LoadingSkeleton.tsx @@ -1,36 +1,36 @@ export default function LoadingSkeleton() { return ( -
+
Loading job data… -