Merge pull request #1386 from Runfusion/gsxdsm/fix-spinners

fix(dashboard): unfreeze animations broken by transition tokens used as durations
This commit is contained in:
gsxdsm
2026-06-03 20:14:09 -07:00
committed by GitHub
19 changed files with 263 additions and 21 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

@@ -1072,7 +1072,7 @@ The `index.html` shell is templated server-side: the server injects a per-user `
### Design tokens
`styles.css` is the source of truth for tokens (`--space-*`, `--radius-*`, `--shadow-*`, `--transition-*`, `--font-*`, `--header-height`, `--mobile-nav-height`, `--standalone-bottom-gap`, `--overlay-padding-top`) and color variables (`--bg`, `--surface`, `--card`, `--text`, `--text-muted`, status colors `--triage`/`--todo`/`--in-progress`/`--in-review`/`--done`, semantic `--color-success`/`--color-error`/`--color-warning`/`--color-info`, status backgrounds `--status-*-bg`).
`styles.css` is the source of truth for tokens (`--space-*`, `--radius-*`, `--shadow-*`, `--duration-*`, `--transition-*`, `--font-*`, `--header-height`, `--mobile-nav-height`, `--standalone-bottom-gap`, `--overlay-padding-top`) and color variables (`--bg`, `--surface`, `--card`, `--text`, `--text-muted`, status colors `--triage`/`--todo`/`--in-progress`/`--in-review`/`--done`, semantic `--color-success`/`--color-error`/`--color-warning`/`--color-info`, status backgrounds `--status-*-bg`).
**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — the only exception is inside `:root`/theme blocks where tokens are *defined*. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`.
@@ -1168,6 +1168,7 @@ Reuse `packages/dashboard/app/utils/filePathLinkify.tsx` and `FileBrowserContext
- **Mobile board scroll-snap (FN-001)** — `scroll-snap-type: x mandatory` on mobile `.board` causes iOS Safari to compress the viewport when switching from ListView. Use `x proximity` + `overflow-anchor: none`.
- **`lucide-react` icon adds** — update `vi.mock("lucide-react")` test mocks immediately; missing exports cascade.
- **`.spin` is global** — don't redefine the generic spin keyframes in component CSS.
- **Animation durations use `--duration-*`, never `--transition-*`** — transition tokens carry a `duration easing` pair; substituting one into an `animation` shorthand that names its own easing (or into `calc()`) is invalid at computed-value time and silently resolves the whole declaration to `animation: none`. Enforced by `animation-duration-tokens.css.test.ts`; see `docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md`.
## Integration Branch Push to Origin

View File

@@ -0,0 +1,106 @@
---
title: CSS animations silently frozen by transition tokens used as durations (IACVT)
date: 2026-06-03
category: ui-bugs
module: dashboard
problem_type: ui_bug
component: frontend_stimulus
symptoms:
- "Spinners, status-dot pulses, and entrance animations render but never move"
- "No console errors, no DevTools strikethrough, no @keyframes parse errors"
- "getComputedStyle(el).animationName returns \"none\" on affected elements; el.getAnimations() is empty"
- "Other animations on the same page work fine, making the bug look intermittent"
root_cause: wrong_api
resolution_type: code_fix
severity: high
related_components:
- tooling
tags: [css-custom-properties, iacvt, animation, design-tokens, transition-tokens, regression-test, dashboard-css]
---
# CSS animations silently frozen by transition tokens used as durations (IACVT)
## Problem
Dashboard spinners, status-dot pulses, and entrance animations rendered but never moved. The `--transition-*` design tokens carry a duration+easing **pair** (`--transition-slow: 0.3s ease`), and 15 `animation` declarations across 14 CSS files reused them as bare durations — which silently invalidated each whole declaration.
## Symptoms
- Spinners/loaders/pulses visible in the DOM but completely frozen
- Zero diagnostics: no console error, no DevTools strikethrough, the declaration looks healthy in the Styles panel
- `getComputedStyle(el).animationName === "none"` and `el.getAnimations().length === 0` on affected elements
- Some animations on the same page kept working (see Why This Works), so the bug appeared intermittent
- Invisible to static CSS reading and to jsdom-based tests
## What Didn't Work
- **Prior partial fixes (FN-5855, FN-5913)** addressed only the `.animate-spin` utility, which uses a literal `1s` duration — a separate code path. The token-misuse pattern survived both fixes.
- Checking for `prefers-reduced-motion` overrides or `animation-play-state` rules — none existed.
- Suspecting duplicate `@keyframes spin` definitions across component CSS files — all were valid `rotate(360deg)` definitions; identical re-definitions are harmless.
- Dev-server repro without a backend (Vite's `/api` proxy prefix-matches the app's own `/api.ts` module requests, blanking the page). Repro that worked: **production build served statically**, injected test elements, measured via `getComputedStyle`/`getAnimations()`.
## Solution
Fix PR: Runfusion/Fusion#1386 (commit `2d2024fb0`).
Split the tokens: bare durations become the source of truth, transition pairs are derived so the two can never drift (`packages/dashboard/app/styles.css`):
```css
/* Before — token encodes BOTH duration and easing */
--transition-slow: 0.3s ease;
/* After — duration is the source of truth; transition pair is derived
(same pattern for instant/fast/normal/slow) */
--duration-slow: 0.3s;
--transition-slow: var(--duration-slow) ease;
```
All 15 animation declarations switched to the duration tokens:
```css
/* Before — IACVT: substitutes to two <easing-function> values */
animation: spin var(--transition-slow) linear infinite;
/* Before — IACVT: calc() cannot multiply "0.3s ease" */
animation: spin calc(var(--transition-slow) * 4) linear infinite;
/* After */
animation: spin var(--duration-slow) linear infinite;
animation: spin calc(var(--duration-slow) * 4) linear infinite;
```
`transition:` consumers of `--transition-*` were always valid and stay unchanged.
## Why This Works
This is the **invalid-at-computed-value-time (IACVT)** mechanism from CSS Custom Properties Level 1. `var()` substitution happens *after* parsing, as opaque tokens — the parser cannot type-check. When the substituted value is invalid for the property, the browser does not ignore just the bad token; **the entire declaration is discarded** and the property falls back to inherited/initial. For `animation`, initial is `none`. No error is reported anywhere.
```
animation: spin var(--transition-slow) linear infinite
↓ substitution
animation: spin 0.3s ease linear infinite
↑ ↑ ↑
<time> <easing> <easing> ← second easing keyword = invalid
↓ IACVT
animation: none ← whole declaration silently dropped
```
Why some animations kept working: `animation: spin var(--transition-slow) infinite` (no second easing) is *valid* — `0.3s ease` parses as `<time> <easing-function>`. Only declarations adding their own easing keyword, or wrapping the token in `calc()`, broke. That inconsistency is what made the bug look intermittent.
## Prevention
- **The failure mode generalizes:** IACVT applies to *any* property consuming a custom property — the same silent whole-declaration drop can disable `transition`, `grid-template-columns`, `transform`, etc. Treat token value *shape* as part of its contract.
- **Token design rule:** never bundle multiple value types in one token if any consumer needs just one of them. Keep duration-only tokens (`--duration-*`) as the source of truth and derive combined tokens (`--transition-*: var(--duration-*) ease`) from them.
- **Regression test:** `packages/dashboard/app/__tests__/animation-duration-tokens.css.test.ts` sweeps every CSS file under `app/` and fails on the three invalid shapes:
1. `animation:` shorthand combining `var(--transition-*)` with an explicit easing keyword
2. `calc(var(--transition-*)` anywhere
3. `animation-duration: var(--transition-*)`
It also asserts the duration tokens exist and that `--transition-*` stay derived. It enumerated all 15 broken sites red before the fix.
- **Verification rule:** IACVT cannot be caught by linters (the CSS is syntactically valid) or jsdom (no computed-value validation). Definitive check is in a real browser engine: `getComputedStyle(el).animationName !== "none"` and `el.getAnimations().length > 0`.
## Related Issues
- Runfusion/Fusion#1386 — fix PR for this bug
- Runfusion/Fusion#39 / PR #40 — earlier frozen-spinner incident with a *different* root cause (`.animate-spin`/`@keyframes spin` not globally scoped); useful contrast case at the same symptom layer
- `docs/dashboard-guide.md` §Design tokens / §Common pitfalls — token family documentation (update candidates)
- The token misuse spread widely because dashboard CSS was previously extracted from a 40k-line monolith into ~56 component files (auto memory [claude])

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",