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:
11
.changeset/fix-frozen-dashboard-animations.md
Normal file
11
.changeset/fix-frozen-dashboard-animations.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user