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/docs/dashboard-guide.md b/docs/dashboard-guide.md index bac1ef0fb4..a3335bb409 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md b/docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md new file mode 100644 index 0000000000..62d174d7b8 --- /dev/null +++ b/docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md @@ -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 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 + ↑ ↑ ↑ +