From 1cadb8a6c09c9f82cfa9e90479ce29b840ec8e57 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 30 Sep 2026 19:51:07 +0300 Subject: [PATCH 1/4] Validate namedEffect params against their registered schema - interact-validate: validate namedEffect params against the schema of the effect registered under their type (options.effects, or the effects registered on Interact) - motion: export getRegisteredEffect (with an optional warning), exposed as Interact.getRegisteredEffect Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/interact-validate/src/errors.ts | 2 + packages/interact-validate/src/index.ts | 9 +- .../src/semantic/namedEffectParams.ts | 109 +++++++++++++++++ packages/interact-validate/src/structural.ts | 6 +- packages/interact-validate/src/types.ts | 12 ++ .../test/rules/namedEffectParams.spec.ts | 113 ++++++++++++++++++ packages/interact/rules/validate.md | 15 ++- packages/interact/src/core/Interact.ts | 2 + skills/interactor/references/validate.md | 2 +- 9 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 packages/interact-validate/src/semantic/namedEffectParams.ts create mode 100644 packages/interact-validate/test/rules/namedEffectParams.spec.ts diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index 2b144003..dc178c02 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -34,6 +34,8 @@ const RULE_CODE_MAP: Record = { INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME', INVALID_INSET: 'VIEW_INSET', FUNCTION_OFFSET_EASING: 'OFFSET_EASING', + NAMED_EFFECT_UNKNOWN_PARAM: 'NAMED_EFFECT_PARAMS', + NAMED_EFFECT_INVALID_PARAM: 'NAMED_EFFECT_PARAMS', }; export function finalize( diff --git a/packages/interact-validate/src/index.ts b/packages/interact-validate/src/index.ts index de1a4f01..de9c28a7 100644 --- a/packages/interact-validate/src/index.ts +++ b/packages/interact-validate/src/index.ts @@ -1,5 +1,12 @@ export { InteractValidationError } from './errors'; -export type { Severity, ValidationResult, ValidationError, ValidateOptions } from './types'; +export type { + Severity, + ValidationResult, + ValidationError, + ValidateOptions, + EffectSchema, + EffectParamSchema, +} from './types'; // Zod schemas and sub-schemas for host-project schema composition export { diff --git a/packages/interact-validate/src/semantic/namedEffectParams.ts b/packages/interact-validate/src/semantic/namedEffectParams.ts new file mode 100644 index 00000000..b38e63d9 --- /dev/null +++ b/packages/interact-validate/src/semantic/namedEffectParams.ts @@ -0,0 +1,109 @@ +import { Interact } from '@wix/interact'; +import { walkConfig } from '../walkConfig'; +import type { + AnyConfig, + EffectParamSchema, + EffectSchema, + ValidateOptions, + ValidationError, +} from '../types'; + +const LENGTH_REGEX = /^-?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)?$/i; +const ANGLE_REGEX = /^-?\d*\.?\d+(deg|rad|grad|turn)?$/i; +const CALC_REGEX = /^calc\(.+\)$/i; +const SIDES = ['top', 'right', 'bottom', 'left']; + +const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const matches = (value: unknown, regex: RegExp) => + typeof value === 'string' && (regex.test(value.trim()) || CALC_REGEX.test(value.trim())); + +// [is valid, expected value description] per param type - unknown types are not validated +const PARAM_TYPES: Record< + string, + { + isValid: (value: unknown, param: EffectParamSchema) => boolean; + expected: (param: EffectParamSchema) => string; + } +> = { + number: { + isValid: (value, { min = -Infinity, max = Infinity }) => + isNumber(value) && value >= min && value <= max, + expected: ({ min, max }) => + `a number${min !== undefined ? ` >= ${min}` : ''}${max !== undefined ? ` <= ${max}` : ''}`, + }, + bool: { + isValid: (value) => typeof value === 'boolean', + expected: () => 'a boolean', + }, + enum: { + isValid: (value, { values = [] }) => values.includes(value), + expected: ({ values = [] }) => `one of ${values.map((v) => JSON.stringify(v)).join(' | ')}`, + }, + length: { + isValid: (value) => + isNumber(value) || + matches(value, LENGTH_REGEX) || + (typeof value === 'object' && + value !== null && + typeof (value as { unit?: unknown }).unit === 'string' && + (isNumber((value as { value?: unknown }).value) || + matches((value as { value?: unknown }).value, LENGTH_REGEX))), + expected: () => "a length (number, '', calc() or { value, unit })", + }, + angle: { + isValid: (value) => + isNumber(value) || + matches(value, ANGLE_REGEX) || + (typeof value === 'string' && SIDES.includes(value.trim())), + expected: () => + `an angle (number, 'deg', calc() or ${SIDES.map((s) => `'${s}'`).join(' | ')})`, + }, +}; + +function getSchema(type: string, effects: ValidateOptions['effects']): EffectSchema | undefined { + const module = effects ? effects[type] : Interact.getRegisteredEffect?.(type, false); + return (module as { schema?: EffectSchema } | null | undefined)?.schema; +} + +// validates namedEffect params against the schema of the effect registered under their type +export function checkNamedEffectParams( + config: AnyConfig, + effects: ValidateOptions['effects'], +): ValidationError[] { + const errors: ValidationError[] = []; + + walkConfig(config, { + onEffect: (path, effect) => { + const { type, ...params } = effect.namedEffect ?? {}; + const schema = typeof type === 'string' ? getSchema(type, effects) : undefined; + if (!schema) return; + + Object.entries(params).forEach(([key, value]) => { + const paramPath = [...path, 'namedEffect', key]; + const param = schema[key]; + if (!param) { + errors.push({ + code: 'NAMED_EFFECT_UNKNOWN_PARAM', + message: `'${key}' is not a parameter of ${type} (expected one of ${Object.keys(schema).join(', ')}).`, + path: paramPath, + severity: 'warning', + }); + return; + } + const paramType = PARAM_TYPES[param.type]; + if (value !== undefined && paramType && !paramType.isValid(value, param)) { + errors.push({ + code: 'NAMED_EFFECT_INVALID_PARAM', + message: `${type} \`${key}\` must be ${paramType.expected(param)}; got ${JSON.stringify(value)}.`, + path: paramPath, + severity: 'error', + }); + } + }); + }, + onSequence: () => {}, + }); + + return errors; +} diff --git a/packages/interact-validate/src/structural.ts b/packages/interact-validate/src/structural.ts index 91f2d616..3d3103ba 100644 --- a/packages/interact-validate/src/structural.ts +++ b/packages/interact-validate/src/structural.ts @@ -2,7 +2,8 @@ import type { ZodIssue } from 'zod'; import { InteractConfigSchema } from './schema'; import type { InteractConfig } from '@wix/interact'; import { finalize, InteractValidationError } from './errors'; -import type { Path, ValidationError, ValidateOptions, ValidationResult } from './types'; +import type { AnyConfig, Path, ValidationError, ValidateOptions, ValidationResult } from './types'; +import { checkNamedEffectParams } from './semantic/namedEffectParams'; // Map Zod issue codes → domain error codes. // Custom issues carry their domain code in `params.domainCode`. @@ -74,7 +75,8 @@ export function validateInteractConfig( path: (w?.path ?? []) as Path, severity: w?.severity ?? 'warning', })); - return finalize([...errors, ...warnings], options); + const paramErrors = checkNamedEffectParams(parsed as unknown as AnyConfig, options?.effects); + return finalize([...errors, ...warnings, ...paramErrors], options); } return finalize(errors, options); diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 6921d0dd..bc76b960 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -25,10 +25,22 @@ export type ValidationResult = { errors: ValidationError[]; }; +export type EffectParamSchema = { + type: string; + values?: readonly unknown[]; + min?: number; + max?: number; + default?: unknown; +}; + +export type EffectSchema = Record; + export type ValidateOptions = { strict?: boolean; max?: number; severityOverrides?: Record; + // effect modules as passed to `registerEffects` - defaults to the effects registered on `Interact` + effects?: Record; }; export type AnyEffect = { diff --git a/packages/interact-validate/test/rules/namedEffectParams.spec.ts b/packages/interact-validate/test/rules/namedEffectParams.spec.ts new file mode 100644 index 00000000..efc31373 --- /dev/null +++ b/packages/interact-validate/test/rules/namedEffectParams.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { Interact } from '@wix/interact'; +import { validateInteractConfig } from '../../src'; + +const Move = { + schema: { + from: { type: 'enum', values: ['left', 'right'], default: 'left' }, + angle: { type: 'angle', default: 0 }, + travel: { type: 'length', default: { value: 10, unit: 'px' } }, + scale: { type: 'number', min: 0, max: 1, default: 0.5 }, + fade: { type: 'bool', default: true }, + }, +}; + +const configWith = (namedEffect: Record) => ({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { namedEffect: { type: 'Move', ...namedEffect }, duration: 400, fill: 'backwards' }, + ], + }, + ], +}); + +const paramErrors = (namedEffect: Record) => + validateInteractConfig(configWith(namedEffect), { effects: { Move } }).errors.filter((e) => + e.code.startsWith('NAMED_EFFECT_'), + ); + +describe('namedEffectParams', () => { + it('accepts valid values of every param type', () => { + expect( + paramErrors({ + from: 'right', + angle: '45deg', + travel: { value: 20, unit: '%' }, + scale: 1, + fade: false, + }), + ).toEqual([]); + expect(paramErrors({ angle: 'top', travel: 'calc(100% - 10px)' })).toEqual([]); + expect(paramErrors({ angle: 90, travel: 12 })).toEqual([]); + expect(paramErrors({ angle: '-0.5turn', travel: '2rem' })).toEqual([]); + }); + + it('errors (NAMED_EFFECT_INVALID_PARAM) on values that do not match the schema', () => { + const errors = paramErrors({ + from: 'top', + angle: 'sideways', + travel: '10 apples', + scale: 2, + fade: 'yes', + }); + expect(errors.map((e) => e.path.at(-1)).sort()).toEqual([ + 'angle', + 'fade', + 'from', + 'scale', + 'travel', + ]); + expect(errors.every((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM')).toBe(true); + expect(errors.every((e) => e.severity === 'error')).toBe(true); + expect(errors.find((e) => e.path.at(-1) === 'from')?.path).toEqual([ + 'interactions', + 0, + 'effects', + 0, + 'namedEffect', + 'from', + ]); + expect(paramErrors({ travel: { value: 'far', unit: 'px' } })).toHaveLength(1); + }); + + it('warns (NAMED_EFFECT_UNKNOWN_PARAM) on params the schema does not declare', () => { + const [error] = paramErrors({ direction: 'left' }); + expect(error.code).toBe('NAMED_EFFECT_UNKNOWN_PARAM'); + expect(error.severity).toBe('warning'); + expect(error.path).toEqual(['interactions', 0, 'effects', 0, 'namedEffect', 'direction']); + }); + + it('skips effects without a schema', () => { + const result = validateInteractConfig(configWith({ anything: 1 }), { effects: {} }); + expect(result.errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]); + }); + + it('validates top-level effect definitions', () => { + const result = validateInteractConfig( + { + effects: { move: { namedEffect: { type: 'Move', scale: -1 } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'move' }] }], + }, + { effects: { Move } }, + ); + const error = result.errors.find((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM'); + expect(error?.path).toEqual(['effects', 'move', 'namedEffect', 'scale']); + }); + + it('uses the effects registered on Interact by default', () => { + Interact.registerEffects({ Move } as any); + const result = validateInteractConfig(configWith({ scale: 5 })); + expect(result.errors.find((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM')).toBeDefined(); + }); + + it('can be turned off with severityOverrides', () => { + const result = validateInteractConfig(configWith({ scale: 5, direction: 'left' }), { + effects: { Move }, + severityOverrides: { NAMED_EFFECT_PARAMS: 'off' }, + }); + expect(result.errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]); + }); +}); diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index cab2129d..6e5e01b8 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -77,14 +77,16 @@ type ValidateOptions = { strict?: boolean; max?: number; severityOverrides?: Record; + effects?: Record; }; ``` -| Option | Effect | -| ------------------- | -------------------------------------------------------------------------------------------------------- | -| `strict` | Promotes every remaining issue to `'error'`. Use in CI to fail on warnings too. | -| `max` | Truncates the returned list to the first N issues (after sorting). Useful for inline diagnostics. | -| `severityOverrides` | Per-**rule-category** severity override. Keys are rule-category codes (see below), not individual codes. | +| Option | Effect | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `strict` | Promotes every remaining issue to `'error'`. Use in CI to fail on warnings too. | +| `max` | Truncates the returned list to the first N issues (after sorting). Useful for inline diagnostics. | +| `severityOverrides` | Per-**rule-category** severity override. Keys are rule-category codes (see below), not individual codes. | +| `effects` | Effect modules, as passed to `registerEffects`, whose `schema` validates `namedEffect` params. Defaults to the effects registered on `Interact`. | ### ValidationError shape @@ -143,6 +145,7 @@ Severity is one of `'error' | 'warning'`. There are exactly two levers: | `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | | `VIEW_INSET` | `INVALID_INSET` | warning | | `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning | +| `NAMED_EFFECT_PARAMS` | `NAMED_EFFECT_INVALID_PARAM` (error), `NAMED_EFFECT_UNKNOWN_PARAM` | warning | For each category, set `'off'` to drop those issues entirely, `'warning'` / `'error'` to set their severity: @@ -239,6 +242,8 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac | `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both casings are accepted; this one cannot be normalized). | `CSS_PROPERTY_NAME` | | `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` | | `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` | +| `NAMED_EFFECT_INVALID_PARAM` | A `namedEffect` param does not match its effect's registered `schema` (type, enum values, min/max, length/angle format). | `NAMED_EFFECT_PARAMS` | +| `NAMED_EFFECT_UNKNOWN_PARAM` | A `namedEffect` param is not declared in its effect's registered `schema` (e.g. a renamed param). | `NAMED_EFFECT_PARAMS` | --- diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 2b393ef3..66564e1a 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -17,6 +17,7 @@ import { getKeyframeSlots, getSlotKey } from './css'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; import { registerEffects, + getRegisteredEffect, getSequence as getMotionSequence, createAnimationGroups, Sequence, @@ -246,6 +247,7 @@ export class Interact { } static registerEffects = registerEffects; + static getRegisteredEffect = getRegisteredEffect; /** * Registers a plugin under a name. When the config carries a plugin field (e.g. `$splitText`) with a matching key diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index c2ebb227..d475c4a7 100644 --- a/skills/interactor/references/validate.md +++ b/skills/interactor/references/validate.md @@ -35,7 +35,7 @@ type ValidationError = { }; ``` -**Options:** `strict` (promote warnings to errors), `max` (truncate issue list), `severityOverrides` (per rule category — see full docs). +**Options:** `strict` (promote warnings to errors), `max` (truncate issue list), `severityOverrides` (per rule category — see full docs), `effects` (effect modules whose `schema` validates `namedEffect` params; defaults to the effects registered on `Interact`). --- From f6bac6f46e68127077dd5a56ec449e8be2bffcd4 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 30 Sep 2026 19:59:58 +0300 Subject: [PATCH 2/4] Check namedEffect params in the existing semantic walk, against the Interact registry - checkNamedEffectParams is a per-effect check in collectSemanticWarnings, like the other rules, reporting each definition's own params - schemas come from the effects registered on Interact only (the effects option is removed) Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/interact-validate/src/index.ts | 9 +- .../src/semantic/collectSemanticWarnings.ts | 3 + .../src/semantic/namedEffectParams.ts | 86 ++++++++----------- packages/interact-validate/src/structural.ts | 6 +- packages/interact-validate/src/types.ts | 2 - .../test/rules/namedEffectParams.spec.ts | 37 ++++---- packages/interact/rules/validate.md | 12 ++- skills/interactor/references/validate.md | 2 +- 8 files changed, 65 insertions(+), 92 deletions(-) diff --git a/packages/interact-validate/src/index.ts b/packages/interact-validate/src/index.ts index de9c28a7..de1a4f01 100644 --- a/packages/interact-validate/src/index.ts +++ b/packages/interact-validate/src/index.ts @@ -1,12 +1,5 @@ export { InteractValidationError } from './errors'; -export type { - Severity, - ValidationResult, - ValidationError, - ValidateOptions, - EffectSchema, - EffectParamSchema, -} from './types'; +export type { Severity, ValidationResult, ValidationError, ValidateOptions } from './types'; // Zod schemas and sub-schemas for host-project schema composition export { diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index f969c94d..a7c7bd2b 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -19,6 +19,7 @@ import { } from './partialData'; import { checkRecommendedFill } from './recommendedPatterns'; import { findAnimationEndWarnings } from './animationEndGraph'; +import { checkNamedEffectParams } from './namedEffectParams'; // Single traversal of top-level registry effects/sequences and per-interaction // effects/sequences, supplying each node's `path`, whether it is a top-level @@ -79,6 +80,8 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { warnings.push(...checkRecommendedFill(path, resolvedEffect, owner)); warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner)); warnings.push(...checkCSSPropertyNames(path, effect)); + // each definition's own params, not again at every reference to it + warnings.push(...checkNamedEffectParams(path, effect)); }, onSequence: (path, sequence, isTopLevel, owner) => { const { sequenceId } = sequence; diff --git a/packages/interact-validate/src/semantic/namedEffectParams.ts b/packages/interact-validate/src/semantic/namedEffectParams.ts index b38e63d9..1cc83d16 100644 --- a/packages/interact-validate/src/semantic/namedEffectParams.ts +++ b/packages/interact-validate/src/semantic/namedEffectParams.ts @@ -1,12 +1,5 @@ import { Interact } from '@wix/interact'; -import { walkConfig } from '../walkConfig'; -import type { - AnyConfig, - EffectParamSchema, - EffectSchema, - ValidateOptions, - ValidationError, -} from '../types'; +import type { AnyEffect, EffectParamSchema, EffectSchema, Path, SemanticIssue } from '../types'; const LENGTH_REGEX = /^-?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)?$/i; const ANGLE_REGEX = /^-?\d*\.?\d+(deg|rad|grad|turn)?$/i; @@ -61,49 +54,42 @@ const PARAM_TYPES: Record< }, }; -function getSchema(type: string, effects: ValidateOptions['effects']): EffectSchema | undefined { - const module = effects ? effects[type] : Interact.getRegisteredEffect?.(type, false); - return (module as { schema?: EffectSchema } | null | undefined)?.schema; -} - -// validates namedEffect params against the schema of the effect registered under their type -export function checkNamedEffectParams( - config: AnyConfig, - effects: ValidateOptions['effects'], -): ValidationError[] { - const errors: ValidationError[] = []; +const issue = ( + path: Path, + domainCode: string, + message: string, + severity: SemanticIssue['severity'], +) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue; - walkConfig(config, { - onEffect: (path, effect) => { - const { type, ...params } = effect.namedEffect ?? {}; - const schema = typeof type === 'string' ? getSchema(type, effects) : undefined; - if (!schema) return; +// the namedEffect's params against the schema of the effect registered on Interact under its type +export function checkNamedEffectParams(path: Path, effect: AnyEffect): SemanticIssue[] { + const { type, ...params } = effect.namedEffect ?? {}; + const module = typeof type === 'string' ? Interact.getRegisteredEffect?.(type, false) : null; + const schema = (module as { schema?: EffectSchema } | null | undefined)?.schema; + if (!schema) return []; - Object.entries(params).forEach(([key, value]) => { - const paramPath = [...path, 'namedEffect', key]; - const param = schema[key]; - if (!param) { - errors.push({ - code: 'NAMED_EFFECT_UNKNOWN_PARAM', - message: `'${key}' is not a parameter of ${type} (expected one of ${Object.keys(schema).join(', ')}).`, - path: paramPath, - severity: 'warning', - }); - return; - } - const paramType = PARAM_TYPES[param.type]; - if (value !== undefined && paramType && !paramType.isValid(value, param)) { - errors.push({ - code: 'NAMED_EFFECT_INVALID_PARAM', - message: `${type} \`${key}\` must be ${paramType.expected(param)}; got ${JSON.stringify(value)}.`, - path: paramPath, - severity: 'error', - }); - } - }); - }, - onSequence: () => {}, + return Object.entries(params).flatMap(([key, value]) => { + const paramPath = [...path, 'namedEffect', key]; + const param = schema[key]; + if (!param) { + return [ + issue( + paramPath, + 'NAMED_EFFECT_UNKNOWN_PARAM', + `'${key}' is not a parameter of ${type} (expected one of ${Object.keys(schema).join(', ')}).`, + 'warning', + ), + ]; + } + const paramType = PARAM_TYPES[param.type]; + if (value === undefined || !paramType || paramType.isValid(value, param)) return []; + return [ + issue( + paramPath, + 'NAMED_EFFECT_INVALID_PARAM', + `${type} \`${key}\` must be ${paramType.expected(param)}; got ${JSON.stringify(value)}.`, + 'error', + ), + ]; }); - - return errors; } diff --git a/packages/interact-validate/src/structural.ts b/packages/interact-validate/src/structural.ts index 3d3103ba..91f2d616 100644 --- a/packages/interact-validate/src/structural.ts +++ b/packages/interact-validate/src/structural.ts @@ -2,8 +2,7 @@ import type { ZodIssue } from 'zod'; import { InteractConfigSchema } from './schema'; import type { InteractConfig } from '@wix/interact'; import { finalize, InteractValidationError } from './errors'; -import type { AnyConfig, Path, ValidationError, ValidateOptions, ValidationResult } from './types'; -import { checkNamedEffectParams } from './semantic/namedEffectParams'; +import type { Path, ValidationError, ValidateOptions, ValidationResult } from './types'; // Map Zod issue codes → domain error codes. // Custom issues carry their domain code in `params.domainCode`. @@ -75,8 +74,7 @@ export function validateInteractConfig( path: (w?.path ?? []) as Path, severity: w?.severity ?? 'warning', })); - const paramErrors = checkNamedEffectParams(parsed as unknown as AnyConfig, options?.effects); - return finalize([...errors, ...warnings, ...paramErrors], options); + return finalize([...errors, ...warnings], options); } return finalize(errors, options); diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index bc76b960..17feb2f3 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -39,8 +39,6 @@ export type ValidateOptions = { strict?: boolean; max?: number; severityOverrides?: Record; - // effect modules as passed to `registerEffects` - defaults to the effects registered on `Interact` - effects?: Record; }; export type AnyEffect = { diff --git a/packages/interact-validate/test/rules/namedEffectParams.spec.ts b/packages/interact-validate/test/rules/namedEffectParams.spec.ts index efc31373..93bf1326 100644 --- a/packages/interact-validate/test/rules/namedEffectParams.spec.ts +++ b/packages/interact-validate/test/rules/namedEffectParams.spec.ts @@ -12,6 +12,8 @@ const Move = { }, }; +Interact.registerEffects({ Move, Unschemed: {} } as any); + const configWith = (namedEffect: Record) => ({ interactions: [ { @@ -25,7 +27,7 @@ const configWith = (namedEffect: Record) => ({ }); const paramErrors = (namedEffect: Record) => - validateInteractConfig(configWith(namedEffect), { effects: { Move } }).errors.filter((e) => + validateInteractConfig(configWith(namedEffect)).errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'), ); @@ -46,6 +48,7 @@ describe('namedEffectParams', () => { }); it('errors (NAMED_EFFECT_INVALID_PARAM) on values that do not match the schema', () => { + expect(validateInteractConfig(configWith({ scale: 2 })).valid).toBe(false); const errors = paramErrors({ from: 'top', angle: 'sideways', @@ -80,32 +83,26 @@ describe('namedEffectParams', () => { expect(error.path).toEqual(['interactions', 0, 'effects', 0, 'namedEffect', 'direction']); }); - it('skips effects without a schema', () => { - const result = validateInteractConfig(configWith({ anything: 1 }), { effects: {} }); - expect(result.errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]); + it('skips effects without a schema, or not registered', () => { + for (const type of ['Unschemed', 'NotRegistered']) { + const config = configWith({ anything: 1 }); + config.interactions[0].effects[0].namedEffect.type = type; + const errors = validateInteractConfig(config).errors; + expect(errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]); + } }); it('validates top-level effect definitions', () => { - const result = validateInteractConfig( - { - effects: { move: { namedEffect: { type: 'Move', scale: -1 } } }, - interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'move' }] }], - }, - { effects: { Move } }, - ); - const error = result.errors.find((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM'); - expect(error?.path).toEqual(['effects', 'move', 'namedEffect', 'scale']); - }); - - it('uses the effects registered on Interact by default', () => { - Interact.registerEffects({ Move } as any); - const result = validateInteractConfig(configWith({ scale: 5 })); - expect(result.errors.find((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM')).toBeDefined(); + const result = validateInteractConfig({ + effects: { move: { namedEffect: { type: 'Move', scale: -1 } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'move' }] }], + }); + const errors = result.errors.filter((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM'); + expect(errors.map((e) => e.path)).toEqual([['effects', 'move', 'namedEffect', 'scale']]); }); it('can be turned off with severityOverrides', () => { const result = validateInteractConfig(configWith({ scale: 5, direction: 'left' }), { - effects: { Move }, severityOverrides: { NAMED_EFFECT_PARAMS: 'off' }, }); expect(result.errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]); diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 6e5e01b8..5affd7cb 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -77,16 +77,14 @@ type ValidateOptions = { strict?: boolean; max?: number; severityOverrides?: Record; - effects?: Record; }; ``` -| Option | Effect | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `strict` | Promotes every remaining issue to `'error'`. Use in CI to fail on warnings too. | -| `max` | Truncates the returned list to the first N issues (after sorting). Useful for inline diagnostics. | -| `severityOverrides` | Per-**rule-category** severity override. Keys are rule-category codes (see below), not individual codes. | -| `effects` | Effect modules, as passed to `registerEffects`, whose `schema` validates `namedEffect` params. Defaults to the effects registered on `Interact`. | +| Option | Effect | +| ------------------- | -------------------------------------------------------------------------------------------------------- | +| `strict` | Promotes every remaining issue to `'error'`. Use in CI to fail on warnings too. | +| `max` | Truncates the returned list to the first N issues (after sorting). Useful for inline diagnostics. | +| `severityOverrides` | Per-**rule-category** severity override. Keys are rule-category codes (see below), not individual codes. | ### ValidationError shape diff --git a/skills/interactor/references/validate.md b/skills/interactor/references/validate.md index d475c4a7..603431c4 100644 --- a/skills/interactor/references/validate.md +++ b/skills/interactor/references/validate.md @@ -35,7 +35,7 @@ type ValidationError = { }; ``` -**Options:** `strict` (promote warnings to errors), `max` (truncate issue list), `severityOverrides` (per rule category — see full docs), `effects` (effect modules whose `schema` validates `namedEffect` params; defaults to the effects registered on `Interact`). +**Options:** `strict` (promote warnings to errors), `max` (truncate issue list), `severityOverrides` (per rule category — see full docs). `namedEffect` params are validated against the `schema` of the effects registered with `Interact.registerEffects`, so register them before validating. --- From 067b6ac626383a6797e1c8c121ace69bc746602c Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 30 Sep 2026 23:01:48 +0300 Subject: [PATCH 3/4] Remove the scroll preset range check, now covered by namedEffect params validation Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/interact-validate/README.md | 3 - packages/interact-validate/src/errors.ts | 2 - .../src/semantic/collectSemanticWarnings.ts | 7 +- .../src/semantic/partialData.ts | 41 +--------- .../test/rules/scrollPresetRange.spec.ts | 74 ------------------- packages/interact/rules/validate.md | 5 +- 6 files changed, 3 insertions(+), 129 deletions(-) delete mode 100644 packages/interact-validate/test/rules/scrollPresetRange.spec.ts diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index faa69957..fb916437 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -152,7 +152,6 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'` | `VALID_MEDIA_QUERIES` | `INVALID_MEDIA_QUERY` | error | | `SAME_ELEMENT_RETRIGGER` | `SAME_ELEMENT_RETRIGGER` | warning | | `HIT_AREA_SHIFT` | `HIT_AREA_SHIFT` | warning | -| `SCROLL_RANGE` | `SCROLL_PRESET_MISSING_RANGE`, `SCROLL_PRESET_BAD_RANGE` | warning | | `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 | @@ -214,8 +213,6 @@ These encode statically-detectable authoring pitfalls from the trigger rule file | `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` | diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index dc178c02..a91199ae 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -20,8 +20,6 @@ const RULE_CODE_MAP: Record = { // Rule-derived semantic checks (see packages/interact/rules/*.md). SAME_ELEMENT_RETRIGGER: 'SAME_ELEMENT_RETRIGGER', HIT_AREA_SHIFT: 'HIT_AREA_SHIFT', - SCROLL_PRESET_MISSING_RANGE: 'SCROLL_RANGE', - SCROLL_PRESET_BAD_RANGE: 'SCROLL_RANGE', ANIMATION_END_SELF_REFERENCE: 'ANIMATION_END_GRAPH', ANIMATION_END_CYCLE: 'ANIMATION_END_GRAPH', LIST_ITEM_SELECTOR_WITHOUT_CONTAINER: 'ELEMENT_SELECTION', diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index a7c7bd2b..64f6d0bd 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -12,11 +12,7 @@ import { checkRedundantSelector, checkPointerAxisIgnored, } from './ignored'; -import { - checkScrollPresetRange, - checkEmptyStyleProperties, - checkStateRemoveWithoutEffectId, -} from './partialData'; +import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId } from './partialData'; import { checkRecommendedFill } from './recommendedPatterns'; import { findAnimationEndWarnings } from './animationEndGraph'; import { checkNamedEffectParams } from './namedEffectParams'; @@ -72,7 +68,6 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { : effect; warnings.push(...checkSameElementRetrigger(path, resolvedEffect, owner)); warnings.push(...checkHitAreaShift(path, resolvedEffect, owner)); - warnings.push(...checkScrollPresetRange(path, resolvedEffect, owner)); warnings.push(...checkListItemSelectorWithoutContainer(path, resolvedEffect)); warnings.push(...checkRedundantSelector(path, resolvedEffect)); warnings.push(...checkEmptyStyleProperties(path, effect)); diff --git a/packages/interact-validate/src/semantic/partialData.ts b/packages/interact-validate/src/semantic/partialData.ts index 3a96fe5a..4f7c66b0 100644 --- a/packages/interact-validate/src/semantic/partialData.ts +++ b/packages/interact-validate/src/semantic/partialData.ts @@ -1,43 +1,4 @@ -import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types'; - -const SCROLL_RANGE_VALUES = ['in', 'out', 'continuous']; - -// Scroll presets (the `*Scroll` motion-presets) all end with `Scroll`; no other -// preset category does. Used to flag a missing/invalid `range` on `viewProgress`. -function isScrollPresetType(type: unknown): boolean { - return typeof type === 'string' && /Scroll$/.test(type); -} - -// `*Scroll` namedEffect on viewProgress without a valid `range` -export function checkScrollPresetRange( - path: Path, - effect: AnyEffect, - owner?: AnyInteraction, -): SemanticIssue[] { - if (!owner || owner.trigger !== 'viewProgress') return []; - const named = effect.namedEffect; - if (!named || !isScrollPresetType(named.type)) return []; - if (named.range === undefined) { - return [ - { - code: 'custom', - params: { domainCode: 'SCROLL_PRESET_MISSING_RANGE' }, - path: [...path, 'namedEffect', 'range'], - message: `Scroll preset '${named.type}' on viewProgress requires \`range: 'in' | 'out' | 'continuous'\` (prefer 'continuous').`, - }, - ]; - } else if (typeof named.range !== 'string' || !SCROLL_RANGE_VALUES.includes(named.range)) { - return [ - { - code: 'custom', - params: { domainCode: 'SCROLL_PRESET_BAD_RANGE' }, - path: [...path, 'namedEffect', 'range'], - message: `Scroll preset \`range\` must be 'in', 'out', or 'continuous' (prefer 'continuous'); got ${JSON.stringify(named.range)}.`, - }, - ]; - } - return []; -} +import type { Path, SemanticIssue, AnyEffect } from '../types'; // state effect that toggles nothing (empty style arrays) export function checkEmptyStyleProperties(path: Path, effect: AnyEffect): SemanticIssue[] { diff --git a/packages/interact-validate/test/rules/scrollPresetRange.spec.ts b/packages/interact-validate/test/rules/scrollPresetRange.spec.ts deleted file mode 100644 index 91cb46c1..00000000 --- a/packages/interact-validate/test/rules/scrollPresetRange.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../src'; - -// A3 — `*Scroll` namedEffect presets used with viewProgress MUST declare -// `range: 'in' | 'out' | 'continuous'` (prefer 'continuous'). -// Source: viewprogress.md Rule 1 + full-lean.md Animation-Payloads CRITICAL. - -describe('scrollPresetRange', () => { - it('warns (SCROLL_PRESET_MISSING_RANGE) when a scroll preset omits range', () => { - const result = validateInteractConfig({ - interactions: [ - { - key: 'el', - trigger: 'viewProgress', - effects: [ - { namedEffect: { type: 'FadeScroll' }, rangeStart: { name: 'cover' }, fill: 'both' }, - ], - }, - ], - }); - const err = result.errors.find((e) => e.code === 'SCROLL_PRESET_MISSING_RANGE'); - expect(err).toBeDefined(); - expect(err?.severity).toBe('warning'); - expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'namedEffect', 'range']); - expect(result.valid).toBe(true); - }); - - it('warns (SCROLL_PRESET_BAD_RANGE) when range is not one of in/out/continuous', () => { - const result = validateInteractConfig({ - interactions: [ - { - key: 'el', - trigger: 'viewProgress', - effects: [{ namedEffect: { type: 'ParallaxScroll', range: 'sideways' }, fill: 'both' }], - }, - ], - }); - const err = result.errors.find((e) => e.code === 'SCROLL_PRESET_BAD_RANGE'); - expect(err).toBeDefined(); - expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'namedEffect', 'range']); - }); - - describe('no warning for the documented valid patterns', () => { - it('does not warn when a scroll preset declares range: continuous', () => { - const result = validateInteractConfig({ - interactions: [ - { - key: 'el', - trigger: 'viewProgress', - effects: [{ namedEffect: { type: 'FadeScroll', range: 'continuous' }, fill: 'both' }], - }, - ], - }); - const scrollCodes = ['SCROLL_PRESET_MISSING_RANGE', 'SCROLL_PRESET_BAD_RANGE']; - expect(result.errors.filter((e) => scrollCodes.includes(e.code))).toHaveLength(0); - }); - - it('does not warn for a non-scroll preset on viewProgress', () => { - const result = validateInteractConfig({ - interactions: [ - { - key: 'el', - trigger: 'viewProgress', - effects: [ - { namedEffect: { type: 'FadeIn' }, rangeStart: { name: 'cover' }, fill: 'both' }, - ], - }, - ], - }); - const scrollCodes = ['SCROLL_PRESET_MISSING_RANGE', 'SCROLL_PRESET_BAD_RANGE']; - expect(result.errors.filter((e) => scrollCodes.includes(e.code))).toHaveLength(0); - }); - }); -}); diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 5affd7cb..68119e2d 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -134,7 +134,6 @@ Severity is one of `'error' | 'warning'`. There are exactly two levers: | `VALID_MEDIA_QUERIES` | `INVALID_MEDIA_QUERY` | error | | `SAME_ELEMENT_RETRIGGER` | `SAME_ELEMENT_RETRIGGER` | warning | | `HIT_AREA_SHIFT` | `HIT_AREA_SHIFT` | warning | -| `SCROLL_RANGE` | `SCROLL_PRESET_MISSING_RANGE`, `SCROLL_PRESET_BAD_RANGE` | warning | | `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 | @@ -227,8 +226,6 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | `SAME_ELEMENT_RETRIGGER` | `viewEnter` with `triggerType` `repeat`/`alternate`/`state` on the same source+target element (re-trigger loop). | `SAME_ELEMENT_RETRIGGER` | | `HIT_AREA_SHIFT` | `hover`/`click`/`interest`/`activate`/`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 for an effect it also produces → it can never start. | `ANIMATION_END_GRAPH` | | `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert) — on an interaction or an effect. | `ELEMENT_SELECTION` | | `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` is ignored when both `listContainer` and `listItemSelector` are present. | `ELEMENT_SELECTION` | @@ -313,6 +310,6 @@ The validator is **static**. It cannot see the DOM, the browser, or the preset r - Whether a `keyframeEffect`/`namedEffect` actually changes size/position when it cannot be introspected (e.g. `namedEffect` options) — only inline `keyframeEffect` transforms are scanned for `HIT_AREA_SHIFT`. - Reduced-motion alternatives, perspective usage, and other authoring guidance with no static signal. -> **Note:** Several pitfalls that previously had "no static signal" are now flagged as **warnings** (see the rule-derived semantic warnings above): same source+target on `viewEnter` with a non-`once` `triggerType` (`SAME_ELEMENT_RETRIGGER`), hit-area shift from inline `keyframeEffect` transforms on `hover`/`pointerMove` (`HIT_AREA_SHIFT`), scroll presets missing/invalid `range` (`SCROLL_PRESET_*`), and missing `fill: 'both'` (`RECOMMENDED_FILL_BOTH`). These are heuristic and conservative (they skip ambiguous cases to avoid false positives), so the trigger rule files remain the authoritative guidance. +> **Note:** Several pitfalls that previously had "no static signal" are now flagged as **warnings** (see the rule-derived semantic warnings above): same source+target on `viewEnter` with a non-`once` `triggerType` (`SAME_ELEMENT_RETRIGGER`), hit-area shift from inline `keyframeEffect` transforms on `hover`/`pointerMove` (`HIT_AREA_SHIFT`), and missing `fill: 'both'` (`RECOMMENDED_FILL_BOTH`). These are heuristic and conservative (they skip ambiguous cases to avoid false positives), so the trigger rule files remain the authoritative guidance. For the full API, usage recipes, and the same catalogue in package form, see the [`@wix/interact-validate` README](https://github.com/wix/interact/blob/master/packages/interact-validate/README.md). From dce78b6745659a69d16cc5cfb278a588f098485a Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Thu, 22 Oct 2026 13:07:09 +0300 Subject: [PATCH 4/4] small fixes --- .../src/semantic/collectSemanticWarnings.ts | 1 - .../src/semantic/namedEffectParams.ts | 22 +++++++++++-------- packages/interact-validate/src/types.ts | 1 + .../test/rules/namedEffectParams.spec.ts | 10 +++++++++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index 64f6d0bd..adf4e88c 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -75,7 +75,6 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { warnings.push(...checkRecommendedFill(path, resolvedEffect, owner)); warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner)); warnings.push(...checkCSSPropertyNames(path, effect)); - // each definition's own params, not again at every reference to it warnings.push(...checkNamedEffectParams(path, effect)); }, onSequence: (path, sequence, isTopLevel, owner) => { diff --git a/packages/interact-validate/src/semantic/namedEffectParams.ts b/packages/interact-validate/src/semantic/namedEffectParams.ts index 1cc83d16..b4ecfafc 100644 --- a/packages/interact-validate/src/semantic/namedEffectParams.ts +++ b/packages/interact-validate/src/semantic/namedEffectParams.ts @@ -10,6 +10,10 @@ const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const matches = (value: unknown, regex: RegExp) => typeof value === 'string' && (regex.test(value.trim()) || CALC_REGEX.test(value.trim())); +const isLengthObject = (value: unknown): value is { value: unknown; unit: string } => + typeof value === 'object' && + value !== null && + typeof (value as { unit?: unknown }).unit === 'string'; // [is valid, expected value description] per param type - unknown types are not validated const PARAM_TYPES: Record< @@ -20,10 +24,10 @@ const PARAM_TYPES: Record< } > = { number: { - isValid: (value, { min = -Infinity, max = Infinity }) => - isNumber(value) && value >= min && value <= max, - expected: ({ min, max }) => - `a number${min !== undefined ? ` >= ${min}` : ''}${max !== undefined ? ` <= ${max}` : ''}`, + isValid: (value, { min = -Infinity, max = Infinity, int }) => + isNumber(value) && (!int || Number.isInteger(value)) && value >= min && value <= max, + expected: ({ min, max, int }) => + `${int ? 'an integer' : 'a number'}${min !== undefined ? ` >= ${min}` : ''}${max !== undefined ? ` <= ${max}` : ''}`, }, bool: { isValid: (value) => typeof value === 'boolean', @@ -37,13 +41,13 @@ const PARAM_TYPES: Record< isValid: (value) => isNumber(value) || matches(value, LENGTH_REGEX) || - (typeof value === 'object' && - value !== null && - typeof (value as { unit?: unknown }).unit === 'string' && - (isNumber((value as { value?: unknown }).value) || - matches((value as { value?: unknown }).value, LENGTH_REGEX))), + (isLengthObject(value) && (isNumber(value.value) || matches(value.value, LENGTH_REGEX))), expected: () => "a length (number, '', calc() or { value, unit })", }, + lengthObject: { + isValid: (value) => isLengthObject(value) && isNumber(value.value), + expected: () => 'a length object ({ value: number, unit })', + }, angle: { isValid: (value) => isNumber(value) || diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 17feb2f3..86c82fdb 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -30,6 +30,7 @@ export type EffectParamSchema = { values?: readonly unknown[]; min?: number; max?: number; + int?: boolean; default?: unknown; }; diff --git a/packages/interact-validate/test/rules/namedEffectParams.spec.ts b/packages/interact-validate/test/rules/namedEffectParams.spec.ts index 93bf1326..3f53b6e0 100644 --- a/packages/interact-validate/test/rules/namedEffectParams.spec.ts +++ b/packages/interact-validate/test/rules/namedEffectParams.spec.ts @@ -7,6 +7,8 @@ const Move = { from: { type: 'enum', values: ['left', 'right'], default: 'left' }, angle: { type: 'angle', default: 0 }, travel: { type: 'length', default: { value: 10, unit: 'px' } }, + depth: { type: 'lengthObject', default: { value: 10, unit: 'px' } }, + count: { type: 'number', int: true, min: 1, default: 1 }, scale: { type: 'number', min: 0, max: 1, default: 0.5 }, fade: { type: 'bool', default: true }, }, @@ -38,6 +40,8 @@ describe('namedEffectParams', () => { from: 'right', angle: '45deg', travel: { value: 20, unit: '%' }, + depth: { value: -5, unit: 'em' }, + count: 3, scale: 1, fade: false, }), @@ -55,9 +59,13 @@ describe('namedEffectParams', () => { travel: '10 apples', scale: 2, fade: 'yes', + depth: '10px', + count: 1.5, }); expect(errors.map((e) => e.path.at(-1)).sort()).toEqual([ 'angle', + 'count', + 'depth', 'fade', 'from', 'scale', @@ -74,6 +82,8 @@ describe('namedEffectParams', () => { 'from', ]); expect(paramErrors({ travel: { value: 'far', unit: 'px' } })).toHaveLength(1); + expect(paramErrors({ depth: { value: 'calc(1px + 2px)', unit: 'px' } })).toHaveLength(1); + expect(paramErrors({ depth: 10 })).toHaveLength(1); }); it('warns (NAMED_EFFECT_UNKNOWN_PARAM) on params the schema does not declare', () => {