From 0744ab3814bb445e56f1dd3548adc34a6943e23d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 03:28:33 -0700 Subject: [PATCH] FN-6961: restore dashboard spinner animations Restore Fusion dashboard loading indicators by preventing spinner keyframe collisions. - Move shared spinner utilities onto the collision-proof fusion-spinner-spin keyframe. - Rename component-local spin keyframes and scoped spinner utility overrides so lazy CSS chunks cannot override global loaders. - Extend spinner CSS contract coverage across shared utilities, CSS-only loaders, and renamed loading affordances. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6961-spinner-animation.md | 7 + .../app/__tests__/spinner-animation.css.test.ts | 149 +++++++++++++++++---- .../dashboard/app/components/AgentMentionPopup.css | 6 - .../app/components/CustomProvidersSection.css | 2 +- .../app/components/CustomProvidersSection.tsx | 6 +- .../dashboard/app/components/FileMentionPopup.css | 8 +- .../dashboard/app/components/GitHubImportModal.css | 2 +- packages/dashboard/app/components/Header.css | 4 +- .../dashboard/app/components/IssueMentionPopup.css | 8 +- packages/dashboard/app/components/NodesView.css | 8 +- packages/dashboard/app/components/ScriptsModal.css | 8 +- packages/dashboard/app/components/TaskCard.css | 15 ++- .../dashboard/app/components/TaskDetailModal.css | 4 +- .../dashboard/app/components/TerminalModal.css | 6 - packages/dashboard/app/components/TodoView.css | 4 +- .../app/components/WorkflowResultsTab.css | 4 +- packages/dashboard/app/styles.css | 13 +- 17 files changed, 178 insertions(+), 76 deletions(-) Fusion-Task-Id: FN-6961 Fusion-Task-Lineage: 464e0a50-52c3-4b89-ac72-2f0227d97ce2 --- .changeset/fn-6961-spinner-animation.md | 7 + .../__tests__/spinner-animation.css.test.ts | 149 +++++++++++++++--- .../app/components/AgentMentionPopup.css | 6 - .../app/components/CustomProvidersSection.css | 2 +- .../app/components/CustomProvidersSection.tsx | 6 +- .../app/components/FileMentionPopup.css | 8 +- .../app/components/GitHubImportModal.css | 2 +- packages/dashboard/app/components/Header.css | 4 +- .../app/components/IssueMentionPopup.css | 8 +- .../dashboard/app/components/NodesView.css | 8 +- .../dashboard/app/components/ScriptsModal.css | 8 +- .../dashboard/app/components/TaskCard.css | 15 +- .../app/components/TaskDetailModal.css | 4 +- .../app/components/TerminalModal.css | 6 - .../dashboard/app/components/TodoView.css | 4 +- .../app/components/WorkflowResultsTab.css | 4 +- packages/dashboard/app/styles.css | 13 +- 17 files changed, 178 insertions(+), 76 deletions(-) create mode 100644 .changeset/fn-6961-spinner-animation.md diff --git a/.changeset/fn-6961-spinner-animation.md b/.changeset/fn-6961-spinner-animation.md new file mode 100644 index 0000000000..1993f01d07 --- /dev/null +++ b/.changeset/fn-6961-spinner-animation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore animated loading spinners across the Fusion dashboard. +category: fix +dev: Dashboard spinner utilities now use a collision-proof keyframe and tests guard component CSS chunks. diff --git a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts index 7e982b1028..6dfbbc3093 100644 --- a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts +++ b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts @@ -1,9 +1,13 @@ import React from "react"; import { describe, expect, it } from "vitest"; -import { readFileSync } from "fs"; -import { resolve } from "path"; +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import { basename, extname, join, relative, resolve } from "path"; import { render, screen } from "@testing-library/react"; -import { Loader2 } from "lucide-react"; +import { Loader2, RefreshCw } from "lucide-react"; + +const SHARED_SPINNER_KEYFRAME = "fusion-spinner-spin"; +const CSS_ROOT = resolve(__dirname, ".."); +const COMPONENTS_ROOT = resolve(CSS_ROOT, "components"); function extractBlock(content: string, pattern: RegExp): string { const match = content.match(pattern); @@ -23,44 +27,137 @@ function extractBlock(content: string, pattern: RegExp): string { return content.slice(match!.index!, index); } +function collectCssFiles(root: string): string[] { + return readdirSync(root).flatMap((entry) => { + const absolutePath = join(root, entry); + const stats = statSync(absolutePath); + if (stats.isDirectory()) { + return collectCssFiles(absolutePath); + } + return extname(entry) === ".css" ? [absolutePath] : []; + }); +} + +function assertBlockUsesSharedAnimation(block: string, selector: string): void { + expect(block, selector).toContain(SHARED_SPINNER_KEYFRAME); + expect(block, selector).toContain("transform-origin: center;"); + expect(block, selector).not.toContain("animation: spin"); + expect(block, selector).not.toContain("animation-name: spin"); +} + function assertSharedSpinnerCssContract(css: string): void { - const topLevelSpinBlock = extractBlock(css, /@keyframes\s+spin\s*\{/); - const animateSpinBlock = css.match(/\.animate-spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const spinBlock = css.match(/\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; - const svgSpinnerBlock = css.match(/svg\.animate-spin,\s*svg\.spin\s*\{[\s\S]*?\}/)?.[0] ?? ""; + const sharedKeyframeBlock = extractBlock(css, new RegExp(`@keyframes\\s+${SHARED_SPINNER_KEYFRAME}\\s*\\{`)); + const animateSpinBlock = extractBlock(css, /\.animate-spin\s*\{/); + const spinBlock = extractBlock(css, /\.spin\s*\{/); + const spinnerBlock = extractBlock(css, /\.spinner,\s*\n\.spinning\s*\{/); + const svgSpinnerBlock = extractBlock(css, /svg\.animate-spin,\s*\nsvg\.spin,\s*\nsvg\.spinner,\s*\nsvg\.spinning\s*\{/); - expect(topLevelSpinBlock).toContain("transform: rotate(360deg);"); - expect(css.indexOf("@keyframes spin")).toBeLessThan(css.indexOf(":root {\n --bg:")); + expect(sharedKeyframeBlock).toContain("transform: rotate(360deg);"); + expect(css).not.toMatch(/@keyframes\s+spin\s*\{/); - expect(animateSpinBlock).toContain("animation: spin 1s linear infinite;"); - expect(spinBlock).toContain("animation: spin 1s linear infinite;"); - expect(animateSpinBlock).toContain("transform-origin: center;"); - expect(spinBlock).toContain("transform-origin: center;"); + assertBlockUsesSharedAnimation(animateSpinBlock, ".animate-spin"); + assertBlockUsesSharedAnimation(spinBlock, ".spin"); + assertBlockUsesSharedAnimation(spinnerBlock, ".spinner/.spinning"); - expect(svgSpinnerBlock).toContain("transform-box: view-box;"); - expect(svgSpinnerBlock).not.toContain("transform-box: fill-box;"); + expect(svgSpinnerBlock).toContain("transform-box: fill-box;"); + expect(svgSpinnerBlock).toContain("svg.animate-spin"); + expect(svgSpinnerBlock).toContain("svg.spin"); + expect(svgSpinnerBlock).toContain("svg.spinner"); + expect(svgSpinnerBlock).toContain("svg.spinning"); +} + +function assertRenderedSpinnerClass(className: string): void { + const testId = `spinner-${className}`; + render(React.createElement(Loader2, { className, "data-testid": testId })); + + const spinner = screen.getByTestId(testId); + expect(spinner.tagName.toLowerCase()).toBe("svg"); + expect(spinner).toHaveAttribute("class", expect.stringContaining(className)); + expect(spinner).toHaveAttribute("fill", "none"); + expect(spinner).toHaveAttribute("viewBox", "0 0 24 24"); } describe("global spinner animation utility", () => { - const css = readFileSync(resolve(__dirname, "../styles.css"), "utf8"); + const css = readFileSync(resolve(CSS_ROOT, "styles.css"), "utf8"); - it("keeps the shared spin utility centered and rotating infinitely", () => { + it("keeps every shared utility on the collision-proof spinner keyframe", () => { assertSharedSpinnerCssContract(css); }); - it("keeps the svg spinner contract aligned with lucide stroke-only loaders", () => { - render(React.createElement(Loader2, { className: "animate-spin", "data-testid": "spinner" })); + it("keeps representative lucide svg spinner classes wired for the shared contract", () => { + ["spin", "animate-spin", "spinner", "spinning"].forEach(assertRenderedSpinnerClass); - const spinner = screen.getByTestId("spinner"); - expect(spinner.tagName.toLowerCase()).toBe("svg"); - expect(spinner).toHaveAttribute("class", expect.stringContaining("animate-spin")); - expect(spinner).toHaveAttribute("fill", "none"); - expect(spinner).toHaveAttribute("viewBox", "0 0 24 24"); + render(React.createElement(RefreshCw, { className: "spinning", "data-testid": "refresh-spinner" })); + expect(screen.getByTestId("refresh-spinner")).toHaveAttribute("class", expect.stringContaining("spinning")); }); - it("fails the contract if svg spinners regress back to fill-box anchoring", () => { - const regressedCss = css.replace("transform-box: view-box;", "transform-box: fill-box;"); + it("protects the FN-6916 first-paint svg transform box contract", () => { + expect(() => assertSharedSpinnerCssContract(css.replace("transform-box: fill-box;", "transform-box: view-box;"))).toThrow(); + }); + + it("fails the contract if shared utilities regress back to a generic spin keyframe", () => { + const regressedCss = css + .replaceAll(SHARED_SPINNER_KEYFRAME, "spin") + .replace("@keyframes spin", "@keyframes spin"); expect(() => assertSharedSpinnerCssContract(regressedCss)).toThrow(); }); + + it("keeps component css chunks from defining globally colliding spin keyframes or utility overrides", () => { + const offenders = collectCssFiles(COMPONENTS_ROOT) + .map((file) => ({ file, content: readFileSync(file, "utf8") })) + .filter(({ content }) => /@keyframes\s+spin\s*\{|^\.(?:spin|animate-spin|spinner|spinning)\s*\{/m.test(content)) + .map(({ file }) => relative(CSS_ROOT, file)); + + expect(offenders).toEqual([]); + }); + + it("keeps css-only spinner surfaces on isolated keyframes", () => { + const expectedCssOnlySurfaces = [ + ["components/TerminalModal.css", ".terminal-spinner", "terminal-spin"], + ["components/TaskCard.css", ".card-edit-loading-spinner", "task-card-edit-spinner-spin"], + ["components/FileMentionPopup.css", ".file-mention-popup-loading .spinner", "file-mention-popup-spinner-spin"], + ["components/IssueMentionPopup.css", ".issue-mention-popup-loading .spinner", "issue-mention-popup-spinner-spin"], + ["components/WorkflowResultsTab.css", ".workflow-results-spinner", "workflow-results-spinner-spin"], + ] as const; + + for (const [relativeFile, selector, keyframeName] of expectedCssOnlySurfaces) { + const filePath = resolve(CSS_ROOT, relativeFile); + expect(existsSync(filePath), relativeFile).toBe(true); + const fileCss = readFileSync(filePath, "utf8"); + expect(fileCss, `${relativeFile} defines ${keyframeName}`).toMatch(new RegExp(`@keyframes\\s+${keyframeName}\\s*\\{`)); + expect(fileCss, `${relativeFile} animates ${selector}`).toContain(`animation: ${keyframeName}`); + } + }); + + it("keeps renamed local spinner classes connected to rendered loading affordances", () => { + const customProvidersSource = readFileSync(resolve(COMPONENTS_ROOT, "CustomProvidersSection.tsx"), "utf8"); + const taskCardSource = readFileSync(resolve(COMPONENTS_ROOT, "TaskCard.tsx"), "utf8"); + const terminalModalSource = readFileSync(resolve(COMPONENTS_ROOT, "TerminalModal.tsx"), "utf8"); + + expect(customProvidersSource).toContain('className="custom-provider-spin"'); + expect(taskCardSource).toContain('className="card-edit-loading-spinner"'); + expect(terminalModalSource).toContain('className="terminal-spinner"'); + }); + + it("records the searched dashboard spinner surface inventory", () => { + const surfaceFiles = collectCssFiles(COMPONENTS_ROOT) + .filter((file) => /spinner|spin|keyframes/.test(readFileSync(file, "utf8"))) + .map((file) => basename(file)) + .sort(); + + expect(surfaceFiles).toEqual(expect.arrayContaining([ + "FileMentionPopup.css", + "GitHubImportModal.css", + "Header.css", + "IssueMentionPopup.css", + "NodesView.css", + "ScriptsModal.css", + "TaskCard.css", + "TaskDetailModal.css", + "TerminalModal.css", + "TodoView.css", + "WorkflowResultsTab.css", + ])); + }); }); diff --git a/packages/dashboard/app/components/AgentMentionPopup.css b/packages/dashboard/app/components/AgentMentionPopup.css index 62c55c9e35..583532e530 100644 --- a/packages/dashboard/app/components/AgentMentionPopup.css +++ b/packages/dashboard/app/components/AgentMentionPopup.css @@ -96,12 +96,6 @@ background: var(--color-success); } -@keyframes spin { - to { - transform: rotate(360deg); - } -} - @media (max-width: 1024px) { .agent-mention-popup { min-width: 200px; diff --git a/packages/dashboard/app/components/CustomProvidersSection.css b/packages/dashboard/app/components/CustomProvidersSection.css index b291e549e7..08040f06db 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.css +++ b/packages/dashboard/app/components/CustomProvidersSection.css @@ -112,7 +112,7 @@ margin-top: var(--space-xs); } -.spin { +.custom-provider-spin { animation: custom-provider-spin calc(var(--duration-slow) * 4) linear infinite; } diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 0f2bc0483a..09b1995c1a 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -278,7 +278,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C <> {embedded ? null : loading ? (
-
) : null} @@ -402,7 +402,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C > {detecting ? ( <> - {t("providers.detecting", "Detecting…")} + {t("providers.detecting", "Detecting…")} ) : ( <> @@ -519,7 +519,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C > {detecting ? ( <> - {t("providers.detecting", "Detecting…")} + {t("providers.detecting", "Detecting…")} ) : ( <> diff --git a/packages/dashboard/app/components/FileMentionPopup.css b/packages/dashboard/app/components/FileMentionPopup.css index 75541414ce..76410321be 100644 --- a/packages/dashboard/app/components/FileMentionPopup.css +++ b/packages/dashboard/app/components/FileMentionPopup.css @@ -170,7 +170,13 @@ border: calc(var(--space-xs) / 2) solid var(--border); border-top-color: var(--text-muted); border-radius: 50%; - animation: spin var(--duration-slow) linear infinite; + animation: file-mention-popup-spinner-spin var(--duration-slow) linear infinite; +} + +@keyframes file-mention-popup-spinner-spin { + to { + transform: rotate(360deg); + } } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 96c8a37a95..5f27a3656d 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -1409,7 +1409,7 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t } } -@keyframes spin { +@keyframes github-import-spinner-spin { to { transform: rotate(360deg); } diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index b7e9ad85b8..02848f1f51 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -640,10 +640,10 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind border: 1px solid var(--triage); border-top-color: transparent; border-radius: 50%; - animation: spin 1s linear infinite; + animation: header-status-spinner-spin 1s linear infinite; } -@keyframes spin { +@keyframes header-status-spinner-spin { to { transform: rotate(360deg); } } diff --git a/packages/dashboard/app/components/IssueMentionPopup.css b/packages/dashboard/app/components/IssueMentionPopup.css index f335dd4259..2f859aea05 100644 --- a/packages/dashboard/app/components/IssueMentionPopup.css +++ b/packages/dashboard/app/components/IssueMentionPopup.css @@ -81,7 +81,13 @@ border: calc(var(--space-xs) / 2) solid var(--border); border-top-color: var(--text-muted); border-radius: 50%; - animation: spin var(--duration-slow) linear infinite; + animation: issue-mention-popup-spinner-spin var(--duration-slow) linear infinite; +} + +@keyframes issue-mention-popup-spinner-spin { + to { + transform: rotate(360deg); + } } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/NodesView.css b/packages/dashboard/app/components/NodesView.css index 52527e5235..167fbed5c8 100644 --- a/packages/dashboard/app/components/NodesView.css +++ b/packages/dashboard/app/components/NodesView.css @@ -557,7 +557,7 @@ border: calc(var(--space-xs) / 2) solid transparent; border-top-color: currentColor; border-radius: 50%; - animation: spin var(--nodes-view-spin-duration) linear infinite; + animation: nodes-view-spinner-spin var(--nodes-view-spin-duration) linear infinite; } .nodes-view .node-status-indicator__name { @@ -580,13 +580,13 @@ } } -@keyframes spin { +@keyframes nodes-view-spinner-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } -.spin { - animation: spin var(--nodes-view-spin-duration) linear infinite; +.nodes-view .spin { + animation: nodes-view-spinner-spin var(--nodes-view-spin-duration) linear infinite; } /* ── Nodes View Mobile Responsive ──────────────────────────────────────── */ diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 12873c1a77..7a6c8551cb 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -923,8 +923,8 @@ The Automations sub-header scope button bar should match the Artifacts tab bar s background: color-mix(in srgb, var(--color-info) 5%, var(--card)); } -.spinner { - animation: spin 1s linear infinite; +.scripts-modal .spinner { + animation: scripts-modal-spinner-spin 1s linear infinite; } .routine-card-header { @@ -1658,10 +1658,10 @@ queries already apply (the container-query rule above also fires and produces th } .activity-log-loading .spin { - animation: spin 1s linear infinite; + animation: scripts-modal-spinner-spin 1s linear infinite; } -@keyframes spin { +@keyframes scripts-modal-spinner-spin { from { transform: rotate(0deg); } diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index c7c4b9feda..98b2514e8e 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -1092,7 +1092,7 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i /* Animation for initializing spinner */ -@keyframes spin { +@keyframes task-card-initializing-spinner-spin { from { transform: rotate(0deg); } @@ -1101,10 +1101,15 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i } } -.animate-spin { - animation: spin 1s linear infinite; -} +@keyframes task-card-edit-spinner-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} /* === Card Inline Editing === */ @@ -1429,7 +1434,7 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i border: 2px solid var(--border); border-top-color: var(--todo); border-radius: 50%; - animation: spin 1s linear infinite; + animation: task-card-edit-spinner-spin 1s linear infinite; } .card-edit-loading-text { diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index cffe2727c8..e48f8b8a91 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -1761,10 +1761,10 @@ The footer Actions/Move dropdown buttons sit at the BOTTOM of the embedded panel border: 2px solid var(--border); border-top-color: var(--text-muted); border-radius: 50%; - animation: spin 0.8s linear infinite; + animation: task-detail-spec-spinner-spin 0.8s linear infinite; } -@keyframes spin { +@keyframes task-detail-spec-spinner-spin { to { transform: rotate(360deg); } } diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 8b92177305..34ec260cee 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -819,12 +819,6 @@ FN-6659 keeps the loaded symbols @font-face out of xterm's measured font option animation: terminal-spin 1s linear infinite; } -@keyframes terminal-spin { - to { - transform: rotate(360deg); - } -} - .terminal-shell-name { font-size: 12px; color: var(--text-muted); diff --git a/packages/dashboard/app/components/TodoView.css b/packages/dashboard/app/components/TodoView.css index b2d697ae0d..8378e5aa41 100644 --- a/packages/dashboard/app/components/TodoView.css +++ b/packages/dashboard/app/components/TodoView.css @@ -391,10 +391,10 @@ The descriptive subtitle renders inside ViewHeader's actions slot; mute it so it } .todo-loading-icon { - animation: spin calc(var(--duration-slow) * 4) linear infinite; + animation: todo-loading-spinner-spin calc(var(--duration-slow) * 4) linear infinite; } -@keyframes spin { +@keyframes todo-loading-spinner-spin { from { transform: rotate(0deg); } diff --git a/packages/dashboard/app/components/WorkflowResultsTab.css b/packages/dashboard/app/components/WorkflowResultsTab.css index e75725c40e..58a729dfb4 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.css +++ b/packages/dashboard/app/components/WorkflowResultsTab.css @@ -110,10 +110,10 @@ border: 2px solid var(--border); border-top-color: var(--todo); border-radius: 50%; - animation: spin 0.6s linear infinite; + animation: workflow-results-spinner-spin 0.6s linear infinite; } -@keyframes spin { +@keyframes workflow-results-spinner-spin { to { transform: rotate(360deg); } } diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 73eb2da2e8..198161b938 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -225,22 +225,15 @@ html { } /* -FNXC:LoadingIndicators 2026-06-23-20:40: -Use a uniquely named global spinner keyframe for shared loading utilities. Component CSS files also define `@keyframes spin`; because keyframes are global, later-loaded modal/sidebar CSS can replace the shared `spin` definition and make SVG spinners appear frozen or inconsistent across sections such as Git Manager. +FNXC:LoadingIndicators 2026-06-25-00:00: +Shared loading utilities must animate with the collision-proof `fusion-spinner-spin` keyframe instead of generic global `spin`. Dashboard modal/view CSS chunks load lazily and keyframes are global, so any later `@keyframes spin` can replace a shared spinner's motion and make pending operations look stalled. */ @keyframes fusion-spinner-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } -/* Global spinner animation — used by 25+ components via className="animate-spin" - or inline animation: "spin ...". Must live at the stylesheet top level (not - inside any selector block) so the keyframes and utility class are available - before any task cards are loaded. */ -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} +/* Global spinner animation — used by dashboard-wide Lucide and CSS-only loaders. Must live at the stylesheet top level so the utilities are available before lazy dashboard chunks render. */ .animate-spin { animation: fusion-spinner-spin 1s linear infinite; transform-origin: center;