diff --git a/.changeset/fix-frozen-dashboard-animations.md b/.changeset/fix-frozen-dashboard-animations.md new file mode 100644 index 0000000000..8f75f4bdd2 --- /dev/null +++ b/.changeset/fix-frozen-dashboard-animations.md @@ -0,0 +1,11 @@ +--- +"@fusion/dashboard": patch +--- + +Unfreeze dashboard spinners and pulse/enter animations. Transition tokens +(`--transition-slow: 0.3s ease`) bundle a duration and an easing; 15 animation +declarations reused them as bare durations, which made the whole `animation` +declaration invalid at computed-value time and silently resolved it to +`animation: none`. Animation rules now use new duration-only tokens +(`--duration-instant/fast/normal/slow`), with the transition tokens derived +from them, and a repo-wide CSS regression test forbids the pattern. diff --git a/packages/dashboard/app/__tests__/animation-duration-tokens.css.test.ts b/packages/dashboard/app/__tests__/animation-duration-tokens.css.test.ts new file mode 100644 index 0000000000..2d8b307c1a --- /dev/null +++ b/packages/dashboard/app/__tests__/animation-duration-tokens.css.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "fs"; +import { join, relative, resolve } from "path"; + +/** + * Transition tokens (--transition-fast/normal/slow/instant) bundle a duration + * AND an easing keyword (e.g. "0.3s ease"). They are only valid where the + * `transition` shorthand expects that pair. + * + * Reusing them as bare durations inside `animation` shorthands silently kills + * the animation: substituting "0.3s ease" next to an explicit easing keyword + * (`linear`, `ease-in-out`, ...) produces two values, which + * makes the whole declaration invalid at computed-value time — the browser + * resolves it to `animation: none` with no console error. Wrapping the token + * in `calc()` fails the same way (you cannot multiply "0.3s ease"). + * + * This froze 14 dashboard spinners/pulses (FN-5855/FN-5913 follow-up). + * Animation durations must use the duration-only tokens (--duration-*). + */ + +const APP_DIR = resolve(__dirname, ".."); + +function collectCssFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry.startsWith(".")) continue; + const full = join(dir, entry); + const info = statSync(full); + if (info.isDirectory()) out.push(...collectCssFiles(full)); + else if (entry.endsWith(".css")) out.push(full); + } + return out; +} + +const EASING_KEYWORD = + /\b(?:linear|ease(?:-in|-out|-in-out)?|cubic-bezier\(|steps\()/; + +function findViolations(css: string): string[] { + const violations: string[] = []; + const lines = css.split("\n"); + + lines.forEach((line, index) => { + const lineNo = index + 1; + + // calc() over a duration+easing pair is always invalid. + if (/calc\(\s*var\(--transition-/.test(line)) { + violations.push(`line ${lineNo}: calc() over a transition token — ${line.trim()}`); + return; + } + + // animation shorthand: a transition token plus an explicit easing keyword + // substitutes to two easing functions and invalidates the declaration. + const animationMatch = line.match(/animation\s*:\s*([^;}]*)/); + if (animationMatch && /var\(--transition-/.test(animationMatch[1])) { + const valueWithoutToken = animationMatch[1].replace(/var\(--transition-[a-z]+\)/g, ""); + if (EASING_KEYWORD.test(valueWithoutToken)) { + violations.push( + `line ${lineNo}: transition token + explicit easing in animation shorthand — ${line.trim()}`, + ); + } + } + + // animation-duration longhand cannot hold "0.3s ease" either. + if (/animation-duration\s*:\s*[^;}]*var\(--transition-/.test(line)) { + violations.push(`line ${lineNo}: transition token as animation-duration — ${line.trim()}`); + } + }); + + return violations; +} + +describe("animation duration tokens", () => { + const cssFiles = collectCssFiles(APP_DIR); + + it("finds the app stylesheets", () => { + expect(cssFiles.length).toBeGreaterThan(10); + }); + + it("defines duration-only tokens alongside the transition tokens", () => { + const styles = readFileSync(resolve(APP_DIR, "styles.css"), "utf8"); + for (const speed of ["instant", "fast", "normal", "slow"]) { + expect(styles).toMatch(new RegExp(`--duration-${speed}:\\s*[\\d.]+m?s\\s*;`)); + // Transition tokens must stay derived from the duration tokens so the + // two cannot drift apart. + expect(styles).toMatch( + new RegExp(`--transition-${speed}:\\s*var\\(--duration-${speed}\\)\\s+ease\\s*;`), + ); + } + }); + + it("never uses duration+easing transition tokens where a bare duration is required", () => { + const allViolations: string[] = []; + + for (const file of cssFiles) { + const css = readFileSync(file, "utf8"); + const violations = findViolations(css); + for (const violation of violations) { + allViolations.push(`${relative(APP_DIR, file)} ${violation}`); + } + } + + expect(allViolations).toEqual([]); + }); + + it("flags the frozen-spinner pattern if it is reintroduced", () => { + expect(findViolations(".x { animation: spin var(--transition-slow) linear infinite; }")).toHaveLength(1); + expect(findViolations(".x { animation: spin calc(var(--transition-slow) * 4) linear infinite; }")).toHaveLength(1); + expect(findViolations(".x { animation-duration: var(--transition-slow); }")).toHaveLength(1); + // Valid: token used in transition shorthand, or animation without a second easing. + expect(findViolations(".x { transition: color var(--transition-fast); }")).toHaveLength(0); + expect(findViolations(".x { animation: fadeIn var(--duration-fast) ease-out; }")).toHaveLength(0); + }); +}); diff --git a/packages/dashboard/app/components/CustomProvidersSection.css b/packages/dashboard/app/components/CustomProvidersSection.css index 6c954cba22..b291e549e7 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.css +++ b/packages/dashboard/app/components/CustomProvidersSection.css @@ -113,7 +113,7 @@ } .spin { - animation: custom-provider-spin calc(var(--transition-slow) * 4) linear infinite; + animation: custom-provider-spin calc(var(--duration-slow) * 4) linear infinite; } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/DbCorruptionBanner.css b/packages/dashboard/app/components/DbCorruptionBanner.css index 76f07035ae..bbdf321de8 100644 --- a/packages/dashboard/app/components/DbCorruptionBanner.css +++ b/packages/dashboard/app/components/DbCorruptionBanner.css @@ -73,7 +73,7 @@ } .db-corruption-banner__refresh-icon--spinning { - animation: status-dot-pulse var(--transition-slow) ease-in-out infinite; + animation: status-dot-pulse var(--duration-slow) ease-in-out infinite; } .db-corruption-banner__link { diff --git a/packages/dashboard/app/components/DockerProvisioningStatus.css b/packages/dashboard/app/components/DockerProvisioningStatus.css index 6c6a2f3a19..0a488ae74f 100644 --- a/packages/dashboard/app/components/DockerProvisioningStatus.css +++ b/packages/dashboard/app/components/DockerProvisioningStatus.css @@ -20,7 +20,7 @@ height: var(--space-sm); border-radius: 50%; background-color: var(--todo); - animation: provisioning-pulse var(--transition-normal) ease-in-out infinite; + animation: provisioning-pulse var(--duration-normal) ease-in-out infinite; } .provisioning-status__dot:nth-child(2) { diff --git a/packages/dashboard/app/components/FileMentionPopup.css b/packages/dashboard/app/components/FileMentionPopup.css index 4906a223b7..ac3af26905 100644 --- a/packages/dashboard/app/components/FileMentionPopup.css +++ b/packages/dashboard/app/components/FileMentionPopup.css @@ -170,7 +170,7 @@ border: calc(var(--space-xs) / 2) solid var(--border); border-top-color: var(--text-muted); border-radius: 50%; - animation: spin var(--transition-slow) linear infinite; + animation: spin var(--duration-slow) linear infinite; } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/IssueMentionPopup.css b/packages/dashboard/app/components/IssueMentionPopup.css index 660614bc3c..f335dd4259 100644 --- a/packages/dashboard/app/components/IssueMentionPopup.css +++ b/packages/dashboard/app/components/IssueMentionPopup.css @@ -81,7 +81,7 @@ border: calc(var(--space-xs) / 2) solid var(--border); border-top-color: var(--text-muted); border-radius: 50%; - animation: spin var(--transition-slow) linear infinite; + animation: spin var(--duration-slow) linear infinite; } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 1c24679382..6a3e2fb1b1 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -815,7 +815,7 @@ } .list-section-header ~ tr { - animation: section-expand var(--transition-normal) ease-out; + animation: section-expand var(--duration-normal) ease-out; } /* Empty section row */ diff --git a/packages/dashboard/app/components/NodesView.css b/packages/dashboard/app/components/NodesView.css index 2d54303184..d9f1f221e1 100644 --- a/packages/dashboard/app/components/NodesView.css +++ b/packages/dashboard/app/components/NodesView.css @@ -14,8 +14,8 @@ } .nodes-view { - --nodes-view-spin-duration: calc(var(--transition-slow) * 4); - --nodes-view-pulse-duration: calc(var(--transition-slow) * 5); + --nodes-view-spin-duration: calc(var(--duration-slow) * 4); + --nodes-view-pulse-duration: calc(var(--duration-slow) * 5); display: flex; flex-direction: column; gap: var(--space-md); diff --git a/packages/dashboard/app/components/OnboardingDisclosure.css b/packages/dashboard/app/components/OnboardingDisclosure.css index c8fff61b1a..0a6f64f2c2 100644 --- a/packages/dashboard/app/components/OnboardingDisclosure.css +++ b/packages/dashboard/app/components/OnboardingDisclosure.css @@ -45,7 +45,7 @@ color: var(--text-muted); font-size: var(--font-size-sm, 12px); line-height: 1.5; - animation: onboarding-disclosure-enter var(--transition-fast) ease-out; + animation: onboarding-disclosure-enter var(--duration-fast) ease-out; } @keyframes onboarding-disclosure-enter { diff --git a/packages/dashboard/app/components/OnboardingResumeCard.css b/packages/dashboard/app/components/OnboardingResumeCard.css index 5779b8abf6..9dc56f285b 100644 --- a/packages/dashboard/app/components/OnboardingResumeCard.css +++ b/packages/dashboard/app/components/OnboardingResumeCard.css @@ -8,7 +8,7 @@ padding: var(--space-sm) var(--space-lg); border-bottom: var(--btn-border-width) solid var(--border); background: var(--surface); - animation: onboarding-resume-card-enter var(--transition-normal) ease-out; + animation: onboarding-resume-card-enter var(--duration-normal) ease-out; } @keyframes onboarding-resume-card-enter { diff --git a/packages/dashboard/app/components/SetupWizardModal.css b/packages/dashboard/app/components/SetupWizardModal.css index 77c75ff9af..41d622d807 100644 --- a/packages/dashboard/app/components/SetupWizardModal.css +++ b/packages/dashboard/app/components/SetupWizardModal.css @@ -39,7 +39,7 @@ /* vh fallback first for browsers without dvh support; dvh upgrade follows. */ max-height: min(720px, calc(100vh - var(--overlay-padding-top, 10vh) - var(--space-lg))); max-height: min(720px, calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg))); - animation: slideUp var(--transition-normal) ease-out; + animation: slideUp var(--duration-normal) ease-out; overflow: hidden; /* Prevent flex parents from squeezing this below its content's needs. */ flex-shrink: 0; diff --git a/packages/dashboard/app/components/SystemStatsModal.css b/packages/dashboard/app/components/SystemStatsModal.css index d40319ea48..803932d0eb 100644 --- a/packages/dashboard/app/components/SystemStatsModal.css +++ b/packages/dashboard/app/components/SystemStatsModal.css @@ -48,7 +48,7 @@ } .system-stats-modal__refresh--spinning { - animation: system-stats-modal-refresh-spin var(--transition-slow) linear infinite; + animation: system-stats-modal-refresh-spin var(--duration-slow) linear infinite; } @keyframes system-stats-modal-refresh-spin { diff --git a/packages/dashboard/app/components/TaskIdIntegrityBanner.css b/packages/dashboard/app/components/TaskIdIntegrityBanner.css index 1f67bb6f60..7f6f9d2f2a 100644 --- a/packages/dashboard/app/components/TaskIdIntegrityBanner.css +++ b/packages/dashboard/app/components/TaskIdIntegrityBanner.css @@ -74,7 +74,7 @@ } .task-id-integrity-banner__refresh-icon--spinning { - animation: status-dot-pulse var(--transition-slow) ease-in-out infinite; + animation: status-dot-pulse var(--duration-slow) ease-in-out infinite; } .task-id-integrity-banner__error { diff --git a/packages/dashboard/app/components/TodoView.css b/packages/dashboard/app/components/TodoView.css index 4934750071..a70ead3d8a 100644 --- a/packages/dashboard/app/components/TodoView.css +++ b/packages/dashboard/app/components/TodoView.css @@ -353,7 +353,7 @@ } .todo-loading-icon { - animation: spin calc(var(--transition-slow) * 4) linear infinite; + animation: spin calc(var(--duration-slow) * 4) linear infinite; } @keyframes spin { diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 6d79a8e50d..14cd285bbe 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -89,7 +89,7 @@ html { .status-dot--connecting { background: var(--color-warning); - animation: status-dot-pulse var(--transition-slow) ease-in-out infinite; + animation: status-dot-pulse var(--duration-slow) ease-in-out infinite; } .status-dot--pending { @@ -160,11 +160,21 @@ html { --focus-ring: 0 0 0 2px rgba(88, 166, 255, 0.15); --focus-ring-strong: 0 0 0 2px rgba(88, 166, 255, 0.3); - /* Animation tokens */ - --transition-instant: 0.1s ease; - --transition-fast: 0.15s ease; - --transition-normal: 0.2s ease; - --transition-slow: 0.3s ease; + /* Animation tokens. + --duration-* are bare durations, safe anywhere (animation shorthands, + animation-duration, calc()). --transition-* bundle duration + easing and + are ONLY valid in `transition` shorthands: substituting "0.3s ease" into + an `animation` shorthand that names its own easing (or into calc()) + invalidates the whole declaration at computed-value time, silently + freezing the animation. Enforced by animation-duration-tokens.css.test.ts. */ + --duration-instant: 0.1s; + --duration-fast: 0.15s; + --duration-normal: 0.2s; + --duration-slow: 0.3s; + --transition-instant: var(--duration-instant) ease; + --transition-fast: var(--duration-fast) ease; + --transition-normal: var(--duration-normal) ease; + --transition-slow: var(--duration-slow) ease; /* Backward-compatible aliases */ --radius: var(--radius-md); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index bf5e608710..44b08d92f7 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -24,6 +24,7 @@ const qualityAppFoundationUiTests = [ "app/__tests__/activity-log-mobile-layout.test.ts", "app/__tests__/agent-css-classes.test.ts", "app/__tests__/agent-runs-ui.test.ts", + "app/__tests__/animation-duration-tokens.css.test.ts", "app/__tests__/auth.test.ts", "app/__tests__/board-mobile-corner-rendering.test.ts", "app/__tests__/board-tablet-overflow.test.ts",