From 6b3734e9fe2b232487f151d6c23833ad63046a81 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Wed, 8 Jul 2026 12:35:07 -0700 Subject: [PATCH 1/2] fix: restore visible loading spinner animation The Loader ring rendered as a uniform circle because tailwind-merge put border-b-transparent and the all-sides color class in the same border-color conflict group, dropping the transparent bottom. Emit per-side color classes (border-t/border-x) so the transparent bottom survives the merge. Also tighten the color prop to the shared six-color palette, fixing two call sites that passed unsupported values (white, primary) and silently fell back to gray. --- .../components/Nuvs/NuvsBlastPending.tsx | 2 +- apps/web/src/base/Loader.tsx | 21 ++++++----- apps/web/src/base/__tests__/Loader.test.tsx | 37 +++++++++++++++++++ .../samples/components/Tag/WorkflowTag.tsx | 2 +- 4 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/base/__tests__/Loader.test.tsx diff --git a/apps/web/src/analyses/components/Nuvs/NuvsBlastPending.tsx b/apps/web/src/analyses/components/Nuvs/NuvsBlastPending.tsx index b427c18e2..cff16780a 100644 --- a/apps/web/src/analyses/components/Nuvs/NuvsBlastPending.tsx +++ b/apps/web/src/analyses/components/Nuvs/NuvsBlastPending.tsx @@ -50,7 +50,7 @@ function RidTiming({ interval, lastCheckedAt }) { export default function NuvsBlastPending({ interval, lastCheckedAt, rid }) { return ( - +
BLAST in progress diff --git a/apps/web/src/base/Loader.tsx b/apps/web/src/base/Loader.tsx index e6b4c7899..a6515d39e 100644 --- a/apps/web/src/base/Loader.tsx +++ b/apps/web/src/base/Loader.tsx @@ -1,22 +1,25 @@ import { cn } from "@app/utils"; -const colorToClass: Record = { - blue: "border-blue-600", - green: "border-green-600", - grey: "border-gray-400", - greyDark: "border-gray-500", - red: "border-red-600", +type LoaderColor = "blue" | "green" | "gray" | "orange" | "purple" | "red"; + +const colorToClass: Record = { + blue: "border-t-blue-600 border-x-blue-600", + green: "border-t-green-600 border-x-green-600", + gray: "border-t-gray-500 border-x-gray-500", + orange: "border-t-orange-600 border-x-orange-600", + purple: "border-t-purple-600 border-x-purple-600", + red: "border-t-red-600 border-x-red-600", }; interface LoaderProps { className?: string; - color?: string; + color?: LoaderColor; size?: string; } export default function Loader({ className, - color = "greyDark", + color = "gray", size = "22px", }: LoaderProps) { return ( @@ -25,7 +28,7 @@ export default function Loader({ aria-label="loading" className={cn( "animate-rotate inline-block rounded-full border-2 border-b-transparent", - colorToClass[color] || "border-gray-500", + colorToClass[color], className, )} style={{ width: size, height: size }} diff --git a/apps/web/src/base/__tests__/Loader.test.tsx b/apps/web/src/base/__tests__/Loader.test.tsx new file mode 100644 index 000000000..cb24dcb06 --- /dev/null +++ b/apps/web/src/base/__tests__/Loader.test.tsx @@ -0,0 +1,37 @@ +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "@tests/setup.js"; +import { describe, expect, it } from "vitest"; +import Loader from "../Loader"; + +describe("", () => { + it("keeps a transparent bottom border so the spin is visible", () => { + renderWithProviders(); + + const loader = screen.getByRole("status", { name: "loading" }); + + expect(loader).toHaveClass("border-b-transparent"); + expect(loader).toHaveClass("animate-rotate"); + }); + + it("keeps the transparent bottom border across every color", () => { + const colors = [ + ["blue", "border-t-blue-600"], + ["green", "border-t-green-600"], + ["gray", "border-t-gray-500"], + ["orange", "border-t-orange-600"], + ["purple", "border-t-purple-600"], + ["red", "border-t-red-600"], + ] as const; + + for (const [color, expectedClass] of colors) { + const { unmount } = renderWithProviders(); + + const loader = screen.getByRole("status", { name: "loading" }); + + expect(loader).toHaveClass("border-b-transparent"); + expect(loader).toHaveClass(expectedClass); + + unmount(); + } + }); +}); diff --git a/apps/web/src/samples/components/Tag/WorkflowTag.tsx b/apps/web/src/samples/components/Tag/WorkflowTag.tsx index e72e1fc9e..983c61788 100644 --- a/apps/web/src/samples/components/Tag/WorkflowTag.tsx +++ b/apps/web/src/samples/components/Tag/WorkflowTag.tsx @@ -25,7 +25,7 @@ export default function WorkflowTag({ {workflowState === "pending" ? ( - + ) : ( )} From 2264bb42aaaa2344a96ef634d49c98797ca23046 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Wed, 8 Jul 2026 12:45:02 -0700 Subject: [PATCH 2/2] test: derive Loader test expectations from colorToClass Avoids duplicating the Tailwind class strings in the test so palette changes to Loader don't require updating hardcoded expectations. --- apps/web/src/base/Loader.tsx | 10 ++++++++-- apps/web/src/base/__tests__/Loader.test.tsx | 19 +++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/web/src/base/Loader.tsx b/apps/web/src/base/Loader.tsx index a6515d39e..4a8b4c9ce 100644 --- a/apps/web/src/base/Loader.tsx +++ b/apps/web/src/base/Loader.tsx @@ -1,8 +1,14 @@ import { cn } from "@app/utils"; -type LoaderColor = "blue" | "green" | "gray" | "orange" | "purple" | "red"; +export type LoaderColor = + | "blue" + | "green" + | "gray" + | "orange" + | "purple" + | "red"; -const colorToClass: Record = { +export const colorToClass: Record = { blue: "border-t-blue-600 border-x-blue-600", green: "border-t-green-600 border-x-green-600", gray: "border-t-gray-500 border-x-gray-500", diff --git a/apps/web/src/base/__tests__/Loader.test.tsx b/apps/web/src/base/__tests__/Loader.test.tsx index cb24dcb06..24ab45d45 100644 --- a/apps/web/src/base/__tests__/Loader.test.tsx +++ b/apps/web/src/base/__tests__/Loader.test.tsx @@ -1,7 +1,7 @@ import { screen } from "@testing-library/react"; import { renderWithProviders } from "@tests/setup.js"; import { describe, expect, it } from "vitest"; -import Loader from "../Loader"; +import Loader, { colorToClass } from "../Loader"; describe("", () => { it("keeps a transparent bottom border so the spin is visible", () => { @@ -14,22 +14,17 @@ describe("", () => { }); it("keeps the transparent bottom border across every color", () => { - const colors = [ - ["blue", "border-t-blue-600"], - ["green", "border-t-green-600"], - ["gray", "border-t-gray-500"], - ["orange", "border-t-orange-600"], - ["purple", "border-t-purple-600"], - ["red", "border-t-red-600"], - ] as const; - - for (const [color, expectedClass] of colors) { + for (const color of Object.keys( + colorToClass, + ) as (keyof typeof colorToClass)[]) { const { unmount } = renderWithProviders(); const loader = screen.getByRole("status", { name: "loading" }); expect(loader).toHaveClass("border-b-transparent"); - expect(loader).toHaveClass(expectedClass); + for (const expectedClass of colorToClass[color].split(" ")) { + expect(loader).toHaveClass(expectedClass); + } unmount(); }