Skip to content
Open
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

#### Added

- `REDUCE_GATED_SCRUB` (new rule category `REDUCED_MOTION`, severity `warning`): a `viewProgress` / `pointerMove` interaction or effect gated on a `prefers-reduced-motion: reduce` condition can never run, since the runtime cancels scrubs under `reduce` regardless of conditions. A `no-preference` gate is not reported — it is redundant, not dead
- Plugin fields: `$`-prefixed keys on interactions and effects are accepted; every other unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (#275)

#### Changed
Expand Down Expand Up @@ -85,11 +86,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

#### Added

- `Interact.reducedMotion` — a read-only static getter resolving to `Interact.forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`, and the single source of truth the handlers consult. Returns `false` where there is no `window`/`matchMedia` (SSR, JSDOM)
- Generic plugin bridge: `Interact.use(name, plugin)` registers a plugin, and a `$<name>` field on an interaction or effect (#275)
- `generate()` accepts a `plugins` option — a map of plugin name → build-time style generator (#275)

#### Changed

- **Reduced motion is now detected and enforced by default.** `prefers-reduced-motion: reduce` is respected with no setup, and enforcement moved into the CSS that `generate()` emits — so it also applies with JS disabled, under SSR, and when the visitor changes the OS setting mid-session. Previously the only reduced-motion signal was an explicit `Interact.forceReducedMotion = true`, and even that was bypassed for any effect whose CSS had been pre-generated. **Pages that animate today for visitors who prefer reduced motion will stop.** To restore the old behavior, set `Interact.forceReducedMotion = false` before `Interact.create()`. Per effect kind: time effects (including `iterations: Infinity`) collapse to a `1ms` single iteration with no delay; state effects keep the state and drop the tween; `viewProgress` and `pointerMove` effects are cancelled. Nothing is suppressed by name, so a collapsed entrance still completes and can never be stranded behind its own FOUC hiding rule
- `Interact.forceReducedMotion` is now `boolean | undefined` and defaults to `undefined` (was `boolean`, default `false`). It is an **override**: `undefined` follows `prefers-reduced-motion`, `true` forces reduced motion on, `false` forces motion on. `undefined` is falsy, so `if (Interact.forceReducedMotion)` and `!Interact.forceReducedMotion` are unaffected; only an explicit `=== false` comparison changes meaning. Setting it suppresses the preference-change listener, so it must be assigned before `Interact.create()`
- An effect (or interaction) whose `conditions` include a `prefers-reduced-motion` media condition is exempt from the automatic collapse and runs exactly as authored — this is how a gentler alternative is expressed, and it exempts only that effect. A `viewProgress` / `pointerMove` interaction gated on `reduce` never runs in either path, so a scrub's alternative must use a time-based trigger
- A `viewEnter` entrance's FOUC hiding rule is now gated on the union of its interaction's and its effect's conditions. Previously an interaction-level condition left the rule unconditional, so a gated entrance (e.g. `conditions: ['desktop']`) could leave its element permanently hidden wherever the condition did not match
- `generate(config, options?)`: the second argument now accepts an options bag — `{ useFirstChild?, plugins? }` — exported as the `GenerateOptions` (#275)
- CSS property names may be authored in either camelCase or kebab-case in `transition.styleProperties`, `transitionProperties` and `keyframeEffect.keyframes`; state-effect properties are normalized to kebab-case for the generated CSS (state rules and the `transition:` shorthand) and keyframes to camelCase for WAAPI

Expand Down
50 changes: 26 additions & 24 deletions packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type ValidationError = {
code: string; // domain code — see the catalogue below
message: string; // human-readable description
path: (string | number)[]; // e.g. ['interactions', 0, 'effects', 0, 'duration']
severity: 'error' | 'warning';
severity: 'error' | 'warning' | 'info';
hint?: string; // optional remediation hint (reserved; not currently populated)
};
```
Expand Down Expand Up @@ -129,10 +129,10 @@ const ExperienceSchema = z.object({

## Severity model

Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'` remains.
Every issue is `'error'`, `'warning'` or `'info'`. `valid` is `true` **iff** no `'error'` remains.

- **`strict: true`** promotes all remaining issues to `'error'`.
- **`severityOverrides`** is keyed by **rule category**, and only these three categories are registered/overridable:
- **`severityOverrides`** is keyed by **rule category**, and only these categories are registered/overridable:

| Rule category | Covers codes | Default severity |
| ------------------------ | --------------------------------------------------------------------------- | ---------------- |
Expand All @@ -145,10 +145,11 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'`
| `ANIMATION_END_GRAPH` | `ANIMATION_END_SELF_REFERENCE`, `ANIMATION_END_CYCLE` | warning |
| `ELEMENT_SELECTION` | `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER`, `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | warning |
| `STATE_EFFECT` | `EMPTY_STYLE_PROPERTIES`, `STATE_REMOVE_WITHOUT_EFFECT_ID` | warning |
| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH` | info |
| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH`, `RECOMMENDED_FILL_BACKWARDS` | info |
| `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning |
| `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning |
| `VIEW_INSET` | `INVALID_INSET` | warning |
| `REDUCED_MOTION` | `REDUCE_GATED_SCRUB` | warning |

Set a category to `'off'` to drop those issues, or `'warning'` / `'error'` to set their severity. **All other codes** (every `SCHEMA_*`, numeric, effect-source, and referential code) are not in a category and **cannot** be silenced or re-leveled via `severityOverrides` — they always emit at their built-in severity. Precedence: `'off'` first (drops the issue), then a `'warning'`/`'error'` override, then `strict` (forces the rest to `'error'`).

Expand Down Expand Up @@ -194,26 +195,27 @@ The single source of truth for every code the validator emits. The agent-facing

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

| Code | Trigger | Rule category |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` |
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
| Code | Trigger | Rule category |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ |
| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` |
| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` |
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` |
| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` |
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` |
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
| `REDUCE_GATED_SCRUB` | A `viewProgress`/`pointerMove` interaction or effect gated on `(prefers-reduced-motion: reduce)` — scrubs are cancelled under `reduce`, so it can never run. | `REDUCED_MOTION` |

## Usage recipes

Expand Down
1 change: 1 addition & 0 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const RULE_CODE_MAP: Record<string, string> = {
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME',
INVALID_INSET: 'VIEW_INSET',
REDUCE_GATED_SCRUB: 'REDUCED_MOTION',
};

export function finalize(
Expand Down
13 changes: 13 additions & 0 deletions packages/interact-validate/src/semantic/collectSemanticWarnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
checkStateRemoveWithoutEffectId,
} from './partialData';
import { checkRecommendedFill } from './recommendedPatterns';
import { checkReduceGatedScrub } from './reducedMotion';
import { findAnimationEndWarnings } from './animationEndGraph';

// Single traversal of top-level registry effects/sequences and per-interaction
Expand Down Expand Up @@ -54,12 +55,21 @@ export function walkConfig(config: AnyConfig, visitors: Visitors): void {
// Collect every warning/info-level semantic issue (consumed by `transform`).
export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
const warnings: SemanticIssue[] = [];
const configConditions = config.conditions ?? {};

walkConfig(config, {
onInteraction: (path, interaction) => {
warnings.push(...checkListItemSelectorWithoutContainer(path, interaction));
warnings.push(...checkRedundantSelector(path, interaction));
warnings.push(...checkInvalidInset(path, interaction));
warnings.push(
...checkReduceGatedScrub(
path,
interaction.conditions,
configConditions,
interaction.trigger,
),
);
},
// checks that only look at depth>1 properties in effects/sequences do not need resolving
onEffect: (path, effect, isTopLevel, owner) => {
Expand All @@ -78,6 +88,9 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
warnings.push(...checkRecommendedFill(path, resolvedEffect, owner));
warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner));
warnings.push(...checkCSSPropertyNames(path, effect));
warnings.push(
...checkReduceGatedScrub(path, resolvedEffect.conditions, configConditions, owner?.trigger),
);
},
onSequence: (path, sequence, isTopLevel, owner) => {
const { sequenceId } = sequence;
Expand Down
40 changes: 40 additions & 0 deletions packages/interact-validate/src/semantic/reducedMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Path, SemanticIssue, AnyCondition } from '../types';

const MOTION_PREFERENCE_FEATURE = 'prefers-reduced-motion';
const NO_PREFERENCE = new RegExp(`${MOTION_PREFERENCE_FEATURE}\\s*:\\s*no-preference`);
const SCRUB_TRIGGERS = ['viewProgress', 'pointerMove'];

// `(prefers-reduced-motion: no-preference)` gates on motion being allowed, which is what the
// runtime already does for a scrub — redundant, not dead. Every other spelling of the feature
// (`: reduce`, or the boolean `(prefers-reduced-motion)`) selects the reduce side.
// `not (prefers-reduced-motion: no-preference)` reads as no-preference here — a deliberate
// false negative, cheaper than the false positives a looser test would produce.
function gatesOnReduce(predicate: string): boolean {
return predicate.includes(MOTION_PREFERENCE_FEATURE) && !NO_PREFERENCE.test(predicate);
}

export function checkReduceGatedScrub(
path: Path,
conditions: string[] | undefined,
configConditions: Record<string, AnyCondition>,
trigger?: string,
): SemanticIssue[] {
if (!trigger || !SCRUB_TRIGGERS.includes(trigger) || !conditions) return [];

const index = conditions.findIndex((conditionId) => {
const condition = configConditions[conditionId];
return condition?.type === 'media' && gatesOnReduce(condition.predicate);
});

if (index === -1) return [];

return [
{
code: 'custom',
params: { domainCode: 'REDUCE_GATED_SCRUB' },
path: [...path, 'conditions', index],
message: `Condition "${conditions[index]}" gates a \`${trigger}\` scrub on reduced motion, so it can never run — scrubbed effects are cancelled under \`(prefers-reduced-motion: reduce)\` whatever their conditions say. Give the reduced-motion alternative a time-based trigger such as \`viewEnter\`, or express it as a plain CSS rule.`,
severity: 'warning',
},
];
}
Loading