Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .cursor/plans/fix_entrance_fouc_fc913a84.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: Fix entrance FOUC
overview: Prevent delayed entrance flashes without changing IntersectionObserver geometry by making the transient neutral styles author-important, using real zero delays, and giving entrance effects backwards fill by default. Align validation, documentation, and Interactor guidance with the runtime behavior.
todos:
- id: important-initial-css
content: Emit author-important transient FOUC declarations and preserve true zero animation delays
status: completed
- id: entrance-fill-defaults
content: Default every entrance preset descriptor to backwards fill while preserving explicit overrides
status: completed
- id: validation-guidance
content: Add backwards-fill validation guidance and align Interact docs, rules, and Interactor skill
status: completed
- id: regression-tests
content: Cover CSS serialization, timing, all entrance presets, validator behavior, and run targeted verification
status: completed
isProject: false
---

# Fix Delayed Entrance FOUC

## Implementation

- Extend the internal declaration shape in [`packages/interact/src/types/css.ts`](packages/interact/src/types/css.ts) with an `important` flag and serialize it in [`packages/interact/src/core/cssUtils.ts`](packages/interact/src/core/cssUtils.ts). Mark every [`DEFAULT_INITIAL`](packages/interact/src/core/css.ts) declaration important so `visibility: hidden` and neutral transform properties override pre-trigger animation values, while retaining the existing `:not([data-interact-enter])` lifecycle.
- In [`packages/motion/src/api/cssAnimations.ts`](packages/motion/src/api/cssAnimations.ts), replace the synthetic `delay || 1` fallback with `delay ?? 0`. Do not add the proposed `0.1ms` initial override, because it would alter computed sequence timing and could move a playing animation back from its active phase into its delay phase.
- Add a small `getEntranceFill()` helper in [`packages/motion-presets/src/utils.ts`](packages/motion-presets/src/utils.ts), returning `options.fill ?? 'backwards'`. Apply it after `...options` to every animation descriptor in all 19 modules under [`packages/motion-presets/src/library/entrance/`](packages/motion-presets/src/library/entrance/) so both CSS and WAAPI paths default to backwards fill while explicit `none`, `forwards`, or `both` values remain authoritative.

## Validation and guidance

- Add an informational `RECOMMENDED_FILL_BACKWARDS` semantic check in [`packages/interact-validate/src/semantic/recommendedPatterns.ts`](packages/interact-validate/src/semantic/recommendedPatterns.ts), wire it through [`collectSemanticWarnings.ts`](packages/interact-validate/src/semantic/collectSemanticWarnings.ts) and [`errors.ts`](packages/interact-validate/src/errors.ts), and recommend explicit `backwards` or `both` for effective `viewEnter`/`once` animation effects, including nested sequence effects. Keep this advisory so preset defaults remain a safety net rather than a schema requirement.
- Update the authoritative entrance/FOUC guidance and generated CSS examples in [`packages/interact/rules/viewenter.md`](packages/interact/rules/viewenter.md), [`packages/interact/rules/full-lean.md`](packages/interact/rules/full-lean.md), [`packages/interact/docs/api/functions.md`](packages/interact/docs/api/functions.md), and [`packages/interact/docs/examples/entrance-animations.md`](packages/interact/docs/examples/entrance-animations.md): explain the important neutral pre-trigger rule, use `fill: 'backwards'` for `once`, and reserve `both` for effects that must retain their final keyframe.
- Update the repository Interactor source in [`skills/interactor/SKILL.md`](skills/interactor/SKILL.md) plus its `config-schema`, `triggers`, `integration-recipes`, and `validate` references. Make explicit backwards fill an invariant in generated `viewEnter`/`once` configs and update the relevant eval expectation; do not manually edit external installed skill snapshots.

## Regression coverage

- Update [`packages/interact/test/css.spec.ts`](packages/interact/test/css.spec.ts) and [`cssUtils.spec.ts`](packages/interact/test/cssUtils.spec.ts) to verify important initial declarations are emitted only for eligible same-element `viewEnter`/`once` effects and disappear from the post-start selector.
- Update [`packages/motion/test/motion.spec.ts`](packages/motion/test/motion.spec.ts) to assert omitted/zero delay produces `0ms`, while positive delays remain unchanged.
- Add centralized entrance-preset tests covering all 19 exports: every generated descriptor defaults to `backwards`, and an explicit fill value is preserved. Retain existing per-preset behavior tests and add coverage for the currently untested `ExpandIn` export through this matrix.
- Add validator tests for direct effects, referenced registry effects, sequence effects, implicit `once`, accepted `backwards`/`both`, and informational severity/category overrides.

## Verification

- Run `nvm use`, then targeted tests and type checks for `@wix/motion`, `@wix/motion-presets`, `@wix/interact`, and `@wix/interact-validate`; finish with repository lint/format checks for touched files.
1 change: 1 addition & 0 deletions packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ These encode statically-detectable authoring pitfalls from the trigger rule file
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` animation effect omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` |
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
Expand Down
1 change: 1 addition & 0 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const RULE_CODE_MAP: Record<string, string> = {
EMPTY_STYLE_PROPERTIES: 'STATE_EFFECT',
STATE_REMOVE_WITHOUT_EFFECT_ID: 'STATE_EFFECT',
RECOMMENDED_FILL_BOTH: 'RECOMMENDED_FILL',
RECOMMENDED_FILL_BACKWARDS: 'RECOMMENDED_FILL',
Comment thread
ameerf-wix marked this conversation as resolved.
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
KEYFRAME_PROP_NOT_CAMEL_CASE: 'KEYFRAME_STYLE',
INVALID_INSET: 'VIEW_INSET',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
checkEmptyStyleProperties,
checkStateRemoveWithoutEffectId,
} from './partialData';
import { checkRecommendedFill } from './recommendedPatterns';
import { checkRecommendedFill, checkRecommendedFillBackwards } from './recommendedPatterns';
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated
import { findAnimationEndWarnings } from './animationEndGraph';

// Single traversal of top-level registry effects/sequences and per-interaction
Expand Down Expand Up @@ -76,6 +76,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
warnings.push(...checkEmptyStyleProperties(path, effect));
warnings.push(...checkStateRemoveWithoutEffectId(path, effect));
warnings.push(...checkRecommendedFill(path, resolvedEffect, owner));
warnings.push(...checkRecommendedFillBackwards(path, resolvedEffect, owner));
warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner));
warnings.push(...checkKeyframePropCamelCase(path, effect));
},
Expand Down
2 changes: 1 addition & 1 deletion packages/interact-validate/src/semantic/fouc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { RETRIGGER_TYPES } from '../types';
const DISCRETE_TRIGGERS = ['hover', 'click', 'interest', 'activate'];
const HIT_AREA_TRANSFORM = /(translate|scale|matrix)/;

function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean {
export function targetsSameElementAsSource(owner: AnyInteraction, effect: AnyEffect): boolean {
if (effect.key !== undefined && effect.key !== owner.key) return false;
const refiners = ['selector', 'listContainer', 'listItemSelector'] as const;
for (const field of refiners) {
Expand Down
33 changes: 33 additions & 0 deletions packages/interact-validate/src/semantic/recommendedPatterns.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';
import { RETRIGGER_TYPES } from '../types';
import { targetsSameElementAsSource } from './fouc';

function isTimeAnimationEffect(effect: AnyEffect): boolean {
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated
return !!(effect.namedEffect || effect.keyframeEffect || effect.customEffect);
}

// recommended `fill: 'both'` for scrubbed and toggling effects
export function checkRecommendedFill(
Expand All @@ -25,3 +30,31 @@ export function checkRecommendedFill(
},
];
}

// recommended `fill: 'backwards'` (or `both`) for viewEnter once entrances
export function checkRecommendedFillBackwards(
path: Path,
effect: AnyEffect,
owner?: AnyInteraction,
): SemanticIssue[] {
if (!owner || owner.trigger !== 'viewEnter') return [];
Comment thread
ameerf-wix marked this conversation as resolved.
const triggerType = effect.triggerType ?? 'once';
if (triggerType !== 'once') return [];
if (!isTimeAnimationEffect(effect)) return [];
if (effect.fill === 'backwards' || effect.fill === 'both') return [];

const sameElement = targetsSameElementAsSource(owner, effect);
const reason = sameElement
? 'viewEnter entrances with delay'
: 'viewEnter targets without FOUC hiding rules';
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated

return [
{
code: 'custom',
params: { domainCode: 'RECOMMENDED_FILL_BACKWARDS' },
path: [...path, 'fill'],
message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) for ${reason} so the starting keyframe applies during any delay.`,
severity: 'info',
},
];
}
1 change: 1 addition & 0 deletions packages/interact-validate/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type AnyEffect = {
fill?: string;
namedEffect?: { type?: string; range?: unknown; [k: string]: unknown };
keyframeEffect?: { name?: string; keyframes?: Array<Record<string, unknown>> };
customEffect?: (element: Element, progress: number | { x: number; y: number }) => void;
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated
transition?: { styleProperties?: unknown[] };
transitionProperties?: unknown[];
rangeStart?: { offset?: { value?: number; unit?: string } };
Expand Down
165 changes: 165 additions & 0 deletions packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { validateInteractConfig } from '../../src';

const CODE = 'RECOMMENDED_FILL_BACKWARDS';

describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => {
it('warns for a viewEnter once effect that omits fill', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, triggerType: 'once' }],
},
],
});

const err = result.errors.find((e) => e.code === CODE);
expect(err).toBeDefined();
expect(err?.severity).toBe('info');
expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'fill']);
expect(result.valid).toBe(true);
});

it('warns for a viewEnter effect with implicit once triggerType', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }],
},
],
});

expect(result.errors.some((e) => e.code === CODE)).toBe(true);
});

it('warns for a viewEnter once keyframeEffect without fill', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [
{
keyframeEffect: { name: 'fade', keyframes: [{ opacity: '0' }, { opacity: '1' }] },
duration: 400,
delay: 200,
},
],
},
],
});

expect(result.errors.some((e) => e.code === CODE)).toBe(true);
});

it('warns for a viewEnter once sequence effect without fill', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
sequences: [
{
effects: [
{
selector: '.card',
namedEffect: { type: 'FadeIn' },
duration: 400,
},
],
},
],
},
],
});

expect(
result.errors.some(
(e) => e.code === CODE && e.path.join('.') === 'interactions.0.sequences.0.effects.0.fill',
),
).toBe(true);
});

describe('no warning for the documented valid patterns', () => {
it('does not warn when fill: backwards is present', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [
{
fill: 'backwards',
namedEffect: { type: 'FadeIn' },
duration: 400,
triggerType: 'once',
},
],
},
],
});

expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0);
});

it('does not warn when fill: both is present', () => {
Comment thread
ydaniv marked this conversation as resolved.
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [
{
fill: 'both',
namedEffect: { type: 'FadeIn' },
duration: 400,
triggerType: 'once',
},
],
},
],
});

expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0);
});

it('does not warn for non-viewEnter triggers', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'click',
effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }],
},
],
});

expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0);
});

it('does not warn for viewEnter repeat effects', () => {
const result = validateInteractConfig({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [
{
key: 'other',
namedEffect: { type: 'FadeIn' },
duration: 400,
triggerType: 'repeat',
},
],
},
],
});

expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0);
});
});
});
4 changes: 2 additions & 2 deletions packages/interact-validate/test/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const VALID_CONFIG = {
{
key: 'el',
trigger: 'viewEnter',
effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }],
effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, fill: 'backwards' }],
},
],
};
Expand All @@ -18,7 +18,7 @@ const CONFIG_WITH_WARNING = {
{
key: 'el',
trigger: 'viewEnter',
effects: [{ namedEffect: { type: 'SlideIn' }, duration: 400 }],
effects: [{ namedEffect: { type: 'SlideIn' }, duration: 400, fill: 'backwards' }],
},
],
};
Expand Down
12 changes: 7 additions & 5 deletions packages/interact/docs/api/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ The output covers every CSS-expressible aspect of the configuration:

For entrance animations where the source and target are the same element, `generate()` emits an initial rule that hides the element until its animation starts. Inject the generated CSS into `<head>` (preferred) or the beginning of `<body>`.

The initial rule uses `:not([data-interact-enter])` so the element becomes visible once the animation begins. This only applies to `viewEnter` interactions with `triggerType: 'once'` (the default for `viewEnter`).
The initial rule uses `:not([data-interact-enter])` so the element becomes visible once the animation begins. This only applies to `viewEnter` interactions with `triggerType: 'once'` (the default for `viewEnter`)

For any entrance `delay`/`offset`, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist). Entrance presets default to `backwards`.
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated

For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`.

Expand All @@ -256,10 +258,10 @@ For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting
```css
[data-interact-key='hero']:not([data-interact-enter]) {
visibility: hidden;
transform: none;
translate: none;
scale: none;
rotate: none;
transform: none !important;
translate: none !important;
scale: none !important;
rotate: none !important;
}
```

Expand Down
1 change: 1 addition & 0 deletions packages/interact/docs/examples/entrance-animations.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Add a delay for dramatic timing.
},
duration: 1000,
delay: 500, // Wait 500ms before starting
fill: 'backwards', // Hold first keyframe during delay
easing: 'ease-out'
}]
}
Expand Down
2 changes: 1 addition & 1 deletion packages/interact/rules/full-lean.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ Each effect applies a visual change to a target element. An effect is either inl
**`fill` guidance:**

- `'both'` — use for scroll-driven (`viewProgress`), pointer-driven (`pointerMove`), and toggling effects (`hover`/`click` with `alternate`, `repeat`, or `state` type).
- `'backwards'` — use for entrance animations with `type: 'once'` when the element's own CSS already matches the final keyframe (applies the initial keyframe during any `delay`).
- `'backwards'` — default for entrance animations (`viewEnter` + `once`). Applies the first keyframe during any `delay` after the entrance marker is set. Use `'both'` when the final keyframe must persist after the animation.
Comment thread
ameerf-wix marked this conversation as resolved.
Outdated

**`composite`** — same as CSS's `animation-composition`. Controls how this effect combines with others on the same property (transforms & filters):

Expand Down
1 change: 1 addition & 0 deletions packages/interact/rules/validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` animation effect omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` |
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` |
Expand Down
Loading
Loading