Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
39 changes: 20 additions & 19 deletions packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,25 +194,26 @@ The single source of truth for every code the validator emits. The agent-facing

These encode statically-detectable authoring pitfalls from the trigger rule files. Each belongs to a [rule category](#severity-model), so set the category to `'off'` to silence it or `'error'` to make it fail `valid`.

| Code | Trigger | Rule category |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
| `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` |
| `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` |
| Code | Trigger | Rule category |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
| `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` named/keyframe effect targeting another element or using a same-element delay 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` |

## Usage recipes

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
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
36 changes: 29 additions & 7 deletions packages/interact-validate/src/semantic/recommendedPatterns.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';
import { RETRIGGER_TYPES } from '../types';

// recommended `fill: 'both'` for scrubbed and toggling effects
function isKeyframeEffect(effect: AnyEffect): boolean {
return !!(effect.namedEffect || effect.keyframeEffect);
}

// recommended fill for scrubbed, toggling, and viewEnter entrance effects
export function checkRecommendedFill(
path: Path,
effect: AnyEffect,
Expand All @@ -11,16 +15,34 @@ export function checkRecommendedFill(
const isScrubbed = owner?.trigger === 'viewProgress' || owner?.trigger === 'pointerMove';
const isToggling =
effect.triggerType !== undefined && RETRIGGER_TYPES.includes(effect.triggerType);
if (!isScrubbed && !isToggling) return [];
const reason = isScrubbed
? `${owner?.trigger} (scrubbed) effects`
: `triggerType '${effect.triggerType}' effects`;

if (isScrubbed || isToggling) {
const reason = isScrubbed
? `${owner?.trigger} (scrubbed) effects`
: `triggerType '${effect.triggerType}' effects`;
return [
{
code: 'custom',
params: { domainCode: 'RECOMMENDED_FILL_BOTH' },
path: [...path, 'fill'],
message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`,
severity: 'info',
},
];
}

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 (!isKeyframeEffect(effect)) return [];
if (effect.fill === 'backwards' || effect.fill === 'both') return [];

return [
{
code: 'custom',
params: { domainCode: 'RECOMMENDED_FILL_BOTH' },
params: { domainCode: 'RECOMMENDED_FILL_BACKWARDS' },
path: [...path, 'fill'],
message: `Include \`fill: 'both'\` for ${reason} so the effect stays applied and is not garbage-collected.`,
message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) so the starting keyframe applies before the animation starts on a target without FOUC hiding rules.`,
severity: 'info',
},
];
Expand Down
1 change: 1 addition & 0 deletions packages/interact-validate/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type AnyEffect = {
triggerType?: string;
stateAction?: string;
fill?: string;
delay?: number;
namedEffect?: { type?: string; range?: unknown; [k: string]: unknown };
keyframeEffect?: { name?: string; keyframes?: Array<Record<string, unknown>> };
transition?: { styleProperties?: unknown[] };
Expand Down
Loading
Loading