fix(dashboard): unfreeze animations broken by transition tokens used as durations

Spinners, status-dot pulses, and entrance animations across the dashboard
rendered frozen: transition tokens (--transition-slow: 0.3s ease) bundle a
duration AND an easing, and 15 animation declarations reused them as bare
durations. Substituting "0.3s ease" next to an explicit easing (linear,
ease-in-out, ease-out) — or inside calc() — makes the declaration invalid at
computed-value time, which per spec resolves the entire property to
animation: none with no console error.

- add duration-only tokens (--duration-instant/fast/normal/slow) and derive
  the --transition-* tokens from them so the two cannot drift
- switch all 15 broken animation declarations across 14 CSS files to the
  duration tokens, preserving effective durations
- add animation-duration-tokens.css.test.ts: sweeps every app CSS file and
  fails on transition-token-as-duration, calc() over a transition token, and
  transition token in animation-duration

Verified in a real browser against the production build: previously frozen
.status-dot--connecting and calc-based NodesView spinners now report running
animations; transition shorthands still resolve to "0.15s / ease".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 19:48:40 -07:00
parent 07dd24e37a
commit 2d2024fb0f
17 changed files with 155 additions and 20 deletions

View File

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

View File

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

View File

@@ -113,7 +113,7 @@
} }
.spin { .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) { @media (max-width: 768px) {

View File

@@ -73,7 +73,7 @@
} }
.db-corruption-banner__refresh-icon--spinning { .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 { .db-corruption-banner__link {

View File

@@ -20,7 +20,7 @@
height: var(--space-sm); height: var(--space-sm);
border-radius: 50%; border-radius: 50%;
background-color: var(--todo); 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) { .provisioning-status__dot:nth-child(2) {

View File

@@ -170,7 +170,7 @@
border: calc(var(--space-xs) / 2) solid var(--border); border: calc(var(--space-xs) / 2) solid var(--border);
border-top-color: var(--text-muted); border-top-color: var(--text-muted);
border-radius: 50%; border-radius: 50%;
animation: spin var(--transition-slow) linear infinite; animation: spin var(--duration-slow) linear infinite;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -81,7 +81,7 @@
border: calc(var(--space-xs) / 2) solid var(--border); border: calc(var(--space-xs) / 2) solid var(--border);
border-top-color: var(--text-muted); border-top-color: var(--text-muted);
border-radius: 50%; border-radius: 50%;
animation: spin var(--transition-slow) linear infinite; animation: spin var(--duration-slow) linear infinite;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -815,7 +815,7 @@
} }
.list-section-header ~ tr { .list-section-header ~ tr {
animation: section-expand var(--transition-normal) ease-out; animation: section-expand var(--duration-normal) ease-out;
} }
/* Empty section row */ /* Empty section row */

View File

@@ -14,8 +14,8 @@
} }
.nodes-view { .nodes-view {
--nodes-view-spin-duration: calc(var(--transition-slow) * 4); --nodes-view-spin-duration: calc(var(--duration-slow) * 4);
--nodes-view-pulse-duration: calc(var(--transition-slow) * 5); --nodes-view-pulse-duration: calc(var(--duration-slow) * 5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);

View File

@@ -45,7 +45,7 @@
color: var(--text-muted); color: var(--text-muted);
font-size: var(--font-size-sm, 12px); font-size: var(--font-size-sm, 12px);
line-height: 1.5; 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 { @keyframes onboarding-disclosure-enter {

View File

@@ -8,7 +8,7 @@
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
border-bottom: var(--btn-border-width) solid var(--border); border-bottom: var(--btn-border-width) solid var(--border);
background: var(--surface); 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 { @keyframes onboarding-resume-card-enter {

View File

@@ -39,7 +39,7 @@
/* vh fallback first for browsers without dvh support; dvh upgrade follows. */ /* 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(100vh - var(--overlay-padding-top, 10vh) - var(--space-lg)));
max-height: min(720px, calc(100dvh - 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; overflow: hidden;
/* Prevent flex parents from squeezing this below its content's needs. */ /* Prevent flex parents from squeezing this below its content's needs. */
flex-shrink: 0; flex-shrink: 0;

View File

@@ -48,7 +48,7 @@
} }
.system-stats-modal__refresh--spinning { .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 { @keyframes system-stats-modal-refresh-spin {

View File

@@ -74,7 +74,7 @@
} }
.task-id-integrity-banner__refresh-icon--spinning { .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 { .task-id-integrity-banner__error {

View File

@@ -353,7 +353,7 @@
} }
.todo-loading-icon { .todo-loading-icon {
animation: spin calc(var(--transition-slow) * 4) linear infinite; animation: spin calc(var(--duration-slow) * 4) linear infinite;
} }
@keyframes spin { @keyframes spin {

View File

@@ -89,7 +89,7 @@ html {
.status-dot--connecting { .status-dot--connecting {
background: var(--color-warning); 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 { .status-dot--pending {
@@ -160,11 +160,21 @@ html {
--focus-ring: 0 0 0 2px rgba(88, 166, 255, 0.15); --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); --focus-ring-strong: 0 0 0 2px rgba(88, 166, 255, 0.3);
/* Animation tokens */ /* Animation tokens.
--transition-instant: 0.1s ease; --duration-* are bare durations, safe anywhere (animation shorthands,
--transition-fast: 0.15s ease; animation-duration, calc()). --transition-* bundle duration + easing and
--transition-normal: 0.2s ease; are ONLY valid in `transition` shorthands: substituting "0.3s ease" into
--transition-slow: 0.3s ease; 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 */ /* Backward-compatible aliases */
--radius: var(--radius-md); --radius: var(--radius-md);

View File

@@ -24,6 +24,7 @@ const qualityAppFoundationUiTests = [
"app/__tests__/activity-log-mobile-layout.test.ts", "app/__tests__/activity-log-mobile-layout.test.ts",
"app/__tests__/agent-css-classes.test.ts", "app/__tests__/agent-css-classes.test.ts",
"app/__tests__/agent-runs-ui.test.ts", "app/__tests__/agent-runs-ui.test.ts",
"app/__tests__/animation-duration-tokens.css.test.ts",
"app/__tests__/auth.test.ts", "app/__tests__/auth.test.ts",
"app/__tests__/board-mobile-corner-rendering.test.ts", "app/__tests__/board-mobile-corner-rendering.test.ts",
"app/__tests__/board-tablet-overflow.test.ts", "app/__tests__/board-tablet-overflow.test.ts",