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
This commit is contained in:
7
.changeset/fn-6961-spinner-animation.md
Normal file
7
.changeset/fn-6961-spinner-animation.md
Normal file
@@ -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.
|
||||
@@ -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",
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,12 +96,6 @@
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.agent-mention-popup {
|
||||
min-width: 200px;
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.spin {
|
||||
.custom-provider-spin {
|
||||
animation: custom-provider-spin calc(var(--duration-slow) * 4) linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
<>
|
||||
{embedded ? null : loading ? (
|
||||
<div className="custom-provider-empty" role="status">
|
||||
<Loader2 aria-hidden="true" className="spin" /> {t("providers.loading", "Loading custom providers…")}
|
||||
<Loader2 aria-hidden="true" className="custom-provider-spin" /> {t("providers.loading", "Loading custom providers…")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -402,7 +402,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
>
|
||||
{detecting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
<Loader2 className="custom-provider-spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -519,7 +519,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
>
|
||||
{detecting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
<Loader2 className="custom-provider-spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user