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:
gsxdsm
2026-06-25 03:28:33 -07:00
parent d984fce0ad
commit 0744ab3814
17 changed files with 178 additions and 76 deletions

View 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.

View File

@@ -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",
]));
});
});

View File

@@ -96,12 +96,6 @@
background: var(--color-success);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1024px) {
.agent-mention-popup {
min-width: 200px;

View File

@@ -112,7 +112,7 @@
margin-top: var(--space-xs);
}
.spin {
.custom-provider-spin {
animation: custom-provider-spin calc(var(--duration-slow) * 4) linear infinite;
}

View File

@@ -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…")}
</>
) : (
<>

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -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) {

View File

@@ -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 ──────────────────────────────────────── */

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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); }
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -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;