From bcf61f8798758b09072c1f9dffec57916039ce0d Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Fri, 24 Jul 2026 12:16:15 +0300 Subject: [PATCH 1/9] Add plan for fixing entrance FOUC mechanism --- .../plans/fix_entrance_fouc_fc913a84.plan.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .cursor/plans/fix_entrance_fouc_fc913a84.plan.md diff --git a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md new file mode 100644 index 00000000..bdda93e8 --- /dev/null +++ b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md @@ -0,0 +1,39 @@ +--- +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: pending + - id: entrance-fill-defaults + content: Default every entrance preset descriptor to backwards fill while preserving explicit overrides + status: pending + - id: validation-guidance + content: Add backwards-fill validation guidance and align Interact docs, rules, and Interactor skill + status: pending + - id: regression-tests + content: Cover CSS serialization, timing, all entrance presets, validator behavior, and run targeted verification + status: pending +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. \ No newline at end of file From 38ef548c32bbd44cdc98bf8a5dc89917a484ecb0 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Fri, 24 Jul 2026 12:16:55 +0300 Subject: [PATCH 2/9] Fix format --- .cursor/plans/fix_entrance_fouc_fc913a84.plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md index bdda93e8..50774440 100644 --- a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md +++ b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md @@ -20,20 +20,24 @@ 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. \ No newline at end of file + +- 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. From d257d0988c359e83ca73950d190a110928f83ff2 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Mon, 27 Jul 2026 10:54:26 +0300 Subject: [PATCH 3/9] Change fouc prevention mechanism and add fill backwards to entrance --- .../plans/fix_entrance_fouc_fc913a84.plan.md | 8 +- packages/interact-validate/README.md | 1 + packages/interact-validate/src/errors.ts | 1 + .../src/semantic/collectSemanticWarnings.ts | 3 +- .../interact-validate/src/semantic/fouc.ts | 2 +- .../src/semantic/recommendedPatterns.ts | 33 ++++ packages/interact-validate/src/types.ts | 1 + .../rules/recommendedFillBackwards.spec.ts | 165 ++++++++++++++++++ .../interact-validate/test/validate.spec.ts | 4 +- packages/interact/docs/api/functions.md | 12 +- .../docs/examples/entrance-animations.md | 1 + packages/interact/rules/full-lean.md | 2 +- packages/interact/rules/validate.md | 1 + packages/interact/rules/viewenter.md | 5 +- packages/interact/src/core/css.ts | 8 +- packages/interact/src/core/cssUtils.ts | 4 +- packages/interact/src/types/css.ts | 2 +- packages/interact/test/css.spec.ts | 34 +++- packages/interact/test/cssUtils.spec.ts | 14 ++ .../src/library/entrance/ArcIn.ts | 4 +- .../src/library/entrance/BlurIn.ts | 4 +- .../src/library/entrance/BounceIn.ts | 4 +- .../src/library/entrance/CurveIn.ts | 4 +- .../src/library/entrance/DropIn.ts | 4 +- .../src/library/entrance/ExpandIn.ts | 4 +- .../src/library/entrance/FadeIn.ts | 3 + .../src/library/entrance/FlipIn.ts | 4 +- .../src/library/entrance/FloatIn.ts | 4 +- .../src/library/entrance/FoldIn.ts | 4 +- .../src/library/entrance/GlideIn.ts | 4 +- .../src/library/entrance/RevealIn.ts | 4 +- .../src/library/entrance/ShapeIn.ts | 4 +- .../src/library/entrance/ShuttersIn.ts | 4 +- .../src/library/entrance/SlideIn.ts | 4 +- .../src/library/entrance/SpinIn.ts | 4 +- .../src/library/entrance/TiltIn.ts | 5 +- .../src/library/entrance/TurnIn.ts | 4 +- .../src/library/entrance/WinkIn.ts | 5 +- .../entrance/test/entranceFill.spec.ts | 42 +++++ packages/motion-presets/src/utils.ts | 7 + packages/motion/src/api/cssAnimations.ts | 2 +- packages/motion/test/motion.spec.ts | 27 ++- skills/interactor/SKILL.md | 8 +- skills/interactor/references/config-schema.md | 20 ++- skills/interactor/references/triggers.md | 7 +- skills/interactor/references/validate.md | 2 +- 46 files changed, 436 insertions(+), 57 deletions(-) create mode 100644 packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts create mode 100644 packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts diff --git a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md index 50774440..5be4e220 100644 --- a/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md +++ b/.cursor/plans/fix_entrance_fouc_fc913a84.plan.md @@ -4,16 +4,16 @@ overview: Prevent delayed entrance flashes without changing IntersectionObserver todos: - id: important-initial-css content: Emit author-important transient FOUC declarations and preserve true zero animation delays - status: pending + status: completed - id: entrance-fill-defaults content: Default every entrance preset descriptor to backwards fill while preserving explicit overrides - status: pending + status: completed - id: validation-guidance content: Add backwards-fill validation guidance and align Interact docs, rules, and Interactor skill - status: pending + status: completed - id: regression-tests content: Cover CSS serialization, timing, all entrance presets, validator behavior, and run targeted verification - status: pending + status: completed isProject: false --- diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 53648fc7..c11aba73 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -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` | diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index 2d4820c5..d2bae0c1 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -29,6 +29,7 @@ const RULE_CODE_MAP: Record = { EMPTY_STYLE_PROPERTIES: 'STATE_EFFECT', STATE_REMOVE_WITHOUT_EFFECT_ID: 'STATE_EFFECT', RECOMMENDED_FILL_BOTH: 'RECOMMENDED_FILL', + RECOMMENDED_FILL_BACKWARDS: 'RECOMMENDED_FILL', POINTER_AXIS_IGNORED: 'POINTER_AXIS', KEYFRAME_PROP_NOT_CAMEL_CASE: 'KEYFRAME_STYLE', INVALID_INSET: 'VIEW_INSET', diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index 7a4f9c7f..11736462 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -16,7 +16,7 @@ import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId, } from './partialData'; -import { checkRecommendedFill } from './recommendedPatterns'; +import { checkRecommendedFill, checkRecommendedFillBackwards } from './recommendedPatterns'; import { findAnimationEndWarnings } from './animationEndGraph'; // Single traversal of top-level registry effects/sequences and per-interaction @@ -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)); }, diff --git a/packages/interact-validate/src/semantic/fouc.ts b/packages/interact-validate/src/semantic/fouc.ts index 7761158c..7d2f585b 100644 --- a/packages/interact-validate/src/semantic/fouc.ts +++ b/packages/interact-validate/src/semantic/fouc.ts @@ -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) { diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index f0a7ccf0..f8d00e6e 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -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 { + return !!(effect.namedEffect || effect.keyframeEffect || effect.customEffect); +} // recommended `fill: 'both'` for scrubbed and toggling effects export function checkRecommendedFill( @@ -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 []; + 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'; + + 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', + }, + ]; +} diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index f624292f..737cff1f 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -42,6 +42,7 @@ export type AnyEffect = { fill?: string; namedEffect?: { type?: string; range?: unknown; [k: string]: unknown }; keyframeEffect?: { name?: string; keyframes?: Array> }; + customEffect?: (element: Element, progress: number | { x: number; y: number }) => void; transition?: { styleProperties?: unknown[] }; transitionProperties?: unknown[]; rangeStart?: { offset?: { value?: number; unit?: string } }; diff --git a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts new file mode 100644 index 00000000..25df0f2b --- /dev/null +++ b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts @@ -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', () => { + 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); + }); + }); +}); diff --git a/packages/interact-validate/test/validate.spec.ts b/packages/interact-validate/test/validate.spec.ts index e4bb4de7..1d0f0c9d 100644 --- a/packages/interact-validate/test/validate.spec.ts +++ b/packages/interact-validate/test/validate.spec.ts @@ -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' }], }, ], }; @@ -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' }], }, ], }; diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index be44558d..56556be8 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -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 `` (preferred) or the beginning of ``. -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`. For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. @@ -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; } ``` diff --git a/packages/interact/docs/examples/entrance-animations.md b/packages/interact/docs/examples/entrance-animations.md index 01aa40d9..721150a9 100644 --- a/packages/interact/docs/examples/entrance-animations.md +++ b/packages/interact/docs/examples/entrance-animations.md @@ -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' }] } diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 5942ed3d..6c5c8ac7 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -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. **`composite`** — same as CSS's `animation-composition`. Controls how this effect combines with others on the same property (transforms & filters): diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 794c0ac5..5bbbc8b4 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -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` | diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index eae35403..1b9b9078 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -23,6 +23,8 @@ This document contains rules for generating interactions that respond to element **Solution:** Call `generate(config, useFirstChild)` at build/generation time and embed the resulting CSS before first paint. Among all the CSS it produces, `generate()` includes initial rules that hide entrance-animated elements from the moment the page renders, before JS runs. The rules use `:not([data-interact-enter])` so elements become visible once the animation starts. +For any `delay`, set `fill: 'backwards'` (or `'both'` when the final keyframe must persist) so the starting keyframe applies during the delay after `data-interact-enter` is set. Entrance presets default to `backwards`; custom `keyframeEffect` entrances should set it explicitly. + ### Generate CSS at build time and embed in HTML Prefer running this for the complete config in a **build/generation script** @@ -90,6 +92,7 @@ const css = generate(config, useFirstChild); // true for web; false for react/va - Inject runtime-generated CSS before the corresponding `Interact.create()` call and before revealing initially hidden content to minimize FOUC. - FOUC initial rules apply only to `viewEnter` + `triggerType: 'once'` (or no `triggerType`, which defaults to `'once'`) where source and target are the same element. +- For `viewEnter` + `once` entrances with any `delay`, or inside a sequence with `delay` and/or `offset`, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist after the animation). - For `viewEnter` with `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. - `generate(config)` processes all interactions in the config, not just `viewEnter`. @@ -152,7 +155,7 @@ Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animatio - `[KEYFRAMES]` — array of keyframe objects (e.g. `[{ opacity: 0 }, { opacity: 1 }]`). Property names in camelCase. - `[EFFECT_NAME]` — unique string identifier for a `keyframeEffect`. - `[NAMED_EFFECT_DEFINITION]` — object with properties of pre-built effect from `@wix/motion-presets`. Refer to motion-presets rules for available presets and their options. -- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: use `'backwards'` when the animation's final keyframe has no additional effect (over element's base style); use `'both'` otherwise. +- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: default to `'backwards'` so the first keyframe applies during any `delay` (use `'both'` when the final keyframe must persist after the animation). - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. - `[DELAY_MS]` — optional delay before the effect starts, in milliseconds. diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index aba27765..d5b1e10d 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -25,10 +25,10 @@ import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/moti export const DEFAULT_INITIAL = [ { name: 'visibility', value: 'hidden' }, - { name: 'transform', value: 'none' }, - { name: 'translate', value: 'none' }, - { name: 'scale', value: 'none' }, - { name: 'rotate', value: 'none' }, + { name: 'transform', value: 'none', important: true }, + { name: 'translate', value: 'none', important: true }, + { name: 'scale', value: 'none', important: true }, + { name: 'rotate', value: 'none', important: true }, ]; const LIST_ANIMATION_PROPERTY_NAMES = [ diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index 5abd8e27..6a9c28eb 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -145,7 +145,9 @@ export function CSSRuleToString(rule: CSSRuleData): string { selector = applySelectorCondition(selector, selectorCondition); } - const declarationsStr = declarations.map(({ name, value }) => `${name}: ${value};`).join('\n'); + const declarationsStr = declarations + .map(({ name, value, important }) => `${name}: ${value}${important ? ' !important' : ''};`) + .join('\n'); const cssRule = `${selector} {\n${declarationsStr}\n}`; return media ? `@media ${media} {\n${cssRule}\n}` : cssRule; diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts index 433dd02d..7e7f05a9 100644 --- a/packages/interact/src/types/css.ts +++ b/packages/interact/src/types/css.ts @@ -19,7 +19,7 @@ export type ListCustomProps = { export type CSSRuleData = { key: string; childSelector?: string; - declarations: { name: string; value: string | number }[]; + declarations: { name: string; value: string | number; important?: boolean }[]; media?: string; states?: string[]; selectorCondition?: string; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 88c9ac3d..18a5834a 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -440,10 +440,11 @@ describe('css._generate', () => { )!; expect(initialRule).toBeDefined(); - DEFAULT_INITIAL.forEach(({ name, value }) => { + DEFAULT_INITIAL.forEach(({ name, value, important }) => { const decl = initialRule.declarations.find((d) => d.name === name); expect(decl, `expected DEFAULT_INITIAL declaration: ${name}`).toBeDefined(); expect(decl!.value).toBe(value); + expect(decl!.important).toBe(important); }); const animationRule = cssRules.find( @@ -456,6 +457,37 @@ describe('css._generate', () => { expect(animDeclOnInitial!.value).toContain('myAnim'); }); + it('should emit 0ms delay and backwards fill for viewEnter with explicit zero delay', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + effectId: 'kf1', + duration: 500, + delay: 0, + fill: 'backwards', + keyframeEffect: { + name: 'myAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const result = generate(config); + + expect(result).toContain('0ms'); + expect(result).not.toContain('1ms'); + expect(result).toContain('backwards'); + expect(result).toContain('visibility: hidden'); + }); + it('should inline animation declarations when initial is false (click trigger)', () => { const config: InteractConfig = { effects: {}, diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index 3a30b0c4..6c13107d 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -221,6 +221,20 @@ describe('CSSRuleToString', () => { expect(CSSRuleToString(rule)).toEqual(expected); }); + it('should serialize important declarations when flagged', () => { + const rule: CSSRuleData = { + key: 'my-el', + dataInteractEnterSelector: ':not([data-interact-enter])', + declarations: [ + { name: 'visibility', value: 'hidden', important: true }, + { name: 'transform', value: 'none', important: true }, + ], + }; + const expected = + '[data-interact-key="my-el"]:not([data-interact-enter]) {\nvisibility: hidden !important;\ntransform: none !important;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + it('should dataInteractEnterSelector when provided', () => { const rule: CSSRuleData = { key: 'my-el', diff --git a/packages/motion-presets/src/library/entrance/ArcIn.ts b/packages/motion-presets/src/library/entrance/ArcIn.ts index e6876044..1b0c1a46 100644 --- a/packages/motion-presets/src/library/entrance/ArcIn.ts +++ b/packages/motion-presets/src/library/entrance/ArcIn.ts @@ -1,5 +1,5 @@ import type { ArcIn, TimeAnimationOptions, EffectFourDirections, DomApi } from '../../types'; -import { toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const ROTATION_ANGLE = 80; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { return [ { ...options, + fill: getEntranceFill(options), name: fadeIn, duration: options.duration! * 0.7, easing: 'sineIn', @@ -54,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { }, { ...options, + fill: getEntranceFill(options), name: arcIn, easing, custom, diff --git a/packages/motion-presets/src/library/entrance/BlurIn.ts b/packages/motion-presets/src/library/entrance/BlurIn.ts index d5df1228..1b1cbf55 100644 --- a/packages/motion-presets/src/library/entrance/BlurIn.ts +++ b/packages/motion-presets/src/library/entrance/BlurIn.ts @@ -1,5 +1,5 @@ import type { BlurIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-blurIn']; @@ -23,6 +23,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.7, easing: 'sineIn', custom: {}, @@ -31,6 +32,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: blurIn, + fill: getEntranceFill(options), easing, composite: 'add' as const, // make sure we don't override existing filters on the component custom, diff --git a/packages/motion-presets/src/library/entrance/BounceIn.ts b/packages/motion-presets/src/library/entrance/BounceIn.ts index 4e10e063..88ee1722 100644 --- a/packages/motion-presets/src/library/entrance/BounceIn.ts +++ b/packages/motion-presets/src/library/entrance/BounceIn.ts @@ -1,4 +1,4 @@ -import { getEasingFamily, getEasing, toKeyframeValue, parseDirection } from '../../utils'; +import { getEasingFamily, getEasing, toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import type { BounceIn, TimeAnimationOptions } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -80,6 +80,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: (options.duration! * BOUNCE_KEYFRAMES[3].offset) / 100, custom: {}, @@ -88,6 +89,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: bounceIn, + fill: getEntranceFill(options), easing: 'linear', custom, keyframes, diff --git a/packages/motion-presets/src/library/entrance/CurveIn.ts b/packages/motion-presets/src/library/entrance/CurveIn.ts index dc4bc8ff..33b94287 100644 --- a/packages/motion-presets/src/library/entrance/CurveIn.ts +++ b/packages/motion-presets/src/library/entrance/CurveIn.ts @@ -1,5 +1,5 @@ import type { CurveIn, TimeAnimationOptions, DomApi } from '../../types'; -import { toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; import { TWO_SIDES_DIRECTIONS } from '../../consts'; const DEFAULT_DEPTH = { value: 300, unit: 'px' }; @@ -45,6 +45,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: curveIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -67,6 +68,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/DropIn.ts b/packages/motion-presets/src/library/entrance/DropIn.ts index 13eb7a87..3218a1fd 100644 --- a/packages/motion-presets/src/library/entrance/DropIn.ts +++ b/packages/motion-presets/src/library/entrance/DropIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions, DropIn } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-dropIn']; @@ -23,6 +23,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: options.duration! * 0.8, custom: {}, @@ -31,6 +32,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: dropIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/ExpandIn.ts b/packages/motion-presets/src/library/entrance/ExpandIn.ts index 3a997ed1..c3acdb01 100644 --- a/packages/motion-presets/src/library/entrance/ExpandIn.ts +++ b/packages/motion-presets/src/library/entrance/ExpandIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection } from '../../utils'; +import { getCssUnits, toKeyframeValue, parseLength, parseDirection, getEntranceFill } from '../../utils'; import type { ExpandIn } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -58,6 +58,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { easing, duration: options.duration! * 0.7, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -65,6 +66,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: expandIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/FadeIn.ts b/packages/motion-presets/src/library/entrance/FadeIn.ts index e4180504..4f25cd47 100644 --- a/packages/motion-presets/src/library/entrance/FadeIn.ts +++ b/packages/motion-presets/src/library/entrance/FadeIn.ts @@ -1,5 +1,7 @@ import { TimeAnimationOptions } from '../../types'; +import { getEntranceFill } from '../../utils'; + export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn']; } @@ -15,6 +17,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'sineInOut', keyframes: [{ offset: 0, opacity: 0 }], }, diff --git a/packages/motion-presets/src/library/entrance/FlipIn.ts b/packages/motion-presets/src/library/entrance/FlipIn.ts index 955e7946..148f4adf 100644 --- a/packages/motion-presets/src/library/entrance/FlipIn.ts +++ b/packages/motion-presets/src/library/entrance/FlipIn.ts @@ -1,4 +1,4 @@ -import { parseDirection, toKeyframeValue } from '../../utils'; +import { parseDirection, toKeyframeValue, getEntranceFill } from '../../utils'; import type { EffectFourDirections, FlipIn, TimeAnimationOptions } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -53,6 +54,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: flipIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/FloatIn.ts b/packages/motion-presets/src/library/entrance/FloatIn.ts index ce74df8e..97d28514 100644 --- a/packages/motion-presets/src/library/entrance/FloatIn.ts +++ b/packages/motion-presets/src/library/entrance/FloatIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions, FloatIn, EffectFourDirections } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -39,6 +39,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: floatIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -61,6 +62,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/FoldIn.ts b/packages/motion-presets/src/library/entrance/FoldIn.ts index c351b154..15a088d2 100644 --- a/packages/motion-presets/src/library/entrance/FoldIn.ts +++ b/packages/motion-presets/src/library/entrance/FoldIn.ts @@ -1,5 +1,5 @@ import type { EffectFourDirections, FoldIn, TimeAnimationOptions } from '../../types'; -import { parseDirection, toKeyframeValue } from '../../utils'; +import { parseDirection, toKeyframeValue, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; export function getNames(_: TimeAnimationOptions) { @@ -52,6 +52,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -59,6 +60,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: foldIn, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/GlideIn.ts b/packages/motion-presets/src/library/entrance/GlideIn.ts index 7efd98b5..96919f66 100644 --- a/packages/motion-presets/src/library/entrance/GlideIn.ts +++ b/packages/motion-presets/src/library/entrance/GlideIn.ts @@ -1,5 +1,5 @@ import type { TimeAnimationOptions, GlideIn } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection } from '../../utils'; +import { getCssUnits, toKeyframeValue, parseLength, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION = 180; @@ -55,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: glideIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -77,6 +78,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ opacity: 0, offset: 0, easing: 'step-start' }], }, diff --git a/packages/motion-presets/src/library/entrance/RevealIn.ts b/packages/motion-presets/src/library/entrance/RevealIn.ts index a30a4437..9f23baaa 100644 --- a/packages/motion-presets/src/library/entrance/RevealIn.ts +++ b/packages/motion-presets/src/library/entrance/RevealIn.ts @@ -1,5 +1,5 @@ import type { RevealIn, TimeAnimationOptions, EffectFourDirections } from '../../types'; -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -30,6 +30,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing, name: revealIn, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -43,6 +44,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/ShapeIn.ts b/packages/motion-presets/src/library/entrance/ShapeIn.ts index fb820841..677855d1 100644 --- a/packages/motion-presets/src/library/entrance/ShapeIn.ts +++ b/packages/motion-presets/src/library/entrance/ShapeIn.ts @@ -1,5 +1,5 @@ import type { Shape, ShapeIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue } from '../../utils'; +import { toKeyframeValue, getEntranceFill } from '../../utils'; export function getNames(_: TimeAnimationOptions) { return ['motion-fadeIn', 'motion-shapeIn']; @@ -39,6 +39,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'quadOut', duration: options.duration! * 0.8, custom: {}, @@ -47,6 +48,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: shapeIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/ShuttersIn.ts b/packages/motion-presets/src/library/entrance/ShuttersIn.ts index 55460f82..bd5b325a 100644 --- a/packages/motion-presets/src/library/entrance/ShuttersIn.ts +++ b/packages/motion-presets/src/library/entrance/ShuttersIn.ts @@ -1,5 +1,5 @@ import { ShuttersIn, TimeAnimationOptions, EffectFourDirections } from '../../types'; -import { getShuttersClipPaths, getEasing, toKeyframeValue, parseDirection } from '../../utils'; +import { getShuttersClipPaths, getEasing, toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'right'; @@ -32,6 +32,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { ...options, easing, name: shuttersIn, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -45,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ opacity: 0, offset: 0, easing: 'step-start' }], }, diff --git a/packages/motion-presets/src/library/entrance/SlideIn.ts b/packages/motion-presets/src/library/entrance/SlideIn.ts index a593fd54..d037f4fa 100644 --- a/packages/motion-presets/src/library/entrance/SlideIn.ts +++ b/packages/motion-presets/src/library/entrance/SlideIn.ts @@ -1,5 +1,5 @@ import type { EffectFourDirections, SlideIn, TimeAnimationOptions } from '../../types'; -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'left'; @@ -47,6 +47,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: slideIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ @@ -63,6 +64,7 @@ export function style(options: TimeAnimationOptions) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing, custom: {}, keyframes: [{ offset: 0, opacity: 0 }], diff --git a/packages/motion-presets/src/library/entrance/SpinIn.ts b/packages/motion-presets/src/library/entrance/SpinIn.ts index 7c4b4e6d..749e5837 100644 --- a/packages/motion-presets/src/library/entrance/SpinIn.ts +++ b/packages/motion-presets/src/library/entrance/SpinIn.ts @@ -1,5 +1,5 @@ import type { SpinIn, TimeAnimationOptions } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { SPIN_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: (typeof SPIN_DIRECTIONS)[number] = 'clockwise'; @@ -35,6 +35,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), easing: 'cubicIn', duration: options.duration! * initialScale, custom: {}, @@ -43,6 +44,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: spinIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/TiltIn.ts b/packages/motion-presets/src/library/entrance/TiltIn.ts index a6a13ecb..8af19eee 100644 --- a/packages/motion-presets/src/library/entrance/TiltIn.ts +++ b/packages/motion-presets/src/library/entrance/TiltIn.ts @@ -1,4 +1,4 @@ -import { getClipPolygonParams, toKeyframeValue, parseDirection, parseLength } from '../../utils'; +import { getClipPolygonParams, toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; import type { TiltIn, TimeAnimationOptions, EffectTwoSides } from '../../types'; import { TWO_SIDES_DIRECTIONS } from '../../consts'; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.2, easing: 'cubicOut', custom: {}, @@ -54,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: tiltInRotate, + fill: getEntranceFill(options), easing, custom: rotateCustom, keyframes: [ @@ -68,6 +70,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: tiltInClip, + fill: getEntranceFill(options), easing, composite: 'add' as const, duration: options.duration! * 0.8, diff --git a/packages/motion-presets/src/library/entrance/TurnIn.ts b/packages/motion-presets/src/library/entrance/TurnIn.ts index 10683750..8d5f679a 100644 --- a/packages/motion-presets/src/library/entrance/TurnIn.ts +++ b/packages/motion-presets/src/library/entrance/TurnIn.ts @@ -1,5 +1,5 @@ import type { TurnIn, TimeAnimationOptions, EffectFourCorners } from '../../types'; -import { toKeyframeValue, parseDirection } from '../../utils'; +import { toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; import { FOUR_CORNERS_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourCorners = 'top-left'; @@ -46,6 +46,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: fadeIn, + fill: getEntranceFill(options), duration: options.duration! * 0.6, easing: 'sineIn', custom: {}, @@ -54,6 +55,7 @@ export function style(options: TimeAnimationOptions, asWeb = false) { { ...options, name: turnIn, + fill: getEntranceFill(options), easing, custom, keyframes: [ diff --git a/packages/motion-presets/src/library/entrance/WinkIn.ts b/packages/motion-presets/src/library/entrance/WinkIn.ts index 3f2fab66..4ad4dfd1 100644 --- a/packages/motion-presets/src/library/entrance/WinkIn.ts +++ b/packages/motion-presets/src/library/entrance/WinkIn.ts @@ -1,4 +1,4 @@ -import { getClipPolygonParams, parseDirection } from '../../utils'; +import { getClipPolygonParams, parseDirection, getEntranceFill } from '../../utils'; import type { TimeAnimationOptions, WinkIn } from '../../types'; import { AXIS_DIRECTIONS } from '../../consts'; @@ -40,6 +40,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing: 'quadOut', name: fadeIn, + fill: getEntranceFill(options), custom: {}, keyframes: [{ offset: 0, opacity: 0 }], }, @@ -47,6 +48,7 @@ export function style(options: TimeAnimationOptions) { ...options, easing, name: winkInClip, + fill: getEntranceFill(options), custom, keyframes: [ { @@ -62,6 +64,7 @@ export function style(options: TimeAnimationOptions) { duration: options.duration! * 0.85, easing, name: winkInRotate, + fill: getEntranceFill(options), custom, keyframes: [ { diff --git a/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts new file mode 100644 index 00000000..0453be9b --- /dev/null +++ b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest'; + +import * as entrancePresets from '../index'; +import { baseMockOptions } from './testUtils'; +import type { TimeAnimationOptions } from '../../../types'; + +const presetEntries = Object.entries(entrancePresets) as [ + string, + { style: (options: TimeAnimationOptions) => unknown[] }, +][]; + +describe('entrance presets fill defaults', () => { + test.each(presetEntries)('%s defaults every animation descriptor to backwards fill', (_name, preset) => { + const mockOptions = { + ...baseMockOptions, + duration: 500, + namedEffect: { type: _name }, + } as TimeAnimationOptions; + + const result = preset.style(mockOptions); + + expect(result.length).toBeGreaterThan(0); + result.forEach((animation) => { + expect((animation as { fill?: string }).fill).toBe('backwards'); + }); + }); + + test.each(presetEntries)('%s preserves an explicit fill override', (_name, preset) => { + const mockOptions = { + ...baseMockOptions, + duration: 500, + fill: 'forwards', + namedEffect: { type: _name }, + } as TimeAnimationOptions; + + const result = preset.style(mockOptions); + + result.forEach((animation) => { + expect((animation as { fill?: string }).fill).toBe('forwards'); + }); + }); +}); diff --git a/packages/motion-presets/src/utils.ts b/packages/motion-presets/src/utils.ts index 852ad1d7..c5e043e9 100644 --- a/packages/motion-presets/src/utils.ts +++ b/packages/motion-presets/src/utils.ts @@ -4,6 +4,9 @@ import type { EffectScrollRange, Point, ScrubTransitionEasing, + AnimationFillMode, + AnimationData, + TimeAnimationOptions, } from '@wix/motion'; export type Direction = @@ -203,6 +206,10 @@ export function deg2rad(angleInDeg: number): number { return (angleInDeg * Math.PI) / 180; } +export function getEntranceFill(options: TimeAnimationOptions): AnimationFillMode { + return options.fill ?? 'backwards'; +} + export function getTransformParams( originDirection: { dx: number; dy: number }, angleInRad: number, diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index edc94cb1..4d61acf0 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -25,7 +25,7 @@ function getAnimationAsCSS( const isAutoDuration = duration === 'auto'; return `${animationName} ${isAutoDuration ? 'auto' : `${duration}ms`}${ - isAutoDuration ? ' ' : ` ${delay || 1}ms ` + isAutoDuration ? ' ' : ` ${delay ?? 0}ms ` }${easing}${fill && fill !== 'none' ? ` ${fill}` : ''} ${ !iterations || iterations === Infinity ? 'infinite' : iterations }${direction === 'normal' ? '' : ` ${direction}`} ${isRunning ? '' : 'paused'}`; diff --git a/packages/motion/test/motion.spec.ts b/packages/motion/test/motion.spec.ts index 01501233..5ba2cac8 100644 --- a/packages/motion/test/motion.spec.ts +++ b/packages/motion/test/motion.spec.ts @@ -315,12 +315,37 @@ describe('motion.ts', () => { expect(result[0]).toMatchObject({ target: '#test-target', - animation: 'fade-in 1000ms 1ms ease-in forwards 2 paused', + animation: 'fade-in 1000ms 0ms ease-in forwards 2 paused', name: 'fade-in', keyframes: [{ opacity: 0 }, { opacity: 1 }], }); }); + test('should preserve explicit zero delay in generated CSS animation shorthand', () => { + const animationOptions: AnimationOptions = { + namedEffect: { type: 'FadeIn', id: 'fade' }, + duration: 1000, + delay: 0, + }; + + const result = getCSSAnimation('test-target', animationOptions); + + expect(result[0].animation).toContain('0ms'); + expect(result[0].animation).not.toContain('1ms'); + }); + + test('should preserve positive delay in generated CSS animation shorthand', () => { + const animationOptions: AnimationOptions = { + namedEffect: { type: 'FadeIn', id: 'fade' }, + duration: 1000, + delay: 200, + }; + + const result = getCSSAnimation('test-target', animationOptions); + + expect(result[0].animation).toContain('200ms'); + }); + test('should handle named effects', () => { const animationOptions: AnimationOptions = { namedEffect: { diff --git a/skills/interactor/SKILL.md b/skills/interactor/SKILL.md index 012ae4ca..4251a271 100644 --- a/skills/interactor/SKILL.md +++ b/skills/interactor/SKILL.md @@ -244,9 +244,11 @@ animation no-ops. Apply them every time, even if you don't open a reference file 3. **FOUC prevention.** Follow the canonical CSS generation policy in `references/integration-recipes.md`. For the generated initial-rule behavior and trigger-specific exceptions, see “CSS generation & FOUC” in - `references/config-schema.md`. For `viewEnter` + `once` where source ≠ target, - `generate()` emits no hiding rules — set `fill: 'backwards'` on the effect so - targets don't flash before the trigger. + `references/config-schema.md`. Same-element `viewEnter` + `once` entrances get + author-important neutral initial rules from `generate()`. Always set + `fill: 'backwards'` on `viewEnter` + `once` animation effects (or `'both'` + when the final keyframe must persist) so delayed entrances hold their first + keyframe after the entrance marker is set. 4. **Vanilla binding.** You must then call the **standalone** `add(element, 'key')` for each element once it exists in the DOM. For clean up call the `remove('key')` function. diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index 5d83ea15..13cae68c 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -122,8 +122,10 @@ referenced entry and may override any of them (`key`, `duration`, `easing`, ``` **`fill` guidance:** use `'both'` for scroll/pointer-driven and for toggling -hover/click (`alternate`/`repeat`/`state`). Use `'backwards'` for `viewEnter` + -`once` entrances when source ≠ target (see [CSS generation & FOUC](#css-generation--fouc)). +hover/click (`alternate`/`repeat`/`state`). Default to `'backwards'` for all +`viewEnter` + `once` animation effects (including custom `keyframeEffect` +entrances) so any `delay` holds the first keyframe. Use `'both'` when the final +keyframe must persist after the animation (see [CSS generation & FOUC](#css-generation--fouc)). **`composite`:** `'replace'` (default) overwrites prior values; `'add'` concatenates transform/filter functions; `'accumulate'` sums matching function args @@ -360,12 +362,14 @@ selectors target `:first-child`; `false` for **vanilla** and **React**. The defa is `true`, so vanilla/React callers must pass `false` explicitly. **FOUC prevention (viewEnter + once):** For entrance animations where source and -target are the **same** element, `generate()` emits initial rules that hide the -target until its animation starts (gated by `:not([data-interact-enter])`). When -source ≠ target, `generate()` emits **no** hiding rules for the targets — so set -`fill: 'backwards'` on the effect to prevent FOUC. This matters most with motion-presets -`namedEffect`s. For `repeat`/`alternate`/`state`, inline the -starting keyframe and use `fill: 'both'`. `viewProgress` needs no FOUC rules. +target are the **same** element, `generate()` emits author-important initial rules +that hide the target and neutralize transforms until its animation starts (gated +by `:not([data-interact-enter])`). When source ≠ target, `generate()` emits **no** +hiding rules for the targets. In both cases, set `fill: 'backwards'` on every +`viewEnter` + `once` animation effect so any `delay` holds the first keyframe +after the entrance marker is set. Use `'both'` when the final keyframe must +persist. Entrance presets default to `backwards`. For `repeat`/`alternate`/`state`, +inline the starting keyframe and use `fill: 'both'`. `viewProgress` needs no FOUC rules. For when and where to emit this CSS, follow the canonical static/pre-rendered policy in `references/integration-recipes.md`. diff --git a/skills/interactor/references/triggers.md b/skills/interactor/references/triggers.md index 2a8e3a98..0acb5728 100644 --- a/skills/interactor/references/triggers.md +++ b/skills/interactor/references/triggers.md @@ -45,9 +45,10 @@ use **separate** source and target elements (trigger on a stable wrapper, animat child via `selector`, or point the effect at a different `key`). **FOUC:** `once` entrances need injected `generate()` CSS before first paint (see -`config-schema.md` and SKILL.md invariant 3). When source ≠ target (staggering -children via `selector`) `generate()` emits no hiding rules for those targets — also -set `fill: 'backwards'` on the effect so they don't flash before the trigger. +`config-schema.md` and SKILL.md invariant 3). Set `fill: 'backwards'` on every +`viewEnter` + `once` animation effect so any `delay` holds the first keyframe. +When source ≠ target (staggering children via `selector`) `generate()` emits no +hiding rules for those targets — `fill: 'backwards'` is still required. ```ts { interactions: [{ key: 'hero', trigger: 'viewEnter', params: { threshold: 0.2 }, diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index bf85c9aa..3b3481d7 100644 --- a/skills/interactor/references/validate.md +++ b/skills/interactor/references/validate.md @@ -108,7 +108,7 @@ Keep applying the semantic checklist in SKILL.md and trigger/preset references f - **Preset registry** — whether `namedEffect.type` is a registered preset or has valid options - **DOM / markup** — element existence for keys/selectors, matching `data-interact-key` / `interactKey` - **`registerEffects()` order** — unregistered presets log a warning, not a validation error -- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity +- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity (validator also emits `RECOMMENDED_FILL_BACKWARDS` when a `viewEnter` + `once` animation effect omits `backwards`/`both`) - **`overflow: clip`** — ancestors with `overflow: hidden` break `viewProgress` --- From ae755077e79643b8c9c2a41d180a8d2cd1c53bc6 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Mon, 27 Jul 2026 11:01:24 +0300 Subject: [PATCH 4/9] Format and cleanup --- packages/interact-validate/README.md | 2 +- packages/interact/rules/validate.md | 2 +- .../src/library/entrance/BounceIn.ts | 8 ++++- .../src/library/entrance/ExpandIn.ts | 8 ++++- .../src/library/entrance/GlideIn.ts | 8 ++++- .../src/library/entrance/ShuttersIn.ts | 8 ++++- .../src/library/entrance/TiltIn.ts | 8 ++++- .../entrance/test/entranceFill.spec.ts | 31 ++++++++++--------- packages/motion-presets/src/utils.ts | 1 - 9 files changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index c11aba73..dddda40d 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -210,7 +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` | +| `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` | diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 5bbbc8b4..1645cfcc 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -233,7 +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` | +| `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` | diff --git a/packages/motion-presets/src/library/entrance/BounceIn.ts b/packages/motion-presets/src/library/entrance/BounceIn.ts index 88ee1722..562aef99 100644 --- a/packages/motion-presets/src/library/entrance/BounceIn.ts +++ b/packages/motion-presets/src/library/entrance/BounceIn.ts @@ -1,4 +1,10 @@ -import { getEasingFamily, getEasing, toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; +import { + getEasingFamily, + getEasing, + toKeyframeValue, + parseDirection, + getEntranceFill, +} from '../../utils'; import type { BounceIn, TimeAnimationOptions } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; diff --git a/packages/motion-presets/src/library/entrance/ExpandIn.ts b/packages/motion-presets/src/library/entrance/ExpandIn.ts index c3acdb01..073c97c8 100644 --- a/packages/motion-presets/src/library/entrance/ExpandIn.ts +++ b/packages/motion-presets/src/library/entrance/ExpandIn.ts @@ -1,5 +1,11 @@ import type { TimeAnimationOptions } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection, getEntranceFill } from '../../utils'; +import { + getCssUnits, + toKeyframeValue, + parseLength, + parseDirection, + getEntranceFill, +} from '../../utils'; import type { ExpandIn } from '../../types'; import { FOUR_DIRECTIONS } from '../../consts'; diff --git a/packages/motion-presets/src/library/entrance/GlideIn.ts b/packages/motion-presets/src/library/entrance/GlideIn.ts index 96919f66..719e528b 100644 --- a/packages/motion-presets/src/library/entrance/GlideIn.ts +++ b/packages/motion-presets/src/library/entrance/GlideIn.ts @@ -1,5 +1,11 @@ import type { TimeAnimationOptions, GlideIn } from '../../types'; -import { getCssUnits, toKeyframeValue, parseLength, parseDirection, getEntranceFill } from '../../utils'; +import { + getCssUnits, + toKeyframeValue, + parseLength, + parseDirection, + getEntranceFill, +} from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION = 180; diff --git a/packages/motion-presets/src/library/entrance/ShuttersIn.ts b/packages/motion-presets/src/library/entrance/ShuttersIn.ts index bd5b325a..34a0ecb0 100644 --- a/packages/motion-presets/src/library/entrance/ShuttersIn.ts +++ b/packages/motion-presets/src/library/entrance/ShuttersIn.ts @@ -1,5 +1,11 @@ import { ShuttersIn, TimeAnimationOptions, EffectFourDirections } from '../../types'; -import { getShuttersClipPaths, getEasing, toKeyframeValue, parseDirection, getEntranceFill } from '../../utils'; +import { + getShuttersClipPaths, + getEasing, + toKeyframeValue, + parseDirection, + getEntranceFill, +} from '../../utils'; import { FOUR_DIRECTIONS } from '../../consts'; const DEFAULT_DIRECTION: EffectFourDirections = 'right'; diff --git a/packages/motion-presets/src/library/entrance/TiltIn.ts b/packages/motion-presets/src/library/entrance/TiltIn.ts index 8af19eee..543c81f5 100644 --- a/packages/motion-presets/src/library/entrance/TiltIn.ts +++ b/packages/motion-presets/src/library/entrance/TiltIn.ts @@ -1,4 +1,10 @@ -import { getClipPolygonParams, toKeyframeValue, parseDirection, parseLength, getEntranceFill } from '../../utils'; +import { + getClipPolygonParams, + toKeyframeValue, + parseDirection, + parseLength, + getEntranceFill, +} from '../../utils'; import type { TiltIn, TimeAnimationOptions, EffectTwoSides } from '../../types'; import { TWO_SIDES_DIRECTIONS } from '../../consts'; diff --git a/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts index 0453be9b..d77eb974 100644 --- a/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts +++ b/packages/motion-presets/src/library/entrance/test/entranceFill.spec.ts @@ -10,20 +10,23 @@ const presetEntries = Object.entries(entrancePresets) as [ ][]; describe('entrance presets fill defaults', () => { - test.each(presetEntries)('%s defaults every animation descriptor to backwards fill', (_name, preset) => { - const mockOptions = { - ...baseMockOptions, - duration: 500, - namedEffect: { type: _name }, - } as TimeAnimationOptions; - - const result = preset.style(mockOptions); - - expect(result.length).toBeGreaterThan(0); - result.forEach((animation) => { - expect((animation as { fill?: string }).fill).toBe('backwards'); - }); - }); + test.each(presetEntries)( + '%s defaults every animation descriptor to backwards fill', + (_name, preset) => { + const mockOptions = { + ...baseMockOptions, + duration: 500, + namedEffect: { type: _name }, + } as TimeAnimationOptions; + + const result = preset.style(mockOptions); + + expect(result.length).toBeGreaterThan(0); + result.forEach((animation) => { + expect((animation as { fill?: string }).fill).toBe('backwards'); + }); + }, + ); test.each(presetEntries)('%s preserves an explicit fill override', (_name, preset) => { const mockOptions = { diff --git a/packages/motion-presets/src/utils.ts b/packages/motion-presets/src/utils.ts index c5e043e9..85598d23 100644 --- a/packages/motion-presets/src/utils.ts +++ b/packages/motion-presets/src/utils.ts @@ -5,7 +5,6 @@ import type { Point, ScrubTransitionEasing, AnimationFillMode, - AnimationData, TimeAnimationOptions, } from '@wix/motion'; From 479e18507f7175ab4d9086c7275b5dbd3184249a Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Mon, 27 Jul 2026 22:38:48 +0300 Subject: [PATCH 5/9] Fixed PR comments --- packages/interact-validate/README.md | 40 +++++++------- .../src/semantic/collectSemanticWarnings.ts | 3 +- .../src/semantic/recommendedPatterns.ts | 54 +++++++++---------- packages/interact-validate/src/types.ts | 2 +- .../rules/recommendedFillBackwards.spec.ts | 45 ++++++++++++++-- packages/interact/docs/api/functions.md | 2 +- packages/interact/rules/full-lean.md | 2 +- packages/interact/rules/validate.md | 2 +- packages/interact/rules/viewenter.md | 4 +- packages/interact/test/css.spec.ts | 31 ----------- skills/interactor/references/config-schema.md | 8 +-- skills/interactor/references/validate.md | 2 +- 12 files changed, 97 insertions(+), 98 deletions(-) diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index dddda40d..5d6de63d 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -194,26 +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` | -| `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` | +| 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 diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index 11736462..7a4f9c7f 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -16,7 +16,7 @@ import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId, } from './partialData'; -import { checkRecommendedFill, checkRecommendedFillBackwards } from './recommendedPatterns'; +import { checkRecommendedFill } from './recommendedPatterns'; import { findAnimationEndWarnings } from './animationEndGraph'; // Single traversal of top-level registry effects/sequences and per-interaction @@ -76,7 +76,6 @@ 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)); }, diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index f8d00e6e..a3528bca 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -2,11 +2,11 @@ import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types'; import { RETRIGGER_TYPES } from '../types'; import { targetsSameElementAsSource } from './fouc'; -function isTimeAnimationEffect(effect: AnyEffect): boolean { - return !!(effect.namedEffect || effect.keyframeEffect || effect.customEffect); +function isKeyframeEffect(effect: AnyEffect): boolean { + return !!(effect.namedEffect || effect.keyframeEffect); } -// recommended `fill: 'both'` for scrubbed and toggling effects +// recommended fill for scrubbed, toggling, and viewEnter entrance effects export function checkRecommendedFill( path: Path, effect: AnyEffect, @@ -16,44 +16,40 @@ 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`; - 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', - }, - ]; -} -// recommended `fill: 'backwards'` (or `both`) for viewEnter once entrances -export function checkRecommendedFillBackwards( - path: Path, - effect: AnyEffect, - owner?: AnyInteraction, -): SemanticIssue[] { + 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 []; const triggerType = effect.triggerType ?? 'once'; if (triggerType !== 'once') return []; - if (!isTimeAnimationEffect(effect)) return []; - if (effect.fill === 'backwards' || effect.fill === 'both') return []; + if (!isKeyframeEffect(effect)) return []; + if (effect.fill === 'backwards') return []; const sameElement = targetsSameElementAsSource(owner, effect); - const reason = sameElement - ? 'viewEnter entrances with delay' - : 'viewEnter targets without FOUC hiding rules'; + if (sameElement && !effect.delay) return []; + const timing = sameElement + ? 'during its delay' + : 'before the animation starts on a target without FOUC hiding rules'; 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.`, + message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) so the starting keyframe applies ${timing}.`, severity: 'info', }, ]; diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 737cff1f..2d9c6140 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -40,9 +40,9 @@ export type AnyEffect = { triggerType?: string; stateAction?: string; fill?: string; + delay?: number; namedEffect?: { type?: string; range?: unknown; [k: string]: unknown }; keyframeEffect?: { name?: string; keyframes?: Array> }; - customEffect?: (element: Element, progress: number | { x: number; y: number }) => void; transition?: { styleProperties?: unknown[] }; transitionProperties?: unknown[]; rangeStart?: { offset?: { value?: number; unit?: string } }; diff --git a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts index 25df0f2b..aae6cb20 100644 --- a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts +++ b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts @@ -4,13 +4,20 @@ import { validateInteractConfig } from '../../src'; const CODE = 'RECOMMENDED_FILL_BACKWARDS'; describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => { - it('warns for a viewEnter once effect that omits fill', () => { + it('warns for a delayed same-element viewEnter once effect that omits fill', () => { const result = validateInteractConfig({ interactions: [ { key: 'el', trigger: 'viewEnter', - effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, triggerType: 'once' }], + effects: [ + { + namedEffect: { type: 'FadeIn' }, + duration: 400, + delay: 200, + triggerType: 'once', + }, + ], }, ], }); @@ -22,13 +29,13 @@ describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => { expect(result.valid).toBe(true); }); - it('warns for a viewEnter effect with implicit once triggerType', () => { + it('warns for a separate-target viewEnter effect with implicit once triggerType', () => { const result = validateInteractConfig({ interactions: [ { - key: 'el', + key: 'source', trigger: 'viewEnter', - effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + effects: [{ key: 'target', namedEffect: { type: 'FadeIn' }, duration: 400 }], }, ], }); @@ -127,6 +134,34 @@ describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => { expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); }); + it('does not warn for a same-element entrance without delay', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a customEffect entrance', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'source', + trigger: 'viewEnter', + effects: [{ key: 'target', customEffect: () => {}, duration: 400 }], + }, + ], + }); + + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + it('does not warn for non-viewEnter triggers', () => { const result = validateInteractConfig({ interactions: [ diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index 56556be8..64f82da3 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -249,7 +249,7 @@ For entrance animations where the source and target are the same element, `gener 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`. +For any entrance animation, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist). Entrance presets default to `backwards`. For `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 6c5c8ac7..5a3587fd 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -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'` — 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. +- `'backwards'` — use for entrance animations (`viewEnter` + `once`). Entrance presets default to `'backwards'`; set it explicitly for inline `keyframeEffect` entrances. Use `'both'` when the final keyframe must persist after the animation. **`composite`** — same as CSS's `animation-composition`. Controls how this effect combines with others on the same property (transforms & filters): diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 1645cfcc..07d46944 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -233,7 +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` | +| `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 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` | diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index 1b9b9078..7cb924b0 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -23,7 +23,7 @@ This document contains rules for generating interactions that respond to element **Solution:** Call `generate(config, useFirstChild)` at build/generation time and embed the resulting CSS before first paint. Among all the CSS it produces, `generate()` includes initial rules that hide entrance-animated elements from the moment the page renders, before JS runs. The rules use `:not([data-interact-enter])` so elements become visible once the animation starts. -For any `delay`, set `fill: 'backwards'` (or `'both'` when the final keyframe must persist) so the starting keyframe applies during the delay after `data-interact-enter` is set. Entrance presets default to `backwards`; custom `keyframeEffect` entrances should set it explicitly. +For entrance animations, set `fill: 'backwards'` (or `'both'` when the final keyframe must persist). Entrance presets default to `backwards`; inline `keyframeEffect` entrances should set it explicitly. ### Generate CSS at build time and embed in HTML @@ -92,7 +92,7 @@ const css = generate(config, useFirstChild); // true for web; false for react/va - Inject runtime-generated CSS before the corresponding `Interact.create()` call and before revealing initially hidden content to minimize FOUC. - FOUC initial rules apply only to `viewEnter` + `triggerType: 'once'` (or no `triggerType`, which defaults to `'once'`) where source and target are the same element. -- For `viewEnter` + `once` entrances with any `delay`, or inside a sequence with `delay` and/or `offset`, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist after the animation). +- For `viewEnter` + `once` entrance animations, use `fill: 'backwards'` (or `'both'` when the final keyframe must persist after the animation). - For `viewEnter` with `triggerType: 'repeat'`/`'alternate'`/`'state'`, manually apply the starting keyframe as inline styles on the target element and use `fill: 'both'`. - `generate(config)` processes all interactions in the config, not just `viewEnter`. diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 18a5834a..dc57ede2 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -457,37 +457,6 @@ describe('css._generate', () => { expect(animDeclOnInitial!.value).toContain('myAnim'); }); - it('should emit 0ms delay and backwards fill for viewEnter with explicit zero delay', () => { - const config: InteractConfig = { - effects: {}, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - effects: [ - { - effectId: 'kf1', - duration: 500, - delay: 0, - fill: 'backwards', - keyframeEffect: { - name: 'myAnim', - keyframes: [{ opacity: '0' }, { opacity: '1' }], - }, - }, - ], - }, - ], - }; - - const result = generate(config); - - expect(result).toContain('0ms'); - expect(result).not.toContain('1ms'); - expect(result).toContain('backwards'); - expect(result).toContain('visibility: hidden'); - }); - it('should inline animation declarations when initial is false (click trigger)', () => { const config: InteractConfig = { effects: {}, diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index 13cae68c..8db1c478 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -122,10 +122,10 @@ referenced entry and may override any of them (`key`, `duration`, `easing`, ``` **`fill` guidance:** use `'both'` for scroll/pointer-driven and for toggling -hover/click (`alternate`/`repeat`/`state`). Default to `'backwards'` for all -`viewEnter` + `once` animation effects (including custom `keyframeEffect` -entrances) so any `delay` holds the first keyframe. Use `'both'` when the final -keyframe must persist after the animation (see [CSS generation & FOUC](#css-generation--fouc)). +hover/click (`alternate`/`repeat`/`state`). Entrance presets default to +`'backwards'`. Set `fill: 'backwards'` explicitly for `viewEnter` + `once` +`keyframeEffect` entrances. Use `'both'` when the final keyframe must persist +after the animation (see [CSS generation & FOUC](#css-generation--fouc)). **`composite`:** `'replace'` (default) overwrites prior values; `'add'` concatenates transform/filter functions; `'accumulate'` sums matching function args diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index 3b3481d7..7d82c7bf 100644 --- a/skills/interactor/references/validate.md +++ b/skills/interactor/references/validate.md @@ -108,7 +108,7 @@ Keep applying the semantic checklist in SKILL.md and trigger/preset references f - **Preset registry** — whether `namedEffect.type` is a registered preset or has valid options - **DOM / markup** — element existence for keys/selectors, matching `data-interact-key` / `interactKey` - **`registerEffects()` order** — unregistered presets log a warning, not a validation error -- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity (validator also emits `RECOMMENDED_FILL_BACKWARDS` when a `viewEnter` + `once` animation effect omits `backwards`/`both`) +- **FOUC / `generate()`** — CSS injection, `useFirstChild` parity (validator also emits `RECOMMENDED_FILL_BACKWARDS` when a `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `backwards`/`both`) - **`overflow: clip`** — ancestors with `overflow: hidden` break `viewProgress` --- From d173ab193a18ec5670eeaf184c51a023b0979d18 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Tue, 28 Jul 2026 11:10:23 +0300 Subject: [PATCH 6/9] Fix phrasing of fill in viewenter rules --- packages/interact/rules/viewenter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index 7cb924b0..0b1c600b 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -155,7 +155,7 @@ Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animatio - `[KEYFRAMES]` — array of keyframe objects (e.g. `[{ opacity: 0 }, { opacity: 1 }]`). Property names in camelCase. - `[EFFECT_NAME]` — unique string identifier for a `keyframeEffect`. - `[NAMED_EFFECT_DEFINITION]` — object with properties of pre-built effect from `@wix/motion-presets`. Refer to motion-presets rules for available presets and their options. -- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: default to `'backwards'` so the first keyframe applies during any `delay` (use `'both'` when the final keyframe must persist after the animation). +- `[FILL_MODE]` — `'both'` for `triggerType: 'alternate'`, `'repeat'`, or `'state'`. For `triggerType: 'once'`: use `'backwards'` for entrance, so the first keyframe applies during any `delay` (use `'both'` when the final keyframe must persist after the animation). - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. - `[DELAY_MS]` — optional delay before the effect starts, in milliseconds. From 9f422d41fddd8e9b2940343890a34e8ab7ad7e9b Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Tue, 28 Jul 2026 20:51:15 +0300 Subject: [PATCH 7/9] Update packages/interact-validate/src/semantic/recommendedPatterns.ts Co-authored-by: Ameer Abu-Fraiha --- packages/interact-validate/src/semantic/recommendedPatterns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index a3528bca..514073cb 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -36,7 +36,7 @@ export function checkRecommendedFill( const triggerType = effect.triggerType ?? 'once'; if (triggerType !== 'once') return []; if (!isKeyframeEffect(effect)) return []; - if (effect.fill === 'backwards') return []; + if (effect.fill === 'backwards' || effect.fill === 'both') return []; const sameElement = targetsSameElementAsSource(owner, effect); if (sameElement && !effect.delay) return []; From 6ec1bffc5b42e4a104ecee19e4bf6dde24b8c77e Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Tue, 28 Jul 2026 21:07:31 +0300 Subject: [PATCH 8/9] Simplified recommendation for fill on entrance --- .../src/semantic/recommendedPatterns.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index 514073cb..decf7bab 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -1,6 +1,5 @@ import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types'; import { RETRIGGER_TYPES } from '../types'; -import { targetsSameElementAsSource } from './fouc'; function isKeyframeEffect(effect: AnyEffect): boolean { return !!(effect.namedEffect || effect.keyframeEffect); @@ -38,18 +37,12 @@ export function checkRecommendedFill( if (!isKeyframeEffect(effect)) return []; if (effect.fill === 'backwards' || effect.fill === 'both') return []; - const sameElement = targetsSameElementAsSource(owner, effect); - if (sameElement && !effect.delay) return []; - const timing = sameElement - ? 'during its delay' - : 'before the animation starts on a target without FOUC hiding rules'; - return [ { code: 'custom', params: { domainCode: 'RECOMMENDED_FILL_BACKWARDS' }, path: [...path, 'fill'], - message: `Include \`fill: 'backwards'\` (or \`'both'\` when the final keyframe must persist) so the starting keyframe applies ${timing}.`, + 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', }, ]; From aabda487a93a031f63c6584188b01a66c640b319 Mon Sep 17 00:00:00 2001 From: Yehonatan Daniv Date: Tue, 28 Jul 2026 21:11:28 +0300 Subject: [PATCH 9/9] Fix tests --- .../test/rules/recommendedFillBackwards.spec.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts index aae6cb20..30983a3b 100644 --- a/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts +++ b/packages/interact-validate/test/rules/recommendedFillBackwards.spec.ts @@ -134,20 +134,6 @@ describe('recommendedFillBackwards — RECOMMENDED_FILL_BACKWARDS', () => { expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); }); - it('does not warn for a same-element entrance without delay', () => { - const result = validateInteractConfig({ - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], - }, - ], - }); - - expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); - }); - it('does not warn for a customEffect entrance', () => { const result = validateInteractConfig({ interactions: [