diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c70eb010..17288036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: - name: Build run: yarn build + - name: Verify generated rules are up to date + run: yarn workspace @wix/interact build:rules --check + - name: Lint run: yarn lint diff --git a/.gitignore b/.gitignore index b6fa3ad0..8716bf49 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ pnpm-lock.yaml package-lock.json .yarn/ tmp/ +.cursor/plans/ diff --git a/.prettierignore b/.prettierignore index ad72a0ac..5601c3b8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,6 @@ package-lock.json **/build/** index.html .yarnrc.yml + +# Build pipeline fragments use markers that Prettier must not reformat +packages/interact/_content/fragments/ diff --git a/packages/interact/_content/data/effects.mjs b/packages/interact/_content/data/effects.mjs new file mode 100644 index 00000000..fb59b00d --- /dev/null +++ b/packages/interact/_content/data/effects.mjs @@ -0,0 +1,120 @@ +export const effects = { + triggerTypes: ['once', 'repeat', 'alternate', 'state'], + + easings: [ + 'linear', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'sineIn', + 'sineOut', + 'sineInOut', + 'quadIn', + 'quadOut', + 'quadInOut', + 'cubicIn', + 'cubicOut', + 'cubicInOut', + 'quartIn', + 'quartOut', + 'quartInOut', + 'quintIn', + 'quintOut', + 'quintInOut', + 'expoIn', + 'expoOut', + 'expoInOut', + 'circIn', + 'circOut', + 'circInOut', + 'backIn', + 'backOut', + 'backInOut', + ], + + transitionEasings: ['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce'], + + presets: { + entrance: [ + 'FadeIn', + 'GlideIn', + 'SlideIn', + 'FloatIn', + 'RevealIn', + 'ExpandIn', + 'BlurIn', + 'FlipIn', + 'ArcIn', + 'ShuttersIn', + 'CurveIn', + 'DropIn', + 'FoldIn', + 'ShapeIn', + 'TiltIn', + 'WinkIn', + 'SpinIn', + 'TurnIn', + 'BounceIn', + ], + ongoing: [ + 'Pulse', + 'Spin', + 'Breathe', + 'Bounce', + 'Wiggle', + 'Flash', + 'Flip', + 'Fold', + 'Jello', + 'Poke', + 'Rubber', + 'Swing', + 'Cross', + ], + scroll: [ + 'FadeScroll', + 'RevealScroll', + 'ParallaxScroll', + 'MoveScroll', + 'SlideScroll', + 'GrowScroll', + 'ShrinkScroll', + 'TiltScroll', + 'PanScroll', + 'BlurScroll', + 'FlipScroll', + 'SpinScroll', + 'ArcScroll', + 'ShapeScroll', + 'ShuttersScroll', + 'SkewPanScroll', + 'Spin3dScroll', + 'StretchScroll', + 'TurnScroll', + ], + mouse: [ + 'TrackMouse', + 'Tilt3DMouse', + 'Track3DMouse', + 'SwivelMouse', + 'AiryMouse', + 'ScaleMouse', + 'BlurMouse', + 'SkewMouse', + 'BlobMouse', + ], + }, + + rangeNames: { + cover: 'full visibility span from first pixel entering to last pixel leaving.', + entry: 'the phase while the element is entering the viewport.', + exit: 'the phase while the element is exiting the viewport.', + contain: + 'while the element is fully contained in the viewport. Typically used with a `position: sticky` container.', + 'entry-crossing': + "from the element's leading edge entering to its leading edge reaching the opposite side.", + 'exit-crossing': + "from the element's trailing edge reaching the start to its trailing edge leaving.", + }, +}; diff --git a/packages/interact/_content/data/meta.mjs b/packages/interact/_content/data/meta.mjs new file mode 100644 index 00000000..f3d0efd7 --- /dev/null +++ b/packages/interact/_content/data/meta.mjs @@ -0,0 +1,11 @@ +export const meta = { + packageName: '@wix/interact', + presetsPackage: '@wix/motion-presets', + motionPackage: '@wix/motion', + installCommand: 'npm install @wix/interact @wix/motion-presets', + entryPoints: { + web: '@wix/interact/web', + react: '@wix/interact/react', + vanilla: '@wix/interact', + }, +}; diff --git a/packages/interact/_content/data/triggers.mjs b/packages/interact/_content/data/triggers.mjs new file mode 100644 index 00000000..790ea099 --- /dev/null +++ b/packages/interact/_content/data/triggers.mjs @@ -0,0 +1,203 @@ +// -------------------------------------------------------------------------- +// Event triggers — rendered by event-trigger-rule.mjs (click.md, hover.md) +// -------------------------------------------------------------------------- +export const triggers = [ + { + name: 'hover', + a11yAlias: 'interest', + a11yNote: + "Use `trigger: 'interest'` instead of `trigger: 'hover'` to also respond to keyboard focus.", + defaultTriggerType: 'alternate', + params: [], + pitfalls: [{ id: 'hit-area', sections: { fullLean: 'full-lean-hover' } }], + showMultipleEffectsNote: true, + hasReversed: false, + hasEffectId: false, + triggerTypeDescriptions: { + alternate: { + full: 'plays forward on enter, reverses on leave. Default. Most common for hover.', + short: 'Play on enter, reverse on leave', + }, + repeat: { + full: 'restarts the animation from the beginning on each enter. On leave, jumps to the beginning and pauses.', + short: 'Play on enter, stop and rewind on leave', + }, + once: { + full: 'plays once on the first enter and never again.', + short: 'Play once on first enter only', + }, + state: { + full: 'resumes on enter, pauses on leave. Useful for continuous loops (`iterations: Infinity`).', + short: 'Play on enter, pause on leave', + }, + }, + stateActionDescriptions: { + toggle: { + full: 'applies the style state on enter, removes on leave. Default.', + short: 'Add style state on enter, remove on leave', + }, + add: { + full: 'applies the style state on enter. Leave does NOT remove it.', + short: 'Add style state on enter; leave does NOT remove', + }, + remove: { + full: "removes a previously applied style state on enter. Use with provided `effectId` to map to a matching interaction with `add` and effect with same `effectId`.", + short: 'Remove style state on enter', + }, + clear: { + full: "clears all previously applied style states on enter. Use to reset multiple stacked `'add'` style changes at once (e.g. a \"reset\" hover area that undoes several accumulated states).", + short: 'Clear/reset all style states on enter', + }, + }, + }, + + { + name: 'click', + a11yAlias: 'activate', + a11yNote: + "Use `trigger: 'activate'` instead of `trigger: 'click'` to also respond to keyboard activation (Enter / Space).", + defaultTriggerType: 'alternate', + params: [], + pitfalls: [], + showMultipleEffectsNote: false, + hasReversed: true, + hasEffectId: true, + triggerTypeDescriptions: { + alternate: { + full: 'plays forward on first click, reverses on next click. Default.', + short: 'Alternate play/reverse per click', + }, + repeat: { + full: 'restarts the animation from the beginning on each click.', + short: 'Restart per click', + }, + once: { + full: 'plays once on the first click and never again.', + short: 'Play once on first click only', + }, + state: { + full: 'resumes/pauses the animation on each click. Useful for continuous loops (`iterations: Infinity`).', + short: 'Toggle play/pause per click', + }, + }, + stateActionDescriptions: { + toggle: { + full: 'applies the style state, removes it on next click. Default.', + short: 'Toggle style state per click', + }, + add: { + full: 'applies the style state. Does not remove on subsequent clicks.', + short: 'Add style state on click', + }, + remove: { + full: 'removes a previously applied style state.', + short: 'Remove style state on click', + }, + clear: { + full: 'clears all previously applied style states. Useful for resetting multiple stacked style states at once.', + short: 'Clear/reset all style states', + }, + }, + }, + +// -------------------------------------------------------------------------- +// Viewport/scroll triggers — each has its own dedicated template. +// -------------------------------------------------------------------------- + { + name: 'viewEnter', + defaultTriggerType: 'once', + params: [ + { + name: 'threshold', + varName: 'VISIBILITY_THRESHOLD', + type: 'number', + optional: true, + description: + 'Number between 0–1 indicating how much of the source element must be visible to trigger (e.g. `0.3` = 30%).', + }, + { + name: 'inset', + varName: 'VIEWPORT_INSETS', + type: 'string', + optional: true, + description: + "String adjusting the viewport detection area (e.g. `'-100px'` extends it, `'50px'` shrinks it).", + }, + ], + pitfalls: [{ id: 'same-element-viewenter', sections: { trigger: 'short', fullLean: 'long' } }], + showMultipleEffectsNote: true, + triggerTypeDescriptions: { + once: { + full: 'plays once when the source element first enters the viewport and never again. Source and target may be the same element.', + short: 'Play once on first enter only', + }, + repeat: { + full: 'restarts the animation every time the source element enters the viewport. Use separate source and target.', + short: 'Restart on each viewport enter', + }, + alternate: { + full: 'plays forward when the source element enters the viewport, reverses when it leaves. Use separate source and target.', + short: 'Play on enter, reverse on leave', + }, + state: { + full: 'resumes on enter, pauses on leave. Useful for continuous loops (`iterations: Infinity`). Use separate source and target.', + short: 'Play on enter, pause on leave', + }, + }, + }, + + { + name: 'viewProgress', + defaultTriggerType: null, + params: [], + pitfalls: [{ id: 'overflow-clip', sections: { trigger: 'short', fullLean: 'long' } }], + showMultipleEffectsNote: true, + }, + + // -------------------------------------------------------------------------- + // Pointer/chaining triggers + // -------------------------------------------------------------------------- + { + name: 'pointerMove', + defaultTriggerType: null, + params: [ + { + name: 'hitArea', + type: "'root' | 'self'", + optional: true, + description: 'determines where mouse movement is tracked', + }, + { + name: 'axis', + type: "'x' | 'y'", + optional: true, + description: 'restricts pointer tracking to a single axis', + }, + ], + pitfalls: [{ id: 'hit-area', sections: { trigger: 'pointermove-source', fullLean: 'full-lean-pointermove' } }], + showMultipleEffectsNote: true, + }, + + { + name: 'animationEnd', + defaultTriggerType: null, + params: [ + { + name: 'effectId', + type: 'string', + optional: false, + description: 'ID of the preceding effect', + }, + ], + pitfalls: [], + showMultipleEffectsNote: false, + }, + + { + name: 'pageVisible', + defaultTriggerType: null, + params: [], + pitfalls: [], + showMultipleEffectsNote: false, + }, +]; diff --git a/packages/interact/_content/fragments/README.md b/packages/interact/_content/fragments/README.md new file mode 100644 index 00000000..1d8119b3 --- /dev/null +++ b/packages/interact/_content/fragments/README.md @@ -0,0 +1,25 @@ +# Fragments + +Reusable markdown content with `` markers for granular inclusion at different detail levels. + +## Section Naming Conventions + +| Pattern | When to use | Example | +| :------------------------- | :------------------------------------------------------------- | :--------------------------- | +| `#default` | Fragment has only one section | `pitfalls/perspective.md` | +| `#brief` / `#detailed` | Two detail levels of the same content | `config-structure.md` | +| `#short` / `#long` | Short and long versions of the same concept | `fouc.md`, `overflow-clip.md`| +| `#` | Trigger-specific variant (e.g. `hover`, `viewEnter`) | `hit-area.md`, `multiple-effects-note.md` | +| `#full-lean` / `#full-lean-` | Condensed wording used by the full-lean reference | `overflow-clip.md`, `hit-area.md` | +| `#code-` | Code example variant (e.g. `code-web`, `code-react`) | `fouc.md`, `quick-start.md` | +| `#` / `#-` | Named concept sections | `element-resolution.md` | + +When adding a new section, follow the closest existing pattern. Prefer descriptive names over generic ones when the section has trigger-specific or template-specific content. + +## Parameter Conventions + +Fragments use `{{paramName}}` placeholders for interpolation. Some conventions: + +- **`{{key}}`** — element key. Pass `'hero'` for concrete examples, `'[SOURCE_KEY]'` for template placeholders. +- **`{{classAttr}}`** — an HTML attribute string **including the leading space** (e.g. `' class="hero"'`), or empty string `''` when omitted. The leading space is required because the placeholder is adjacent to the tag name (``). +- **`{{installCommand}}`**, **`{{webEntry}}`**, etc. — package metadata from `data/meta.mjs`, passed as `metaParams` by the build script. diff --git a/packages/interact/_content/fragments/config-structure.md b/packages/interact/_content/fragments/config-structure.md new file mode 100644 index 00000000..b4068fe7 --- /dev/null +++ b/packages/interact/_content/fragments/config-structure.md @@ -0,0 +1,32 @@ + +```ts +type InteractConfig = { + interactions: Interaction[]; // REQUIRED + effects?: Record; // reusable effects referenced by effectId + sequences?: Record; // reusable sequences by sequenceId + conditions?: Record; // named conditions; keys are condition ids +}; +``` + +All cross-references (by id) MUST point to existing entries. Element keys MUST be stable for the config's lifetime. + + +### InteractConfig + +```typescript +type InteractConfig = { + interactions: Interaction[]; + effects?: Record; + sequences?: Record; + conditions?: Record; +}; +``` + +| Field | Description | +| :------------- | :---------------------------------------------------------------------- | +| `interactions` | Required. Array of interaction definitions binding triggers to effects. | +| `effects?` | Reusable effects referenced by `effectId` from interactions. | +| `sequences?` | Reusable sequence definitions, referenced by `sequenceId`. | +| `conditions?` | Named conditions (media/container/selector queries), referenced by ID. | + +Each call to `Interact.create(config)` creates a new `Interact` instance. A single config can define multiple interactions. diff --git a/packages/interact/_content/fragments/element-resolution.md b/packages/interact/_content/fragments/element-resolution.md new file mode 100644 index 00000000..68fa75d5 --- /dev/null +++ b/packages/interact/_content/fragments/element-resolution.md @@ -0,0 +1,40 @@ + +For simple use cases, `key` on the interaction matches the element, and the same element is both trigger source and animation target. The fields below are only needed for advanced patterns (lists, delegated triggers, child targeting). + +### Source element resolution (Interaction level) + +The source element is what the trigger attaches to. Resolved in priority order: + +1. **`listContainer` + `listItemSelector`** — trigger attaches to each element matching `listItemSelector` within the `listContainer`. Use `listItemSelector` only when you need to **filter** which children participate (e.g. select only `.active` items). If all immediate children should participate, omit `listItemSelector`. +2. **`listContainer` only** — trigger attaches to each immediate child of the container. This is the common case for lists. +3. **`listContainer` + `selector`** — trigger attaches to the element found via `querySelector` within each immediate child of the container. +4. **`selector` only** — trigger attaches to all elements matching `querySelectorAll` within the root ``. +5. **Fallback** — first child of `` (web) or the root element (react/vanilla). + +### Target element resolution (Effect level) + +The target element is what the effect animates. Resolved in priority order: + +1. **`Effect.key`** — the `` with matching `data-interact-key`. +2. **Registry Effect's `key`** — if the effect is an `EffectRef`, the `key` from the referenced registry entry is used. +3. **Fallback to `Interaction.key`** — the same `key` is used for the source will be used for the target. +4. After resolving the root target, `selector`, `listContainer`, and `listItemSelector` on the effect further refine which child elements within that target are animated (same priority order as source resolution). + +#### Source element resolution (Interaction level) + +The source element is what the trigger attaches to. Resolved in priority order: + +1. **`listContainer` + `listItemSelector`** — matches only the elements matching `listItemSelector` within the the `listContainer`. +2. **`listContainer` only** — trigger attaches to all immediate children of the container (common case). +3. **`listContainer` + `selector`** — matches via `querySelector` within each immediate child of the container. +4. **`selector` only** — matches via `querySelectorAll` within the root element. +5. **Fallback** — first child of `` (web) or the root element (react/vanilla). + +#### Target element resolution (Effect level) + +The target element is what the effect animates. Resolved in priority order: + +1. **`Effect.key`** — the root with matching `data-interact-key`. +2. **Registry Effect's `key`** — if the effect is an `EffectRef`, the `key` from the referenced registry entry is used. +3. **Fallback to `Interaction.key`** — the source element acts as the target's root. +4. After resolving the target's root, `selector`, `listContainer`, and `listItemSelector` on the effect further refine which child elements within that target are animated (same priority order as source resolution). diff --git a/packages/interact/_content/fragments/fouc.md b/packages/interact/_content/fragments/fouc.md new file mode 100644 index 00000000..e2f87038 --- /dev/null +++ b/packages/interact/_content/fragments/fouc.md @@ -0,0 +1,30 @@ + +**Append to `` or beginning of ``:** + +```html + +``` + +**Web (Custom Elements):** + +```html + + ... + +``` + +**React:** + +```tsx + + ... + +``` + +**Vanilla:** + +```html +
...
+``` diff --git a/packages/interact/_content/fragments/multiple-effects-note.md b/packages/interact/_content/fragments/multiple-effects-note.md new file mode 100644 index 00000000..b9f33146 --- /dev/null +++ b/packages/interact/_content/fragments/multiple-effects-note.md @@ -0,0 +1,8 @@ + +**Multiple effects:** The `effects` array can contain multiple effects — all share the same {{triggerName}} trigger and fire together. Use this to animate different targets from a single {{triggerEvent}}. + +**Multiple effects:** The `effects` array can contain multiple effects — all share the same viewEnter trigger and fire together when the element enters the viewport. Each effect can have its own `triggerType`. Use this to animate different targets from a single viewport entry event. + +**Multiple effects:** The `effects` array can contain multiple effects — all are driven by the same scroll progress. Use this to animate different targets or properties in sync with the same scroll position. + +**Multiple effects:** The `effects` array can contain multiple effects — all share the same pointer tracking and fire together. Use this to animate different targets from the same pointer movement. diff --git a/packages/interact/_content/fragments/pitfalls/dont-guess-presets.md b/packages/interact/_content/fragments/pitfalls/dont-guess-presets.md new file mode 100644 index 00000000..7852bb4d --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/dont-guess-presets.md @@ -0,0 +1,2 @@ + +- **CRITICAL — Do NOT guess preset options**: If you don't know the expected type/structure for a `namedEffect` param, omit it — rely on defaults rather than guessing. diff --git a/packages/interact/_content/fragments/pitfalls/hit-area.md b/packages/interact/_content/fragments/pitfalls/hit-area.md new file mode 100644 index 00000000..70afd3c9 --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/hit-area.md @@ -0,0 +1,13 @@ + +- **CRITICAL**: MUST AVOID using the same element as both trigger source and effect target with effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. + +- **CRITICAL**: MUST AVOID using the same element as both source and target with effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. + +- **CRITICAL - Hit-area shift**: When a hover effect changes the size or position of the hovered element (e.g., `transform: scale(…)`), MUST use a separate source and target elements. Otherwise the hit-area shifts, causing rapid enter/leave events and flickering. Use `selector` to target a child element, or set the effect's `key` to a different element. + +- **CRITICAL**: For `pointerMove` trigger MUST AVOID using the same element as both source and target with `hitArea: 'self'` and effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. + +When using `hitArea: 'self'`, the source element is the hit area for pointer tracking: + +- The source element **MUST NOT** have `pointer-events: none` — it needs to receive pointer events. +- **CRITICAL**: MUST AVOID using the same element as both source and target with effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. diff --git a/packages/interact/_content/fragments/pitfalls/overflow-clip.md b/packages/interact/_content/fragments/pitfalls/overflow-clip.md new file mode 100644 index 00000000..1545efd1 --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/overflow-clip.md @@ -0,0 +1,6 @@ + +> **CRITICAL:** You MUST replace all usage of `overflow: hidden` with `overflow: clip` on every element between the trigger source element and the scroll container. `overflow: hidden` creates a new scroll context that breaks the ViewTimeline; `overflow: clip` clips overflow visually without affecting scroll ancestry. If using Tailwind, replace all `overflow-hidden` classes with `overflow-clip`. + +- **CRITICAL — `overflow: hidden` breaks `viewProgress`**: Replace with `overflow: clip` on all ancestors between source and scroll container. In Tailwind, replace `overflow-hidden` with `overflow-clip`. + +**CRITICAL:** Replace ALL `overflow: hidden` with `overflow: clip` on every element between the trigger source and the scroll container. `overflow: hidden` creates a new scroll context that breaks ViewTimeline. In Tailwind replace `overflow-hidden` with `overflow-clip`. diff --git a/packages/interact/_content/fragments/pitfalls/perspective.md b/packages/interact/_content/fragments/pitfalls/perspective.md new file mode 100644 index 00000000..8ddcc117 --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/perspective.md @@ -0,0 +1,2 @@ + +- **Perspective**: Prefer `transform: perspective(...)` inside keyframes. Use the CSS `perspective` property only when multiple children share the same `perspective-origin`. diff --git a/packages/interact/_content/fragments/pitfalls/reduced-motion.md b/packages/interact/_content/fragments/pitfalls/reduced-motion.md new file mode 100644 index 00000000..5ce7d125 --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/reduced-motion.md @@ -0,0 +1,2 @@ + +- **Reduced motion**: Use conditions to provide gentler alternatives (shorter durations, fewer transforms, no perpetual motion) for users who prefer reduced motion. You can also set `Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches` to force a global reduced-motion behavior programmatically. diff --git a/packages/interact/_content/fragments/pitfalls/same-element-viewenter.md b/packages/interact/_content/fragments/pitfalls/same-element-viewenter.md new file mode 100644 index 00000000..d1445ff9 --- /dev/null +++ b/packages/interact/_content/fragments/pitfalls/same-element-viewenter.md @@ -0,0 +1,6 @@ + +> **CRITICAL:** When the source (trigger) and target (effect) elements are the **same element**, use ONLY `triggerType: 'once'`. For all other types (`'repeat'`, `'alternate'`, `'state'`), MUST use **separate** source and target elements — animating the observed element itself can cause it to leave/re-enter the viewport, leading to rapid re-triggers or the animation never firing. + +- **CRITICAL**: When using `viewEnter` trigger and source (trigger) and target (effect) elements are the **same element**, use ONLY `triggerType: 'once'`. For all other types (`'repeat'`, `'alternate'`, `'state'`) MUST use **separate** source and target elements — animating the observed element itself can cause it to leave/re-enter the viewport, leading to rapid re-triggers or the animation never firing. + +**CRITICAL:** When source and target are the **same element**, MUST use `triggerType: 'once'`. For `'repeat'` / `'alternate'` / `'state'`, ALWAYS use **separate** source and target elements — animating the observed element can cause it to leave/re-enter the viewport, causing rapid re-triggers. diff --git a/packages/interact/_content/fragments/progress-type.md b/packages/interact/_content/fragments/progress-type.md new file mode 100644 index 00000000..c9915bfa --- /dev/null +++ b/packages/interact/_content/fragments/progress-type.md @@ -0,0 +1,23 @@ + +When using `customEffect` with `pointerMove`, the progress parameter is an object: + +```typescript +type Progress = { + x: number; // 0-1: horizontal position (0 = left edge, 1 = right edge) + y: number; // 0-1: vertical position (0 = top edge, 1 = bottom edge) + v?: { + x: number; // Horizontal velocity: negative = moving left, positive = moving right. Magnitude reflects speed. + y: number; // Vertical velocity: negative = moving up, positive = moving down. Magnitude reflects speed. + }; + active?: boolean; // Whether mouse is currently in the hit area +}; +``` + +**Progress object** (for `customEffect`): + +```ts +{ x: number; y: number; v?: { x: number; y: number }; active?: boolean } +// x, y: 0–1 normalized position within hit area +// v: velocity vector (unbounded, typically -1 to 1 range at moderate speed; 0 = stationary) +// active: whether pointer is within the active hit area +``` diff --git a/packages/interact/_content/fragments/quick-start.md b/packages/interact/_content/fragments/quick-start.md new file mode 100644 index 00000000..eb689414 --- /dev/null +++ b/packages/interact/_content/fragments/quick-start.md @@ -0,0 +1,80 @@ + +```bash +{{installCommand}} +``` + +**Web (Custom Elements):** + +```ts +import { Interact } from '{{webEntry}}'; +const instance = Interact.create(config); +``` + +The `config` object is an `InteractConfig` containing `interactions` (required), and optionally shared `effects`, `sequences`, and `conditions`. + +```typescript +import { Interact } from '{{webEntry}}'; + +Interact.create(config); +``` + +The `config` object contains `interactions` (trigger-effect bindings), and optionally `effects`, `sequences`, and `conditions`. See [Configuration Schema](#configuration-schema) for full details. + +**React:** + +- Wrap the `Interact.create()` call in a `useEffect` hook to prevent it from running on server-side. +- Store the returned instance, and call its `.destroy()` method on the effect's cleanup function. + +```ts +import { useEffect } from 'react'; +import { Interact } from '{{reactEntry}}'; + +useEffect(() => { + const instance = Interact.create(config); + + return () => { + instance.destroy(); + }; +}, [config]); +``` + +**Vanilla JS:** + +```ts +import { Interact } from '{{vanillaEntry}}'; +const instance = Interact.create(config); +instance.add(element, 'hero'); // bind after element exists in DOM +instance.remove('hero'); // unregister +``` + +```typescript +import { Interact } from '{{vanillaEntry}}'; + +const interact = Interact.create(config); +interact.add(element, 'hero'); +``` + +**CDN (no build tools):** + +```html + +``` + +**Registering presets** — MUST be called before calling `Interact.create()` with usage of `namedEffect`: + +```ts +import * as presets from '{{presetsPackage}}'; +Interact.registerEffects(presets); +``` + +Or selectively: + +```ts +import { FadeIn, ParallaxScroll } from '{{presetsPackage}}'; +Interact.registerEffects({ FadeIn, ParallaxScroll }); +``` + +Create the full config up-front and pass it in a single `create` call. Subsequent calls create new `Interact` instances. When creating multiple instances, each manages its own set of interactions independently — use separate instances for isolated component scopes or lazy-loaded sections. diff --git a/packages/interact/_content/fragments/sequences.md b/packages/interact/_content/fragments/sequences.md new file mode 100644 index 00000000..b9287100 --- /dev/null +++ b/packages/interact/_content/fragments/sequences.md @@ -0,0 +1,96 @@ + +Coordinate multiple effects with staggered timing. Prefer sequences over manual delay stagger. + +### Sequence As type + +```ts +{ + effects: (Effect | EffectRef)[]; // REQUIRED + delay?: number; // ms before sequence starts + offset?: number; // ms between each child's animation start + offsetEasing?: string; // easing curve for staggering offsets + sequenceId?: string; // for caching/referencing + conditions?: string[]; // ids referencing the top-level conditions map +} +``` + +### Template + +```ts +{ + interactions: [ + { + key: '[SOURCE_KEY]', + trigger: '[TRIGGER]', + params: [TRIGGER_PARAMS], + sequences: [ + { + offset: [OFFSET_MS], // optional + offsetEasing: '[OFFSET_EASING]', // optional + delay: [DELAY_MS], // optional + effects: [ + // if used `listContainer` each item in the list is a target of a child effect + { + effectId: '[EFFECT_ID]', + listContainer: '[LIST_CONTAINER_SELECTOR]', + }, + // if multiple effects are given each generated effect is added to the sequence + ], + }, + ], + }, + ], + effects: { + '[EFFECT_ID]': { + // effect definition (namedEffect, keyframeEffect, or customEffect) + }, + }, +} +``` + +### Variables + +- `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for web/vanilla, `interactKey` for React). +- `[TRIGGER]` — any trigger for time-based animation effects (e.g., `'viewEnter'`, `'activate'`, `'interest'`). +- `[TRIGGER_PARAMS]` — trigger-specific parameters (e.g., `{ type: 'once', threshold: 0.3 }`). +- `[OFFSET_MS]` — ms between each child's animation start. +- `[OFFSET_EASING]` — CSS easing string or named easing from `@wix/motion`. +- `[DELAY_MS]` — optional. Base delay (ms) before the entire sequence starts. +- `[EFFECT_ID]` — string key referencing an entry in the top-level `effects` map. +- `[LIST_CONTAINER_SELECTOR]` — optional. CSS selector for the container whose children will be staggered. + +Reusable sequences can be defined in `InteractConfig.sequences` and referenced by `sequenceId`. + + +Sequences coordinate multiple effects with staggered timing. + +```typescript +{ + offset: number, // ms between consecutive items + offsetEasing: string, // Any valid easing string for stagger distribution curve + delay: number, // ms base delay before the sequence starts + effects: [ + /* ... effect definitions */, + ], +} +``` + +Define reusable sequences in `InteractConfig.sequences` and reference by `sequenceId`: + +```typescript +{ + sequences: { + 'stagger-fade': { + /* ... sequence definition */ + }, + }, + interactions: [ + { + key: '[SOURCE_KEY]', + trigger: '[TRIGGER]', + params: [TRIGGER_PARAMS], + sequences: [{ sequenceId: 'stagger-fade' }], + }, + ], +} +``` diff --git a/packages/interact/_content/fragments/static-api.md b/packages/interact/_content/fragments/static-api.md new file mode 100644 index 00000000..7dee2b9c --- /dev/null +++ b/packages/interact/_content/fragments/static-api.md @@ -0,0 +1,34 @@ + +| Method / Property | Description | +| :---------------------------------- | :------------------------------------------------------------------------------------------------------------ | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | +| `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `create`. | +| `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | +| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | +| `Interact.allowA11yTriggers` | `boolean` (default: `false`) — enable accessibility trigger variants (`interest`, `activate`). | +| `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | + +**`Interact.setup(options)`** — optional configuration object: + +| Option | Type | Description | +| :--------------------- | :----------------------------- | :-------------------------------------------------------------------- | +| `scrollOptionsGetter` | `() => Partial` | Function returning defaults for scroll-driven animation configuration | +| `pointerOptionsGetter` | `() => Partial` | Function returning defaults for pointer-move animation configuration | +| `viewEnter` | `Partial` | Defaults for all viewEnter triggers (`threshold`,`inset`) | +| `allowA11yTriggers` | `boolean` | Enable accessibility trigger variants (use `interest` and `activate`) | + +Use `setup()` when you need to override default observer thresholds or provide global configuration that applies to all interactions of a given trigger type. + +Each `Interact.create()` call returns an instance. Store instances and call `instance.destroy()` when no longer needed (e.g. on component unmount) to prevent stale listeners and memory leaks. + + +Each `Interact.create(config)` call returns an instance. Keep a reference if you need to add/remove elements dynamically (vanilla JS) or to destroy a specific instance. Call `Interact.destroy()` to tear down all instances at once (e.g. on page navigation). + +| Method / Property | Description | +| :---------------------------------- | :------------------------------------------------------------------------------------------- | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Multiple configs create separate instances. | +| `Interact.registerEffects(presets)` | Register named effect presets before `create`. Required for `namedEffect` usage. | +| `Interact.destroy()` | Tear down all instances. | +| `Interact.forceReducedMotion` | `boolean` — force reduced-motion behavior regardless of OS setting. Default: `false`. | +| `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `false`. | +| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | diff --git a/packages/interact/_content/templates/_helpers.mjs b/packages/interact/_content/templates/_helpers.mjs new file mode 100644 index 00000000..94eb891f --- /dev/null +++ b/packages/interact/_content/templates/_helpers.mjs @@ -0,0 +1,58 @@ +export function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Builds the pitfalls block for a trigger template. + * Iterates trigger.pitfalls from data, resolving each fragment section. + */ +export function buildPitfallsBlock(trigger, fragments) { + if (!trigger.pitfalls?.length) return ''; + return trigger.pitfalls + .map((p) => fragments.get(`pitfalls/${p.id}`, p.sections?.trigger || trigger.name)) + .join('\n'); +} + +const COMMON_VARS = { + SOURCE_KEY: (suffix) => + `identifier matching the element's key (\`data-interact-key\` for web, \`interactKey\` for React).${suffix ? ` ${suffix}` : ''}`, + TARGET_KEY: (desc) => + desc || "identifier matching the element's key on the element that animates.", + 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.', + KEYFRAMES: () => + 'array of keyframe objects (e.g. `[{ opacity: 0 }, { opacity: 1 }]`). Property names in camelCase.', + FILL_MODE: (desc) => + desc || "fill mode for the animation (`'none'`, `'forwards'`, `'backwards'`, `'both'`).", + DURATION_MS: () => 'animation duration in milliseconds.', + EASING_FUNCTION: (desc) => + desc || 'CSS easing string or named easing from `@wix/motion`.', + DELAY_MS: () => 'optional delay before the effect starts, in milliseconds.', + ITERATIONS: (desc) => + desc || 'optional. Number of iterations, or `Infinity` for continuous loops.', + ALTERNATE_BOOL: (suffix) => + `optional. \`true\` to alternate direction on every other iteration (within a single playback).${suffix ? ` ${suffix}` : ''}`, + UNIQUE_EFFECT_ID: () => + 'optional. String identifier used by `animationEnd` triggers for chaining, and by sequences for referencing effects from the top-level `effects` map.', + CUSTOM_EFFECT_CALLBACK: () => + 'function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with the target element and `progress` from 0 to 1.', + TRANSITION_DURATION_MS: () => + 'optional number. Milliseconds for smoothing (interpolating) between progress updates. The animation does not jump to the new progress value instantly; instead it transitions over this duration. Use to add inertia/lag to the effect, making it feel more physical (e.g. `200`–`600`).', + TRANSITION_EASING: () => + 'optional string. CSS easing or named easing from `@wix/motion`. Adds a natural deceleration feel when used with `transitionDuration`.', + CENTERED_TO_TARGET: () => + '`true` or `false`. See **Centering with `centeredToTarget`** above.', + HIT_AREA: () => + "`'self'` (track pointer within source element) or `'root'` (track pointer anywhere in viewport).", + VISIBILITY_THRESHOLD: () => + 'optional. Number between 0–1 indicating how much of the source element must be visible to trigger (e.g. `0.3` = 30%).', + VIEWPORT_INSETS: () => + "optional. String adjusting the viewport detection area (e.g. `'-100px'` extends it, `'50px'` shrinks it).", +}; + +export function varLine(name, extra) { + const fn = COMMON_VARS[name]; + if (!fn) throw new Error(`Unknown common variable: ${name}`); + return `- \`[${name}]\` — ${fn(extra)}`; +} diff --git a/packages/interact/_content/templates/event-trigger-rule.mjs b/packages/interact/_content/templates/event-trigger-rule.mjs new file mode 100644 index 00000000..ef41e2db --- /dev/null +++ b/packages/interact/_content/templates/event-trigger-rule.mjs @@ -0,0 +1,263 @@ +import { capitalize, buildPitfallsBlock, varLine } from './_helpers.mjs'; + +const OVERRIDES = { + hover: { + vars: { + SOURCE_KEY: 'The element that listens for hover.', + TARGET_KEY: + "identifier matching the element's key on the element that animates. Use a different key from `[SOURCE_KEY]` when source and target must be separated (see hit-area shift above).", + FILL_MODE: + "usually `'both'`. Keeps the final state applied while hovering, and prevents garbage-collection of animation when finished.", + EASING_FUNCTION: + "CSS easing string (e.g. `'ease-out'`, `'ease-in-out'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`), or named easing from `@wix/motion`.", + ITERATIONS: + "optional. Number of iterations, or `Infinity` for continuous loops. Primarily useful with `triggerType: 'state'`.", + ALTERNATE_BOOL: '', + }, + fillCritical: + "Always include `fill: 'both'` for `triggerType: 'alternate'`, `'repeat'` — keeps the effect applied while hovering and prevents garbage-collection. For `triggerType: 'once'` use `fill: 'backwards'`.", + customEffectExamples: '', + offsetEasingSuffix: ' CSS easing string, or named easing from `@wix/motion`.', + }, + click: { + vars: { + SOURCE_KEY: 'The element that listens for clicks.', + TARGET_KEY: + "identifier matching the element's key on the element that animates. If missing it defaults to `[SOURCE_KEY]` for targeting the source element.", + FILL_MODE: + "optional. Always `'both'` with `triggerType: 'alternate'` or `'repeat'`, otherwise depends on the effect.", + EASING_FUNCTION: 'CSS easing string, or named easing from `@wix/motion`.', + ALTERNATE_BOOL: "Different from `triggerType: 'alternate'` which alternates per click.", + }, + fillCritical: + "Always include `fill: 'both'` for `triggerType: 'alternate'` or `'repeat'` — keeps the effect applied while finished and prevents garbage-collection, allowing efficient toggling. For `triggerType: 'once'` use `fill: 'backwards'`.", + customEffectExamples: ', randomized behavior', + offsetEasingSuffix: '', + }, +}; + +function buildVariables(trigger, vo, hasReversed, hasEffectId) { + const v = vo.vars; + const lines = [ + varLine('SOURCE_KEY', v.SOURCE_KEY), + varLine('TARGET_KEY', v.TARGET_KEY), + `- \`[TRIGGER_TYPE]\` — \`triggerType\` on the effect. One of:`, + ...Object.entries(trigger.triggerTypeDescriptions).map( + ([k, d]) => ` - \`'${k}'\` — ${d.full}`, + ), + varLine('KEYFRAMES'), + varLine('EFFECT_NAME'), + varLine('NAMED_EFFECT_DEFINITION'), + varLine('FILL_MODE', v.FILL_MODE), + ]; + if (hasReversed) { + lines.push( + `- \`[INITIAL_REVERSED_BOOL]\` — optional. \`true\` to start in the finished state so the entire effect is reversed.`, + ); + } + lines.push( + varLine('DURATION_MS'), + varLine('EASING_FUNCTION', v.EASING_FUNCTION), + varLine('DELAY_MS'), + varLine('ITERATIONS', v.ITERATIONS), + varLine('ALTERNATE_BOOL', v.ALTERNATE_BOOL), + ); + if (hasEffectId) { + lines.push(varLine('UNIQUE_EFFECT_ID')); + } + return lines.join('\n'); +} + +/** + * Renders a trigger-specific rule file (click.md or hover.md). + * @param {{ triggers: object[], effects: object, meta: object, trigger: object }} data + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const { trigger } = data; + const { name } = trigger; + const vo = OVERRIDES[name]; + if (!vo) throw new Error(`Unknown event trigger: ${name}`); + const Name = capitalize(name); + const hasReversed = trigger.hasReversed; + const hasEffectId = trigger.hasEffectId; + + const pitfalls = buildPitfallsBlock(trigger, fragments); + + const multipleEffectsNote = trigger.showMultipleEffectsNote + ? `\n${fragments.get('multiple-effects-note', 'default', { triggerName: name, triggerEvent: `${name} event` })}\n` + : ''; + + return `# ${Name} Trigger Rules for ${data.meta.packageName} + +This document contains rules for generating ${name}-triggered interactions in \`${data.meta.packageName}\`. + +**CRITICAL — Accessible ${name}**: ${trigger.a11yNote} +${pitfalls ? `\n${pitfalls}\n` : ''} +## Table of Contents + +- [Rule 1: keyframeEffect / namedEffect (TimeEffect)](#rule-1-keyframeeffect--namedeffect-timeeffect) +- [Rule 2: transition / transitionProperties (StateEffect)](#rule-2-transition--transitionproperties-stateeffect) +- [Rule 3: customEffect (TimeEffect)](#rule-3-customeffect-timeeffect) +- [Rule 4: Sequences](#rule-4-sequences) + +--- + +## Rule 1: keyframeEffect / namedEffect (TimeEffect) + +Use \`keyframeEffect\` or \`namedEffect\` when the ${name} should play an animation (CSS or WAAPI). Set \`triggerType\` on each effect to control playback behavior. + +**CRITICAL:** ${vo.fillCritical} +${multipleEffectsNote} +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: '${name}', + effects: [ + { + key: '[TARGET_KEY]', + triggerType: '[TRIGGER_TYPE]', + + // --- pick ONE of the two effect types --- + keyframeEffect: { + name: '[EFFECT_NAME]', + keyframes: [KEYFRAMES], + }, + // OR + namedEffect: [NAMED_EFFECT_DEFINITION], + + fill: '[FILL_MODE]',${hasReversed ? `\n reversed: [INITIAL_REVERSED_BOOL],` : ''} + duration: [DURATION_MS], + easing: '[EASING_FUNCTION]', + delay: [DELAY_MS], + iterations: [ITERATIONS], + alternate: [ALTERNATE_BOOL]${hasEffectId ? `,\n effectId: '[UNIQUE_EFFECT_ID]'` : ''} + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +${buildVariables(trigger, vo, hasReversed, hasEffectId)} + +--- + +## Rule 2: transition / transitionProperties (StateEffect) + +Use \`transition\` or \`transitionProperties\` when the ${name} should toggle styles via DOM attribute change and CSS transitions rather than keyframe animations. Set \`stateAction\` on the effect to control how the style is applied. + +Use \`transition\` when all properties share timing. Use \`transitionProperties\` when each property needs independent \`duration\`, \`delay\`, or \`easing\`. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: '${name}', + effects: [ + { + key: '[TARGET_KEY]', + stateAction: '[STATE_ACTION]', + + // --- pick ONE of the two transition forms --- + transition: { + duration: [DURATION_MS], + delay: [DELAY_MS], + easing: '[EASING_FUNCTION]', + styleProperties: [ + { name: '[CSS_PROP]', value: '[VALUE]' }, + // ... more properties + ] + }, + // OR (when each property needs its own timing) + transitionProperties: [ + { + name: '[CSS_PROP]', + value: '[VALUE]', + duration: [DURATION_MS], + delay: [DELAY_MS], + easing: '[EASING_FUNCTION]' + }, + // ... more properties + ] + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` — same as Rule 1. +- \`[STATE_ACTION]\` — \`stateAction\` on the effect. One of: +${Object.entries(trigger.stateActionDescriptions) + .map(([k, v]) => ` - \`'${k}'\` — ${v.full}`) + .join('\n')} +- \`[CSS_PROP]\` — CSS property name as a string in camelCase format (e.g. \`'backgroundColor'\`, \`'borderRadius'\`, \`'opacity'\`). +- \`[VALUE]\` — target CSS value for the property. +- \`[DURATION_MS]\` — transition duration in milliseconds. +- \`[DELAY_MS]\` — optional transition delay in milliseconds. +- \`[EASING_FUNCTION]\` — CSS easing string, or named easing from \`@wix/motion\`. + +--- + +## Rule 3: customEffect (TimeEffect) + +Use \`customEffect\` when you need imperative control over the animation (e.g. counters, canvas drawing, custom DOM manipulation${vo.customEffectExamples || ''}). The callback receives the target element and a \`progress\` value (0–1) driven by the animation timeline. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: '${name}', + effects: [ + { + key: '[TARGET_KEY]', + triggerType: '[TRIGGER_TYPE]', + customEffect: [CUSTOM_EFFECT_CALLBACK], + duration: [DURATION_MS], + easing: '[EASING_FUNCTION]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` / \`[TRIGGER_TYPE]\` — same as Rule 1. +- \`[CUSTOM_EFFECT_CALLBACK]\` — function with signature \`(element: HTMLElement, progress: number) => void\`. Called on each animation frame with the target element and \`progress\` from 0 to 1. +- \`[DURATION_MS]\` — animation duration in milliseconds. +- \`[EASING_FUNCTION]\` — CSS easing string, or named easing from \`@wix/motion\`. + +--- + +## Rule 4: Sequences + +Use sequences when a ${name} should sync/stagger animations across multiple elements. Set \`triggerType\` on the sequence config to control playback behavior. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: '${name}', + sequences: [ + { + triggerType: '[TRIGGER_TYPE]', + offset: [OFFSET_MS], + offsetEasing: '[OFFSET_EASING]', + effects: [ + [EFFECT_DEFINITION], + // .. more effects as necessary + ] + } + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TRIGGER_TYPE]\` — same as Rule 1. \`triggerType\` is set on the sequence config, not on individual effects within the sequence. +- \`[OFFSET_MS]\` — time offset for staggering each child's animation start, in milliseconds. +- \`[OFFSET_EASING]\` — easing curve for the offset staggering distribution.${vo.offsetEasingSuffix || ''} Defaults to \`'linear'\`. +- \`[EFFECT_DEFINITION]\` — a definition of or a reference to a time-based animation effect. +`; +} diff --git a/packages/interact/_content/templates/full-lean.mjs b/packages/interact/_content/templates/full-lean.mjs new file mode 100644 index 00000000..8c005616 --- /dev/null +++ b/packages/interact/_content/templates/full-lean.mjs @@ -0,0 +1,558 @@ +import { capitalize } from './_helpers.mjs'; + +function collectFullLeanPitfalls(triggers) { + return triggers.flatMap((t) => + (t.pitfalls || []) + .filter((p) => p.sections?.fullLean) + .map((p) => ({ id: p.id, section: p.sections.fullLean })), + ); +} + +function requireTrigger(triggers, name) { + const t = triggers.find((t) => t.name === name); + if (!t) throw new Error(`Trigger "${name}" not found in data/triggers.mjs`); + return t; +} + +function buildMarkdownTable(headers, rows) { + return [ + `| ${headers.join(' | ')} |`, + `| ${headers.map(() => ':---').join(' | ')} |`, + ...rows.map((r) => `| ${r.join(' | ')} |`), + ].join('\n'); +} + +function buildBehaviorTable(headerLabel, behaviorKey, triggers, { defaultKey } = {}) { + const keys = [...new Set(triggers.flatMap((t) => Object.keys(t[behaviorKey])))]; + + const rows = keys.map((k) => { + const isDefault = k === defaultKey; + const label = isDefault ? `\`'${k}'\` (default)` : `\`'${k}'\``; + return [label, ...triggers.map((t) => t[behaviorKey][k]?.short ?? '—')]; + }); + + return buildMarkdownTable( + [headerLabel, ...triggers.map((t) => `${t.name} behavior`)], + rows, + ); +} + +/** + * Renders full-lean.md — the comprehensive reference for all triggers, effects, and API surface. + * + * Static prose sections are kept inline — they interleave with dynamic content and have a single + * consumer, so extraction would add indirection without deduplication benefit. + * + * @param {{ triggers: object[], effects: object, meta: object }} data — no `trigger`; receives the full data object + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const hover = requireTrigger(data.triggers, 'hover'); + const click = requireTrigger(data.triggers, 'click'); + const viewEnter = requireTrigger(data.triggers, 'viewEnter'); + + const triggerTypeUnion = data.effects.triggerTypes.map((t) => `'${t}'`).join(' | '); + const rangeNameUnion = Object.keys(data.effects.rangeNames) + .map((n) => `'${n}'`) + .join(' | '); + const easingList = data.effects.easings.map((e) => `\`'${e}'\``).join(', '); + + const presetRows = Object.entries(data.effects.presets).map(([category, names]) => [ + capitalize(category), + `\`${names.join('`, `')}\``, + ]); + const presetTable = buildMarkdownTable(['Category', 'Presets'], presetRows) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + + const rangeTable = buildMarkdownTable( + ['Range name', 'Meaning'], + Object.entries(data.effects.rangeNames).map(([name, desc]) => [`\`${name}\``, desc]), + ); + + const { metaParams } = data; + + return `# ${data.meta.packageName} — Rules + +Declarative configuration-driven interaction library. Binds animations to triggers via JSON config. + +## Table of Contents + +- [Common Pitfalls](#common-pitfalls) +- [Quick Start](#quick-start) +- [Element Binding](#element-binding) +- [Config Structure](#config-structure) +- [Interactions](#interactions) +- [Triggers](#triggers) + - [hover / click](#hover--click) + - [viewEnter](#viewenter) + - [viewProgress](#viewprogress) + - [pointerMove](#pointermove) + - [animationEnd](#animationend) +- [Effects](#effects) + - [Time-based Effect](#time-based-effect) + - [Scroll / Pointer-driven Effect](#scroll--pointer-driven-effect) + - [State Effect](#stateeffect-css-style-toggle) + - [Animation Payloads](#animation-payloads) +- [Sequences](#sequences) +- [Conditions](#conditions) +- [FOUC Prevention](#fouc-prevention) +- [Element Resolution](#element-resolution) +- [Static API](#static-api) + +--- + +## Common Pitfalls + +Each item here is CRITICAL — ignoring any of them will break animations. + +${collectFullLeanPitfalls(data.triggers).map((p) => fragments.get(`pitfalls/${p.id}`, p.section)).join('\n')} +${fragments.get('pitfalls/dont-guess-presets', 'default')} +${fragments.get('pitfalls/reduced-motion', 'default')} +${fragments.get('pitfalls/perspective', 'default')} + +--- + +## Quick Start + +${fragments.get('quick-start', 'install', metaParams)} + +${fragments.get('quick-start', 'multiple-instances')} + +${fragments.get('quick-start', 'web', metaParams)} + +${fragments.get('quick-start', 'react', metaParams)} + +${fragments.get('quick-start', 'vanilla', metaParams)} + +${fragments.get('quick-start', 'cdn', metaParams)} + +${fragments.get('quick-start', 'register-presets', metaParams)} + +--- + +## Element Binding + +**CRITICAL:** Do NOT add observers/event listeners manually. The runtime binds triggers and effects via element keys. + +### Web: \`\` + +- MUST set \`data-interact-key\` to a unique value. +- MUST contain at least one child element (the library targets \`.firstElementChild\`). +- If an effect targets a different element, that element also needs its own \`\`. + +\`\`\`html + +
...
+
+\`\`\` + +### React: \`\` component + +- MUST set \`tagName\` to the replaced element's HTML tag. +- MUST set \`interactKey\` to a unique string. + +\`\`\`tsx +import { Interaction } from '${data.meta.entryPoints.react}'; + + + ... +; +\`\`\` + +--- + +## Config Structure + +${fragments.get('config-structure', 'detailed')} + +--- + +## Interactions + +Each interaction maps a source element + trigger to one or more effects. + +**Multiple effects per interaction:** A single interaction can contain multiple effects in its \`effects\` array. All effects in the same interaction share the same trigger — they all fire together when the trigger activates. Use this to apply different animations to different targets from the same trigger event, rather than creating separate interactions with duplicate trigger configs. + +\`\`\`ts +{ + key: string; // REQUIRED — matches data-interact-key / interactKey - the root element + trigger: TriggerType; // REQUIRED + params?: TriggerParams; // trigger-specific options + effects?: (Effect | EffectRef)[]; // possible to add multiple effects for same trigger + sequences?: (SequenceConfig | SequenceConfigRef)[]; // possible to add multiple sequences for same trigger + conditions?: string[]; // ids referencing the top-level conditions map; all must pass + selector?: string; // optional - CSS selector to refine source element selection within the root element + listContainer?: string; // optional — CSS selector for list container + listItemSelector?: string; // optional — CSS selector to filter which children of listContainer are observed as sources +} +\`\`\` + +At least one of \`effects\` or \`sequences\` MUST be provided. + +For most use cases, \`key\` alone is sufficient for both source and target resolution. The \`selector\`, \`listContainer\`, and \`listItemSelector\` fields are only needed for advanced patterns (lists, delegated triggers, child targeting). See [Element Resolution](#element-resolution) for details. + +--- + +## Triggers + +- **interactions: Interaction[]** + - **Purpose**: Declarative mapping from a source element and trigger to one or more target effects. + - Each \`Interaction\` contains: + - **key: string** + - REQUIRED. The source element path. The trigger attaches to this element. + - **listContainer?: string** + - OPTIONAL. A CSS selector for a list container context. When present, the trigger is scoped to items within this list. + - **listItemSelector?: string** + - OPTIONAL. A CSS selector used to select items within \`listContainer\`. + - **trigger: TriggerType** + - REQUIRED. One of: + - \`'hover' | 'click' | 'activate' | 'interest'\`: Pointer interactions (\`activate\` = click with keyboard Space/Enter; \`interest\` = hover with focus). + - \`'viewEnter' | 'viewProgress'\`: Viewport visibility/progress triggers. + - \`'animationEnd'\`: Fires when a specific effect completes on the source element. + - \`'pointerMove'\`: Continuous pointer motion over an area. + - **params?: TriggerParams** + - OPTIONAL. Parameter object that MUST match the trigger: + - hover/click/activate/interest: No params needed. Behavior is configured on the effect itself. + - viewEnter: \`ViewEnterParams\` + - \`threshold?\`: number in [0,1] describing intersection threshold + - \`inset?\`: string CSS-style inset for rootMargin/observer geometry + - viewProgress: No trigger params. Progress is driven by ViewTimeline/scroll scenes. Control the range via \`ScrubEffect.rangeStart/rangeEnd\` and \`namedEffect.range\`. + - animationEnd: \`AnimationEndParams\` + - \`effectId\`: string of the effect to wait for completion + - Usage: Fire when the specified effect (by \`effectId\`) on the source element finishes, useful for chaining sequences. + - pointerMove: \`PointerMoveParams\` + - \`hitArea?\`: \`'root' | 'self'\` (default \`'self'\`) + - \`axis?\`: \`'x' | 'y'\` - when using \`keyframeEffect\` with \`pointerMove\`, selects which pointer coordinate maps to linear 0-1 progress; defaults to \`'y'\`. Ignored for \`namedEffect\` and \`customEffect\`. + - Usage: + - \`'self'\`: Track pointer within the source element's bounds. + - \`'root'\`: Track pointer anywhere in the viewport (document root). + - Only use with \`ScrubEffect\` mouse presets (\`namedEffect\`) or \`customEffect\` that consumes pointer progress; avoid \`keyframeEffect\` with \`pointerMove\` unless mapping a single axis via \`axis\`. + - When using \`customEffect\` with \`pointerMove\`, the progress parameter is an object: + - \`\`\`typescript + type Progress = { + x: number; // 0-1: horizontal position (0 = left edge, 1 = right edge) + y: number; // 0-1: vertical position (0 = top edge, 1 = bottom edge) + v?: { + // Velocity (optional) + x: number; // Horizontal velocity + y: number; // Vertical velocity + }; + active?: boolean; // Whether mouse is currently in the hit area + }; + \`\`\` + +### hover / click + +For \`TimeEffect\` (keyframe/named/custom effects), set \`triggerType\` on the effect. For \`StateEffect\` (transitions), set \`stateAction\` on the effect. Do NOT mix \`triggerType\` and \`stateAction\` on the same effect. + +**\`triggerType\`** — on \`TimeEffect\`: + +${buildBehaviorTable('Type', 'triggerTypeDescriptions', [hover, click], { defaultKey: hover.defaultTriggerType })} + +**\`stateAction\`** — on \`StateEffect\`: + +${buildBehaviorTable('Action', 'stateActionDescriptions', [hover, click], { defaultKey: 'toggle' })} + +### viewEnter + +\`\`\`ts +params: { + threshold?: number; // 0–1, IntersectionObserver threshold + inset?: string; // like view-timeline-inset, e.g. '-100px' or '-50px 0px' +} +// Playback behavior is set on each effect: +effect.triggerType: ${triggerTypeUnion}; // default: '${viewEnter.defaultTriggerType}' +\`\`\` + +${fragments.get('pitfalls/same-element-viewenter', 'full-lean')} + +### viewProgress + +Scroll-driven animations using native \`ViewTimeline\`, with polyfill where not supported. Progress is driven by scroll position. Control the range via \`rangeStart\`/\`rangeEnd\` on the effect (see [Scroll / Pointer-driven Effect](#scroll--pointer-driven-effect)). + +\`viewProgress\` has no trigger params. Range configuration (\`rangeStart\`/\`rangeEnd\`) is on the effect, not on the trigger. + +${fragments.get('pitfalls/overflow-clip', 'full-lean')} + +### pointerMove + +\`\`\`ts +params: { + hitArea?: 'self' | 'root'; // 'self' = source element bounds, 'root' = viewport + axis?: 'x' | 'y'; // restricts tracking to a single axis (for keyframeEffect) +} +\`\`\` + +**Rules:** + +- Source element MUST NOT have \`pointer-events: none\`. +- MUST NOT use the same element as both source and target with size or position effects — use \`selector\` to target a child or set a different \`key\`. +- Use a \`(hover: hover)\` media condition to disable on touch-only devices. On touch-only devices prefer \`viewEnter\` or \`viewProgress\` fallbacks. +- For 2D effects, use \`namedEffect\` mouse presets or \`customEffect\`. \`keyframeEffect\` only supports a single axis. +- For independent 2-axis control with keyframes, use two separate interactions (one \`axis: 'x'\`, one \`axis: 'y'\`) with \`composite: 'add'\` or \`'accumulate'\` on the second effect. + +**\`centeredToTarget\`** — set \`true\` to remap the \`0–1\` progress range so that \`0.5\` progress corresponds to the center of the target element. Use when source and target are different elements, or when \`hitArea: 'root'\` is used, so that the pointer resting over the target center produces 50% progress regardless of position in viewport. + +${fragments.get('progress-type', 'brief')} + +### animationEnd + +\`\`\`ts +params: { + effectId: string; +} // the effect to wait for +\`\`\` + +Fires when the specified effect completes on the source element. Useful for chaining sequences. + +--- + +## Effects + +Each effect applies a visual change to a target element. An effect is either inline or referenced by \`effectId\` from the top-level \`effects\` registry (\`EffectRef\`). An \`EffectRef\` inherits all properties from the registry entry, and can override any of them (e.g. \`key\`, \`duration\`, \`easing\`, \`fill\`, etc.) — not just the target. See [Element Resolution](#element-resolution) for how the target is determined. + +### Common fields + +\`\`\`ts +{ + key?: string; // target element key; omit to target the source + effectId?: string; // reference to effects registry (EffectRef) + conditions?: string[]; // ids referencing the top-level conditions map; all must pass + selector?: string; // optional — CSS selector to refine target element + listContainer?: string; // optional — CSS selector for list container + listItemSelector?: string; // optional — filter which children of listContainer are selected + composite?: 'replace' | 'add' | 'accumulate'; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; +} +\`\`\` + +**\`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\`). + +**\`composite\`** — same as CSS's \`animation-composition\`. Controls how this effect combines with others on the same property (transforms & filters): + +- \`'replace'\` (default): fully replaces prior values. +- \`'add'\`: concatenates transform/filter functions after any existing ones (e.g. existing \`translateX(10px)\` + added \`translateY(20px)\` → both apply). +- \`'accumulate'\`: merges arguments of matching functions (e.g. \`translateX(10px)\` + \`translateX(20px)\` → \`translateX(30px)\`); non-matching functions concatenate like \`'add'\`. + +**\`easing\` guidance:** from \`@wix/motion\` (in addition to standard CSS easings): + +${easingList}, or any \`'cubic-bezier(...)'\` / \`'linear(...)'\` string. + +### Time-based Effect + +Used with \`hover\`, \`click\`, \`viewEnter\`, \`animationEnd\` triggers. + +\`\`\`ts +{ + duration: number; // REQUIRED (ms) + easing?: string; // CSS easing or named easing (see below) + delay?: number; // ms + iterations?: number; // >=1 or Infinity; 0 is treated as Infinity + alternate?: boolean; + reversed?: boolean; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + composite?: 'replace' | 'add' | 'accumulate'; + // + exactly one animation payload (see below) +} +\`\`\` + +### Scroll / Pointer-driven Effect + +Used with \`viewProgress\` and \`pointerMove\` triggers. + +\`\`\`ts +{ + rangeStart?: RangeOffset; // REQUIRED for viewProgress + rangeEnd?: RangeOffset; // REQUIRED for viewProgress + easing?: string; // CSS easing or named easing (see above) + iterations?: number; // NOT Infinity + alternate?: boolean; + reversed?: boolean; + fill?: 'none' | 'forwards' | 'backwards' | 'both'; + composite?: 'replace' | 'add' | 'accumulate'; + centeredToTarget?: boolean; + transitionDuration?: number; // ms, smoothing on progress jumps (primarily for pointerMove) + transitionDelay?: number; // ms (primarily for pointerMove) + transitionEasing?: '${data.effects.transitionEasings.join("' | '")}'; + // + exactly one animation payload (see below) +} +\`\`\` + +**RangeOffset** — works like CSS's \`animation-range\`: + +\`\`\`ts +{ + name?: ${rangeNameUnion}; + offset?: { value: number; unit: 'percentage' | 'px' | 'vh' | 'vw' } +} +\`\`\` + +${rangeTable} + +**Sticky container pattern** — for scroll-driven animations inside a stuck \`position: sticky\` container: + +- Tall wrapper: height defines scroll distance (e.g. \`300vh\` for ~2 viewport-heights of scroll travel). +- Sticky child (\`key\`) with \`position: sticky; top: 0; height: 100vh\`: stays fixed while the wrapper scrolls. This is the ViewTimeline source. +- Use \`rangeStart/rangeEnd\` with \`name: 'contain'\` to animate only during the stuck phase. + +### StateEffect (CSS style toggle) + +Used with \`hover\` / \`click\` triggers. Set \`stateAction\` on the effect to control state behavior. + +**StateEffect** (CSS transition-style state toggles): + +- \`key?\`: string (target override; see TARGET CASCADE) +- \`effectId?\`: string (when used as a reference identity) +- One of: + - \`transition?\`: \`{ duration?: number; delay?: number; easing?: string; styleProperties: { name: string; value: string }[] }\` + - Applies a single transition options block to all listed style properties. + - \`transitionProperties?\`: \`Array<{ name: string; value: string; duration?: number; delay?: number; easing?: string }>\` + - Allows per-property transition options. If both \`transition\` and \`transitionProperties\` are provided, the system SHOULD apply both with per-property entries taking precedence for overlapping properties. + +\`\`\`ts +// Shared timing for all properties: +{ + transition: { + duration?: number; delay?: number; easing?: string; + styleProperties: [{ name: string; value: string }] + } +} + +// Per-property timing: +{ + transitionProperties: [ + { name: string; value: string; duration?: number; delay?: number; easing?: string } + ] +} +\`\`\` + +CSS property names use **camelCase** (e.g. \`'backgroundColor'\`, \`'borderRadius'\`). + +### Animation Payloads + +Exactly one MUST be provided per time-based or scroll/pointer-driven effect: + +1. **\`namedEffect\`** (preferred) — pre-built presets from \`${data.meta.presetsPackage}\`. GPU-friendly and tuned. + + \`\`\`ts + namedEffect: { + type: '[PRESET_NAME]', + // ...optional [PRESET_OPTIONS] as additional properties + } + \`\`\` + + - \`[PRESET_NAME]\` — one of the registered preset names (see table below). + - \`[PRESET_OPTIONS]\` — optional preset-specific properties spread as additional keys on the object. **CRITICAL:** Do NOT guess option names/types. Omit unknown options and rely on defaults. + + Available presets: + +${presetTable} + - **CRITICAL** — Scroll presets (\`*Scroll\`) used with \`viewProgress\` MUST include \`range\` in options: \`'in'\` (ends at idle state), \`'out'\` (starts from idle state), or \`'continuous'\` (passes through idle). Prefer \`'continuous'\`. + - Mouse presets are preferred over \`keyframeEffect\` for \`pointerMove\` 2D effects. + +2. **\`keyframeEffect\`** — custom keyframe animations. + + \`\`\`ts + keyframeEffect: { name: '[EFFECT_NAME]', keyframes: [KEYFRAMES] } + \`\`\` + + - \`[EFFECT_NAME]\` — unique string identifier for this effect. + - \`[KEYFRAMES]\` — array of keyframe objects using standard WAAPI format (e.g. \`[{ opacity: '0' }, { opacity: '1' }]\`). Property names in camelCase. + +3. **\`customEffect\`** — imperative update callback. Use only when CSS-based effects cannot express the desired behavior (e.g., animating SVG attributes, canvas, text content). + + \`\`\`ts + customEffect: [CUSTOM_EFFECT_CALLBACK]; + \`\`\` + + - \`[CUSTOM_EFFECT_CALLBACK]\` — function with signature \`(element: Element, progress: number | ProgressObject) => void\`. Called on each animation frame. + +--- + +## Sequences + +${fragments.get('sequences', 'detailed')} + +--- + +## Conditions + +Named conditions that gate interactions, effects, or sequences. + +| Type | Predicate | +| :--------- | :------------------------------------------------------------------------ | +| \`media\` | CSS media query condition without \`@media\` (e.g., \`'(min-width: 768px)'\`) | +| \`selector\` | CSS selector; \`&\` is replaced with the base element selector | + +Attach via \`conditions: ['[CONDITION_ID]']\` on interactions, effects, or sequences. On an interaction, conditions gate the entire trigger; on an effect, only that specific effect is skipped. All listed conditions must pass. + +### Examples + +\`\`\`ts +conditions: { + 'desktop': { type: 'media', predicate: '(min-width: 768px)' }, + 'hover-device': { type: 'media', predicate: '(hover: hover)' }, + 'reduced-motion': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, + 'odd-items': { type: 'selector', predicate: ':nth-of-type(odd)' }, +} +\`\`\` + +--- + +## FOUC Prevention + +**Problem:** Elements with entrance animations (e.g. \`viewEnter\` + \`type: 'once'\` with \`FadeIn\`) start in their final visible state. Before the animation framework initializes and applies the starting keyframe (e.g. \`opacity: 0\`), the element is briefly visible at full opacity — causing a flash of unstyled/un-animated content (FOUC). + +**Solution:** Two things are required — both MUST be present: + +1. **Generate critical CSS** using \`generate(config)\` — produces CSS rules that hide entrance-animated elements from the moment the page renders. +2. **Mark elements with \`initial\`** — tells the runtime which elements have critical CSS applied so it can coordinate with the generated styles. + +### Step 1: Generate CSS + +Call \`generate(config)\` server-side or at build time and inject the result into the \`\` (preferred), or insert to beginning of \`\`, so it loads before the page content is painted: + +\`\`\`ts +import { generate } from '${data.meta.entryPoints.web}'; +const css = generate(config); +\`\`\` + +${fragments.get('fouc', 'code-inject')} + +### Step 2: Mark elements + +${fragments.get('fouc', 'code-web', { key: 'hero', classAttr: ' class="hero"' })} + +${fragments.get('fouc', 'code-react', { key: 'hero', classAttr: ' className="hero"' })} + +${fragments.get('fouc', 'code-vanilla', { key: 'hero', classAttr: ' class="hero"' })} + +### Rules + +- \`generate()\` should be called server-side or at build time. Can also be called on client-side if page content is initially hidden (e.g. behind a loader/splash screen). +- **Both** \`generate(config)\` CSS **and** \`initial\` on the element are required. Using only one has no effect. +- \`initial\` is only valid for \`viewEnter\` + \`type: 'once'\` where source and target are the same element. +- For \`repeat\`/\`alternate\`/\`state\`, do NOT use \`initial\`. Instead, manually apply the initial keyframe as inline styles on the target element and use \`fill: 'both'\`. + +--- + +## Element Resolution + +${fragments.get('element-resolution', 'intro')} + +${fragments.get('element-resolution', 'source')} + +${fragments.get('element-resolution', 'target')} + +--- + +## Static API + +${fragments.get('static-api', 'detailed')} +`; +} diff --git a/packages/interact/_content/templates/integration.mjs b/packages/interact/_content/templates/integration.mjs new file mode 100644 index 00000000..49527faa --- /dev/null +++ b/packages/interact/_content/templates/integration.mjs @@ -0,0 +1,231 @@ +/** + * Renders integration.md — integration guide covering entry points, config schema, and triggers overview. + * @param {{ triggers: object[], effects: object, meta: object }} data — no `trigger`; receives the full data object + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const { metaParams } = data; + + return `# ${data.meta.packageName} Integration Rules + +Rules for integrating \`${data.meta.packageName}\` into a webpage — binding animations and effects to user-driven triggers via declarative configuration. + +## Table of Contents + +- [Entry Points](#entry-points) + - [Web (Custom Elements)](#web-custom-elements) + - [React](#react) + - [Vanilla JS](#vanilla-js) +- [Named Effects & registerEffects](#named-effects--registereffects) +- [Configuration Schema](#configuration-schema) + - [InteractConfig](#interactconfig) + - [Interaction](#interaction) + - [Element Selection](#element-selection) +- [Triggers](#triggers) +- [Sequences](#sequences) +- [Critical CSS (FOUC Prevention)](#critical-css-fouc-prevention) +- [Static API](#static-api) + +--- + +## Entry Points + +Install with your package manager: + +${fragments.get('quick-start', 'install', metaParams)} + +### Web (Custom Elements) + +${fragments.get('quick-start', 'web-brief', metaParams)} + +Wrap target elements with \`\`: + +\`\`\`html + +
...
+
+\`\`\` + +**Rules:** + +- MUST set \`data-interact-key\` to a unique string within the page. +- MUST contain at least one child element (the library targets \`.firstElementChild\` by default). + +### React + +- Wrap the \`Interact.create()\` call in a \`useEffect\` hook to prevent it from running on server-side. +- Store the returned instance, and call its \`.destroy()\` method on the effect's cleanup function. + +\`\`\`typescript +import { useEffect } from 'react'; +import { Interact } from '${data.meta.entryPoints.react}'; + +useEffect(() => { + const instance = Interact.create(config); + + return () => { + instance.destroy(); + }; +}, [config]); +\`\`\` + +Replace target elements with \`\`: + +\`\`\`tsx +import { Interaction } from '${data.meta.entryPoints.react}'; + + + ... +; +\`\`\` + +**Rules:** + +- MUST set \`tagName\` to a valid HTML tag string for the element being replaced. +- MUST set \`interactKey\` to a unique string within the page. + +### Vanilla JS + +${fragments.get('quick-start', 'vanilla-brief', metaParams)} + +**Rules:** + +- Call \`add(element, key)\` after elements exist in the DOM. +- Call \`remove(key)\` to unregister all interactions for a key. + +--- + +## Named Effects & registerEffects + +To use \`namedEffect\` presets from \`${data.meta.presetsPackage}\`, register them before calling \`Interact.create\`. For full effect type syntax (\`keyframeEffect\`, \`customEffect\`, \`StateEffect\`, \`ScrubEffect\`), see \`full-lean.md\`. + +**Install:** + +\`\`\`bash +> npm install ${data.meta.presetsPackage} +\`\`\` + +**Import and register:** + +\`\`\`typescript +import { Interact } from '${data.meta.entryPoints.web}'; +import * as presets from '${data.meta.presetsPackage}'; + +Interact.registerEffects(presets); +\`\`\` + +Or register selectively: + +\`\`\`typescript +import { FadeIn, ParallaxScroll } from '${data.meta.presetsPackage}'; +Interact.registerEffects({ FadeIn, ParallaxScroll }); +\`\`\` + +Then use in effects: + +\`\`\`typescript +{ namedEffect: { type: 'FadeIn' }, duration: 800, easing: 'ease-out' } +\`\`\` + +For full effect type syntax (\`keyframeEffect\`, \`namedEffect\`, \`customEffect\`, \`transition\`/\`transitionProperties\`), see [full-lean.md](./full-lean.md) and the trigger-specific rule files. + +--- + +## Configuration Schema + +${fragments.get('config-structure', 'brief')} + +### Interaction + +\`\`\`typescript +{ + key: string; // REQUIRED — matches data-interact-key / interactKey + trigger: TriggerType; // REQUIRED — trigger type + params?: TriggerParams; // trigger-specific parameters + selector?: string; // CSS selector to refine target within the element + listContainer?: string; // CSS selector for a list container + listItemSelector?: string; // optional — CSS selector to filter which children of listContainer are selected + conditions?: string[]; // array of condition IDs; all must pass + effects?: Effect[]; // effects to apply + sequences?: SequenceConfig[]; // sequences to apply +} +\`\`\` + +At least one of \`effects\` or \`sequences\` MUST be provided. + +**Multiple effects per interaction:** A single interaction can contain multiple effects in its \`effects\` array. All effects share the same trigger — they fire together when the trigger activates. Use this to animate different targets from the same trigger event instead of duplicating interactions. + +### Element Selection + +**Most common**: Omit \`selector\`/\`listContainer\`/\`listItemSelector\` entirely — the element with the matching key is used as both source and target. Use \`selector\` to target a child element within the keyed element. Use \`listContainer\` for staggered sequences across list items. + +\`listItemSelector\` is **optional** — only use it when you need to **filter** which children of \`listContainer\` participate (e.g. select only \`.active\` items). When omitted, all immediate children of the \`listContainer\` are selected. + +${fragments.get('element-resolution', 'source-brief')} + +${fragments.get('element-resolution', 'target-brief')} + +--- + +## Triggers + +| Trigger | Description | Trigger \`params\` | Rules | +| :------------- | :------------------------------------- | :-------------------------------------------------------------------------------------- | :----------------------------------- | +| \`hover\` | Mouse enter/leave | No params. Set \`triggerType\` on TimeEffect or \`stateAction\` on StateEffect. | [hover.md](./hover.md) | +| \`click\` | Mouse click | Same as \`hover\` | [click.md](./click.md) | +| \`interest\` | Accessible hover (hover + focus) | Same as \`hover\` | [hover.md](./hover.md) | +| \`activate\` | Accessible click (click + Enter/Space) | Same as \`click\` | [click.md](./click.md) | +| \`viewEnter\` | Element enters viewport | \`threshold?\`; \`inset?\`. Set \`triggerType\` on TimeEffect or sequence config. | [viewenter.md](./viewenter.md) | +| \`viewProgress\` | Scroll-driven (ViewTimeline) | No trigger params. Configure \`rangeStart\`/\`rangeEnd\` on the **effect**, not on \`params\` | [viewprogress.md](./viewprogress.md) | +| \`pointerMove\` | Mouse movement | \`hitArea?\`: \`'self'\` \\| \`'root'\`; \`axis?\`: \`'x'\` \\| \`'y'\` | [pointermove.md](./pointermove.md) | +| \`animationEnd\` | Chain after another effect | \`effectId\`: ID of the preceding effect | — | +| \`pageVisible\` | Page visibility change | No params. Fires when the page becomes visible (e.g. tab switch). | — | + +For \`hover\`/\`click\` (and their accessible variants \`interest\`/\`activate\`): set \`triggerType\` on the effect for keyframe/named/custom effects (TimeEffect), or \`stateAction\` on the effect for transitions (StateEffect). Do not mix both on the same effect. + +--- + +## Sequences + +${fragments.get('sequences', 'brief')} + +--- + +## Critical CSS (FOUC Prevention) + +**Problem:** Elements with entrance animations (e.g. \`FadeIn\` on \`viewEnter\`) are initially visible in their final state. Before the animation framework applies the starting keyframe, the content flashes visibly — a flash of un-animated content (FOUC). + +**Solution:** Two things are required — both MUST be present: + +1. **Generate critical CSS** with \`generate(config)\` — produces CSS that hides entrance-animated elements until the animation plays. +2. **Mark elements with \`initial\`** — \`data-interact-initial="true"\` on \`\`, or \`initial={true}\` on \`\` in React. + +Using only one of these has no effect — both are required. + +See [viewenter.md](./viewenter.md) for full details. + +**Rules:** + +- \`generate()\` should be called server-side or at build time. Can also be called on the client if page content is initially hidden (e.g. behind a loader). +- Only valid for \`viewEnter\` + \`triggerType: 'once'\` (or no \`triggerType\`, which defaults to \`'once'\`) where source and target are the same element. + +\`\`\`javascript +import { generate } from '${data.meta.entryPoints.web}'; +const css = generate(config); +\`\`\` + +${fragments.get('fouc', 'code-inject')} + +${fragments.get('fouc', 'code-web', { key: 'hero', classAttr: ' class="hero"' })} + +${fragments.get('fouc', 'code-react', { key: 'hero', classAttr: ' className="hero"' })} + +${fragments.get('fouc', 'code-vanilla', { key: 'hero', classAttr: ' class="hero"' })} + +--- + +## Static API + +${fragments.get('static-api', 'brief')} +`; +} diff --git a/packages/interact/_content/templates/pointermove-rule.mjs b/packages/interact/_content/templates/pointermove-rule.mjs new file mode 100644 index 00000000..2d0838c9 --- /dev/null +++ b/packages/interact/_content/templates/pointermove-rule.mjs @@ -0,0 +1,272 @@ +import { buildPitfallsBlock, varLine } from './_helpers.mjs'; + +/** + * Renders pointermove.md — rules for pointer-driven interactions with 2D mouse tracking. + * @param {{ triggers: object[], effects: object, meta: object, trigger: object }} data + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const { trigger } = data; + + const pitfallsBlock = buildPitfallsBlock(trigger, fragments); + + const paramsTypeFields = trigger.params + .map((p) => ` ${p.name}${p.optional ? '?' : ''}: ${p.type};`) + .join('\n'); + + return `# PointerMove Trigger Rules for ${data.meta.packageName} + +These rules help generate pointer-driven interactions using \`${data.meta.packageName}\`. PointerMove triggers create real-time animations that respond to mouse movement over elements or the entire viewport. + +## Table of Contents + +- [Trigger Source Elements with \`hitArea: 'self'\`](#trigger-source-elements-with-hitarea-self) +- [PointerMoveParams](#pointermoveparams) +- [Progress Object Structure](#progress-object-structure) +- [Centering with \`centeredToTarget\`](#centering-with-centeredtotarget) +- [Device Conditions](#device-conditions) +- [Rule 1: namedEffect](#rule-1-namedeffect) +- [Rule 2: keyframeEffect with Single Axis](#rule-2-keyframeeffect-with-single-axis) +- [Rule 3: Two keyframeEffects with Two Axes and \`composite\`](#rule-3-two-keyframeeffects-with-two-axes-and-composite) +- [Rule 4: customEffect](#rule-4-customeffect) + +## Trigger Source Elements with \`hitArea: 'self'\` + +${pitfallsBlock} + +--- + +## PointerMoveParams + +\`params\` object for \`pointerMove\` interactions: + +\`\`\`typescript +type PointerMoveParams = { +${paramsTypeFields} +}; +\`\`\` + +### Properties + +- \`hitArea\` — determines where mouse movement is tracked: + - \`'self'\` — tracks pointer within the source element's bounds only. Use for local pointer-tracking effects on a specific element. + - \`'root'\` — tracks pointer anywhere in the viewport. Use for global cursor followers, ambient effects. +- \`axis\` — restricts pointer tracking to a single axis. Used with \`keyframeEffect\` to map one axis to 0–1 progress; ignored by \`namedEffect\` and \`customEffect\` which receive the full 2D progress: + - \`'x'\` — maps horizontal pointer position to 0–1 progress for keyframe interpolation. + - \`'y'\` — maps vertical pointer position to 0–1 progress for keyframe interpolation. **Default** when \`keyframeEffect\` is used. + - For \`namedEffect\` or \`customEffect\` both axes are available via the 2D progress object, and will be ignored. + +--- + +## Progress Object Structure + +${fragments.get('progress-type', 'detailed')} + +--- + +## Centering with \`centeredToTarget\` + +Controls which element's bounds define the 0–1 progress range. + +- **\`false\` (default)**: Progress is calculated against the **source element's** (or viewport's) bounds. The \`50%\` progress of the timeline is at the center of the source element. +- **\`true\`**: \`50%\` progress of the timeline is calculated against the **target element's center**. The edges of the timeline are still calculated against the edges of the source element/viewport depending on \`hitArea\`. + +--- + +## Device Conditions + +\`pointerMove\` works best on hover-capable devices. Use a \`conditions\` entry with a \`(hover: hover)\` media query to prevent the interaction from registering on touch-only devices. On touch-only devices, consider a fallback to \`viewEnter\` or \`viewProgress\` based interactions: + +\`\`\`typescript +{ + conditions: { + '[CONDITION_NAME]': { type: 'media', predicate: '(hover: hover)' } + }, + interactions: [ + { + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + conditions: ['[CONDITION_NAME]'], + params: { hitArea: '[HIT_AREA]' }, + effects: [ /* ... */ ] + } + ] +} +\`\`\` + +For devices with dynamic viewport sizes (e.g. mobile browsers where the address bar collapses), consider using viewport-relative units carefully and prefer \`lvh\`/\`svh\` over \`dvh\` unless dynamic viewport behavior is specifically desired. + +--- + +## Rule 1: namedEffect + +Use pre-built mouse presets from \`${data.meta.presetsPackage}\` that handle 2D mouse tracking internally. Mouse presets are preferred over \`keyframeEffect\` for 2D effects. Available mouse presets: ${data.effects.presets.mouse.map((n) => `\`${n}\``).join(', ')}. + +${trigger.showMultipleEffectsNote ? fragments.get('multiple-effects-note', 'pointerMove') : ''} + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + params: { + hitArea: '[HIT_AREA]' + }, + effects: [ + { + key: '[TARGET_KEY]', + namedEffect: { + type: '[NAMED_EFFECT_TYPE]', + [EFFECT_PROPERTIES] + }, + centeredToTarget: [CENTERED_TO_TARGET], + transitionDuration: [TRANSITION_DURATION_MS], + transitionEasing: '[TRANSITION_EASING]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +${varLine('SOURCE_KEY', 'The element that tracks pointer movement.')} +${varLine('TARGET_KEY', "identifier matching the element's key on the element to animate (can be same as source or different).")} +${varLine('HIT_AREA')} +- \`[NAMED_EFFECT_TYPE]\` — a registered effect name, or a preset from \`${data.meta.presetsPackage}\` \`mouse\` library. +- \`[EFFECT_PROPERTIES]\` — preset-specific options. Refer to motion-presets rules for each preset's available options and their value types. Do NOT guess preset option names or types; omit unknown options and rely on defaults. +${varLine('CENTERED_TO_TARGET')} +${varLine('TRANSITION_DURATION_MS')} +${varLine('TRANSITION_EASING')} + +--- + +## Rule 2: keyframeEffect with Single Axis + +Use \`keyframeEffect\` when the pointer position along a single axis should drive a keyframe animation. The pointer's position on the chosen axis is mapped to linear 0–1 progress. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + params: { + hitArea: '[HIT_AREA]', + axis: '[AXIS]' + }, + effects: [ + { + key: '[TARGET_KEY]', + keyframeEffect: { + name: '[EFFECT_NAME]', + keyframes: [KEYFRAMES] + }, + fill: 'both', + centeredToTarget: [CENTERED_TO_TARGET], + transitionDuration: [TRANSITION_DURATION_MS], + transitionEasing: '[TRANSITION_EASING]', + effectId: '[UNIQUE_EFFECT_ID]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` / \`[HIT_AREA]\` — same as Rule 1. +- \`[AXIS]\` — \`'x'\` (horizontal) or \`'y'\` (vertical). Defaults to \`'y'\` when omitted. +${varLine('EFFECT_NAME')} +- \`[KEYFRAMES]\` — array of CSS keyframe objects (e.g. \`[{ transform: 'rotate(-10deg)' }, { transform: 'rotate(0)' }, { transform: 'rotate(10deg)' }]\`). Distributed evenly across 0–1 progress: first keyframe = progress 0 (left/top edge), last = progress 1 (right/bottom edge). Any number of keyframes is allowed. +- \`[CENTERED_TO_TARGET]\` / \`[TRANSITION_DURATION_MS]\` / \`[TRANSITION_EASING]\` / \`[UNIQUE_EFFECT_ID]\` — same as Rule 1. + +--- + +## Rule 3: Two keyframeEffects with Two Axes and \`composite\` + +Use two separate interactions on the same source/target pair — one for \`axis: 'x'\`, one for \`axis: 'y'\` — for independent 2D control with keyframes. When both effects animate the same CSS property (e.g. \`transform\` or \`filter\`), use \`composite\` to combine them. + +\`\`\`typescript +{ + interactions: [ + { + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + params: { hitArea: '[HIT_AREA]', axis: 'x' }, + effects: [{ key: '[TARGET_KEY]', effectId: '[X_EFFECT_ID]' }] + }, + { + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + params: { hitArea: '[HIT_AREA]', axis: 'y' }, + effects: [{ key: '[TARGET_KEY]', effectId: '[Y_EFFECT_ID]' }] + } + ], + effects: { + '[X_EFFECT_ID]': { + keyframeEffect: { + name: '[X_EFFECT_NAME]', + keyframes: [X_KEYFRAMES] + }, + fill: '[FILL_MODE]', // usually 'both' + composite: '[COMPOSITE_OPERATION]', + transitionDuration: [TRANSITION_DURATION_MS], + transitionEasing: '[TRANSITION_EASING]' + }, + '[Y_EFFECT_ID]': { + keyframeEffect: { + name: '[Y_EFFECT_NAME]', + keyframes: [Y_KEYFRAMES] + }, + fill: '[FILL_MODE]', // usually 'both' + composite: '[COMPOSITE_OPERATION]', + transitionDuration: [TRANSITION_DURATION_MS], + transitionEasing: '[TRANSITION_EASING]' + } + } +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` / \`[HIT_AREA]\` — same as Rule 1. +- \`[X_EFFECT_ID]\` / \`[Y_EFFECT_ID]\` — unique string identifiers for the X-axis and Y-axis effects. Required — they map to keys in the top-level \`effects\` map. +- \`[X_EFFECT_NAME]\` / \`[Y_EFFECT_NAME]\` — unique string names for each keyframe effect. +- \`[X_KEYFRAMES]\` / \`[Y_KEYFRAMES]\` — arrays of WAAPI keyframe objects for the X-axis and Y-axis effects respectively. Each effect can vary in properties and keyframes. +- \`[COMPOSITE_OPERATION]\` — \`'add'\` or \`'accumulate'\`. Required when both effects animate \`transform\` and/or both animate \`filter\`, so their values combine rather than override. \`'add'\`: composited transform functions are appended. \`'accumulate'\`: matching function arguments are summed. +${varLine('FILL_MODE', "typically `'both'` to ensure the effect keeps applying after exiting the effect's active range.")} +- \`[TRANSITION_DURATION_MS]\` / \`[TRANSITION_EASING]\` — same as Rule 1. + +--- + +## Rule 4: customEffect + +Use \`customEffect\` when you need full imperative control over pointer-driven animations — custom physics, complex multi-property animations, velocity-reactive effects, or controlling WebGL/WebGPU and other JavaScript-driven effects. The callback receives the 2D progress object (see **Progress Object Structure**). + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'pointerMove', + params: { + hitArea: '[HIT_AREA]' + }, + effects: [ + { + key: '[TARGET_KEY]', + customEffect: (element: Element, progress: Progress) => { + [CUSTOM_ANIMATION_LOGIC] + }, + centeredToTarget: [CENTERED_TO_TARGET], + transitionDuration: [TRANSITION_DURATION_MS], + transitionEasing: '[TRANSITION_EASING]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` / \`[HIT_AREA]\` — same as Rule 1. +- \`[CUSTOM_ANIMATION_LOGIC]\` — JavaScript using \`progress.x\`, \`progress.y\`, \`progress.v\`, and \`progress.active\` to apply the effect. See **Progress Object Structure** above. +- \`[CENTERED_TO_TARGET]\` / \`[TRANSITION_DURATION_MS]\` / \`[TRANSITION_EASING]\` — same as Rule 1. +`; +} diff --git a/packages/interact/_content/templates/viewenter-rule.mjs b/packages/interact/_content/templates/viewenter-rule.mjs new file mode 100644 index 00000000..e13de24e --- /dev/null +++ b/packages/interact/_content/templates/viewenter-rule.mjs @@ -0,0 +1,216 @@ +import { buildPitfallsBlock, varLine } from './_helpers.mjs'; + +/** + * Renders viewenter.md — rules for viewport-entry triggered animations. + * @param {{ triggers: object[], effects: object, meta: object, trigger: object }} data + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const { trigger } = data; + + const pitfalls = buildPitfallsBlock(trigger, fragments); + + return `# ViewEnter Trigger Rules for ${data.meta.packageName} + +This document contains rules for generating interactions that respond to elements entering the viewport using the \`${data.meta.packageName}\`. ViewEnter triggers use IntersectionObserver to detect when elements become visible and are ideal for entrance animations, content reveals, and lazy-loading effects. + +--- +${pitfalls ? `\n${pitfalls}\n` : ''} +## Table of Contents + +- [Preventing Flash of Unstyled Content (FOUC)](#preventing-flash-of-unstyled-content-fouc) +- [Rule 1: keyframeEffect / namedEffect (TimeEffect)](#rule-1-keyframeeffect--namedeffect-timeeffect) +- [Rule 2: customEffect (TimeEffect)](#rule-2-customeffect-timeeffect) +- [Rule 3: Sequences](#rule-3-sequences) + +--- + +## Preventing Flash of Unstyled Content (FOUC) + +**Problem:** Elements with entrance animations (e.g. \`FadeIn\`) start in their final visible state (e.g. \`opacity: 1\`). Before the animation framework initializes and applies the starting keyframe (e.g. \`opacity: 0\`), the element is briefly visible at full opacity — a flash of un-animated content. + +**Solution:** Two things are required — **both** MUST be present for FOUC prevention to work: + +1. **Generate critical CSS** using \`generate(config)\` — produces CSS rules that hide entrance-animated elements from the moment the page renders, before JavaScript runs. +2. **Mark elements with \`initial\`** — set \`data-interact-initial="true"\` on \`\`, or \`initial={true}\` on the \`\` React component. This tells the runtime which elements have critical CSS applied. + +If only one of these is present, FOUC prevention will **not** work. Both the CSS and the \`initial\` attribute are required. + +### Step 1: Generate CSS and inject into \`\` (preferred), or beginning of \`\` + +Call \`generate(config)\` server-side or at build time. Inject the resulting CSS into the document \`\` (or in \`\` before your content) so it loads before the page content is painted: + +\`\`\`typescript +import { generate } from '${data.meta.packageName}'; + +const config: InteractConfig = { + interactions: [ + { + key: '[SOURCE_KEY]', + trigger: 'viewEnter', + params: { + threshold: [VIEW_TRIGGER_THRESHOLD], + inset: [VIEW_TRIGGER_INSET], + }, + effects: [EFFECT_DEFINITIONS], + // and/or + sequences: [SEQUENCE_DEFINITIONS], + }, + ], +}; + +const css = generate(config); +\`\`\` + +${fragments.get('fouc', 'code-inject')} + +### Step 2: Mark elements with \`initial\` + +${fragments.get('fouc', 'code-web', { key: '[SOURCE_KEY]', classAttr: '' })} + +${fragments.get('fouc', 'code-react', { key: '[SOURCE_KEY]', classAttr: '' })} + +${fragments.get('fouc', 'code-vanilla', { key: '[SOURCE_KEY]', classAttr: '' })} + +### Rules + +- \`generate()\` should be called server-side or at build time. Can also be called on the client if the page content is initially hidden (e.g. behind a loader/splash screen). +- \`initial\` is only valid for \`viewEnter\` + \`triggerType: 'once'\` (or no \`triggerType\`, which defaults to \`'once'\`) where source and target are the same element. +- Do NOT use \`initial\` for \`viewEnter\` with \`triggerType: 'repeat'\`/\`'alternate'\`/\`'state'\`. For those, manually apply the initial keyframe as inline styles on the target element and use \`fill: 'both'\`. +- If other interactions in the config also need FOUC prevention, \`generate(config)\` covers them all — set \`initial\` only on the relevant \`viewEnter\` + \`triggerType: 'once'\` elements. + +## Rule 1: keyframeEffect / namedEffect (TimeEffect) + +Use \`keyframeEffect\` or \`namedEffect\` when the viewEnter should play an animation (CSS or WAAPI). Set \`triggerType\` on each effect to control playback behavior. Use \`params\` only for observer configuration (\`threshold\`, \`inset\`). + +${trigger.showMultipleEffectsNote ? fragments.get('multiple-effects-note', 'viewEnter') : ''} + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'viewEnter', + params: { + threshold: [VISIBILITY_THRESHOLD], + inset: '[VIEWPORT_INSETS]' + }, + effects: [ + { + key: '[TARGET_KEY]', + selector: '[TARGET_SELECTOR]', + triggerType: '[TRIGGER_TYPE]', + + // --- pick ONE of the two effect types --- + keyframeEffect: { + name: '[EFFECT_NAME]', + keyframes: [KEYFRAMES], + }, + // OR + namedEffect: [NAMED_EFFECT_DEFINITION], + + fill: '[FILL_MODE]', + duration: [DURATION_MS], + easing: '[EASING_FUNCTION]', + delay: [DELAY_MS], + iterations: [ITERATIONS], + alternate: [ALTERNATE_BOOL], + effectId: '[UNIQUE_EFFECT_ID]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +${varLine('SOURCE_KEY', 'The **source element** is observed for viewport intersection. This is the element the IntersectionObserver watches.')} +${varLine('TARGET_KEY', "identifier matching the element's key on the element that animates.")} +- \`[TARGET_SELECTOR]\` - optional. Selector for the child element to select inside the root element. For \`triggerType\` of \`'alternate'\`/\`'repeat'\`/\`'state'\` MUST either use a separate \`[TARGET_KEY]\` from \`[SOURCE_KEY]\` or \`selector\` for selecting a child element as target. +- \`[TRIGGER_TYPE]\` — \`triggerType\` on the effect. One of: +${Object.entries(trigger.triggerTypeDescriptions) + .map(([k, v]) => { + const isDefault = k === trigger.defaultTriggerType; + return ` - \`'${k}'\`${isDefault ? ' (default)' : ''} — ${v.full}`; + }) + .join('\n')} +${varLine('VISIBILITY_THRESHOLD')} +${varLine('VIEWPORT_INSETS')} +${varLine('KEYFRAMES')} +${varLine('EFFECT_NAME')} +${varLine('NAMED_EFFECT_DEFINITION')} +${varLine('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.")} +${varLine('DURATION_MS')} +${varLine('EASING_FUNCTION')} +${varLine('DELAY_MS')} +${varLine('ITERATIONS', "optional. Number of iterations, or `Infinity` for continuous loops. Primarily useful with `triggerType: 'state'`.")} +${varLine('ALTERNATE_BOOL')} +${varLine('UNIQUE_EFFECT_ID')} + +--- + +## Rule 2: customEffect (TimeEffect) + +Use \`customEffect\` when you need imperative control over the animation (e.g. counters, canvas drawing, custom DOM manipulation). The callback receives the target element and a \`progress\` value (0–1) driven by the animation timeline. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'viewEnter', + params: { + threshold: [VISIBILITY_THRESHOLD], + inset: '[VIEWPORT_INSETS]' + }, + effects: [ + { + key: '[TARGET_KEY]', + triggerType: '[TRIGGER_TYPE]', + customEffect: [CUSTOM_EFFECT_CALLBACK], + duration: [DURATION_MS], + easing: '[EASING_FUNCTION]', + effectId: '[UNIQUE_EFFECT_ID]' + } + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` / \`[TRIGGER_TYPE]\` / \`[VISIBILITY_THRESHOLD]\` / \`[VIEWPORT_INSETS]\` / \`[DURATION_MS]\` / \`[EASING_FUNCTION]\` / \`[UNIQUE_EFFECT_ID]\` — same as Rule 1. +- \`[CUSTOM_EFFECT_CALLBACK]\` — function with signature \`(element: HTMLElement, progress: number) => void\`. Called on each animation frame with \`element\` being the target element, and \`progress\` from 0 to 1. + +--- + +## Rule 3: Sequences + +Use sequences when a viewEnter should sync/stagger animations across multiple elements. Set \`triggerType\` on the sequence config to control playback behavior. + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'viewEnter', + params: { + threshold: [VISIBILITY_THRESHOLD], + inset: '[VIEWPORT_INSETS]' + }, + sequences: [ + { + triggerType: '[TRIGGER_TYPE]', + offset: [OFFSET_MS], + offsetEasing: '[OFFSET_EASING]', + effects: [ + [EFFECT_DEFINITION], + // .. more effects as necessary + ] + } + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[VISIBILITY_THRESHOLD]\` / \`[VIEWPORT_INSETS]\` — same as Rule 1. +- \`[TRIGGER_TYPE]\` — same as Rule 1. \`triggerType\` is set on the sequence config, not on individual effects within the sequence. +- \`[OFFSET_MS]\` — time offset between each child's animation start, in milliseconds. +- \`[OFFSET_EASING]\` — CSS easing or named easing from \`@wix/motion\`, for the stagger distribution. Defaults to \`'linear'\`. +- \`[EFFECT_DEFINITION]\` — a definition of or a reference to a time-based animation effect. +`; +} diff --git a/packages/interact/_content/templates/viewprogress-rule.mjs b/packages/interact/_content/templates/viewprogress-rule.mjs new file mode 100644 index 00000000..efce65ce --- /dev/null +++ b/packages/interact/_content/templates/viewprogress-rule.mjs @@ -0,0 +1,154 @@ +import { buildPitfallsBlock, varLine } from './_helpers.mjs'; + +/** + * Renders viewprogress.md — rules for scroll-driven animations using ViewTimeline. + * @param {{ triggers: object[], effects: object, meta: object, trigger: object }} data + * @param {import('../lib/fragments.mjs').Fragments} fragments + */ +export function render(data, fragments) { + const { trigger } = data; + + const pitfalls = buildPitfallsBlock(trigger, fragments); + + const rangeList = Object.entries(data.effects.rangeNames) + .map(([name, desc]) => { + return ` - \`'${name}'\` — ${desc}`; + }) + .join('\n'); + + return `# ViewProgress Trigger Rules for ${data.meta.packageName} + +These rules help generate scroll-driven interactions using \`${data.meta.packageName}\`. ViewProgress triggers create animations that update continuously as elements move through the viewport, leveraging native CSS ViewTimelines where supported, and using a polyfill library where unsupported. Use when animation progress should be tied to the element's scroll position. +${pitfalls ? `\n${pitfalls}\n` : ''} +**Offset semantics:** The \`offset\` inside \`rangeStart\`/\`rangeEnd\` is an object \`{ unit: 'percentage', value: NUMBER }\` where value is 0–100. For absolute lengths use \`{ unit: 'px', value: NUMBER }\` (or other CSS length units). Positive values move the effective range boundary forward along the scroll axis. + +## Table of Contents + +- [Rule 1: ViewProgress with keyframeEffect or namedEffect](#rule-1-viewprogress-with-keyframeeffect-or-namedeffect) +- [Rule 2: ViewProgress with customEffect](#rule-2-viewprogress-with-customeffect) +- [Rule 3: ViewProgress with Tall Wrapper + Sticky Container (contain range)](#rule-3-viewprogress-with-tall-wrapper--sticky-container-contain-range) + +--- + +## Rule 1: ViewProgress with keyframeEffect or namedEffect + +**Use Case**: Scroll-driven CSS-based effects. + +${trigger.showMultipleEffectsNote ? fragments.get('multiple-effects-note', 'viewProgress') : ''} + +### Template + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'viewProgress', + effects: [ + { + key: '[TARGET_KEY]', + // --- pick ONE of the two effect types --- + namedEffect: [NAMED_EFFECT_DEFINITION], + // OR + keyframeEffect: { name: '[EFFECT_NAME]', keyframes: [EFFECT_KEYFRAMES] }, + + rangeStart: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [START_PERCENTAGE] } }, + rangeEnd: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [END_PERCENTAGE] } }, + easing: '[EASING_FUNCTION]', // usually 'linear' + fill: 'both', + effectId: '[UNIQUE_EFFECT_ID]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +${varLine('SOURCE_KEY', 'The element whose scroll position drives the animation.')} +${varLine('TARGET_KEY', "identifier matching the element's key (`data-interact-key` for web, `interactKey` for React) on the element to animate (can be same as source or different).")} +- \`[NAMED_EFFECT_DEFINITION]\` — object with properties of pre-built effect from \`@wix/motion-presets\`. **CRITICAL:** Scroll presets (\`*Scroll\`) MUST include \`range: 'in' | 'out' | 'continuous'\` in their options. \`'in'\` ends at the idle state, \`'out'\` starts from the idle state, \`'continuous'\` passes through it. +${varLine('EFFECT_NAME')} +- \`[EFFECT_KEYFRAMES]\` — array of keyframe objects defining CSS property values (e.g. \`[{ opacity: 0 }, { opacity: 1 }]\`). Property names in camelCase. +- \`[RANGE_NAME]\` — scroll range name: +${rangeList} +- \`[START_PERCENTAGE]\` — 0–100, starting point within the named range. +- \`[END_PERCENTAGE]\` — 0–100, end point within the named range. +${varLine('EASING_FUNCTION', "CSS easing string or named easing from `@wix/motion`. Typically `'linear'` for scrolling effects.")} +${varLine('UNIQUE_EFFECT_ID')} + +--- + +## Rule 2: ViewProgress with customEffect + +**Use Case**: Scroll-driven effects requiring JavaScript logic (e.g., changing SVG attributes, controlling WebGL/WebGPU effects). + +### Template + +\`\`\`typescript +{ + key: '[SOURCE_KEY]', + trigger: 'viewProgress', + effects: [ + { + key: '[TARGET_KEY]', + customEffect: [CUSTOM_EFFECT_CALLBACK], + rangeStart: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [START_PERCENTAGE] } }, + rangeEnd: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [END_PERCENTAGE] } }, + easing: '[EASING_FUNCTION]', // usually 'linear' + fill: 'both', + effectId: '[UNIQUE_EFFECT_ID]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[SOURCE_KEY]\` / \`[TARGET_KEY]\` — same as Rule 1. +${varLine('CUSTOM_EFFECT_CALLBACK')} +- \`[RANGE_NAME]\` / \`[START_PERCENTAGE]\` / \`[END_PERCENTAGE]\` / \`[EASING_FUNCTION]\` / \`[UNIQUE_EFFECT_ID]\` — same as Rule 1. + +--- + +## Rule 3: ViewProgress with Tall Wrapper + Sticky Container (contain range) + +**Use Case**: Scroll-driven animations inside a sticky-positioned container, where the source element is a tall wrapper and the effect applies during the "stuck" phase using \`position: sticky\` to lock a container and \`contain\` range to animate only during the stuck phase. Good for heavy effects on large media elements or scrolly-telling effects. + +**Layout Structure**: + +- **Tall wrapper** (\`[TALL_WRAPPER_KEY]\`): An element with enough height to create scroll distance (e.g., \`height: 300vh\`). This is the ViewTimeline source. The taller it is relative to the viewport, the longer the scroll distance and the more "duration" the animation has. +- **Sticky container**: A direct child with \`position: sticky; top: 0; height: 100vh\` that stays fixed in the viewport while the wrapper scrolls past. +- **Animated elements** (\`[STICKY_CHILD_KEY]\`): Children of the sticky container that receive the effects. + +### Template + +\`\`\`typescript +{ + key: '[TALL_WRAPPER_KEY]', + trigger: 'viewProgress', + effects: [ + { + key: '[STICKY_CHILD_KEY]', + // Use keyframeEffect, namedEffect, or customEffect as in Rules 1–2 + keyframeEffect: { name: '[EFFECT_NAME]', keyframes: [EFFECT_KEYFRAMES] }, + rangeStart: { name: 'contain', offset: { unit: 'percentage', value: [START_PERCENTAGE] } }, + rangeEnd: { name: 'contain', offset: { unit: 'percentage', value: [END_PERCENTAGE] } }, + easing: '[EASING_FUNCTION]', // usually 'linear' + fill: 'both', + effectId: '[UNIQUE_EFFECT_ID]' + }, + // additional effects targeting other elements can be added here + ] +} +\`\`\` + +### Variables + +- \`[TALL_WRAPPER_KEY]\` — key for the tall outer element that defines the scroll distance — this is the ViewTimeline source. +- \`[STICKY_CHILD_KEY]\` — key for the animated element inside the sticky container. +- \`[EFFECT_NAME]\` / \`[EFFECT_KEYFRAMES]\` — same as Rule 1. +- \`[START_PERCENTAGE]\` — 0–100, starting point within the \`contain\` range (the stuck phase). +- \`[END_PERCENTAGE]\` — 0–100, end point within the \`contain\` range. +- \`[EASING_FUNCTION]\` / \`[UNIQUE_EFFECT_ID]\` — same as Rule 1. +`; +} diff --git a/packages/interact/package.json b/packages/interact/package.json index 1cc31494..bb042891 100644 --- a/packages/interact/package.json +++ b/packages/interact/package.json @@ -33,6 +33,7 @@ "dev": "vite dev --open", "build": "rimraf dist && vite build && npm run build:types", "build:landing": "../../scripts/build-landing.sh", + "build:rules": "node scripts/build-rules.mjs", "build:types": "tsc -p tsconfig.build.json", "lint": "tsc --noEmit", "test": "vitest run", diff --git a/packages/interact/rules/click.md b/packages/interact/rules/click.md index 83a2e0d8..446f7639 100644 --- a/packages/interact/rules/click.md +++ b/packages/interact/rules/click.md @@ -61,8 +61,8 @@ Use `keyframeEffect` or `namedEffect` when the click should play an animation (C - `'state'` — resumes/pauses the animation on each click. Useful for continuous loops (`iterations: Infinity`). - `[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, time-based animation effect from `@wix/motion-presets`. Refer to motion-presets rules for available presets and their options. -- `[FILL_MODE]` - optional. Always `'both'` with `triggerType: 'alternate'` or `'repeat'`, otherwise depends on the effect. +- `[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]` — optional. Always `'both'` with `triggerType: 'alternate'` or `'repeat'`, otherwise depends on the effect. - `[INITIAL_REVERSED_BOOL]` — optional. `true` to start in the finished state so the entire effect is reversed. - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string, or named easing from `@wix/motion`. @@ -75,7 +75,7 @@ Use `keyframeEffect` or `namedEffect` when the click should play an animation (C ## Rule 2: transition / transitionProperties (StateEffect) -Use `transition` or `transitionProperties` when the click should toggle styles via DOM attribute change and CSS transitions rather than keyframe animations. Uses the `transition` CSS property. Set `stateAction` on the effect to control how the style is applied. +Use `transition` or `transitionProperties` when the click should toggle styles via DOM attribute change and CSS transitions rather than keyframe animations. Set `stateAction` on the effect to control how the style is applied. Use `transition` when all properties share timing. Use `transitionProperties` when each property needs independent `duration`, `delay`, or `easing`. @@ -109,7 +109,8 @@ Use `transition` when all properties share timing. Use `transitionProperties` wh }, // ... more properties ] - } + }, + // additional effects targeting other elements can be added here ] } ``` @@ -145,7 +146,8 @@ Use `customEffect` when you need imperative control over the animation (e.g. cou customEffect: [CUSTOM_EFFECT_CALLBACK], duration: [DURATION_MS], easing: '[EASING_FUNCTION]' - } + }, + // additional effects targeting other elements can be added here ] } ``` @@ -153,7 +155,7 @@ Use `customEffect` when you need imperative control over the animation (e.g. cou ### Variables - `[SOURCE_KEY]` / `[TARGET_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. -- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with target element and `progress` from 0 to 1. +- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with the target element and `progress` from 0 to 1. - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string, or named easing from `@wix/motion`. @@ -173,7 +175,7 @@ Use sequences when a click should sync/stagger animations across multiple elemen offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -186,4 +188,4 @@ Use sequences when a click should sync/stagger animations across multiple elemen - `[SOURCE_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset for staggering each child's animation start, in milliseconds. - `[OFFSET_EASING]` — easing curve for the offset staggering distribution. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of, or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of or a reference to a time-based animation effect. diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index cb13b9df..8b6b797e 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -32,10 +32,9 @@ Declarative configuration-driven interaction library. Binds animations to trigge Each item here is CRITICAL — ignoring any of them will break animations. +- **CRITICAL - Hit-area shift**: When a hover effect changes the size or position of the hovered element (e.g., `transform: scale(…)`), MUST use a separate source and target elements. Otherwise the hit-area shifts, causing rapid enter/leave events and flickering. Use `selector` to target a child element, or set the effect's `key` to a different element. +- **CRITICAL**: When using `viewEnter` trigger and source (trigger) and target (effect) elements are the **same element**, use ONLY `triggerType: 'once'`. For all other types (`'repeat'`, `'alternate'`, `'state'`) MUST use **separate** source and target elements — animating the observed element itself can cause it to leave/re-enter the viewport, leading to rapid re-triggers or the animation never firing. - **CRITICAL — `overflow: hidden` breaks `viewProgress`**: Replace with `overflow: clip` on all ancestors between source and scroll container. In Tailwind, replace `overflow-hidden` with `overflow-clip`. -- **CRITICAL**: When using `viewEnter` trigger and source (trigger) and target (effect) elements are the **same element**, use ONLY `type: 'once'`. For all other types (`'repeat'`, `'alternate'`, `'state'`) MUST use **separate** source and target elements — animating the observed element itself can cause it to leave/re-enter the viewport, leading to rapid re-triggers or the animation never firing. -- **CRITICAL - Hit-area shift**: When a hover effect changes the size or position of the hovered element (e.g., `transform: scale(…)`), MUST use a separate source and target elements. Otherwise the hit-area shifts, causing rapid enter/leave. - events and flickering. Use `selector` to target a child element, or set the effect's `key` to a different element. - **CRITICAL**: For `pointerMove` trigger MUST AVOID using the same element as both source and target with `hitArea: 'self'` and effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. - **CRITICAL — Do NOT guess preset options**: If you don't know the expected type/structure for a `namedEffect` param, omit it — rely on defaults rather than guessing. - **Reduced motion**: Use conditions to provide gentler alternatives (shorter durations, fewer transforms, no perpetual motion) for users who prefer reduced motion. You can also set `Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches` to force a global reduced-motion behavior programmatically. @@ -215,7 +214,7 @@ For most use cases, `key` alone is sufficient for both source and target resolut - `hitArea?`: `'root' | 'self'` (default `'self'`) - `axis?`: `'x' | 'y'` - when using `keyframeEffect` with `pointerMove`, selects which pointer coordinate maps to linear 0-1 progress; defaults to `'y'`. Ignored for `namedEffect` and `customEffect`. - Usage: - - `'self'`: Track pointer within the source element’s bounds. + - `'self'`: Track pointer within the source element's bounds. - `'root'`: Track pointer anywhere in the viewport (document root). - Only use with `ScrubEffect` mouse presets (`namedEffect`) or `customEffect` that consumes pointer progress; avoid `keyframeEffect` with `pointerMove` unless mapping a single axis via `axis`. - When using `customEffect` with `pointerMove`, the progress parameter is an object: @@ -238,21 +237,21 @@ For `TimeEffect` (keyframe/named/custom effects), set `triggerType` on the effec **`triggerType`** — on `TimeEffect`: -| Type | hover behavior | click behavior | -| :---------------------- | :-------------------------------------- | :------------------------------- | -| `'alternate'` (default) | Play on enter, reverse on leave | Alternate play/reverse per click | -| `'repeat'` | Play on enter, stop and rewind on leave | Restart per click | -| `'once'` | Play once on first enter only | Play once on first click only | -| `'state'` | Play on enter, pause on leave | Toggle play/pause per click | +| Type | hover behavior | click behavior | +| :--- | :--- | :--- | +| `'alternate'` (default) | Play on enter, reverse on leave | Alternate play/reverse per click | +| `'repeat'` | Play on enter, stop and rewind on leave | Restart per click | +| `'once'` | Play once on first enter only | Play once on first click only | +| `'state'` | Play on enter, pause on leave | Toggle play/pause per click | **`stateAction`** — on `StateEffect`: -| Action | hover behavior | click behavior | -| :------------------- | :---------------------------------------------- | :--------------------------- | -| `'toggle'` (default) | Add style state on enter, remove on leave | Toggle style state per click | -| `'add'` | Add style state on enter; leave does NOT remove | Add style state on click | -| `'remove'` | Remove style state on enter | Remove style state on click | -| `'clear'` | Clear/reset all style states on enter | Clear/reset all style states | +| Action | hover behavior | click behavior | +| :--- | :--- | :--- | +| `'toggle'` (default) | Add style state on enter, remove on leave | Toggle style state per click | +| `'add'` | Add style state on enter; leave does NOT remove | Add style state on click | +| `'remove'` | Remove style state on enter | Remove style state on click | +| `'clear'` | Clear/reset all style states on enter | Clear/reset all style states | ### viewEnter @@ -393,19 +392,19 @@ Used with `viewProgress` and `pointerMove` triggers. ```ts { - name?: 'entry' | 'exit' | 'contain' | 'cover' | 'entry-crossing' | 'exit-crossing'; + name?: 'cover' | 'entry' | 'exit' | 'contain' | 'entry-crossing' | 'exit-crossing'; offset?: { value: number; unit: 'percentage' | 'px' | 'vh' | 'vw' } } ``` -| Range name | Meaning | -| :--------------- | :------------------------------------------------------------- | -| `entry` | Element entering viewport | -| `exit` | Element exiting viewport | -| `contain` | After `entry` range and before `exit` range | -| `cover` | Full range from `entry` through `contain` and `exit` | -| `entry-crossing` | From element's leading edge entering to trailing edge entering | -| `exit-crossing` | From element's leading edge exiting to trailing edge exiting | +| Range name | Meaning | +| :--- | :--- | +| `cover` | full visibility span from first pixel entering to last pixel leaving. | +| `entry` | the phase while the element is entering the viewport. | +| `exit` | the phase while the element is exiting the viewport. | +| `contain` | while the element is fully contained in the viewport. Typically used with a `position: sticky` container. | +| `entry-crossing` | from the element's leading edge entering to its leading edge reaching the opposite side. | +| `exit-crossing` | from the element's trailing edge reaching the start to its trailing edge leaving. | **Sticky container pattern** — for scroll-driven animations inside a stuck `position: sticky` container: @@ -464,12 +463,12 @@ Exactly one MUST be provided per time-based or scroll/pointer-driven effect: Available presets: - | Category | Presets | - | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | Entrance | `FadeIn`, `GlideIn`, `SlideIn`, `FloatIn`, `RevealIn`, `ExpandIn`, `BlurIn`, `FlipIn`, `ArcIn`, `ShuttersIn`, `CurveIn`, `DropIn`, `FoldIn`, `ShapeIn`, `TiltIn`, `WinkIn`, `SpinIn`, `TurnIn`, `BounceIn` | - | Ongoing | `Pulse`, `Spin`, `Breathe`, `Bounce`, `Wiggle`, `Flash`, `Flip`, `Fold`, `Jello`, `Poke`, `Rubber`, `Swing`, `Cross` | - | Scroll | `FadeScroll`, `RevealScroll`, `ParallaxScroll`, `MoveScroll`, `SlideScroll`, `GrowScroll`, `ShrinkScroll`, `TiltScroll`, `PanScroll`, `BlurScroll`, `FlipScroll`, `SpinScroll`, `ArcScroll`, `ShapeScroll`, `ShuttersScroll`, `SkewPanScroll`, `Spin3dScroll`, `StretchScroll`, `TurnScroll` | - | Mouse | `TrackMouse`, `Tilt3DMouse`, `Track3DMouse`, `SwivelMouse`, `AiryMouse`, `ScaleMouse`, `BlurMouse`, `SkewMouse`, `BlobMouse` | + | Category | Presets | + | :--- | :--- | + | Entrance | `FadeIn`, `GlideIn`, `SlideIn`, `FloatIn`, `RevealIn`, `ExpandIn`, `BlurIn`, `FlipIn`, `ArcIn`, `ShuttersIn`, `CurveIn`, `DropIn`, `FoldIn`, `ShapeIn`, `TiltIn`, `WinkIn`, `SpinIn`, `TurnIn`, `BounceIn` | + | Ongoing | `Pulse`, `Spin`, `Breathe`, `Bounce`, `Wiggle`, `Flash`, `Flip`, `Fold`, `Jello`, `Poke`, `Rubber`, `Swing`, `Cross` | + | Scroll | `FadeScroll`, `RevealScroll`, `ParallaxScroll`, `MoveScroll`, `SlideScroll`, `GrowScroll`, `ShrinkScroll`, `TiltScroll`, `PanScroll`, `BlurScroll`, `FlipScroll`, `SpinScroll`, `ArcScroll`, `ShapeScroll`, `ShuttersScroll`, `SkewPanScroll`, `Spin3dScroll`, `StretchScroll`, `TurnScroll` | + | Mouse | `TrackMouse`, `Tilt3DMouse`, `Track3DMouse`, `SwivelMouse`, `AiryMouse`, `ScaleMouse`, `BlurMouse`, `SkewMouse`, `BlobMouse` | - **CRITICAL** — Scroll presets (`*Scroll`) used with `viewProgress` MUST include `range` in options: `'in'` (ends at idle state), `'out'` (starts from idle state), or `'continuous'` (passes through idle). Prefer `'continuous'`. - Mouse presets are preferred over `keyframeEffect` for `pointerMove` 2D effects. @@ -545,7 +544,7 @@ Coordinate multiple effects with staggered timing. Prefer sequences over manual ### Variables -- `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for /vanilla, `interactKey` for React). +- `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for web/vanilla, `interactKey` for React). - `[TRIGGER]` — any trigger for time-based animation effects (e.g., `'viewEnter'`, `'activate'`, `'interest'`). - `[TRIGGER_PARAMS]` — trigger-specific parameters (e.g., `{ type: 'once', threshold: 0.3 }`). - `[OFFSET_MS]` — ms between each child's animation start. diff --git a/packages/interact/rules/hover.md b/packages/interact/rules/hover.md index f2f5d3b6..a85945ac 100644 --- a/packages/interact/rules/hover.md +++ b/packages/interact/rules/hover.md @@ -64,12 +64,12 @@ Use `keyframeEffect` or `namedEffect` when the hover should play an animation (C - `[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]` — usually `'both'`. Keeps the final state applied while hovering, and prevents garbage-collection of animation when finished. - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string (e.g. `'ease-out'`, `'ease-in-out'`, `'cubic-bezier(0.4, 0, 0.2, 1)'`), or named easing from `@wix/motion`. - `[DELAY_MS]` — optional delay before the effect starts, in milliseconds. - `[ITERATIONS]` — optional. Number of iterations, or `Infinity` for continuous loops. Primarily useful with `triggerType: 'state'`. - `[ALTERNATE_BOOL]` — optional. `true` to alternate direction on every other iteration (within a single playback). -- `[FILL_MODE]` — usually `'both'`. Keeps the final state applied while hovering, and prevents garbage-collection of animation when finished. --- @@ -155,7 +155,7 @@ Use `customEffect` when you need imperative control over the animation (e.g. cou ### Variables - `[SOURCE_KEY]` / `[TARGET_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. -- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(target: HTMLElement, progress: number) => void`. Called on each animation frame with the target element and `progress` from 0 to 1. +- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with the target element and `progress` from 0 to 1. - `[DURATION_MS]` — animation duration in milliseconds. - `[EASING_FUNCTION]` — CSS easing string, or named easing from `@wix/motion`. @@ -175,7 +175,7 @@ Use sequences when a hover should sync/stagger animations across multiple elemen offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -188,4 +188,4 @@ Use sequences when a hover should sync/stagger animations across multiple elemen - `[SOURCE_KEY]` / `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset for staggering each child's animation start, in milliseconds. - `[OFFSET_EASING]` — easing curve for the offset staggering distribution. CSS easing string, or named easing from `@wix/motion`. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of or a reference to a time-based animation effect. diff --git a/packages/interact/rules/integration.md b/packages/interact/rules/integration.md index e5a75121..2afe17ab 100644 --- a/packages/interact/rules/integration.md +++ b/packages/interact/rules/integration.md @@ -217,6 +217,7 @@ The target element is what the effect animates. Resolved in priority order: | `viewProgress` | Scroll-driven (ViewTimeline) | No trigger params. Configure `rangeStart`/`rangeEnd` on the **effect**, not on `params` | [viewprogress.md](./viewprogress.md) | | `pointerMove` | Mouse movement | `hitArea?`: `'self'` \| `'root'`; `axis?`: `'x'` \| `'y'` | [pointermove.md](./pointermove.md) | | `animationEnd` | Chain after another effect | `effectId`: ID of the preceding effect | — | +| `pageVisible` | Page visibility change | No params. Fires when the page becomes visible (e.g. tab switch). | — | For `hover`/`click` (and their accessible variants `interest`/`activate`): set `triggerType` on the effect for keyframe/named/custom effects (TimeEffect), or `stateAction` on the effect for transitions (StateEffect). Do not mix both on the same effect. @@ -248,9 +249,9 @@ Define reusable sequences in `InteractConfig.sequences` and reference by `sequen }, interactions: [ { - key: `'[SOURCE_KEY]'`, - trigger: `'[TRIGGER]'`, - params: `[TRIGGER_PARAMS]`, + key: '[SOURCE_KEY]', + trigger: '[TRIGGER]', + params: [TRIGGER_PARAMS], sequences: [{ sequenceId: 'stagger-fade' }], }, ], @@ -290,11 +291,11 @@ const css = generate(config); ``` -**Web:** +**Web (Custom Elements):** ```html -
...
+
...
``` diff --git a/packages/interact/rules/pointermove.md b/packages/interact/rules/pointermove.md index d471ea29..19c72e89 100644 --- a/packages/interact/rules/pointermove.md +++ b/packages/interact/rules/pointermove.md @@ -69,7 +69,7 @@ type Progress = { Controls which element's bounds define the 0–1 progress range. - **`false` (default)**: Progress is calculated against the **source element's** (or viewport's) bounds. The `50%` progress of the timeline is at the center of the source element. -- **`true`**: `50%` progress of the timeline is calculated against the **target element's center**. The edges of the timeline are still calculated against the edges of the source element/viewport depending on `hitAea`. +- **`true`**: `50%` progress of the timeline is calculated against the **target element's center**. The edges of the timeline are still calculated against the edges of the source element/viewport depending on `hitArea`. --- @@ -100,7 +100,7 @@ For devices with dynamic viewport sizes (e.g. mobile browsers where the address ## Rule 1: namedEffect -Use pre-built mouse presets from `@wix/motion-presets` that handle 2D mouse tracking internally. Mouse presets are preferred over `keyframeEffect` for 2D effects. +Use pre-built mouse presets from `@wix/motion-presets` that handle 2D mouse tracking internally. Mouse presets are preferred over `keyframeEffect` for 2D effects. Available mouse presets: `TrackMouse`, `Tilt3DMouse`, `Track3DMouse`, `SwivelMouse`, `AiryMouse`, `ScaleMouse`, `BlurMouse`, `SkewMouse`, `BlobMouse`. **Multiple effects:** The `effects` array can contain multiple effects — all share the same pointer tracking and fire together. Use this to animate different targets from the same pointer movement. @@ -172,15 +172,11 @@ Use `keyframeEffect` when the pointer position along a single axis should drive ### Variables -- `[SOURCE_KEY]` / `[TARGET_KEY]` — same as Rule 1. -- `[HIT_AREA]` — `'self'` or `'root'`. +- `[SOURCE_KEY]` / `[TARGET_KEY]` / `[HIT_AREA]` — same as Rule 1. - `[AXIS]` — `'x'` (horizontal) or `'y'` (vertical). Defaults to `'y'` when omitted. -- `[EFFECT_NAME]` — unique string name for the keyframe effect. +- `[EFFECT_NAME]` — unique string identifier for a `keyframeEffect`. - `[KEYFRAMES]` — array of CSS keyframe objects (e.g. `[{ transform: 'rotate(-10deg)' }, { transform: 'rotate(0)' }, { transform: 'rotate(10deg)' }]`). Distributed evenly across 0–1 progress: first keyframe = progress 0 (left/top edge), last = progress 1 (right/bottom edge). Any number of keyframes is allowed. -- `[CENTERED_TO_TARGET]` — optional. `true` or `false`. See **Centering with `centeredToTarget`** above. Defaults to `false`. -- `[TRANSITION_DURATION_MS]` — optional. Milliseconds for smoothing between progress updates. See Rule 1 for details. -- `[TRANSITION_EASING]` — optional. CSS easing string or named easing from `@wix/motion`. See Rule 1 for supported values. -- `[UNIQUE_EFFECT_ID]` — optional string identifier. +- `[CENTERED_TO_TARGET]` / `[TRANSITION_DURATION_MS]` / `[TRANSITION_EASING]` / `[UNIQUE_EFFECT_ID]` — same as Rule 1. --- @@ -231,15 +227,13 @@ Use two separate interactions on the same source/target pair — one for `axis: ### Variables -- `[SOURCE_KEY]` / `[TARGET_KEY]` — same as Rule 1. -- `[HIT_AREA]` — `'self'` or `'root'`. +- `[SOURCE_KEY]` / `[TARGET_KEY]` / `[HIT_AREA]` — same as Rule 1. - `[X_EFFECT_ID]` / `[Y_EFFECT_ID]` — unique string identifiers for the X-axis and Y-axis effects. Required — they map to keys in the top-level `effects` map. - `[X_EFFECT_NAME]` / `[Y_EFFECT_NAME]` — unique string names for each keyframe effect. -- `[X_KEYFRAMES]` / `[Y_KEYFRAMES]` — arrays of WAAPI keyframe objects for the X-axis and Y-axis effects respectively. Each effect can vary in propertise and keyframes. +- `[X_KEYFRAMES]` / `[Y_KEYFRAMES]` — arrays of WAAPI keyframe objects for the X-axis and Y-axis effects respectively. Each effect can vary in properties and keyframes. - `[COMPOSITE_OPERATION]` — `'add'` or `'accumulate'`. Required when both effects animate `transform` and/or both animate `filter`, so their values combine rather than override. `'add'`: composited transform functions are appended. `'accumulate'`: matching function arguments are summed. - `[FILL_MODE]` — typically `'both'` to ensure the effect keeps applying after exiting the effect's active range. -- `[TRANSITION_DURATION_MS]` — optional. Milliseconds for smoothing between progress updates. See Rule 1 for details. -- `[TRANSITION_EASING]` — optional. CSS easing function for the smoothing transition. See Rule 1 for supported values. +- `[TRANSITION_DURATION_MS]` / `[TRANSITION_EASING]` — same as Rule 1. --- @@ -271,9 +265,6 @@ Use `customEffect` when you need full imperative control over pointer-driven ani ### Variables -- `[SOURCE_KEY]` / `[TARGET_KEY]` — same as Rule 1. -- `[HIT_AREA]` — `'self'` or `'root'`. +- `[SOURCE_KEY]` / `[TARGET_KEY]` / `[HIT_AREA]` — same as Rule 1. - `[CUSTOM_ANIMATION_LOGIC]` — JavaScript using `progress.x`, `progress.y`, `progress.v`, and `progress.active` to apply the effect. See **Progress Object Structure** above. -- `[CENTERED_TO_TARGET]` — optional. `true` or `false`. See **Centering with `centeredToTarget`** above. Defaults to `false`. -- `[TRANSITION_DURATION_MS]` — optional. Milliseconds for smoothing between progress updates. See Rule 1 for details. -- `[TRANSITION_EASING]` — optional. CSS easing function for the smoothing transition. See Rule 1 for supported values. +- `[CENTERED_TO_TARGET]` / `[TRANSITION_DURATION_MS]` / `[TRANSITION_EASING]` — same as Rule 1. diff --git a/packages/interact/rules/viewenter.md b/packages/interact/rules/viewenter.md index e6cf1171..0894102c 100644 --- a/packages/interact/rules/viewenter.md +++ b/packages/interact/rules/viewenter.md @@ -134,7 +134,7 @@ Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animatio ### Variables -- `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for web/vanilla, `interactKey` for React). The **source element** is observed for viewport intersection. This is the element the IntersectionObserver watches. +- `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for web, `interactKey` for React). The **source element** is observed for viewport intersection. This is the element the IntersectionObserver watches. - `[TARGET_KEY]` — identifier matching the element's key on the element that animates. - `[TARGET_SELECTOR]` - optional. Selector for the child element to select inside the root element. For `triggerType` of `'alternate'`/`'repeat'`/`'state'` MUST either use a separate `[TARGET_KEY]` from `[SOURCE_KEY]` or `selector` for selecting a child element as target. - `[TRIGGER_TYPE]` — `triggerType` on the effect. One of: @@ -153,7 +153,7 @@ Use `keyframeEffect` or `namedEffect` when the viewEnter should play an animatio - `[DELAY_MS]` — optional delay before the effect starts, in milliseconds. - `[ITERATIONS]` — optional. Number of iterations, or `Infinity` for continuous loops. Primarily useful with `triggerType: 'state'`. - `[ALTERNATE_BOOL]` — optional. `true` to alternate direction on every other iteration (within a single playback). -- `[UNIQUE_EFFECT_ID]` — optional. String identifier used by `animationEnd` triggers for chaining, and by sequences for referencing effects. +- `[UNIQUE_EFFECT_ID]` — optional. String identifier used by `animationEnd` triggers for chaining, and by sequences for referencing effects from the top-level `effects` map. --- @@ -207,7 +207,7 @@ Use sequences when a viewEnter should sync/stagger animations across multiple el offset: [OFFSET_MS], offsetEasing: '[OFFSET_EASING]', effects: [ - [EFFECT_DEFINTION], + [EFFECT_DEFINITION], // .. more effects as necessary ] } @@ -221,4 +221,4 @@ Use sequences when a viewEnter should sync/stagger animations across multiple el - `[TRIGGER_TYPE]` — same as Rule 1. `triggerType` is set on the sequence config, not on individual effects within the sequence. - `[OFFSET_MS]` — time offset between each child's animation start, in milliseconds. - `[OFFSET_EASING]` — CSS easing or named easing from `@wix/motion`, for the stagger distribution. Defaults to `'linear'`. -- `[EFFECT_DEFINTION]` — a definition of or a reference to a time-based animation effect. +- `[EFFECT_DEFINITION]` — a definition of or a reference to a time-based animation effect. diff --git a/packages/interact/rules/viewprogress.md b/packages/interact/rules/viewprogress.md index a5758e30..c5db681d 100644 --- a/packages/interact/rules/viewprogress.md +++ b/packages/interact/rules/viewprogress.md @@ -50,7 +50,7 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View - `[SOURCE_KEY]` — identifier matching the element's key (`data-interact-key` for web, `interactKey` for React). The element whose scroll position drives the animation. - `[TARGET_KEY]` — identifier matching the element's key (`data-interact-key` for web, `interactKey` for React) on the element to animate (can be same as source or different). - `[NAMED_EFFECT_DEFINITION]` — object with properties of pre-built effect from `@wix/motion-presets`. **CRITICAL:** Scroll presets (`*Scroll`) MUST include `range: 'in' | 'out' | 'continuous'` in their options. `'in'` ends at the idle state, `'out'` starts from the idle state, `'continuous'` passes through it. -- `[EFFECT_NAME]` — unique name for custom keyframe effect. +- `[EFFECT_NAME]` — unique string identifier for a `keyframeEffect`. - `[EFFECT_KEYFRAMES]` — array of keyframe objects defining CSS property values (e.g. `[{ opacity: 0 }, { opacity: 1 }]`). Property names in camelCase. - `[RANGE_NAME]` — scroll range name: - `'cover'` — full visibility span from first pixel entering to last pixel leaving. @@ -61,8 +61,8 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View - `'exit-crossing'` — from the element's trailing edge reaching the start to its trailing edge leaving. - `[START_PERCENTAGE]` — 0–100, starting point within the named range. - `[END_PERCENTAGE]` — 0–100, end point within the named range. -- `[EASING_FUNCTION]` - CSS easing string or named easing from `@wix/motion`. Typically `'linear'` for scrolling effects. -- `[UNIQUE_EFFECT_ID]` — optional identifier for referencing the effect externally. +- `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. Typically `'linear'` for scrolling effects. +- `[UNIQUE_EFFECT_ID]` — optional. String identifier used by `animationEnd` triggers for chaining, and by sequences for referencing effects from the top-level `effects` map. --- @@ -82,7 +82,7 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View customEffect: [CUSTOM_EFFECT_CALLBACK], rangeStart: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [START_PERCENTAGE] } }, rangeEnd: { name: '[RANGE_NAME]', offset: { unit: 'percentage', value: [END_PERCENTAGE] } }, - easing: `'[EASING_FUNCTION]'`, // usually 'linear' + easing: '[EASING_FUNCTION]', // usually 'linear' fill: 'both', effectId: '[UNIQUE_EFFECT_ID]' }, @@ -94,10 +94,8 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View ### Variables - `[SOURCE_KEY]` / `[TARGET_KEY]` — same as Rule 1. -- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with `progress` from 0 to 1. -- `[RANGE_NAME]` / `[START_PERCENTAGE]` / `[END_PERCENTAGE]` — same as Rule 1. -- `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. Typically `'linear'` for scrolling effects. -- `[UNIQUE_EFFECT_ID]` — optional identifier for referencing the effect externally. +- `[CUSTOM_EFFECT_CALLBACK]` — function with signature `(element: HTMLElement, progress: number) => void`. Called on each animation frame with the target element and `progress` from 0 to 1. +- `[RANGE_NAME]` / `[START_PERCENTAGE]` / `[END_PERCENTAGE]` / `[EASING_FUNCTION]` / `[UNIQUE_EFFECT_ID]` — same as Rule 1. --- @@ -140,5 +138,4 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View - `[EFFECT_NAME]` / `[EFFECT_KEYFRAMES]` — same as Rule 1. - `[START_PERCENTAGE]` — 0–100, starting point within the `contain` range (the stuck phase). - `[END_PERCENTAGE]` — 0–100, end point within the `contain` range. -- `[UNIQUE_EFFECT_ID]` — same as Rule 1. -- `[EASING_FUNCTION]` — CSS easing string or named easing from `@wix/motion`. Typically `'linear'` for scrolling effects. +- `[EASING_FUNCTION]` / `[UNIQUE_EFFECT_ID]` — same as Rule 1. diff --git a/packages/interact/scripts/build-rules.mjs b/packages/interact/scripts/build-rules.mjs new file mode 100644 index 00000000..223ce7e5 --- /dev/null +++ b/packages/interact/scripts/build-rules.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Fragments } from '../_content/lib/fragments.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const PKG_ROOT = join(__dirname, '..'); +const CONTENT_DIR = join(PKG_ROOT, '_content'); +const OUTPUT_DIR = join(PKG_ROOT, 'rules'); + +// --------------------------------------------------------------------------- +// 1. Load data modules +// --------------------------------------------------------------------------- + +const { triggers } = await import(join(CONTENT_DIR, 'data', 'triggers.mjs')); +const { effects } = await import(join(CONTENT_DIR, 'data', 'effects.mjs')); +const { meta } = await import(join(CONTENT_DIR, 'data', 'meta.mjs')); + +const metaParams = { + installCommand: meta.installCommand, + webEntry: meta.entryPoints.web, + reactEntry: meta.entryPoints.react, + vanillaEntry: meta.entryPoints.vanilla, + presetsPackage: meta.presetsPackage, +}; + +const data = { triggers, effects, meta, metaParams }; + +// --------------------------------------------------------------------------- +// 2. Load fragments +// --------------------------------------------------------------------------- + +const fragments = new Fragments(join(CONTENT_DIR, 'fragments')); + +// --------------------------------------------------------------------------- +// 3. Import templates and render via manifest +// --------------------------------------------------------------------------- + +const manifest = [ + { + template: 'event-trigger-rule.mjs', + triggers: ['click', 'hover'], + output: (name) => `${name}.md`, + }, + { template: 'viewenter-rule.mjs', triggers: ['viewEnter'], output: () => 'viewenter.md' }, + { + template: 'viewprogress-rule.mjs', + triggers: ['viewProgress'], + output: () => 'viewprogress.md', + }, + { template: 'pointermove-rule.mjs', triggers: ['pointerMove'], output: () => 'pointermove.md' }, + { template: 'full-lean.mjs', triggers: null, output: () => 'full-lean.md' }, + { template: 'integration.mjs', triggers: null, output: () => 'integration.md' }, +]; + +const outputs = []; + +for (const entry of manifest) { + const mod = await import(join(CONTENT_DIR, 'templates', entry.template)); + if (entry.triggers) { + for (const name of entry.triggers) { + const trigger = data.triggers.find((t) => t.name === name); + if (!trigger) throw new Error(`Trigger "${name}" not found in data/triggers.mjs`); + outputs.push({ + file: entry.output(name), + content: mod.render({ ...data, trigger }, fragments), + }); + } + } else { + outputs.push({ file: entry.output(), content: mod.render(data, fragments) }); + } +} + +// --------------------------------------------------------------------------- +// 4. Write or check outputs +// --------------------------------------------------------------------------- + +const checkMode = process.argv.includes('--check'); + +mkdirSync(OUTPUT_DIR, { recursive: true }); + +let stale = 0; +for (const { file, content } of outputs) { + const outPath = join(OUTPUT_DIR, file); + if (checkMode) { + let existing = ''; + try { + existing = readFileSync(outPath, 'utf8'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + if (existing !== content) { + console.error(` ✗ ${relative(PKG_ROOT, outPath)} is stale`); + const existingLines = existing.split('\n'); + const contentLines = content.split('\n'); + for (let i = 0; i < Math.max(existingLines.length, contentLines.length); i++) { + if (existingLines[i] !== contentLines[i]) { + console.error(` first diff at line ${i + 1}:`); + if (existingLines[i] !== undefined) console.error(` - ${existingLines[i]}`); + if (contentLines[i] !== undefined) console.error(` + ${contentLines[i]}`); + break; + } + } + stale++; + } else { + console.log(` ✓ ${relative(PKG_ROOT, outPath)} is up to date`); + } + } else { + writeFileSync(outPath, content, 'utf8'); + console.log(` ✓ ${relative(PKG_ROOT, outPath)}`); + } +} + +if (checkMode && stale > 0) { + console.error(`\n${stale} file(s) are stale. Run \`yarn build:rules\` to regenerate.`); + process.exit(1); +} else if (checkMode) { + console.log(`\nAll ${outputs.length} rule files are up to date.`); +} else { + console.log(`\nGenerated ${outputs.length} rule files.`); +}