From 340031a4f25220f301cb85c414468c280ba0cc2c Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 16:29:44 +0300 Subject: [PATCH 01/19] copying files + adding plan to integrate --- ...tconfig_schema_validation_918ab0af.plan.md | 440 ++++++++++++++++++ packages/interact/src/schema/controls.ts | 82 ++++ packages/interact/src/schema/effects.ts | 177 +++++++ packages/interact/src/schema/experience.ts | 27 ++ packages/interact/src/schema/index.ts | 125 +++++ packages/interact/src/schema/interactions.ts | 104 +++++ packages/interact/src/schema/primitives.ts | 64 +++ packages/interact/src/schema/sequences.ts | 25 + packages/interact/src/validate/context.ts | 221 +++++++++ packages/interact/src/validate/errors.ts | 24 + packages/interact/src/validate/index.ts | 67 +++ .../rules/conditions/validMediaQueries.ts | 48 ++ .../controls/rangeDefaultWithinBounds.ts | 32 ++ .../controls/selectDefaultMatchesOption.ts | 23 + .../rules/controls/selectMapCoverage.ts | 29 ++ .../rules/controls/uniqueControlIds.ts | 23 + .../rules/controls/validTransformTypes.ts | 17 + .../rules/controls/variableUsageReferenced.ts | 16 + packages/interact/src/validate/rules/index.ts | 52 +++ .../referential/animationEndEffectExists.ts | 24 + .../referential/bindingPropertyRequired.ts | 25 + .../rules/referential/conditionsExist.ts | 16 + .../rules/referential/controlTargetsExist.ts | 59 +++ .../rules/referential/effectIdsExist.ts | 16 + .../referential/effectKeyExistsInElements.ts | 16 + .../interactionHasEffectsOrSequences.ts | 16 + .../rules/referential/interactionKeysExist.ts | 16 + .../rules/referential/sequenceIdsExist.ts | 16 + .../referential/styleBindingSelectorExists.ts | 16 + .../variableBindingIsCustomProperty.ts | 16 + packages/interact/src/validate/semantic.ts | 19 + packages/interact/src/validate/structural.ts | 36 ++ 32 files changed, 1887 insertions(+) create mode 100644 .cursor/plans/interactconfig_schema_validation_918ab0af.plan.md create mode 100644 packages/interact/src/schema/controls.ts create mode 100644 packages/interact/src/schema/effects.ts create mode 100644 packages/interact/src/schema/experience.ts create mode 100644 packages/interact/src/schema/index.ts create mode 100644 packages/interact/src/schema/interactions.ts create mode 100644 packages/interact/src/schema/primitives.ts create mode 100644 packages/interact/src/schema/sequences.ts create mode 100644 packages/interact/src/validate/context.ts create mode 100644 packages/interact/src/validate/errors.ts create mode 100644 packages/interact/src/validate/index.ts create mode 100644 packages/interact/src/validate/rules/conditions/validMediaQueries.ts create mode 100644 packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts create mode 100644 packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts create mode 100644 packages/interact/src/validate/rules/controls/selectMapCoverage.ts create mode 100644 packages/interact/src/validate/rules/controls/uniqueControlIds.ts create mode 100644 packages/interact/src/validate/rules/controls/validTransformTypes.ts create mode 100644 packages/interact/src/validate/rules/controls/variableUsageReferenced.ts create mode 100644 packages/interact/src/validate/rules/index.ts create mode 100644 packages/interact/src/validate/rules/referential/animationEndEffectExists.ts create mode 100644 packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts create mode 100644 packages/interact/src/validate/rules/referential/conditionsExist.ts create mode 100644 packages/interact/src/validate/rules/referential/controlTargetsExist.ts create mode 100644 packages/interact/src/validate/rules/referential/effectIdsExist.ts create mode 100644 packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts create mode 100644 packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts create mode 100644 packages/interact/src/validate/rules/referential/interactionKeysExist.ts create mode 100644 packages/interact/src/validate/rules/referential/sequenceIdsExist.ts create mode 100644 packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts create mode 100644 packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts create mode 100644 packages/interact/src/validate/semantic.ts create mode 100644 packages/interact/src/validate/structural.ts diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md new file mode 100644 index 00000000..75938a24 --- /dev/null +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -0,0 +1,440 @@ +--- +name: InteractConfig Schema Validation +overview: Ship schema + referential + semantic validation for `InteractConfig` as a new `@wix/interact/validate` subpath, by retargeting the existing validation harness and stripping the host project's Experience/controls layer. Uses Opus's plan as the base with Sonnet's concise todo structure and canonical type re-export pattern added. +todos: + - id: phase0-deps-wiring + content: "Phase 0: Add zod as production dep (v4); wire validate entry in vite.config.ts and package.json exports; confirm bundle isolation (dist/es/index.js has no zod)" + status: pending + - id: phase1-schema + content: "Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts" + status: pending + - id: phase2-harness-rename + content: "Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas" + status: pending + - id: phase3-context + content: "Phase 3: Rewrite validate/context.ts — drop elementKeys/controlIds/styleSelectors/etc.; walk InteractConfig directly; add isEffectRef/isSequenceRef predicates; collect trigger+effect tuples and definition maps for new rules" + status: pending + - id: phase4-rules-trim + content: "Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners" + status: pending + - id: phase4-rules-add + content: "Phase 4b: Add 5 new semantic rules — triggerEffectCompatible (warning), numericBounds, conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions (warnings); update rules/index.ts RULES array" + status: pending + - id: phase5-tests + content: "Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)" + status: pending + - id: phase6-docs + content: "Phase 6: README section for @wix/interact/validate; error-code table (§7); llms.txt entry" + status: pending +isProject: false +--- + +# InteractConfig Schema Validation — Implementation Plan + +> Status: proposal. Goal: ship schema + referential + semantic validation for +> `InteractConfig` as an opt-in subpath of `@wix/interact`, reusing the +> already-copied validation harness but stripping the host project's +> `Experience` / controls / styles / bindings layer. + +## 0. Guiding decisions (the "why") + +The copied code in `src/schema/` and `src/validate/` validates the **host +project's `Experience` document**, not Interact's config. Interact's real config +is `InteractConfig = { effects?, sequences?, conditions?, interactions }` +(`src/types/config.ts:46`). So the task is: **keep the harness, extract the +Interact slice, amputate everything else.** + +- **Location:** new opt-in subpath `@wix/interact/validate`. Keeps `zod` (a new + runtime dep, currently not installed) out of the default bundle — it is + imported *only* from this subpath, so tree-shaking excludes it for consumers + who don't import it. Same package (not a separate one) so validation can never + version-skew from the config types it must track. +- **Target type:** validate `InteractConfig` directly. The host app composes its + own `Experience` schema on top if it wants one. +- **Serializable subset:** zod cannot model function fields (`customEffect`, + function `offsetEasing`). Decide (see §8) whether to (a) validate the JSON form + only, or (b) accept+skip function fields so JS-authored configs pass. + +--- + +## 1. Final-state file layout + +``` +packages/interact/src/ + validate/ + index.ts # public API: validateInteractConfig, assertValidInteractConfig, types, RULES + errors.ts # ValidationError/Result + InteractValidationError (renamed) + structural.ts # zod safeParse → ValidationError[] (kept, retargeted to InteractConfigSchema) + semantic.ts # rule runner (kept as-is) + context.ts # REWRITTEN: walks InteractConfig, drops elements/controls/styles + schema/ # MOVED here from src/schema (only the Interact slice) + index.ts + primitives.ts # trimmed + effects.ts # kept ~as-is (+ exported field-group constants) + sequences.ts # kept + interactions.ts # rewritten root → InteractConfigSchema + rules/ + index.ts # trimmed RULES list + Rule type + referenceRule factory + _factory.ts # NEW: referenceRule() helper + referential/ + effectIdsExist.ts + sequenceIdsExist.ts + animationEndEffectExists.ts + conditionsExist.ts + interactionHasEffectsOrSequences.ts + semantic/ # NEW bucket + triggerEffectCompatible.ts # NEW (the big one) + numericBounds.ts # NEW + conditionPredicateRequired.ts # NEW + uniqueDefinitionIds.ts # NEW (dup effect/sequence keys, keyframe names) + unusedDefinitions.ts # NEW (warnings) + conditions/ + validMediaQueries.ts + validate.test.ts (or __tests__/) # NEW +``` + +**Deleted entirely:** +- `src/schema/experience.ts`, `src/schema/controls.ts` +- `src/validate/rules/controls/*` (all six) +- `src/validate/rules/referential/{controlTargetsExist,styleBindingSelectorExists,variableBindingIsCustomProperty,bindingPropertyRequired,interactionKeysExist,effectKeyExistsInElements}.ts` +- the stray empty `src/rules/` tree (copy artifact) + +> Note: I'm folding `src/schema/` under `src/validate/schema/` so the entire +> zod-dependent surface lives under one subpath dir. If you'd rather keep +> `src/schema/` top-level, that's fine — just ensure nothing outside +> `src/validate/**` imports it, or zod leaks into the main bundle. + +--- + +## 2. Phase 0 — deps & build wiring + +1. **Add zod as a regular dependency** (decision 8.4; pin v4 — the schemas use v4 + issue codes like `invalid_value`, `code:'custom'`): + - `package.json` → `dependencies: { "zod": "^4.x" }`. Run `nvm use && yarn`. + - Externalized in the bundle (step 3), so it's tree-shaken out of the + main/`react`/`web` entries and only loaded when `/validate` is imported. +2. **`exports` map** — add subpath: + ```jsonc + "./validate": { + "types": "./dist/types/validate/index.d.ts", + "import": "./dist/es/validate.js", + "require": "./dist/cjs/validate.js" + } + ``` +3. **`vite.config.ts`** — add entry + externalize zod: + ```ts + lib: { entry: { index: …, react: …, web: …, + validate: path.resolve(__dirname, 'src/validate/index.ts') } } + rollupOptions: { external: ['react', 'react-dom', 'zod'] } + ``` +4. **`tsconfig.build.json`** already emits declarations for all of `src`, so + `dist/types/validate/index.d.ts` is produced automatically. No change needed. +5. **Sanity:** `yarn build` then confirm `dist/es/validate.js` exists and that + `dist/es/index.js` does **not** contain `zod` (grep the bundle). + +--- + +## 3. Phase 1 — schema, trimmed to InteractConfig + +### `schema/primitives.ts` +- **Keep:** `Keyframe`, `LengthPercentage`, `RangeOffset`, `Condition`, + `MediaCondition`. +- **Delete:** `ElementEntry`, `StyleRule`, `ExperienceMeta`, + `ExperienceSchemaVersion`. + +### `schema/effects.ts` +- **Keep all of it.** Additionally **export** the field-group constants for reuse + by the new compatibility rule: + ```ts + export const SCRUB_FIELDS = […] // already defined locally + export const STATE_FIELDS = […] + export const TIME_FIELDS = ['duration','easing','iterations','alternate', + 'reversed','delay','fill','composite'] as const // NEW + ``` +- **Remove dead aliases** `SerializableScrubEffect`/`SerializableStateEffect` + (they're `= SerializableEffect` no-ops). Update the barrel accordingly. +- **Accept `customEffect`** (decision 8.1): add + `customEffect: z.custom((v) => typeof v === 'function').optional()` to + `SourceFields`, and count it as a valid third source in all three refinements + (`SerializableEffectSource`, `SerializableEffect.superRefine`, + `SerializableTimeEffect`). Opaque — no deep validation. + +### `schema/sequences.ts` +- **Keep as-is** (`SerializableSequenceConfig`, `SerializableSequenceConfigRef`). +- **Accept function `offsetEasing`** (decision 8.1): + `offsetEasing: z.union([z.string(), z.custom((v) => typeof v === 'function')]).optional()`. + +### `schema/interactions.ts` +- **Keep:** `TriggerType`, `ViewEnterParams`, `PointerMoveParams`, + `AnimationEndParams`, `TriggerParams`, and the four per-trigger interaction + shapes. +- **Fixes (resolve drift vs `src/types`):** + - `InteractionBase`: **remove `id`** (not on the `Interaction` type). Keep + `key: z.string().min(1)`. + - **Do NOT add effect-level `interactionId`** (decision 8.3): it is + runtime-generated (`Interact.ts:468`), never author-provided. Leaving it out + means `.strict()` rejects it if mistakenly supplied — desired. + - Convert the interaction union to `z.discriminatedUnion('trigger', […])` for + better errors + speed. +- **Replace `ExperienceInteractConfig` with the real root** (rename file export): + ```ts + export const InteractConfigSchema = z.object({ + effects: z.record(z.string().min(1), SerializableEffect).optional(), // optional, matches type + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z.record(z.string().min(1), Condition).optional(), // primitives.Condition → includes 'container' + interactions: z.array(Interaction), + }).strict(); + ``` + Note the two drift fixes vs the copied version: `effects` becomes **optional**, + and `conditions` uses the full `Condition` (with `'container'`). + +### `schema/index.ts` +- Strip all `Experience*`, `Control*`, `Binding*`, `ElementEntry`, `StyleRule`, + `ExperienceMeta` re-exports. Keep effect/sequence/interaction/primitive + exports + the new `InteractConfigSchema`. +- Re-export the canonical types from `../types/config.ts` rather than re-deriving + them via `z.infer<>`. This eliminates the naming collision (value `Condition` + and type `Condition` with the same name) and keeps a single source of truth: + ```typescript + // values + export { InteractConfigSchema, SerializableEffect, ... } from './interactions'; + // types — single source of truth + export type { InteractConfig, Effect, Condition, ... } from '../types/config'; + ``` + +--- + +## 4. Phase 2 — harness rename (mechanical) + +### `validate/errors.ts` +- Rename `ExperienceValidationError` → `InteractValidationError`; message text + "Interact config validation failed…". Keep `Severity`, `ValidationError`, + `ValidationResult` unchanged. + +### `validate/structural.ts` +- Point at `InteractConfigSchema` instead of `ExperienceSchema`. Keep + `mapZodCode`; **extend it** with `too_small` → `SCHEMA_TOO_SMALL`, + `invalid_enum_value`/`invalid_value` already handled. Return type `parsed?: + InteractConfig`. + +### `validate/semantic.ts` +- **No change** (already generic over `ctx` + `RULES`). + +### `validate/index.ts` +- Rename `validateExperience` → `validateInteractConfig`, + `assertValidExperience` → `assertValidInteractConfig` (`asserts input is + InteractConfig`). Keep `finalize`/`comparePath`/`ValidateOptions` + (`strict`/`severityOverrides`/`max`) verbatim. +- **Export** `RULES` and the `Rule` type so consumers can register custom rules. +- **Re-export the zod schemas** (decision 8.2): `InteractConfigSchema`, its + `z.infer` type, and the sub-schemas (effects/sequences/interactions/primitives) + so the host "Experience" project can compose its own schema on top. + +--- + +## 5. Phase 3 — `context.ts` rewrite + +Rewrite `buildContext(config: InteractConfig)`: + +- **Drop:** `elementKeys`, `controlIds`, `styleSelectors`, `interactionIds`, + `controlBindingReferences`, `variableBindings`, `controls`, `cssVarUsage`, + `interactionKeyReferences`, `effectKeyReferences`, and the `var()` collector. +- **Keep:** `effectIds`, `sequenceIds`, `conditionIds` (from + `config.effects/sequences/conditions` keys); reference lists + `effectIdReferences`, `sequenceIdReferences`, `conditionReferences`, and the + `interactions` list (with paths). +- Root paths change from `['interact', 'effects', id]` → `['effects', id]` (no + `interact` wrapper). +- **Extract a single `isEffectRef(entry)` predicate** and a single + `isSequenceRef(entry)` predicate (replaces the duplicated + `'effectId' in entry && !('namedEffect' in entry) && …` sniffing in + `walkSequence` and the interaction loop). Prefer deriving ref-ness from the + schema discriminant over key-presence sniffing. +- For new rules, also collect: per-interaction `(trigger, effectEntry, path)` + tuples (the compatibility rule needs trigger + effect together), and the raw + definition maps for unused/duplicate detection. + +--- + +## 6. Phase 4 — rules: trim + add + +### Keep (retargeted, content unchanged except code paths) + +| File | Code | Severity | +|---|---|---| +| `referential/effectIdsExist` | `EFFECT_ID_NOT_FOUND` | error | +| `referential/sequenceIdsExist` | `SEQUENCE_ID_NOT_FOUND` | error | +| `referential/animationEndEffectExists` | `ANIMATION_END_EFFECT_NOT_FOUND` | error | +| `referential/conditionsExist` | `CONDITION_NOT_FOUND` | error | +| `referential/interactionHasEffectsOrSequences` | `INTERACTION_EMPTY` | error | +| `conditions/validMediaQueries` | `INVALID_MEDIA_QUERY` | warning | + +### Delete +All `controls/*`, the four binding/style referential rules, plus +`interactionKeysExist` and `effectKeyExistsInElements` (they checked +`Experience.elements`, which doesn't exist in `InteractConfig`; `key` is a +runtime DOM identifier — see §8 for the runtime-only alternative). + +### Add `rules/_factory.ts` +Collapse the near-identical referential rules: +```ts +export function referenceRule(opts: { + code: string; + severity: Severity; + refs: (ctx: ValidationContext) => T[]; + has: (ctx: ValidationContext, ref: T) => boolean; + message: (ref: T) => string; + hint?: string; +}): Rule { /* filter !has, map to ValidationError */ } +``` +Rewrite the four ID-existence rules as one-line `referenceRule({...})` calls. + +### New rules (see §7 for the catalogue) +`semantic/triggerEffectCompatible`, `semantic/numericBounds`, +`semantic/conditionPredicateRequired`, `semantic/uniqueDefinitionIds`, +`semantic/unusedDefinitions`. + +### `rules/index.ts` +New `RULES` array: the 5 kept referential + `validMediaQueries` + the 5 new +semantic rules. Keep the `Rule` type exported. + +--- + +## 7. New validation rules — catalogue + +| Code | What it checks | Severity | Notes | +|---|---|---|---| +| `TRIGGER_EFFECT_INCOMPATIBLE` | Scrub fields (`SCRUB_FIELDS`) only on `viewProgress`/`pointerMove`; time fields (`TIME_FIELDS`) only on discrete triggers; state fields not on scrub triggers. | **warning** initially | The big one. Runtime silently drops bad combos (`resolvers.ts:87`). Start as warning to avoid false positives, promote to error once stable. Reuse exported field-group constants. | +| `THRESHOLD_OUT_OF_RANGE` | `viewEnter.threshold` ∈ [0,1]. | error | | +| `NEGATIVE_DURATION` etc. (`numericBounds`) | `duration`/`delay`/`iterations`/sequence `offset`/`delay` ≥ 0. | error | One rule, multiple checks. | +| `CONDITION_PREDICATE_REQUIRED` | `predicate` present for condition `type` `media`/`container`. | error | Fixes schema marking it optional. | +| `DUPLICATE_KEYFRAME_NAME` | `keyframeEffect.name` unique across effects. | warning | Part of `uniqueDefinitionIds`. | +| `UNUSED_EFFECT` / `UNUSED_SEQUENCE` / `UNUSED_CONDITION` | Defined but never referenced by any interaction. | warning | Mirror of referential rules; dead-config hygiene. | + +**Deferred / runtime-only (document, don't ship in static validator):** +- `NAMED_EFFECT_NOT_REGISTERED` — `namedEffect.type` must be in motion's registry + (`getRegisteredEffect`). Only knowable *after* `registerEffects`, so expose as + an optional runtime check, not part of static `validateInteractConfig`. +- `UNKNOWN_EASING` — `easing`/`offsetEasing` resolvable by motion's `getJsEasing` + or valid `cubic-bezier()`. Warning; needs the easing name list from motion. + +--- + +## 8. Resolved decisions + +All five resolved below with codebase evidence. Consequences are propagated into +the phases above. + +### 8.1 Function fields → **accept + skip (opaque)** ✅ +`customEffect` and function-valued `offsetEasing` are **genuine authored +options**, not just type noise: +- `resolvers.ts:74,95` — `else if (customEffect)` is a first-class effect source. +- `resolvers.ts:146` — `if (typeof offsetEasing === 'function')` branch. +- Referenced in `README.md`, `docs/guides/effects-and-animations.md`, + `test/resolvers.spec.ts`. + +A JSON-only schema would **reject valid JS-authored configs** → unacceptable. +**Resolution:** +- `schema/effects.ts`: add `customEffect: z.custom((v) => typeof v === 'function').optional()` + to `SourceFields`, and **count it as a valid third source** in all three + refinements (`SerializableEffectSource`, `SerializableEffect.superRefine`'s + source/state checks, and `SerializableTimeEffect`'s "must have a source"). + No deep validation of the function — opaque. +- `schema/sequences.ts`: `offsetEasing: z.union([z.string(), z.custom(...)]).optional()`. +- **Defer** (not v1): an opt-in `serializableOnly` flag that emits a + `NOT_SERIALIZABLE` *warning* for function fields, for consumers exporting + configs to JSON (e.g. AI round-tripping). Add to `ValidateOptions` later. + +### 8.2 Schema dir placement → **fold into `src/validate/schema/` + export publicly** ✅ +Evidence: nothing outside `src/validate` imports `src/schema`; `core/`+`dom/` +import neither `zod` nor any schema. So folding is safe and keeps the entire +zod-dependent surface under the one subpath. +**Resolution:** move `src/schema/` → `src/validate/schema/`. **Additionally, +re-export `InteractConfigSchema`, its `z.infer` type, and the sub-schemas +(effects/sequences/interactions/primitives) from `src/validate/index.ts`** — the +host "Experience" project that prompted this work needs to *compose* its own +schema on top of Interact's, so the zod schemas are part of the public `/validate` +API, not just internals. + +### 8.3 `interactionId` on interaction effects → **internal-only; keep OUT of schema** ✅ +(Reverses the earlier "add it optional" lean.) Evidence: it is **runtime-generated, +never author-provided** — `Interact.ts:467-468`: +```ts +const interactionId = `${source}::${target}::${effectId}::${interactionIdx}`; +effect.interactionId = interactionId; // mutated in place at runtime +``` +It only exists on the `Effect` type because the runtime mutates the parsed +objects. Validation runs on the *authored* config (pre-mutation), where the field +is absent. +**Resolution:** do **not** add `interactionId` to the interaction-effect schema. +`.strict()` will then correctly reject it if a user supplies it by mistake — which +is the desired behavior, since authoring it is meaningless. (This makes 8.1's +field set the only author-facing additions.) + +### 8.4 zod dependency form → **regular `dependencies`** ✅ +Considered the optional-peer pattern the package uses for `react`/`react-dom`, +but that precedent exists because React must be a **deduped singleton** owned by +the host — zod has no such constraint. For zod the only thing peer-optional buys +is a smaller install footprint, at the cost of real DX friction (a missing-module +error if the consumer forgets to add zod). Bundle size is **not** a factor: zod is +listed in `rollupOptions.external` and is imported only from `/validate`, so it is +tree-shaken out of the main/`react`/`web` bundles regardless of dependency form. +**Resolution:** `dependencies: { "zod": "^4.x" }` (lowest friction, conventional, +already externalized in the bundle). Revisit peer-optional only if install +footprint becomes a real complaint. + +### 8.5 Element-key checks → **drop for v1; document a future runtime helper** ✅ +`InteractConfig` has **no element registry** to check `key`/`selector` against — +they resolve against the live DOM at runtime (`Interact.ts:391`: +"Interaction … is missing a key for source element"). So static validation +**cannot** verify them; `interactionKeysExist` / `effectKeyExistsInElements` are +deleted (already in the Phase-4 delete list). +**Resolution:** drop statically for v1. **Note as future scope:** an optional +runtime helper `validateInteractConfigInDOM(config, root = document)` that checks +each `key`/`selector` resolves to ≥1 live element — shipped from `/validate` but +clearly separated from the static `validateInteractConfig`. Not in v1. + +--- + +## 9. Phase 5 — tests + +- **Unit per rule** under `src/validate/**`: a valid config (0 errors) + one + fixture per error `code`. Reuse existing fixtures where the copied tests exist; + otherwise build minimal `InteractConfig` literals. +- **Structural tests:** unknown key (`.strict`) → `SCHEMA_UNRECOGNIZED_KEYS`; + wrong type → `SCHEMA_INVALID_TYPE`; discriminated-union bad `trigger`. +- **`ValidateOptions`:** `strict` promotes warnings→errors; `severityOverrides` + `'off'` skips a rule; `max` truncates; output sorted by path. +- **Type-parity test (drift guard) — important:** with `vitest`'s `expectTypeOf`, + assert the schema and the hand-written type agree: + ```ts + expectTypeOf>().toMatchTypeOf(); + // and the reverse direction for the serializable subset + ``` + This is what prevents the schema from silently drifting from + `src/types/config.ts` (it already had: `id` field, dropped `'container'`, + string-only `offsetEasing`). +- **Bundle test (CI grep):** assert `dist/es/index.js` has no `zod` import. + +--- + +## 10. Phase 6 — docs & DX + +- README section for `@wix/interact/validate`: `validateInteractConfig(config, + opts)`, `assertValidInteractConfig`, the `ValidationError`/`code` catalogue, + `severityOverrides`/`strict`, and custom-rule registration via exported + `RULES`/`Rule`. +- Add the error-code table (§7) to docs and, if applicable, to `llms.txt`. + +--- + +## 11. Suggested commit/PR sequence + +1. Phase 0 build wiring + add zod (no behavior yet). +2. Phase 1 schema trim + drift fixes (+ schema unit tests). +3. Phases 2–3 harness rename + `context.ts` rewrite. +4. Phase 4 rule trim + `referenceRule` factory (kept rules green). +5. New semantic rules one commit each (§7), each with tests. +6. Type-parity test + bundle test + docs. + +Each step keeps `yarn lint` (tsc) and `yarn test` green. diff --git a/packages/interact/src/schema/controls.ts b/packages/interact/src/schema/controls.ts new file mode 100644 index 00000000..63b360d9 --- /dev/null +++ b/packages/interact/src/schema/controls.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +export const ControlType = z.enum(['range', 'select', 'color', 'toggle', 'text']); + +export const ControlValue = z.union([z.number(), z.string(), z.boolean()]); + +export const ControlOption = z + .object({ + value: z.union([z.string(), z.number()]), + label: z.string(), + }) + .strict(); + +export const ControlConstraints = z + .object({ + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), + unit: z.string().optional(), + options: z.array(ControlOption).optional(), + }) + .strict(); + +export const BindingTarget = z.enum([ + 'effect', + 'sequence', + 'style', + 'element', + 'interaction', + 'variable', +]); + +export const ValueTransform = z.union([ + z.object({ type: z.literal('direct') }).strict(), + z + .object({ + type: z.literal('linear'), + factor: z.number(), + offset: z.number().optional(), + }) + .strict(), + z + .object({ + type: z.literal('inverse'), + numerator: z.number(), + }) + .strict(), + z + .object({ + type: z.literal('map'), + entries: z.record(z.string(), ControlValue), + }) + .strict(), + z + .object({ + type: z.literal('template'), + template: z.string(), + }) + .strict(), +]); + +export const ControlBinding = z + .object({ + target: BindingTarget, + targetId: z.string().min(1), + property: z.string().optional(), + transform: ValueTransform.optional(), + }) + .strict(); + +export const Control = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + group: z.string().optional(), + type: ControlType, + defaultValue: ControlValue, + constraints: ControlConstraints.optional(), + bindings: z.array(ControlBinding), + }) + .strict(); diff --git a/packages/interact/src/schema/effects.ts b/packages/interact/src/schema/effects.ts new file mode 100644 index 00000000..128035a9 --- /dev/null +++ b/packages/interact/src/schema/effects.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; +import { Keyframe, RangeOffset } from './primitives'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const NamedEffect = z + .object({ type: z.string().min(1) }) + .catchall(z.unknown()); + +const KeyframeEffectInline = z + .object({ + name: z.string().min(1), + keyframes: z.array(Keyframe).min(1), + }) + .strict(); + +export const EffectBase = z.object({ + key: z.string().optional(), + effectId: z.string().optional(), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableEffectRef = EffectBase.extend({ + effectId: z.string().min(1), +}).strict(); + +const TimeEffectFields = { + duration: z.number().optional(), + easing: z.string().optional(), + iterations: z.number().optional(), + alternate: z.boolean().optional(), + reversed: z.boolean().optional(), + delay: z.number().optional(), + fill: z.enum(['none', 'forwards', 'backwards', 'both']).optional(), + composite: z.enum(['replace', 'add', 'accumulate']).optional(), + triggerType: TriggerType.optional(), +}; + +const ScrubEffectFields = { + rangeStart: RangeOffset.optional(), + rangeEnd: RangeOffset.optional(), + centeredToTarget: z.boolean().optional(), + transitionDuration: z.number().optional(), + transitionDelay: z.number().optional(), + transitionEasing: z + .enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']) + .optional(), +}; + +const StateEffectFields = { + stateAction: z.enum(['add', 'remove', 'toggle', 'clear']).optional(), + transition: z + .object({ + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + styleProperties: z.array( + z.object({ name: z.string(), value: z.string() }), + ), + }) + .optional(), + transitionProperties: z + .array( + z.object({ + name: z.string(), + value: z.string(), + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + }), + ) + .optional(), +}; + +const SourceFields = { + namedEffect: NamedEffect.optional(), + keyframeEffect: KeyframeEffectInline.optional(), +}; + +export const SerializableEffectSource = z + .object(SourceFields) + .strict() + .refine( + (v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) === 1, + { message: 'Effect source must define exactly one of namedEffect or keyframeEffect' }, + ); + +const EffectShape = EffectBase.extend({ + ...SourceFields, + ...TimeEffectFields, + ...ScrubEffectFields, + ...StateEffectFields, +}).strict(); + +export const SerializableEffect = EffectShape.superRefine((v, ctx) => { + const hasNamed = v.namedEffect !== undefined; + const hasKeyframe = v.keyframeEffect !== undefined; + const hasSource = hasNamed || hasKeyframe; + const hasState = + v.stateAction !== undefined || + v.transition !== undefined || + v.transitionProperties !== undefined; + + if (hasNamed && hasKeyframe) { + ctx.addIssue({ + code: 'custom', + message: 'Effect cannot define both namedEffect and keyframeEffect.', + path: ['keyframeEffect'], + }); + } + if (hasSource && hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect source fields (namedEffect or keyframeEffect) cannot be combined with state effect fields.', + path: [], + }); + } + if (!hasSource && !hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect must define an effect source (namedEffect or keyframeEffect) or be a state effect (stateAction / transition / transitionProperties).', + path: [], + }); + } +}); + +// Time effects are the only variant allowed inside a sequence: +// must have an effect source, and must not carry scrub or state fields. +const SCRUB_FIELDS = [ + 'rangeStart', + 'rangeEnd', + 'centeredToTarget', + 'transitionDuration', + 'transitionDelay', + 'transitionEasing', +] as const; + +const STATE_FIELDS = ['stateAction', 'transition', 'transitionProperties'] as const; + +export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => { + if (v.namedEffect === undefined && v.keyframeEffect === undefined) { + ctx.addIssue({ + code: 'custom', + message: + 'Time effect must define an effect source (namedEffect or keyframeEffect).', + path: [], + }); + } + for (const field of SCRUB_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a scrub-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } + for (const field of STATE_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a state-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } +}); + +// Aliases preserved for the schema barrel; the unified SerializableEffect is the +// runtime for interaction-level effects (which may be time, scrub, or state). +export const SerializableScrubEffect = SerializableEffect; +export const SerializableStateEffect = SerializableEffect; diff --git a/packages/interact/src/schema/experience.ts b/packages/interact/src/schema/experience.ts new file mode 100644 index 00000000..18654f3e --- /dev/null +++ b/packages/interact/src/schema/experience.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { + ElementEntry, + ExperienceMeta, + ExperienceSchemaVersion, + MediaCondition, + StyleRule, +} from './primitives'; +import { ExperienceInteractConfig } from './interactions'; +import { Control } from './controls'; + +export const ExperienceSchema = z + .object({ + $schema: ExperienceSchemaVersion, + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + elements: z.record(z.string().min(1), ElementEntry), + styles: z.array(StyleRule).optional(), + interact: ExperienceInteractConfig, + controls: z.array(Control), + disableWhen: z.array(MediaCondition).optional(), + meta: ExperienceMeta.optional(), + }) + .strict(); + +export type Experience = z.infer; diff --git a/packages/interact/src/schema/index.ts b/packages/interact/src/schema/index.ts new file mode 100644 index 00000000..973779f5 --- /dev/null +++ b/packages/interact/src/schema/index.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; + +import { + Condition as ConditionSchema, + ElementEntry as ElementEntrySchema, + ExperienceMeta as ExperienceMetaSchema, + ExperienceSchemaVersion as ExperienceSchemaVersionSchema, + Keyframe as KeyframeSchema, + LengthPercentage as LengthPercentageSchema, + MediaCondition as MediaConditionSchema, + RangeOffset as RangeOffsetSchema, + StyleRule as StyleRuleSchema, +} from './primitives'; +import { + EffectBase as EffectBaseSchema, + NamedEffect as NamedEffectSchema, + SerializableEffect as SerializableEffectSchema, + SerializableEffectRef as SerializableEffectRefSchema, + SerializableEffectSource as SerializableEffectSourceSchema, + SerializableScrubEffect as SerializableScrubEffectSchema, + SerializableStateEffect as SerializableStateEffectSchema, + SerializableTimeEffect as SerializableTimeEffectSchema, +} from './effects'; +import { + SerializableSequenceConfig as SerializableSequenceConfigSchema, + SerializableSequenceConfigRef as SerializableSequenceConfigRefSchema, +} from './sequences'; +import { + AnimationEndParams as AnimationEndParamsSchema, + ExperienceInteractConfig as ExperienceInteractConfigSchema, + ExperienceInteraction as ExperienceInteractionSchema, + PointerMoveParams as PointerMoveParamsSchema, + TriggerParams as TriggerParamsSchema, + TriggerType as TriggerTypeSchema, + ViewEnterParams as ViewEnterParamsSchema, +} from './interactions'; +import { + BindingTarget as BindingTargetSchema, + Control as ControlSchema, + ControlBinding as ControlBindingSchema, + ControlConstraints as ControlConstraintsSchema, + ControlOption as ControlOptionSchema, + ControlType as ControlTypeSchema, + ControlValue as ControlValueSchema, + ValueTransform as ValueTransformSchema, +} from './controls'; + +export { + ConditionSchema as Condition, + ElementEntrySchema as ElementEntry, + ExperienceMetaSchema as ExperienceMeta, + ExperienceSchemaVersionSchema as ExperienceSchemaVersion, + KeyframeSchema as Keyframe, + LengthPercentageSchema as LengthPercentage, + MediaConditionSchema as MediaCondition, + RangeOffsetSchema as RangeOffset, + StyleRuleSchema as StyleRule, + EffectBaseSchema as EffectBase, + NamedEffectSchema as NamedEffect, + SerializableEffectSchema as SerializableEffect, + SerializableEffectRefSchema as SerializableEffectRef, + SerializableEffectSourceSchema as SerializableEffectSource, + SerializableScrubEffectSchema as SerializableScrubEffect, + SerializableStateEffectSchema as SerializableStateEffect, + SerializableTimeEffectSchema as SerializableTimeEffect, + SerializableSequenceConfigSchema as SerializableSequenceConfig, + SerializableSequenceConfigRefSchema as SerializableSequenceConfigRef, + AnimationEndParamsSchema as AnimationEndParams, + ExperienceInteractConfigSchema as ExperienceInteractConfig, + ExperienceInteractionSchema as ExperienceInteraction, + PointerMoveParamsSchema as PointerMoveParams, + TriggerParamsSchema as TriggerParams, + TriggerTypeSchema as TriggerType, + ViewEnterParamsSchema as ViewEnterParams, + BindingTargetSchema as BindingTarget, + ControlSchema as Control, + ControlBindingSchema as ControlBinding, + ControlConstraintsSchema as ControlConstraints, + ControlOptionSchema as ControlOption, + ControlTypeSchema as ControlType, + ControlValueSchema as ControlValue, + ValueTransformSchema as ValueTransform, +}; + +export { ExperienceSchema } from './experience'; +export type { Experience } from './experience'; + +export type Condition = z.infer; +export type ElementEntry = z.infer; +export type ExperienceMeta = z.infer; +export type ExperienceSchemaVersion = z.infer; +export type Keyframe = z.infer; +export type LengthPercentage = z.infer; +export type MediaCondition = z.infer; +export type RangeOffset = z.infer; +export type StyleRule = z.infer; + +export type EffectBase = z.infer; +export type NamedEffect = z.infer; +export type SerializableEffect = z.infer; +export type SerializableEffectRef = z.infer; +export type SerializableEffectSource = z.infer; +export type SerializableScrubEffect = z.infer; +export type SerializableStateEffect = z.infer; +export type SerializableTimeEffect = z.infer; + +export type SerializableSequenceConfig = z.infer; +export type SerializableSequenceConfigRef = z.infer; + +export type AnimationEndParams = z.infer; +export type ExperienceInteractConfig = z.infer; +export type ExperienceInteraction = z.infer; +export type PointerMoveParams = z.infer; +export type TriggerParams = z.infer; +export type TriggerType = z.infer; +export type ViewEnterParams = z.infer; + +export type BindingTarget = z.infer; +export type Control = z.infer; +export type ControlBinding = z.infer; +export type ControlConstraints = z.infer; +export type ControlOption = z.infer; +export type ControlType = z.infer; +export type ControlValue = z.infer; +export type ValueTransform = z.infer; diff --git a/packages/interact/src/schema/interactions.ts b/packages/interact/src/schema/interactions.ts new file mode 100644 index 00000000..b0ab6071 --- /dev/null +++ b/packages/interact/src/schema/interactions.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import { SerializableEffect, SerializableEffectRef } from './effects'; +import { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; + +export const TriggerType = z.enum([ + 'hover', + 'click', + 'interest', + 'activate', + 'viewEnter', + 'viewProgress', + 'pointerMove', + 'animationEnd', + 'pageVisible', +]); + +export const ViewEnterParams = z + .object({ + threshold: z.number().optional(), + inset: z.string().optional(), + useSafeViewEnter: z.boolean().optional(), + }) + .strict(); + +export const PointerMoveParams = z + .object({ + hitArea: z.enum(['root', 'self']).optional(), + axis: z.enum(['x', 'y']).optional(), + }) + .strict(); + +export const AnimationEndParams = z + .object({ + effectId: z.string().min(1), + }) + .strict(); + +export const TriggerParams = z.union([ViewEnterParams, PointerMoveParams, AnimationEndParams]); + +const InteractionBase = { + id: z.string().optional(), + key: z.string().min(1), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), + effects: z.array(z.union([SerializableEffect, SerializableEffectRef])).optional(), + sequences: z + .array(z.union([SerializableSequenceConfig, SerializableSequenceConfigRef])) + .optional(), +}; + +const ViewEnterInteraction = z + .object({ + ...InteractionBase, + trigger: z.enum(['viewEnter', 'pageVisible']), + params: ViewEnterParams.optional(), + }) + .strict(); + +const PointerMoveInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('pointerMove'), + params: PointerMoveParams.optional(), + }) + .strict(); + +const AnimationEndInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('animationEnd'), + params: AnimationEndParams, + }) + .strict(); + +const SimpleInteraction = z + .object({ + ...InteractionBase, + trigger: z.enum(['hover', 'click', 'interest', 'activate', 'viewProgress']), + }) + .strict(); + +export const ExperienceInteraction = z.union([ + ViewEnterInteraction, + PointerMoveInteraction, + AnimationEndInteraction, + SimpleInteraction, +]); + +export const ExperienceInteractConfig = z.object({ + effects: z.record(z.string().min(1), SerializableEffect), + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z + .record( + z.string().min(1), + z.object({ + type: z.enum(['media', 'selector']), + predicate: z.string().optional(), + }), + ) + .optional(), + interactions: z.array(ExperienceInteraction), +}); diff --git a/packages/interact/src/schema/primitives.ts b/packages/interact/src/schema/primitives.ts new file mode 100644 index 00000000..42aa2fd7 --- /dev/null +++ b/packages/interact/src/schema/primitives.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +export const ExperienceSchemaVersion = z.literal('interact-experience/1.0'); + +export const ElementEntry = z + .object({ + selector: z.string().min(1), + styles: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +export const StyleRule = z + .object({ + selector: z.string().min(1), + properties: z.record(z.string(), z.string()), + mediaQuery: z.string().optional(), + }) + .strict(); + +export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()])); + +export const LengthPercentage = z.union([ + z.object({ + value: z.number(), + unit: z.enum(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax']), + }), + z.object({ + value: z.number(), + unit: z.literal('percentage'), + }), +]); + +export const RangeOffset = z + .object({ + name: z + .enum(['entry', 'exit', 'contain', 'cover', 'entry-crossing', 'exit-crossing']) + .optional(), + offset: LengthPercentage.optional(), + }) + .strict(); + +export const Condition = z + .object({ + type: z.enum(['media', 'container', 'selector']), + predicate: z.string().optional(), + }) + .strict(); + +export const MediaCondition = z + .object({ + mediaQuery: z.string().min(1), + label: z.string().optional(), + }) + .strict(); + +export const ExperienceMeta = z + .object({ + category: z.string().optional(), + tags: z.array(z.string()).optional(), + previewUrl: z.string().optional(), + author: z.string().optional(), + createdAt: z.string().optional(), + }) + .strict(); diff --git a/packages/interact/src/schema/sequences.ts b/packages/interact/src/schema/sequences.ts new file mode 100644 index 00000000..c4732cbc --- /dev/null +++ b/packages/interact/src/schema/sequences.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { SerializableEffectRef, SerializableTimeEffect } from './effects'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const SerializableSequenceConfig = z.object({ + effects: z.array(z.union([SerializableTimeEffect, SerializableEffectRef])), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z.string().optional(), + triggerType: TriggerType.optional(), + sequenceId: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableSequenceConfigRef = z + .object({ + sequenceId: z.string().min(1), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z.string().optional(), + triggerType: TriggerType.optional(), + conditions: z.array(z.string()).optional(), + }) + .strict(); diff --git a/packages/interact/src/validate/context.ts b/packages/interact/src/validate/context.ts new file mode 100644 index 00000000..75cbf5ed --- /dev/null +++ b/packages/interact/src/validate/context.ts @@ -0,0 +1,221 @@ +import type { + Control, + ControlBinding, + Experience, + ExperienceInteraction, + SerializableEffect, + SerializableSequenceConfig, +} from '../schema'; + +export type Path = (string | number)[]; + +export type EffectIdRef = { path: Path; effectId: string }; +export type SequenceIdRef = { path: Path; sequenceId: string }; +export type ElementKeyRef = { path: Path; key: string }; +export type ConditionRef = { path: Path; conditionId: string }; +export type ControlBindingRef = { path: Path; binding: ControlBinding; controlId: string }; +export type VariableBindingRef = { path: Path; name: string; controlId: string }; +export type InteractionRef = { path: Path; interaction: ExperienceInteraction }; + +export type ValidationContext = { + experience: Experience; + + elementKeys: Set; + effectIds: Set; + sequenceIds: Set; + conditionIds: Set; + controlIds: Set; + styleSelectors: Set; + interactionIds: Set; + + effectIdReferences: EffectIdRef[]; + sequenceIdReferences: SequenceIdRef[]; + interactionKeyReferences: ElementKeyRef[]; + effectKeyReferences: ElementKeyRef[]; + conditionReferences: ConditionRef[]; + controlBindingReferences: ControlBindingRef[]; + variableBindings: VariableBindingRef[]; + interactions: InteractionRef[]; + controls: Control[]; + + cssVarUsage: Set; +}; + +const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g; + +function collectVarUsage(value: string, out: Set): void { + for (const m of value.matchAll(VAR_RE)) out.add(m[1]!); +} + +function walkEffect( + effect: SerializableEffect, + basePath: Path, + ctx: Pick, +): void { + if (effect.key !== undefined) { + ctx.effectKeyReferences.push({ path: [...basePath, 'key'], key: effect.key }); + } + if (effect.conditions) { + effect.conditions.forEach((c, i) => + ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), + ); + } +} + +function walkSequence( + seq: SerializableSequenceConfig, + basePath: Path, + ctx: Pick, +): void { + seq.effects.forEach((entry, i) => { + const path = [...basePath, 'effects', i]; + if ('effectId' in entry && entry.effectId !== undefined && !('namedEffect' in entry) && !('keyframeEffect' in entry)) { + // Ref-only entry + ctx.effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId }); + } else { + walkEffect(entry as SerializableEffect, path, ctx); + } + }); + if (seq.conditions) { + seq.conditions.forEach((c, i) => + ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), + ); + } +} + +export function buildContext(experience: Experience): ValidationContext { + const elementKeys = new Set(Object.keys(experience.elements)); + const effectIds = new Set(Object.keys(experience.interact.effects)); + const sequenceIds = new Set(Object.keys(experience.interact.sequences ?? {})); + const conditionIds = new Set(Object.keys(experience.interact.conditions ?? {})); + const controlIds = new Set(experience.controls.map((c) => c.id)); + const styleSelectors = new Set((experience.styles ?? []).map((s) => s.selector)); + const interactionIds = new Set( + experience.interact.interactions.map((i) => i.id).filter((id): id is string => Boolean(id)), + ); + + const effectIdReferences: EffectIdRef[] = []; + const sequenceIdReferences: SequenceIdRef[] = []; + const interactionKeyReferences: ElementKeyRef[] = []; + const effectKeyReferences: ElementKeyRef[] = []; + const conditionReferences: ConditionRef[] = []; + const controlBindingReferences: ControlBindingRef[] = []; + const variableBindings: VariableBindingRef[] = []; + const interactions: InteractionRef[] = []; + const cssVarUsage = new Set(); + + // Effects (top-level) + for (const [id, effect] of Object.entries(experience.interact.effects)) { + walkEffect(effect, ['interact', 'effects', id], { + effectKeyReferences, + conditionReferences, + }); + } + + // Sequences + for (const [id, seq] of Object.entries(experience.interact.sequences ?? {})) { + walkSequence(seq, ['interact', 'sequences', id], { + effectIdReferences, + effectKeyReferences, + conditionReferences, + }); + } + + // Interactions + experience.interact.interactions.forEach((interaction, i) => { + const base: Path = ['interact', 'interactions', i]; + interactions.push({ path: base, interaction }); + + interactionKeyReferences.push({ path: [...base, 'key'], key: interaction.key }); + + if (interaction.conditions) { + interaction.conditions.forEach((c, ci) => + conditionReferences.push({ + path: [...base, 'conditions', ci], + conditionId: c, + }), + ); + } + + if (interaction.trigger === 'animationEnd' && interaction.params) { + effectIdReferences.push({ + path: [...base, 'params', 'effectId'], + effectId: interaction.params.effectId, + }); + } + + interaction.effects?.forEach((entry, ei) => { + const path: Path = [...base, 'effects', ei]; + if ( + 'effectId' in entry && + entry.effectId !== undefined && + !('namedEffect' in entry) && + !('keyframeEffect' in entry) + ) { + effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId }); + } else { + walkEffect(entry as SerializableEffect, path, { + effectKeyReferences, + conditionReferences, + }); + } + }); + + interaction.sequences?.forEach((entry, si) => { + const path: Path = [...base, 'sequences', si]; + if ('sequenceId' in entry && !('effects' in entry)) { + sequenceIdReferences.push({ + path: [...path, 'sequenceId'], + sequenceId: entry.sequenceId, + }); + } else { + walkSequence(entry as SerializableSequenceConfig, path, { + effectIdReferences, + effectKeyReferences, + conditionReferences, + }); + } + }); + }); + + // Controls + experience.controls.forEach((control, ci) => { + control.bindings.forEach((binding, bi) => { + const path: Path = ['controls', ci, 'bindings', bi]; + controlBindingReferences.push({ path, binding, controlId: control.id }); + if (binding.target === 'variable') { + variableBindings.push({ path, name: binding.targetId, controlId: control.id }); + } + }); + }); + + // CSS var() usage in element styles + top-level styles + for (const el of Object.values(experience.elements)) { + if (!el.styles) continue; + for (const v of Object.values(el.styles)) collectVarUsage(v, cssVarUsage); + } + for (const rule of experience.styles ?? []) { + for (const v of Object.values(rule.properties)) collectVarUsage(v, cssVarUsage); + } + + return { + experience, + elementKeys, + effectIds, + sequenceIds, + conditionIds, + controlIds, + styleSelectors, + interactionIds, + effectIdReferences, + sequenceIdReferences, + interactionKeyReferences, + effectKeyReferences, + conditionReferences, + controlBindingReferences, + variableBindings, + interactions, + controls: experience.controls, + cssVarUsage, + }; +} diff --git a/packages/interact/src/validate/errors.ts b/packages/interact/src/validate/errors.ts new file mode 100644 index 00000000..e3b14bd4 --- /dev/null +++ b/packages/interact/src/validate/errors.ts @@ -0,0 +1,24 @@ +export type Severity = 'error' | 'warning'; + +export type ValidationError = { + code: string; + message: string; + path: (string | number)[]; + severity: Severity; + hint?: string; +}; + +export type ValidationResult = { + valid: boolean; + errors: ValidationError[]; +}; + +export class ExperienceValidationError extends Error { + readonly errors: ValidationError[]; + + constructor(errors: ValidationError[]) { + super(`Experience validation failed with ${errors.length} issue(s).`); + this.name = 'ExperienceValidationError'; + this.errors = errors; + } +} diff --git a/packages/interact/src/validate/index.ts b/packages/interact/src/validate/index.ts new file mode 100644 index 00000000..efd98ce6 --- /dev/null +++ b/packages/interact/src/validate/index.ts @@ -0,0 +1,67 @@ +import type { Experience } from '../schema'; +import { buildContext } from './context'; +import { + ExperienceValidationError, + type Severity, + type ValidationError, + type ValidationResult, +} from './errors'; +import { validateSemantic } from './semantic'; +import { validateStructural } from './structural'; + +export { ExperienceValidationError }; +export type { Severity, ValidationError, ValidationResult } from './errors'; + +export type ValidateOptions = { + strict?: boolean; + severityOverrides?: Record; + max?: number; +}; + +function comparePath(a: (string | number)[], b: (string | number)[]): number { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + const av = a[i]!; + const bv = b[i]!; + if (av === bv) continue; + if (typeof av === 'number' && typeof bv === 'number') return av - bv; + return String(av) < String(bv) ? -1 : 1; + } + return a.length - b.length; +} + +function finalize(errors: ValidationError[], opts: ValidateOptions): ValidationResult { + let next = errors; + if (opts.strict) { + next = next.map((e) => (e.severity === 'warning' ? { ...e, severity: 'error' } : e)); + } + next = [...next].sort((a, b) => comparePath(a.path, b.path)); + if (opts.max !== undefined && next.length > opts.max) { + next = next.slice(0, opts.max); + } + const valid = !next.some((e) => e.severity === 'error'); + return { valid, errors: next }; +} + +export function validateExperience( + input: unknown, + options: ValidateOptions = {}, +): ValidationResult { + const layer1 = validateStructural(input); + if (!layer1.ok || !layer1.parsed) { + return finalize(layer1.errors, options); + } + const ctx = buildContext(layer1.parsed); + const layer2 = validateSemantic(ctx, options.severityOverrides); + return finalize(layer2, options); +} + +export function assertValidExperience( + input: unknown, + options: ValidateOptions = {}, +): asserts input is Experience { + const result = validateExperience(input, options); + if (!result.valid) { + throw new ExperienceValidationError(result.errors); + } +} diff --git a/packages/interact/src/validate/rules/conditions/validMediaQueries.ts b/packages/interact/src/validate/rules/conditions/validMediaQueries.ts new file mode 100644 index 00000000..ce7f3264 --- /dev/null +++ b/packages/interact/src/validate/rules/conditions/validMediaQueries.ts @@ -0,0 +1,48 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Minimal CSS media query syntactic check — accepts any non-empty string that +// contains balanced parens and only sane top-level tokens. Falls through to +// `window.matchMedia` when available for the strictest possible check. +function isValidMediaQuery(query: string): boolean { + const q = query.trim(); + if (!q) return false; + if (typeof globalThis !== 'undefined' && 'matchMedia' in globalThis) { + try { + const mql = (globalThis as { matchMedia(q: string): { media: string } }).matchMedia(q); + // Invalid queries make matchMedia return `media: ''` in most engines. + return mql.media !== '' || q === 'all'; + } catch { + return false; + } + } + let depth = 0; + for (const ch of q) { + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth < 0) return false; + } + } + if (depth !== 0) return false; + return /^[A-Za-z0-9_\-:,()\s.%/<>=]+$/.test(q); +} + +export const validMediaQueries: Rule = { + code: 'INVALID_MEDIA_QUERY', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + (ctx.experience.disableWhen ?? []).forEach((condition, i) => { + if (!isValidMediaQuery(condition.mediaQuery)) { + errors.push({ + code: 'INVALID_MEDIA_QUERY' as const, + severity: 'error' as const, + path: ['disableWhen', i, 'mediaQuery'], + message: `Invalid media query: ${JSON.stringify(condition.mediaQuery)}.`, + }); + } + }); + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts b/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts new file mode 100644 index 00000000..e886a90c --- /dev/null +++ b/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts @@ -0,0 +1,32 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const rangeDefaultWithinBounds: Rule = { + code: 'RANGE_DEFAULT_OUT_OF_BOUNDS', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + ctx.controls.forEach((control, ci) => { + if (control.type !== 'range') return; + const { min, max } = control.constraints ?? {}; + const value = control.defaultValue; + if (typeof value !== 'number') return; + if (min !== undefined && value < min) { + errors.push({ + code: 'RANGE_DEFAULT_OUT_OF_BOUNDS' as const, + severity: 'error' as const, + path: ['controls', ci, 'defaultValue'], + message: `Range control "${control.id}" defaultValue ${value} is below min ${min}.`, + }); + } else if (max !== undefined && value > max) { + errors.push({ + code: 'RANGE_DEFAULT_OUT_OF_BOUNDS' as const, + severity: 'error' as const, + path: ['controls', ci, 'defaultValue'], + message: `Range control "${control.id}" defaultValue ${value} is above max ${max}.`, + }); + } + }); + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts b/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts new file mode 100644 index 00000000..8941ba98 --- /dev/null +++ b/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts @@ -0,0 +1,23 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const selectDefaultMatchesOption: Rule = { + code: 'SELECT_DEFAULT_NOT_IN_OPTIONS', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + ctx.controls.forEach((control, ci) => { + if (control.type !== 'select') return; + const options = control.constraints?.options ?? []; + if (!options.some((o) => o.value === control.defaultValue)) { + errors.push({ + code: 'SELECT_DEFAULT_NOT_IN_OPTIONS' as const, + severity: 'error' as const, + path: ['controls', ci, 'defaultValue'], + message: `Select control "${control.id}" defaultValue ${JSON.stringify(control.defaultValue)} does not match any option.`, + }); + } + }); + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/controls/selectMapCoverage.ts b/packages/interact/src/validate/rules/controls/selectMapCoverage.ts new file mode 100644 index 00000000..09289e4c --- /dev/null +++ b/packages/interact/src/validate/rules/controls/selectMapCoverage.ts @@ -0,0 +1,29 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const selectMapCoverage: Rule = { + code: 'MAP_MISSING_OPTION_ENTRY', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + ctx.controls.forEach((control, ci) => { + if (control.type !== 'select') return; + const options = control.constraints?.options ?? []; + control.bindings.forEach((binding, bi) => { + if (binding.transform?.type !== 'map') return; + const entries = binding.transform.entries; + for (const option of options) { + if (!(String(option.value) in entries)) { + errors.push({ + code: 'MAP_MISSING_OPTION_ENTRY' as const, + severity: 'error' as const, + path: ['controls', ci, 'bindings', bi, 'transform', 'entries'], + message: `Map transform for control "${control.id}" is missing an entry for option ${JSON.stringify(option.value)}.`, + }); + } + } + }); + }); + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/controls/uniqueControlIds.ts b/packages/interact/src/validate/rules/controls/uniqueControlIds.ts new file mode 100644 index 00000000..c87b0d24 --- /dev/null +++ b/packages/interact/src/validate/rules/controls/uniqueControlIds.ts @@ -0,0 +1,23 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const uniqueControlIds: Rule = { + code: 'DUPLICATE_CONTROL_ID', + defaultSeverity: 'error', + run: (ctx) => { + const seen = new Set(); + const errors: ValidationError[] = []; + ctx.controls.forEach((control, ci) => { + if (seen.has(control.id)) { + errors.push({ + code: 'DUPLICATE_CONTROL_ID' as const, + severity: 'error' as const, + path: ['controls', ci, 'id'], + message: `Duplicate control id "${control.id}".`, + }); + } + seen.add(control.id); + }); + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/controls/validTransformTypes.ts b/packages/interact/src/validate/rules/controls/validTransformTypes.ts new file mode 100644 index 00000000..2d08bffb --- /dev/null +++ b/packages/interact/src/validate/rules/controls/validTransformTypes.ts @@ -0,0 +1,17 @@ +import type { Rule } from '..'; + +const VALID = new Set(['direct', 'linear', 'inverse', 'map', 'template']); + +export const validTransformTypes: Rule = { + code: 'INVALID_TRANSFORM_TYPE', + defaultSeverity: 'error', + run: (ctx) => + ctx.controlBindingReferences + .filter(({ binding }) => binding.transform && !VALID.has(binding.transform.type)) + .map(({ path, binding }) => ({ + code: 'INVALID_TRANSFORM_TYPE', + severity: 'error' as const, + path: [...path, 'transform', 'type'], + message: `Invalid transform type "${binding.transform!.type}".`, + })), +}; diff --git a/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts b/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts new file mode 100644 index 00000000..9637b429 --- /dev/null +++ b/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const variableUsageReferenced: Rule = { + code: 'VARIABLE_UNUSED', + defaultSeverity: 'warning', + run: (ctx) => + ctx.variableBindings + .filter(({ name }) => name.startsWith('--') && !ctx.cssVarUsage.has(name)) + .map(({ path, name, controlId }) => ({ + code: 'VARIABLE_UNUSED', + severity: 'warning' as const, + path, + message: `Variable "${name}" written by control "${controlId}" is not referenced via var() in any style.`, + hint: `Reference ${name} from elements[*].styles or styles[*].properties, or remove the binding.`, + })), +}; diff --git a/packages/interact/src/validate/rules/index.ts b/packages/interact/src/validate/rules/index.ts new file mode 100644 index 00000000..814f9b21 --- /dev/null +++ b/packages/interact/src/validate/rules/index.ts @@ -0,0 +1,52 @@ +import type { Severity, ValidationError } from '../errors'; +import type { ValidationContext } from '../context'; + +import { interactionKeysExist } from './referential/interactionKeysExist'; +import { effectKeyExistsInElements } from './referential/effectKeyExistsInElements'; +import { effectIdsExist } from './referential/effectIdsExist'; +import { sequenceIdsExist } from './referential/sequenceIdsExist'; +import { animationEndEffectExists } from './referential/animationEndEffectExists'; +import { conditionsExist } from './referential/conditionsExist'; +import { interactionHasEffectsOrSequences } from './referential/interactionHasEffectsOrSequences'; +import { controlTargetsExist } from './referential/controlTargetsExist'; +import { styleBindingSelectorExists } from './referential/styleBindingSelectorExists'; +import { variableBindingIsCustomProperty } from './referential/variableBindingIsCustomProperty'; +import { bindingPropertyRequired } from './referential/bindingPropertyRequired'; + +import { rangeDefaultWithinBounds } from './controls/rangeDefaultWithinBounds'; +import { selectDefaultMatchesOption } from './controls/selectDefaultMatchesOption'; +import { uniqueControlIds } from './controls/uniqueControlIds'; +import { validTransformTypes } from './controls/validTransformTypes'; +import { selectMapCoverage } from './controls/selectMapCoverage'; +import { variableUsageReferenced } from './controls/variableUsageReferenced'; + +import { validMediaQueries } from './conditions/validMediaQueries'; + +export type Rule = { + code: string; + defaultSeverity: Severity; + run: (ctx: ValidationContext) => ValidationError[]; +}; + +export const RULES: Rule[] = [ + interactionKeysExist, + effectKeyExistsInElements, + effectIdsExist, + sequenceIdsExist, + animationEndEffectExists, + conditionsExist, + interactionHasEffectsOrSequences, + controlTargetsExist, + styleBindingSelectorExists, + variableBindingIsCustomProperty, + bindingPropertyRequired, + + rangeDefaultWithinBounds, + selectDefaultMatchesOption, + uniqueControlIds, + validTransformTypes, + selectMapCoverage, + variableUsageReferenced, + + validMediaQueries, +]; diff --git a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts new file mode 100644 index 00000000..9b8c5798 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts @@ -0,0 +1,24 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const animationEndEffectExists: Rule = { + code: 'ANIMATION_END_EFFECT_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + for (const { path, interaction } of ctx.interactions) { + if (interaction.trigger !== 'animationEnd' || !interaction.params) continue; + const effectId = interaction.params.effectId; + if (!ctx.effectIds.has(effectId)) { + errors.push({ + code: 'ANIMATION_END_EFFECT_NOT_FOUND', + severity: 'error' as const, + path: [...path, 'params', 'effectId'], + message: `animationEnd interaction references effect "${effectId}" which is not defined.`, + hint: 'Define the effect in interact.effects or fix the params.effectId.', + }); + } + } + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts b/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts new file mode 100644 index 00000000..08c9347e --- /dev/null +++ b/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts @@ -0,0 +1,25 @@ +import type { Rule } from '..'; + +const REQUIRES_PROPERTY = new Set([ + 'effect', + 'sequence', + 'style', + 'element', + 'interaction', +]); + +export const bindingPropertyRequired: Rule = { + code: 'BINDING_PROPERTY_REQUIRED', + defaultSeverity: 'error', + run: (ctx) => + ctx.controlBindingReferences + .filter( + ({ binding }) => REQUIRES_PROPERTY.has(binding.target) && !binding.property, + ) + .map(({ path, binding }) => ({ + code: 'BINDING_PROPERTY_REQUIRED', + severity: 'error' as const, + path, + message: `Binding to ${binding.target} "${binding.targetId}" requires a "property".`, + })), +}; diff --git a/packages/interact/src/validate/rules/referential/conditionsExist.ts b/packages/interact/src/validate/rules/referential/conditionsExist.ts new file mode 100644 index 00000000..268be269 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/conditionsExist.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const conditionsExist: Rule = { + code: 'CONDITION_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.conditionReferences + .filter((ref) => !ctx.conditionIds.has(ref.conditionId)) + .map((ref) => ({ + code: 'CONDITION_NOT_FOUND', + severity: 'error', + path: ref.path, + message: `Condition "${ref.conditionId}" is referenced but not defined in interact.conditions.`, + hint: 'Add an entry to interact.conditions or remove the reference.', + })), +}; diff --git a/packages/interact/src/validate/rules/referential/controlTargetsExist.ts b/packages/interact/src/validate/rules/referential/controlTargetsExist.ts new file mode 100644 index 00000000..24900219 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/controlTargetsExist.ts @@ -0,0 +1,59 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +export const controlTargetsExist: Rule = { + code: 'CONTROL_TARGET_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => { + const errors: ValidationError[] = []; + for (const { path, binding } of ctx.controlBindingReferences) { + switch (binding.target) { + case 'element': + if (!ctx.elementKeys.has(binding.targetId)) { + errors.push({ + code: 'CONTROL_TARGET_NOT_FOUND' as const, + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Element "${binding.targetId}" referenced by control binding is not defined.`, + }); + } + break; + case 'effect': + if (!ctx.effectIds.has(binding.targetId)) { + errors.push({ + code: 'CONTROL_TARGET_NOT_FOUND' as const, + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Effect "${binding.targetId}" referenced by control binding is not defined.`, + }); + } + break; + case 'sequence': + if (!ctx.sequenceIds.has(binding.targetId)) { + errors.push({ + code: 'CONTROL_TARGET_NOT_FOUND' as const, + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Sequence "${binding.targetId}" referenced by control binding is not defined.`, + }); + } + break; + case 'interaction': + if (!ctx.interactionIds.has(binding.targetId)) { + errors.push({ + code: 'CONTROL_TARGET_NOT_FOUND' as const, + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Interaction "${binding.targetId}" referenced by control binding is not defined.`, + }); + } + break; + case 'style': + case 'variable': + // Validated in dedicated rules below. + break; + } + } + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/referential/effectIdsExist.ts b/packages/interact/src/validate/rules/referential/effectIdsExist.ts new file mode 100644 index 00000000..263f5502 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/effectIdsExist.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const effectIdsExist: Rule = { + code: 'EFFECT_ID_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.effectIdReferences + .filter((ref) => !ctx.effectIds.has(ref.effectId)) + .map((ref) => ({ + code: 'EFFECT_ID_NOT_FOUND', + severity: 'error', + path: ref.path, + message: `Effect "${ref.effectId}" is referenced but not defined in interact.effects.`, + hint: 'Add an entry to interact.effects or fix the reference.', + })), +}; diff --git a/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts b/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts new file mode 100644 index 00000000..3f17738b --- /dev/null +++ b/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const effectKeyExistsInElements: Rule = { + code: 'EFFECT_KEY_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.effectKeyReferences + .filter((ref) => !ctx.elementKeys.has(ref.key)) + .map((ref) => ({ + code: 'EFFECT_KEY_NOT_FOUND', + severity: 'error', + path: ref.path, + message: `Effect override key "${ref.key}" is not defined in elements.`, + hint: `Add "${ref.key}" to elements or remove the override.`, + })), +}; diff --git a/packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts b/packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts new file mode 100644 index 00000000..3c1772e0 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const interactionHasEffectsOrSequences: Rule = { + code: 'INTERACTION_EMPTY', + defaultSeverity: 'error', + run: (ctx) => + ctx.interactions + .filter(({ interaction }) => !interaction.effects?.length && !interaction.sequences?.length) + .map(({ path }) => ({ + code: 'INTERACTION_EMPTY', + severity: 'error' as const, + path, + message: 'Interaction has neither effects nor sequences.', + hint: 'Add at least one effect or sequence to the interaction.', + })), +}; diff --git a/packages/interact/src/validate/rules/referential/interactionKeysExist.ts b/packages/interact/src/validate/rules/referential/interactionKeysExist.ts new file mode 100644 index 00000000..c7978160 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/interactionKeysExist.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const interactionKeysExist: Rule = { + code: 'INTERACTION_KEY_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.interactionKeyReferences + .filter((ref) => !ctx.elementKeys.has(ref.key)) + .map((ref) => ({ + code: 'INTERACTION_KEY_NOT_FOUND', + severity: 'error', + path: ref.path, + message: `Interaction targets element key "${ref.key}" which is not defined in elements.`, + hint: `Add "${ref.key}" to elements or fix the interaction key.`, + })), +}; diff --git a/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts b/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts new file mode 100644 index 00000000..d98c23ea --- /dev/null +++ b/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const sequenceIdsExist: Rule = { + code: 'SEQUENCE_ID_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.sequenceIdReferences + .filter((ref) => !ctx.sequenceIds.has(ref.sequenceId)) + .map((ref) => ({ + code: 'SEQUENCE_ID_NOT_FOUND', + severity: 'error', + path: ref.path, + message: `Sequence "${ref.sequenceId}" is referenced but not defined in interact.sequences.`, + hint: 'Add an entry to interact.sequences or fix the reference.', + })), +}; diff --git a/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts b/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts new file mode 100644 index 00000000..46997a26 --- /dev/null +++ b/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const styleBindingSelectorExists: Rule = { + code: 'STYLE_BINDING_SELECTOR_NOT_FOUND', + defaultSeverity: 'error', + run: (ctx) => + ctx.controlBindingReferences + .filter(({ binding }) => binding.target === 'style' && !ctx.styleSelectors.has(binding.targetId)) + .map(({ path, binding }) => ({ + code: 'STYLE_BINDING_SELECTOR_NOT_FOUND', + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Style binding selector "${binding.targetId}" does not match any styles[].selector.`, + hint: 'Add a matching entry to styles or fix the binding targetId.', + })), +}; diff --git a/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts b/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts new file mode 100644 index 00000000..399f058e --- /dev/null +++ b/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts @@ -0,0 +1,16 @@ +import type { Rule } from '..'; + +export const variableBindingIsCustomProperty: Rule = { + code: 'VARIABLE_BINDING_INVALID_NAME', + defaultSeverity: 'error', + run: (ctx) => + ctx.variableBindings + .filter(({ name }) => !name.startsWith('--')) + .map(({ path, name }) => ({ + code: 'VARIABLE_BINDING_INVALID_NAME', + severity: 'error' as const, + path: [...path, 'targetId'], + message: `Variable binding "${name}" is not a valid CSS custom property name.`, + hint: 'CSS custom properties must start with "--".', + })), +}; diff --git a/packages/interact/src/validate/semantic.ts b/packages/interact/src/validate/semantic.ts new file mode 100644 index 00000000..7d3b06a4 --- /dev/null +++ b/packages/interact/src/validate/semantic.ts @@ -0,0 +1,19 @@ +import { RULES } from './rules'; +import type { ValidationContext } from './context'; +import type { Severity, ValidationError } from './errors'; + +export function validateSemantic( + ctx: ValidationContext, + severityOverrides: Record = {}, +): ValidationError[] { + const out: ValidationError[] = []; + for (const rule of RULES) { + const override = severityOverrides[rule.code]; + if (override === 'off') continue; + const errs = rule.run(ctx); + if (!errs.length) continue; + const severity = override ?? rule.defaultSeverity; + for (const e of errs) out.push({ ...e, severity }); + } + return out; +} diff --git a/packages/interact/src/validate/structural.ts b/packages/interact/src/validate/structural.ts new file mode 100644 index 00000000..2e29d144 --- /dev/null +++ b/packages/interact/src/validate/structural.ts @@ -0,0 +1,36 @@ +import type { ZodIssue } from 'zod'; +import { ExperienceSchema, type Experience } from '../schema'; +import type { ValidationError } from './errors'; + +function mapZodCode(issue: ZodIssue): string { + switch (issue.code) { + case 'invalid_type': + return 'SCHEMA_INVALID_TYPE'; + case 'unrecognized_keys': + return 'SCHEMA_UNRECOGNIZED_KEYS'; + case 'invalid_union': + return 'SCHEMA_INVALID_UNION'; + case 'invalid_value': + return 'SCHEMA_INVALID_LITERAL'; + default: + return 'SCHEMA_INVALID'; + } +} + +export function validateStructural(input: unknown): { + ok: boolean; + parsed?: Experience; + errors: ValidationError[]; +} { + const result = ExperienceSchema.safeParse(input); + if (result.success) { + return { ok: true, parsed: result.data, errors: [] }; + } + const errors: ValidationError[] = result.error.issues.map((issue) => ({ + code: mapZodCode(issue), + message: issue.message, + path: [...issue.path] as (string | number)[], + severity: 'error', + })); + return { ok: false, errors }; +} From 371deffd30124b948e4e9613b0f5c488c401d6aa Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 16:43:21 +0300 Subject: [PATCH 02/19] basic setup of validate sub-path --- packages/interact/package.json | 8 +++++++- packages/interact/vite.config.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/interact/package.json b/packages/interact/package.json index 181dcca2..aa1ad503 100644 --- a/packages/interact/package.json +++ b/packages/interact/package.json @@ -23,6 +23,11 @@ "types": "./dist/types/web/index.d.ts", "import": "./dist/es/web.js", "require": "./dist/cjs/web.js" + }, + "./validate": { + "types": "./dist/types/validate/index.d.ts", + "import": "./dist/es/validate.js", + "require": "./dist/cjs/validate.js" } }, "files": [ @@ -71,7 +76,8 @@ "@wix/motion": "^2.1.7", "fastdom": "^1.0.12", "fizban": "^0.7.2", - "kuliso": "^0.4.13" + "kuliso": "^0.4.13", + "zod": "^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", diff --git a/packages/interact/vite.config.ts b/packages/interact/vite.config.ts index 6c383a2d..1a7ba9aa 100644 --- a/packages/interact/vite.config.ts +++ b/packages/interact/vite.config.ts @@ -15,12 +15,13 @@ export default defineConfig(({ command }) => { index: path.resolve(__dirname, 'src/index.ts'), react: path.resolve(__dirname, 'src/react/index.ts'), web: path.resolve(__dirname, 'src/web/index.ts'), + validate: path.resolve(__dirname, 'src/validate/index.ts'), }, formats: ['es', 'cjs'], }, sourcemap: true, rollupOptions: { - external: ['react', 'react-dom'], + external: ['react', 'react-dom', 'zod'], output: { entryFileNames: '[format]/[name].js', compact: true, From 9c2834b146e0db2938d636713ca038660b4d001d Mon Sep 17 00:00:00 2001 From: Ameer Abu-Fraiha Date: Mon, 1 Jun 2026 13:56:22 +0000 Subject: [PATCH 03/19] updating deps --- yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/yarn.lock b/yarn.lock index c3b4e422..4a27e63e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1423,6 +1423,7 @@ __metadata: typescript: "npm:^5.9.3" vite: "npm:^7.2.2" vitest: "npm:^4.0.14" + zod: "npm:^4.0.0" peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -6344,6 +6345,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.0.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 0f02ad2eb91f0dee9085e310eca8aaecbaa8d43f Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 17:01:15 +0300 Subject: [PATCH 04/19] updating plan's todo --- .cursor/plans/interactconfig_schema_validation_918ab0af.plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index 75938a24..31a96bb7 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -4,7 +4,7 @@ overview: Ship schema + referential + semantic validation for `InteractConfig` a todos: - id: phase0-deps-wiring content: "Phase 0: Add zod as production dep (v4); wire validate entry in vite.config.ts and package.json exports; confirm bundle isolation (dist/es/index.js has no zod)" - status: pending + status: completed - id: phase1-schema content: "Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts" status: pending From e17c2cc5a597b099236a91bc4a3dfc95dc694c51 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 17:42:55 +0300 Subject: [PATCH 05/19] phase 1 --- ...tconfig_schema_validation_918ab0af.plan.md | 2 +- .../interact/src/validate/schema/effects.ts | 184 ++++++++++++++++++ .../interact/src/validate/schema/index.ts | 50 +++++ .../src/validate/schema/interactions.ts | 139 +++++++++++++ .../src/validate/schema/primitives.ts | 37 ++++ .../interact/src/validate/schema/sequences.ts | 29 +++ 6 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 packages/interact/src/validate/schema/effects.ts create mode 100644 packages/interact/src/validate/schema/index.ts create mode 100644 packages/interact/src/validate/schema/interactions.ts create mode 100644 packages/interact/src/validate/schema/primitives.ts create mode 100644 packages/interact/src/validate/schema/sequences.ts diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index 31a96bb7..f5bad290 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -7,7 +7,7 @@ todos: status: completed - id: phase1-schema content: "Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts" - status: pending + status: completed - id: phase2-harness-rename content: "Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas" status: pending diff --git a/packages/interact/src/validate/schema/effects.ts b/packages/interact/src/validate/schema/effects.ts new file mode 100644 index 00000000..6c32425c --- /dev/null +++ b/packages/interact/src/validate/schema/effects.ts @@ -0,0 +1,184 @@ +import { z } from 'zod'; +import { Keyframe, RangeOffset } from './primitives'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const NamedEffect = z + .object({ type: z.string().min(1) }) + .catchall(z.unknown()); + +const KeyframeEffectInline = z + .object({ + name: z.string().min(1), + keyframes: z.array(Keyframe).min(1), + }) + .strict(); + +export const EffectBase = z.object({ + key: z.string().optional(), + effectId: z.string().optional(), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableEffectRef = EffectBase.extend({ + effectId: z.string().min(1), +}).strict(); + +const TimeEffectFields = { + duration: z.number().optional(), + easing: z.string().optional(), + iterations: z.number().optional(), + alternate: z.boolean().optional(), + reversed: z.boolean().optional(), + delay: z.number().optional(), + fill: z.enum(['none', 'forwards', 'backwards', 'both']).optional(), + composite: z.enum(['replace', 'add', 'accumulate']).optional(), + triggerType: TriggerType.optional(), +}; + +export const SCRUB_FIELDS = [ + 'rangeStart', + 'rangeEnd', + 'centeredToTarget', + 'transitionDuration', + 'transitionDelay', + 'transitionEasing', +] as const; + +export const STATE_FIELDS = ['stateAction', 'transition', 'transitionProperties'] as const; + +export const TIME_FIELDS = [ + 'duration', + 'easing', + 'iterations', + 'alternate', + 'reversed', + 'delay', + 'fill', + 'composite', +] as const; + +const ScrubEffectFields = { + rangeStart: RangeOffset.optional(), + rangeEnd: RangeOffset.optional(), + centeredToTarget: z.boolean().optional(), + transitionDuration: z.number().optional(), + transitionDelay: z.number().optional(), + transitionEasing: z + .enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']) + .optional(), +}; + +const StateEffectFields = { + stateAction: z.enum(['add', 'remove', 'toggle', 'clear']).optional(), + transition: z + .object({ + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + styleProperties: z.array( + z.object({ name: z.string(), value: z.string() }), + ), + }) + .optional(), + transitionProperties: z + .array( + z.object({ + name: z.string(), + value: z.string(), + duration: z.number().optional(), + delay: z.number().optional(), + easing: z.string().optional(), + }), + ) + .optional(), +}; + +const SourceFields = { + namedEffect: NamedEffect.optional(), + keyframeEffect: KeyframeEffectInline.optional(), + customEffect: z.custom((v) => typeof v === 'function').optional(), +}; + +export const SerializableEffectSource = z + .object(SourceFields) + .strict() + .refine( + (v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) + (v.customEffect ? 1 : 0) === 1, + { message: 'Effect source must define exactly one of namedEffect, keyframeEffect, or customEffect' }, + ); + +const EffectShape = EffectBase.extend({ + ...SourceFields, + ...TimeEffectFields, + ...ScrubEffectFields, + ...StateEffectFields, +}).strict(); + +export const SerializableEffect = EffectShape.superRefine((v, ctx) => { + const hasNamed = v.namedEffect !== undefined; + const hasKeyframe = v.keyframeEffect !== undefined; + const hasCustom = v.customEffect !== undefined; + const sourceCount = (hasNamed ? 1 : 0) + (hasKeyframe ? 1 : 0) + (hasCustom ? 1 : 0); + const hasSource = sourceCount > 0; + const hasState = + v.stateAction !== undefined || + v.transition !== undefined || + v.transitionProperties !== undefined; + + if (sourceCount > 1) { + ctx.addIssue({ + code: 'custom', + message: 'Effect must define exactly one of namedEffect, keyframeEffect, or customEffect.', + path: [], + }); + } + if (hasSource && hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect source fields (namedEffect, keyframeEffect, or customEffect) cannot be combined with state effect fields.', + path: [], + }); + } + if (!hasSource && !hasState) { + ctx.addIssue({ + code: 'custom', + message: + 'Effect must define an effect source (namedEffect, keyframeEffect, or customEffect) or be a state effect (stateAction / transition / transitionProperties).', + path: [], + }); + } +}); + +export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => { + if (v.namedEffect === undefined && v.keyframeEffect === undefined && v.customEffect === undefined) { + ctx.addIssue({ + code: 'custom', + message: + 'Time effect must define an effect source (namedEffect, keyframeEffect, or customEffect).', + path: [], + }); + } + for (const field of SCRUB_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a scrub-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } + for (const field of STATE_FIELDS) { + if ((v as Record)[field] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `"${field}" is a state-effect field and is not allowed on a time effect.`, + path: [field], + }); + } + } +}); diff --git a/packages/interact/src/validate/schema/index.ts b/packages/interact/src/validate/schema/index.ts new file mode 100644 index 00000000..3161467a --- /dev/null +++ b/packages/interact/src/validate/schema/index.ts @@ -0,0 +1,50 @@ +// Zod schema values — for validation and host-project schema composition +export { + InteractConfigSchema, + Interaction, + TriggerType, + ViewEnterParams, + PointerMoveParams, + AnimationEndParams, + TriggerParams, +} from './interactions'; + +export { + SerializableEffect, + SerializableEffectRef, + SerializableEffectSource, + SerializableTimeEffect, + EffectBase, + NamedEffect, + SCRUB_FIELDS, + STATE_FIELDS, + TIME_FIELDS, +} from './effects'; + +export { + SerializableSequenceConfig, + SerializableSequenceConfigRef, +} from './sequences'; + +export { + Keyframe, + LengthPercentage, + RangeOffset, + Condition, + MediaCondition, +} from './primitives'; + +// Canonical types — single source of truth, no z.infer<> re-derivation. +// Names that collide with a zod schema value above are exported with a +// `Def` suffix; unambiguous names are exported as-is. +export type { + InteractConfig, + Condition as ConditionDef, + SequenceOptionsConfig, + SequenceConfig, + SequenceConfigRef, + Interaction as InteractionDef, + InteractionTrigger, +} from '../../types/config'; + +export type { Effect, EffectRef } from '../../types/effects'; diff --git a/packages/interact/src/validate/schema/interactions.ts b/packages/interact/src/validate/schema/interactions.ts new file mode 100644 index 00000000..fab7d75f --- /dev/null +++ b/packages/interact/src/validate/schema/interactions.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import { Condition } from './primitives'; +import { SerializableEffect, SerializableEffectRef } from './effects'; +import { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; + +export const TriggerType = z.enum([ + 'hover', + 'click', + 'interest', + 'activate', + 'viewEnter', + 'viewProgress', + 'pointerMove', + 'animationEnd', + 'pageVisible', +]); + +export const ViewEnterParams = z + .object({ + threshold: z.number().optional(), + inset: z.string().optional(), + useSafeViewEnter: z.boolean().optional(), + }) + .strict(); + +export const PointerMoveParams = z + .object({ + hitArea: z.enum(['root', 'self']).optional(), + axis: z.enum(['x', 'y']).optional(), + }) + .strict(); + +export const AnimationEndParams = z + .object({ + effectId: z.string().min(1), + }) + .strict(); + +export const TriggerParams = z.union([ViewEnterParams, PointerMoveParams, AnimationEndParams]); + +const InteractionBase = { + key: z.string().min(1), + selector: z.string().optional(), + listContainer: z.string().optional(), + listItemSelector: z.string().optional(), + conditions: z.array(z.string()).optional(), + effects: z.array(z.union([SerializableEffect, SerializableEffectRef])).optional(), + sequences: z + .array(z.union([SerializableSequenceConfig, SerializableSequenceConfigRef])) + .optional(), +}; + +const ViewEnterInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('viewEnter'), + params: ViewEnterParams.optional(), + }) + .strict(); + +const PageVisibleInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('pageVisible'), + params: ViewEnterParams.optional(), + }) + .strict(); + +const PointerMoveInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('pointerMove'), + params: PointerMoveParams.optional(), + }) + .strict(); + +const AnimationEndInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('animationEnd'), + params: AnimationEndParams, + }) + .strict(); + +const HoverInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('hover'), + }) + .strict(); + +const ClickInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('click'), + }) + .strict(); + +const InterestInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('interest'), + }) + .strict(); + +const ActivateInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('activate'), + }) + .strict(); + +const ViewProgressInteraction = z + .object({ + ...InteractionBase, + trigger: z.literal('viewProgress'), + }) + .strict(); + +export const Interaction = z.discriminatedUnion('trigger', [ + ViewEnterInteraction, + PageVisibleInteraction, + PointerMoveInteraction, + AnimationEndInteraction, + HoverInteraction, + ClickInteraction, + InterestInteraction, + ActivateInteraction, + ViewProgressInteraction, +]); + +export const InteractConfigSchema = z + .object({ + effects: z.record(z.string().min(1), SerializableEffect).optional(), + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z.record(z.string().min(1), Condition).optional(), + interactions: z.array(Interaction), + }) + .strict(); diff --git a/packages/interact/src/validate/schema/primitives.ts b/packages/interact/src/validate/schema/primitives.ts new file mode 100644 index 00000000..c69904e5 --- /dev/null +++ b/packages/interact/src/validate/schema/primitives.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()])); + +export const LengthPercentage = z.union([ + z.object({ + value: z.number(), + unit: z.enum(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax']), + }), + z.object({ + value: z.number(), + unit: z.literal('percentage'), + }), +]); + +export const RangeOffset = z + .object({ + name: z + .enum(['entry', 'exit', 'contain', 'cover', 'entry-crossing', 'exit-crossing']) + .optional(), + offset: LengthPercentage.optional(), + }) + .strict(); + +export const Condition = z + .object({ + type: z.enum(['media', 'container', 'selector']), + predicate: z.string().optional(), + }) + .strict(); + +export const MediaCondition = z + .object({ + mediaQuery: z.string().min(1), + label: z.string().optional(), + }) + .strict(); diff --git a/packages/interact/src/validate/schema/sequences.ts b/packages/interact/src/validate/schema/sequences.ts new file mode 100644 index 00000000..0e531afc --- /dev/null +++ b/packages/interact/src/validate/schema/sequences.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { SerializableEffectRef, SerializableTimeEffect } from './effects'; + +const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); + +export const SerializableSequenceConfig = z.object({ + effects: z.array(z.union([SerializableTimeEffect, SerializableEffectRef])), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z + .union([z.string(), z.custom((v) => typeof v === 'function')]) + .optional(), + triggerType: TriggerType.optional(), + sequenceId: z.string().optional(), + conditions: z.array(z.string()).optional(), +}); + +export const SerializableSequenceConfigRef = z + .object({ + sequenceId: z.string().min(1), + delay: z.number().optional(), + offset: z.number().optional(), + offsetEasing: z + .union([z.string(), z.custom((v) => typeof v === 'function')]) + .optional(), + triggerType: TriggerType.optional(), + conditions: z.array(z.string()).optional(), + }) + .strict(); From 234b2469f3cc55cc6e7e9b94e14f77edaff1dc1c Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 17:47:03 +0300 Subject: [PATCH 06/19] lint --- packages/interact/src/validate/schema/effects.ts | 2 +- packages/interact/src/validate/schema/sequences.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/interact/src/validate/schema/effects.ts b/packages/interact/src/validate/schema/effects.ts index 6c32425c..278cb5a5 100644 --- a/packages/interact/src/validate/schema/effects.ts +++ b/packages/interact/src/validate/schema/effects.ts @@ -100,7 +100,7 @@ const StateEffectFields = { const SourceFields = { namedEffect: NamedEffect.optional(), keyframeEffect: KeyframeEffectInline.optional(), - customEffect: z.custom((v) => typeof v === 'function').optional(), + customEffect: z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function').optional(), }; export const SerializableEffectSource = z diff --git a/packages/interact/src/validate/schema/sequences.ts b/packages/interact/src/validate/schema/sequences.ts index 0e531afc..7ee24da0 100644 --- a/packages/interact/src/validate/schema/sequences.ts +++ b/packages/interact/src/validate/schema/sequences.ts @@ -8,7 +8,7 @@ export const SerializableSequenceConfig = z.object({ delay: z.number().optional(), offset: z.number().optional(), offsetEasing: z - .union([z.string(), z.custom((v) => typeof v === 'function')]) + .union([z.string(), z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function')]) .optional(), triggerType: TriggerType.optional(), sequenceId: z.string().optional(), @@ -21,7 +21,7 @@ export const SerializableSequenceConfigRef = z delay: z.number().optional(), offset: z.number().optional(), offsetEasing: z - .union([z.string(), z.custom((v) => typeof v === 'function')]) + .union([z.string(), z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function')]) .optional(), triggerType: TriggerType.optional(), conditions: z.array(z.string()).optional(), From 8d9c089f7551dc37b45fd80fd25a81ae117527af Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 17:49:20 +0300 Subject: [PATCH 07/19] lint --- ...tconfig_schema_validation_918ab0af.plan.md | 110 +++++++++++------- packages/interact/src/schema/effects.ts | 22 ++-- packages/interact/src/validate/context.ts | 12 +- .../referential/bindingPropertyRequired.ts | 12 +- .../referential/styleBindingSelectorExists.ts | 4 +- .../interact/src/validate/schema/effects.ts | 27 +++-- .../interact/src/validate/schema/index.ts | 13 +-- .../interact/src/validate/schema/sequences.ts | 5 +- 8 files changed, 113 insertions(+), 92 deletions(-) diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index f5bad290..ed965c92 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -3,28 +3,28 @@ name: InteractConfig Schema Validation overview: Ship schema + referential + semantic validation for `InteractConfig` as a new `@wix/interact/validate` subpath, by retargeting the existing validation harness and stripping the host project's Experience/controls layer. Uses Opus's plan as the base with Sonnet's concise todo structure and canonical type re-export pattern added. todos: - id: phase0-deps-wiring - content: "Phase 0: Add zod as production dep (v4); wire validate entry in vite.config.ts and package.json exports; confirm bundle isolation (dist/es/index.js has no zod)" + content: 'Phase 0: Add zod as production dep (v4); wire validate entry in vite.config.ts and package.json exports; confirm bundle isolation (dist/es/index.js has no zod)' status: completed - id: phase1-schema - content: "Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts" + content: 'Phase 1: Move src/schema/ → src/validate/schema/; trim primitives (keep MediaCondition), effects (accept customEffect), sequences (accept function offsetEasing), interactions (remove id, no interactionId, discriminatedUnion); rewrite index.ts to re-export canonical types from types/config.ts' status: completed - id: phase2-harness-rename - content: "Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas" + content: 'Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas' status: pending - id: phase3-context - content: "Phase 3: Rewrite validate/context.ts — drop elementKeys/controlIds/styleSelectors/etc.; walk InteractConfig directly; add isEffectRef/isSequenceRef predicates; collect trigger+effect tuples and definition maps for new rules" + content: 'Phase 3: Rewrite validate/context.ts — drop elementKeys/controlIds/styleSelectors/etc.; walk InteractConfig directly; add isEffectRef/isSequenceRef predicates; collect trigger+effect tuples and definition maps for new rules' status: pending - id: phase4-rules-trim - content: "Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners" + content: 'Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners' status: pending - id: phase4-rules-add - content: "Phase 4b: Add 5 new semantic rules — triggerEffectCompatible (warning), numericBounds, conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions (warnings); update rules/index.ts RULES array" + content: 'Phase 4b: Add 5 new semantic rules — triggerEffectCompatible (warning), numericBounds, conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions (warnings); update rules/index.ts RULES array' status: pending - id: phase5-tests - content: "Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)" + content: 'Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)' status: pending - id: phase6-docs - content: "Phase 6: README section for @wix/interact/validate; error-code table (§7); llms.txt entry" + content: 'Phase 6: README section for @wix/interact/validate; error-code table (§7); llms.txt entry' status: pending isProject: false --- @@ -46,7 +46,7 @@ Interact slice, amputate everything else.** - **Location:** new opt-in subpath `@wix/interact/validate`. Keeps `zod` (a new runtime dep, currently not installed) out of the default bundle — it is - imported *only* from this subpath, so tree-shaking excludes it for consumers + imported _only_ from this subpath, so tree-shaking excludes it for consumers who don't import it. Same package (not a separate one) so validation can never version-skew from the config types it must track. - **Target type:** validate `InteractConfig` directly. The host app composes its @@ -94,6 +94,7 @@ packages/interact/src/ ``` **Deleted entirely:** + - `src/schema/experience.ts`, `src/schema/controls.ts` - `src/validate/rules/controls/*` (all six) - `src/validate/rules/referential/{controlTargetsExist,styleBindingSelectorExists,variableBindingIsCustomProperty,bindingPropertyRequired,interactionKeysExist,effectKeyExistsInElements}.ts` @@ -137,12 +138,14 @@ packages/interact/src/ ## 3. Phase 1 — schema, trimmed to InteractConfig ### `schema/primitives.ts` + - **Keep:** `Keyframe`, `LengthPercentage`, `RangeOffset`, `Condition`, `MediaCondition`. - **Delete:** `ElementEntry`, `StyleRule`, `ExperienceMeta`, `ExperienceSchemaVersion`. ### `schema/effects.ts` + - **Keep all of it.** Additionally **export** the field-group constants for reuse by the new compatibility rule: ```ts @@ -160,11 +163,13 @@ packages/interact/src/ `SerializableTimeEffect`). Opaque — no deep validation. ### `schema/sequences.ts` + - **Keep as-is** (`SerializableSequenceConfig`, `SerializableSequenceConfigRef`). - **Accept function `offsetEasing`** (decision 8.1): `offsetEasing: z.union([z.string(), z.custom((v) => typeof v === 'function')]).optional()`. ### `schema/interactions.ts` + - **Keep:** `TriggerType`, `ViewEnterParams`, `PointerMoveParams`, `AnimationEndParams`, `TriggerParams`, and the four per-trigger interaction shapes. @@ -178,17 +183,20 @@ packages/interact/src/ better errors + speed. - **Replace `ExperienceInteractConfig` with the real root** (rename file export): ```ts - export const InteractConfigSchema = z.object({ - effects: z.record(z.string().min(1), SerializableEffect).optional(), // optional, matches type - sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), - conditions: z.record(z.string().min(1), Condition).optional(), // primitives.Condition → includes 'container' - interactions: z.array(Interaction), - }).strict(); + export const InteractConfigSchema = z + .object({ + effects: z.record(z.string().min(1), SerializableEffect).optional(), // optional, matches type + sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), + conditions: z.record(z.string().min(1), Condition).optional(), // primitives.Condition → includes 'container' + interactions: z.array(Interaction), + }) + .strict(); ``` Note the two drift fixes vs the copied version: `effects` becomes **optional**, and `conditions` uses the full `Condition` (with `'container'`). ### `schema/index.ts` + - Strip all `Experience*`, `Control*`, `Binding*`, `ElementEntry`, `StyleRule`, `ExperienceMeta` re-exports. Keep effect/sequence/interaction/primitive exports + the new `InteractConfigSchema`. @@ -207,23 +215,27 @@ packages/interact/src/ ## 4. Phase 2 — harness rename (mechanical) ### `validate/errors.ts` + - Rename `ExperienceValidationError` → `InteractValidationError`; message text "Interact config validation failed…". Keep `Severity`, `ValidationError`, `ValidationResult` unchanged. ### `validate/structural.ts` + - Point at `InteractConfigSchema` instead of `ExperienceSchema`. Keep `mapZodCode`; **extend it** with `too_small` → `SCHEMA_TOO_SMALL`, `invalid_enum_value`/`invalid_value` already handled. Return type `parsed?: - InteractConfig`. +InteractConfig`. ### `validate/semantic.ts` + - **No change** (already generic over `ctx` + `RULES`). ### `validate/index.ts` + - Rename `validateExperience` → `validateInteractConfig`, `assertValidExperience` → `assertValidInteractConfig` (`asserts input is - InteractConfig`). Keep `finalize`/`comparePath`/`ValidateOptions` +InteractConfig`). Keep `finalize`/`comparePath`/`ValidateOptions` (`strict`/`severityOverrides`/`max`) verbatim. - **Export** `RULES` and the `Rule` type so consumers can register custom rules. - **Re-export the zod schemas** (decision 8.2): `InteractConfigSchema`, its @@ -260,23 +272,26 @@ Rewrite `buildContext(config: InteractConfig)`: ### Keep (retargeted, content unchanged except code paths) -| File | Code | Severity | -|---|---|---| -| `referential/effectIdsExist` | `EFFECT_ID_NOT_FOUND` | error | -| `referential/sequenceIdsExist` | `SEQUENCE_ID_NOT_FOUND` | error | -| `referential/animationEndEffectExists` | `ANIMATION_END_EFFECT_NOT_FOUND` | error | -| `referential/conditionsExist` | `CONDITION_NOT_FOUND` | error | -| `referential/interactionHasEffectsOrSequences` | `INTERACTION_EMPTY` | error | -| `conditions/validMediaQueries` | `INVALID_MEDIA_QUERY` | warning | +| File | Code | Severity | +| ---------------------------------------------- | -------------------------------- | -------- | +| `referential/effectIdsExist` | `EFFECT_ID_NOT_FOUND` | error | +| `referential/sequenceIdsExist` | `SEQUENCE_ID_NOT_FOUND` | error | +| `referential/animationEndEffectExists` | `ANIMATION_END_EFFECT_NOT_FOUND` | error | +| `referential/conditionsExist` | `CONDITION_NOT_FOUND` | error | +| `referential/interactionHasEffectsOrSequences` | `INTERACTION_EMPTY` | error | +| `conditions/validMediaQueries` | `INVALID_MEDIA_QUERY` | warning | ### Delete + All `controls/*`, the four binding/style referential rules, plus `interactionKeysExist` and `effectKeyExistsInElements` (they checked `Experience.elements`, which doesn't exist in `InteractConfig`; `key` is a runtime DOM identifier — see §8 for the runtime-only alternative). ### Add `rules/_factory.ts` + Collapse the near-identical referential rules: + ```ts export function referenceRule(opts: { code: string; @@ -285,16 +300,21 @@ export function referenceRule(opts: { has: (ctx: ValidationContext, ref: T) => boolean; message: (ref: T) => string; hint?: string; -}): Rule { /* filter !has, map to ValidationError */ } +}): Rule { + /* filter !has, map to ValidationError */ +} ``` + Rewrite the four ID-existence rules as one-line `referenceRule({...})` calls. ### New rules (see §7 for the catalogue) + `semantic/triggerEffectCompatible`, `semantic/numericBounds`, `semantic/conditionPredicateRequired`, `semantic/uniqueDefinitionIds`, `semantic/unusedDefinitions`. ### `rules/index.ts` + New `RULES` array: the 5 kept referential + `validMediaQueries` + the 5 new semantic rules. Keep the `Rule` type exported. @@ -302,18 +322,19 @@ semantic rules. Keep the `Rule` type exported. ## 7. New validation rules — catalogue -| Code | What it checks | Severity | Notes | -|---|---|---|---| -| `TRIGGER_EFFECT_INCOMPATIBLE` | Scrub fields (`SCRUB_FIELDS`) only on `viewProgress`/`pointerMove`; time fields (`TIME_FIELDS`) only on discrete triggers; state fields not on scrub triggers. | **warning** initially | The big one. Runtime silently drops bad combos (`resolvers.ts:87`). Start as warning to avoid false positives, promote to error once stable. Reuse exported field-group constants. | -| `THRESHOLD_OUT_OF_RANGE` | `viewEnter.threshold` ∈ [0,1]. | error | | -| `NEGATIVE_DURATION` etc. (`numericBounds`) | `duration`/`delay`/`iterations`/sequence `offset`/`delay` ≥ 0. | error | One rule, multiple checks. | -| `CONDITION_PREDICATE_REQUIRED` | `predicate` present for condition `type` `media`/`container`. | error | Fixes schema marking it optional. | -| `DUPLICATE_KEYFRAME_NAME` | `keyframeEffect.name` unique across effects. | warning | Part of `uniqueDefinitionIds`. | -| `UNUSED_EFFECT` / `UNUSED_SEQUENCE` / `UNUSED_CONDITION` | Defined but never referenced by any interaction. | warning | Mirror of referential rules; dead-config hygiene. | +| Code | What it checks | Severity | Notes | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TRIGGER_EFFECT_INCOMPATIBLE` | Scrub fields (`SCRUB_FIELDS`) only on `viewProgress`/`pointerMove`; time fields (`TIME_FIELDS`) only on discrete triggers; state fields not on scrub triggers. | **warning** initially | The big one. Runtime silently drops bad combos (`resolvers.ts:87`). Start as warning to avoid false positives, promote to error once stable. Reuse exported field-group constants. | +| `THRESHOLD_OUT_OF_RANGE` | `viewEnter.threshold` ∈ [0,1]. | error | | +| `NEGATIVE_DURATION` etc. (`numericBounds`) | `duration`/`delay`/`iterations`/sequence `offset`/`delay` ≥ 0. | error | One rule, multiple checks. | +| `CONDITION_PREDICATE_REQUIRED` | `predicate` present for condition `type` `media`/`container`. | error | Fixes schema marking it optional. | +| `DUPLICATE_KEYFRAME_NAME` | `keyframeEffect.name` unique across effects. | warning | Part of `uniqueDefinitionIds`. | +| `UNUSED_EFFECT` / `UNUSED_SEQUENCE` / `UNUSED_CONDITION` | Defined but never referenced by any interaction. | warning | Mirror of referential rules; dead-config hygiene. | **Deferred / runtime-only (document, don't ship in static validator):** + - `NAMED_EFFECT_NOT_REGISTERED` — `namedEffect.type` must be in motion's registry - (`getRegisteredEffect`). Only knowable *after* `registerEffects`, so expose as + (`getRegisteredEffect`). Only knowable _after_ `registerEffects`, so expose as an optional runtime check, not part of static `validateInteractConfig`. - `UNKNOWN_EASING` — `easing`/`offsetEasing` resolvable by motion's `getJsEasing` or valid `cubic-bezier()`. Warning; needs the easing name list from motion. @@ -326,8 +347,10 @@ All five resolved below with codebase evidence. Consequences are propagated into the phases above. ### 8.1 Function fields → **accept + skip (opaque)** ✅ + `customEffect` and function-valued `offsetEasing` are **genuine authored options**, not just type noise: + - `resolvers.ts:74,95` — `else if (customEffect)` is a first-class effect source. - `resolvers.ts:146` — `if (typeof offsetEasing === 'function')` branch. - Referenced in `README.md`, `docs/guides/effects-and-animations.md`, @@ -335,6 +358,7 @@ options**, not just type noise: A JSON-only schema would **reject valid JS-authored configs** → unacceptable. **Resolution:** + - `schema/effects.ts`: add `customEffect: z.custom((v) => typeof v === 'function').optional()` to `SourceFields`, and **count it as a valid third source** in all three refinements (`SerializableEffectSource`, `SerializableEffect.superRefine`'s @@ -342,29 +366,33 @@ A JSON-only schema would **reject valid JS-authored configs** → unacceptable. No deep validation of the function — opaque. - `schema/sequences.ts`: `offsetEasing: z.union([z.string(), z.custom(...)]).optional()`. - **Defer** (not v1): an opt-in `serializableOnly` flag that emits a - `NOT_SERIALIZABLE` *warning* for function fields, for consumers exporting + `NOT_SERIALIZABLE` _warning_ for function fields, for consumers exporting configs to JSON (e.g. AI round-tripping). Add to `ValidateOptions` later. ### 8.2 Schema dir placement → **fold into `src/validate/schema/` + export publicly** ✅ + Evidence: nothing outside `src/validate` imports `src/schema`; `core/`+`dom/` import neither `zod` nor any schema. So folding is safe and keeps the entire zod-dependent surface under the one subpath. **Resolution:** move `src/schema/` → `src/validate/schema/`. **Additionally, re-export `InteractConfigSchema`, its `z.infer` type, and the sub-schemas (effects/sequences/interactions/primitives) from `src/validate/index.ts`** — the -host "Experience" project that prompted this work needs to *compose* its own +host "Experience" project that prompted this work needs to _compose_ its own schema on top of Interact's, so the zod schemas are part of the public `/validate` API, not just internals. ### 8.3 `interactionId` on interaction effects → **internal-only; keep OUT of schema** ✅ + (Reverses the earlier "add it optional" lean.) Evidence: it is **runtime-generated, never author-provided** — `Interact.ts:467-468`: + ```ts const interactionId = `${source}::${target}::${effectId}::${interactionIdx}`; -effect.interactionId = interactionId; // mutated in place at runtime +effect.interactionId = interactionId; // mutated in place at runtime ``` + It only exists on the `Effect` type because the runtime mutates the parsed -objects. Validation runs on the *authored* config (pre-mutation), where the field +objects. Validation runs on the _authored_ config (pre-mutation), where the field is absent. **Resolution:** do **not** add `interactionId` to the interaction-effect schema. `.strict()` will then correctly reject it if a user supplies it by mistake — which @@ -372,6 +400,7 @@ is the desired behavior, since authoring it is meaningless. (This makes 8.1's field set the only author-facing additions.) ### 8.4 zod dependency form → **regular `dependencies`** ✅ + Considered the optional-peer pattern the package uses for `react`/`react-dom`, but that precedent exists because React must be a **deduped singleton** owned by the host — zod has no such constraint. For zod the only thing peer-optional buys @@ -384,6 +413,7 @@ already externalized in the bundle). Revisit peer-optional only if install footprint becomes a real complaint. ### 8.5 Element-key checks → **drop for v1; document a future runtime helper** ✅ + `InteractConfig` has **no element registry** to check `key`/`selector` against — they resolve against the live DOM at runtime (`Interact.ts:391`: "Interaction … is missing a key for source element"). So static validation @@ -421,7 +451,7 @@ clearly separated from the static `validateInteractConfig`. Not in v1. ## 10. Phase 6 — docs & DX - README section for `@wix/interact/validate`: `validateInteractConfig(config, - opts)`, `assertValidInteractConfig`, the `ValidationError`/`code` catalogue, +opts)`, `assertValidInteractConfig`, the `ValidationError`/`code` catalogue, `severityOverrides`/`strict`, and custom-rule registration via exported `RULES`/`Rule`. - Add the error-code table (§7) to docs and, if applicable, to `llms.txt`. diff --git a/packages/interact/src/schema/effects.ts b/packages/interact/src/schema/effects.ts index 128035a9..da52f57a 100644 --- a/packages/interact/src/schema/effects.ts +++ b/packages/interact/src/schema/effects.ts @@ -3,9 +3,7 @@ import { Keyframe, RangeOffset } from './primitives'; const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); -export const NamedEffect = z - .object({ type: z.string().min(1) }) - .catchall(z.unknown()); +export const NamedEffect = z.object({ type: z.string().min(1) }).catchall(z.unknown()); const KeyframeEffectInline = z .object({ @@ -45,9 +43,7 @@ const ScrubEffectFields = { centeredToTarget: z.boolean().optional(), transitionDuration: z.number().optional(), transitionDelay: z.number().optional(), - transitionEasing: z - .enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']) - .optional(), + transitionEasing: z.enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']).optional(), }; const StateEffectFields = { @@ -57,9 +53,7 @@ const StateEffectFields = { duration: z.number().optional(), delay: z.number().optional(), easing: z.string().optional(), - styleProperties: z.array( - z.object({ name: z.string(), value: z.string() }), - ), + styleProperties: z.array(z.object({ name: z.string(), value: z.string() })), }) .optional(), transitionProperties: z @@ -83,10 +77,9 @@ const SourceFields = { export const SerializableEffectSource = z .object(SourceFields) .strict() - .refine( - (v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) === 1, - { message: 'Effect source must define exactly one of namedEffect or keyframeEffect' }, - ); + .refine((v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) === 1, { + message: 'Effect source must define exactly one of namedEffect or keyframeEffect', + }); const EffectShape = EffectBase.extend({ ...SourceFields, @@ -146,8 +139,7 @@ export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => if (v.namedEffect === undefined && v.keyframeEffect === undefined) { ctx.addIssue({ code: 'custom', - message: - 'Time effect must define an effect source (namedEffect or keyframeEffect).', + message: 'Time effect must define an effect source (namedEffect or keyframeEffect).', path: [], }); } diff --git a/packages/interact/src/validate/context.ts b/packages/interact/src/validate/context.ts index 75cbf5ed..6ff3f0be 100644 --- a/packages/interact/src/validate/context.ts +++ b/packages/interact/src/validate/context.ts @@ -65,11 +65,19 @@ function walkEffect( function walkSequence( seq: SerializableSequenceConfig, basePath: Path, - ctx: Pick, + ctx: Pick< + ValidationContext, + 'effectIdReferences' | 'conditionReferences' | 'effectKeyReferences' + >, ): void { seq.effects.forEach((entry, i) => { const path = [...basePath, 'effects', i]; - if ('effectId' in entry && entry.effectId !== undefined && !('namedEffect' in entry) && !('keyframeEffect' in entry)) { + if ( + 'effectId' in entry && + entry.effectId !== undefined && + !('namedEffect' in entry) && + !('keyframeEffect' in entry) + ) { // Ref-only entry ctx.effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId }); } else { diff --git a/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts b/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts index 08c9347e..eadb6ef9 100644 --- a/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts +++ b/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts @@ -1,21 +1,13 @@ import type { Rule } from '..'; -const REQUIRES_PROPERTY = new Set([ - 'effect', - 'sequence', - 'style', - 'element', - 'interaction', -]); +const REQUIRES_PROPERTY = new Set(['effect', 'sequence', 'style', 'element', 'interaction']); export const bindingPropertyRequired: Rule = { code: 'BINDING_PROPERTY_REQUIRED', defaultSeverity: 'error', run: (ctx) => ctx.controlBindingReferences - .filter( - ({ binding }) => REQUIRES_PROPERTY.has(binding.target) && !binding.property, - ) + .filter(({ binding }) => REQUIRES_PROPERTY.has(binding.target) && !binding.property) .map(({ path, binding }) => ({ code: 'BINDING_PROPERTY_REQUIRED', severity: 'error' as const, diff --git a/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts b/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts index 46997a26..8ef3f4b5 100644 --- a/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts +++ b/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts @@ -5,7 +5,9 @@ export const styleBindingSelectorExists: Rule = { defaultSeverity: 'error', run: (ctx) => ctx.controlBindingReferences - .filter(({ binding }) => binding.target === 'style' && !ctx.styleSelectors.has(binding.targetId)) + .filter( + ({ binding }) => binding.target === 'style' && !ctx.styleSelectors.has(binding.targetId), + ) .map(({ path, binding }) => ({ code: 'STYLE_BINDING_SELECTOR_NOT_FOUND', severity: 'error' as const, diff --git a/packages/interact/src/validate/schema/effects.ts b/packages/interact/src/validate/schema/effects.ts index 278cb5a5..229271ed 100644 --- a/packages/interact/src/validate/schema/effects.ts +++ b/packages/interact/src/validate/schema/effects.ts @@ -3,9 +3,7 @@ import { Keyframe, RangeOffset } from './primitives'; const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); -export const NamedEffect = z - .object({ type: z.string().min(1) }) - .catchall(z.unknown()); +export const NamedEffect = z.object({ type: z.string().min(1) }).catchall(z.unknown()); const KeyframeEffectInline = z .object({ @@ -67,9 +65,7 @@ const ScrubEffectFields = { centeredToTarget: z.boolean().optional(), transitionDuration: z.number().optional(), transitionDelay: z.number().optional(), - transitionEasing: z - .enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']) - .optional(), + transitionEasing: z.enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']).optional(), }; const StateEffectFields = { @@ -79,9 +75,7 @@ const StateEffectFields = { duration: z.number().optional(), delay: z.number().optional(), easing: z.string().optional(), - styleProperties: z.array( - z.object({ name: z.string(), value: z.string() }), - ), + styleProperties: z.array(z.object({ name: z.string(), value: z.string() })), }) .optional(), transitionProperties: z @@ -100,7 +94,9 @@ const StateEffectFields = { const SourceFields = { namedEffect: NamedEffect.optional(), keyframeEffect: KeyframeEffectInline.optional(), - customEffect: z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function').optional(), + customEffect: z + .custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function') + .optional(), }; export const SerializableEffectSource = z @@ -108,7 +104,10 @@ export const SerializableEffectSource = z .strict() .refine( (v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) + (v.customEffect ? 1 : 0) === 1, - { message: 'Effect source must define exactly one of namedEffect, keyframeEffect, or customEffect' }, + { + message: + 'Effect source must define exactly one of namedEffect, keyframeEffect, or customEffect', + }, ); const EffectShape = EffectBase.extend({ @@ -155,7 +154,11 @@ export const SerializableEffect = EffectShape.superRefine((v, ctx) => { }); export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => { - if (v.namedEffect === undefined && v.keyframeEffect === undefined && v.customEffect === undefined) { + if ( + v.namedEffect === undefined && + v.keyframeEffect === undefined && + v.customEffect === undefined + ) { ctx.addIssue({ code: 'custom', message: diff --git a/packages/interact/src/validate/schema/index.ts b/packages/interact/src/validate/schema/index.ts index 3161467a..0012f4de 100644 --- a/packages/interact/src/validate/schema/index.ts +++ b/packages/interact/src/validate/schema/index.ts @@ -21,18 +21,9 @@ export { TIME_FIELDS, } from './effects'; -export { - SerializableSequenceConfig, - SerializableSequenceConfigRef, -} from './sequences'; +export { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; -export { - Keyframe, - LengthPercentage, - RangeOffset, - Condition, - MediaCondition, -} from './primitives'; +export { Keyframe, LengthPercentage, RangeOffset, Condition, MediaCondition } from './primitives'; // Canonical types — single source of truth, no z.infer<> re-derivation. // Names that collide with a zod schema value above are exported with a diff --git a/packages/interact/src/validate/schema/sequences.ts b/packages/interact/src/validate/schema/sequences.ts index 7ee24da0..00f4ada5 100644 --- a/packages/interact/src/validate/schema/sequences.ts +++ b/packages/interact/src/validate/schema/sequences.ts @@ -21,7 +21,10 @@ export const SerializableSequenceConfigRef = z delay: z.number().optional(), offset: z.number().optional(), offsetEasing: z - .union([z.string(), z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function')]) + .union([ + z.string(), + z.custom<(...args: unknown[]) => unknown>((v) => typeof v === 'function'), + ]) .optional(), triggerType: TriggerType.optional(), conditions: z.array(z.string()).optional(), From bf4c5822aeed7f9d02d9cf60688c2ccb3e9ec9b4 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 18:55:03 +0300 Subject: [PATCH 08/19] phases 2 + 3 --- ...tconfig_schema_validation_918ab0af.plan.md | 4 +- packages/interact/src/validate/context.ts | 215 ++++++------------ packages/interact/src/validate/errors.ts | 6 +- packages/interact/src/validate/index.ts | 56 ++++- .../rules/conditions/validMediaQueries.ts | 22 +- .../controls/rangeDefaultWithinBounds.ts | 32 --- .../controls/selectDefaultMatchesOption.ts | 23 -- .../rules/controls/selectMapCoverage.ts | 29 --- .../rules/controls/uniqueControlIds.ts | 23 -- .../rules/controls/validTransformTypes.ts | 17 -- .../rules/controls/variableUsageReferenced.ts | 16 -- packages/interact/src/validate/rules/index.ts | 27 --- .../referential/animationEndEffectExists.ts | 2 +- .../referential/bindingPropertyRequired.ts | 17 -- .../rules/referential/controlTargetsExist.ts | 59 ----- .../referential/effectKeyExistsInElements.ts | 16 -- .../rules/referential/interactionKeysExist.ts | 16 -- .../referential/styleBindingSelectorExists.ts | 18 -- .../variableBindingIsCustomProperty.ts | 16 -- packages/interact/src/validate/structural.ts | 11 +- 20 files changed, 145 insertions(+), 480 deletions(-) delete mode 100644 packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts delete mode 100644 packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts delete mode 100644 packages/interact/src/validate/rules/controls/selectMapCoverage.ts delete mode 100644 packages/interact/src/validate/rules/controls/uniqueControlIds.ts delete mode 100644 packages/interact/src/validate/rules/controls/validTransformTypes.ts delete mode 100644 packages/interact/src/validate/rules/controls/variableUsageReferenced.ts delete mode 100644 packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts delete mode 100644 packages/interact/src/validate/rules/referential/controlTargetsExist.ts delete mode 100644 packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts delete mode 100644 packages/interact/src/validate/rules/referential/interactionKeysExist.ts delete mode 100644 packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts delete mode 100644 packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index ed965c92..ee788120 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -10,10 +10,10 @@ todos: status: completed - id: phase2-harness-rename content: 'Phase 2: Rename ExperienceValidationError → InteractValidationError; retarget structural.ts to InteractConfigSchema (extend mapZodCode); rename public API to validateInteractConfig / assertValidInteractConfig; export RULES, Rule type, and zod sub-schemas' - status: pending + status: completed - id: phase3-context content: 'Phase 3: Rewrite validate/context.ts — drop elementKeys/controlIds/styleSelectors/etc.; walk InteractConfig directly; add isEffectRef/isSequenceRef predicates; collect trigger+effect tuples and definition maps for new rules' - status: pending + status: completed - id: phase4-rules-trim content: 'Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners' status: pending diff --git a/packages/interact/src/validate/context.ts b/packages/interact/src/validate/context.ts index 6ff3f0be..d961aba2 100644 --- a/packages/interact/src/validate/context.ts +++ b/packages/interact/src/validate/context.ts @@ -1,229 +1,158 @@ import type { - Control, - ControlBinding, - Experience, - ExperienceInteraction, - SerializableEffect, - SerializableSequenceConfig, -} from '../schema'; + InteractConfig, + SequenceConfig, + SequenceConfigRef, + Interaction, +} from '../types/config'; +import type { Effect, EffectRef } from '../types/effects'; export type Path = (string | number)[]; export type EffectIdRef = { path: Path; effectId: string }; export type SequenceIdRef = { path: Path; sequenceId: string }; -export type ElementKeyRef = { path: Path; key: string }; export type ConditionRef = { path: Path; conditionId: string }; -export type ControlBindingRef = { path: Path; binding: ControlBinding; controlId: string }; -export type VariableBindingRef = { path: Path; name: string; controlId: string }; -export type InteractionRef = { path: Path; interaction: ExperienceInteraction }; +export type InteractionRef = { path: Path; interaction: Interaction }; + +export type TriggerEffectTuple = { + trigger: string; + effect: Effect; + path: Path; +}; + +export type KeyframeNameRef = { + name: string; + path: Path; +}; export type ValidationContext = { - experience: Experience; + config: InteractConfig; - elementKeys: Set; effectIds: Set; sequenceIds: Set; conditionIds: Set; - controlIds: Set; - styleSelectors: Set; - interactionIds: Set; effectIdReferences: EffectIdRef[]; sequenceIdReferences: SequenceIdRef[]; - interactionKeyReferences: ElementKeyRef[]; - effectKeyReferences: ElementKeyRef[]; conditionReferences: ConditionRef[]; - controlBindingReferences: ControlBindingRef[]; - variableBindings: VariableBindingRef[]; interactions: InteractionRef[]; - controls: Control[]; - cssVarUsage: Set; + triggerEffectTuples: TriggerEffectTuple[]; + keyframeNames: KeyframeNameRef[]; }; -const VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g; +function isEffectRef(entry: Effect | EffectRef): entry is EffectRef { + return !('keyframeEffect' in entry) && !('namedEffect' in entry) && !('customEffect' in entry); +} + +function isSequenceRef(entry: SequenceConfig | SequenceConfigRef): entry is SequenceConfigRef { + return !('effects' in entry); +} -function collectVarUsage(value: string, out: Set): void { - for (const m of value.matchAll(VAR_RE)) out.add(m[1]!); +function collectKeyframeName(effect: Effect, basePath: Path, out: KeyframeNameRef[]): void { + const ke = (effect as Record)['keyframeEffect'] as { name: string } | undefined; + if (ke) { + out.push({ name: ke.name, path: [...basePath, 'keyframeEffect', 'name'] }); + } } function walkEffect( - effect: SerializableEffect, + effect: Effect, basePath: Path, - ctx: Pick, + ctx: Pick, ): void { - if (effect.key !== undefined) { - ctx.effectKeyReferences.push({ path: [...basePath, 'key'], key: effect.key }); - } - if (effect.conditions) { - effect.conditions.forEach((c, i) => - ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), - ); - } + effect.conditions?.forEach((c, i) => + ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), + ); + collectKeyframeName(effect, basePath, ctx.keyframeNames); } function walkSequence( - seq: SerializableSequenceConfig, + seq: SequenceConfig, basePath: Path, - ctx: Pick< - ValidationContext, - 'effectIdReferences' | 'conditionReferences' | 'effectKeyReferences' - >, + ctx: Pick, ): void { seq.effects.forEach((entry, i) => { const path = [...basePath, 'effects', i]; - if ( - 'effectId' in entry && - entry.effectId !== undefined && - !('namedEffect' in entry) && - !('keyframeEffect' in entry) - ) { - // Ref-only entry + if (isEffectRef(entry)) { ctx.effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId }); } else { - walkEffect(entry as SerializableEffect, path, ctx); + walkEffect(entry, path, ctx); } }); - if (seq.conditions) { - seq.conditions.forEach((c, i) => - ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), - ); - } + seq.conditions?.forEach((c, i) => + ctx.conditionReferences.push({ path: [...basePath, 'conditions', i], conditionId: c }), + ); } -export function buildContext(experience: Experience): ValidationContext { - const elementKeys = new Set(Object.keys(experience.elements)); - const effectIds = new Set(Object.keys(experience.interact.effects)); - const sequenceIds = new Set(Object.keys(experience.interact.sequences ?? {})); - const conditionIds = new Set(Object.keys(experience.interact.conditions ?? {})); - const controlIds = new Set(experience.controls.map((c) => c.id)); - const styleSelectors = new Set((experience.styles ?? []).map((s) => s.selector)); - const interactionIds = new Set( - experience.interact.interactions.map((i) => i.id).filter((id): id is string => Boolean(id)), - ); +export function buildContext(config: InteractConfig): ValidationContext { + const effectIds = new Set(Object.keys(config.effects ?? {})); + const sequenceIds = new Set(Object.keys(config.sequences ?? {})); + const conditionIds = new Set(Object.keys(config.conditions ?? {})); const effectIdReferences: EffectIdRef[] = []; const sequenceIdReferences: SequenceIdRef[] = []; - const interactionKeyReferences: ElementKeyRef[] = []; - const effectKeyReferences: ElementKeyRef[] = []; const conditionReferences: ConditionRef[] = []; - const controlBindingReferences: ControlBindingRef[] = []; - const variableBindings: VariableBindingRef[] = []; const interactions: InteractionRef[] = []; - const cssVarUsage = new Set(); + const triggerEffectTuples: TriggerEffectTuple[] = []; + const keyframeNames: KeyframeNameRef[] = []; - // Effects (top-level) - for (const [id, effect] of Object.entries(experience.interact.effects)) { - walkEffect(effect, ['interact', 'effects', id], { - effectKeyReferences, - conditionReferences, - }); + for (const [id, effect] of Object.entries(config.effects ?? {})) { + walkEffect(effect, ['effects', id], { conditionReferences, keyframeNames }); } - // Sequences - for (const [id, seq] of Object.entries(experience.interact.sequences ?? {})) { - walkSequence(seq, ['interact', 'sequences', id], { + for (const [id, seq] of Object.entries(config.sequences ?? {})) { + walkSequence(seq, ['sequences', id], { effectIdReferences, - effectKeyReferences, conditionReferences, + keyframeNames, }); } - // Interactions - experience.interact.interactions.forEach((interaction, i) => { - const base: Path = ['interact', 'interactions', i]; + config.interactions.forEach((interaction, i) => { + const base: Path = ['interactions', i]; interactions.push({ path: base, interaction }); - interactionKeyReferences.push({ path: [...base, 'key'], key: interaction.key }); - - if (interaction.conditions) { - interaction.conditions.forEach((c, ci) => - conditionReferences.push({ - path: [...base, 'conditions', ci], - conditionId: c, - }), - ); - } + interaction.conditions?.forEach((c, ci) => + conditionReferences.push({ path: [...base, 'conditions', ci], conditionId: c }), + ); if (interaction.trigger === 'animationEnd' && interaction.params) { effectIdReferences.push({ path: [...base, 'params', 'effectId'], - effectId: interaction.params.effectId, + effectId: (interaction.params as { effectId: string }).effectId, }); } interaction.effects?.forEach((entry, ei) => { const path: Path = [...base, 'effects', ei]; - if ( - 'effectId' in entry && - entry.effectId !== undefined && - !('namedEffect' in entry) && - !('keyframeEffect' in entry) - ) { + if (isEffectRef(entry)) { effectIdReferences.push({ path: [...path, 'effectId'], effectId: entry.effectId }); } else { - walkEffect(entry as SerializableEffect, path, { - effectKeyReferences, - conditionReferences, - }); + walkEffect(entry, path, { conditionReferences, keyframeNames }); + triggerEffectTuples.push({ trigger: interaction.trigger, effect: entry, path }); } }); interaction.sequences?.forEach((entry, si) => { const path: Path = [...base, 'sequences', si]; - if ('sequenceId' in entry && !('effects' in entry)) { - sequenceIdReferences.push({ - path: [...path, 'sequenceId'], - sequenceId: entry.sequenceId, - }); + if (isSequenceRef(entry)) { + sequenceIdReferences.push({ path: [...path, 'sequenceId'], sequenceId: entry.sequenceId }); } else { - walkSequence(entry as SerializableSequenceConfig, path, { - effectIdReferences, - effectKeyReferences, - conditionReferences, - }); + walkSequence(entry, path, { effectIdReferences, conditionReferences, keyframeNames }); } }); }); - // Controls - experience.controls.forEach((control, ci) => { - control.bindings.forEach((binding, bi) => { - const path: Path = ['controls', ci, 'bindings', bi]; - controlBindingReferences.push({ path, binding, controlId: control.id }); - if (binding.target === 'variable') { - variableBindings.push({ path, name: binding.targetId, controlId: control.id }); - } - }); - }); - - // CSS var() usage in element styles + top-level styles - for (const el of Object.values(experience.elements)) { - if (!el.styles) continue; - for (const v of Object.values(el.styles)) collectVarUsage(v, cssVarUsage); - } - for (const rule of experience.styles ?? []) { - for (const v of Object.values(rule.properties)) collectVarUsage(v, cssVarUsage); - } - return { - experience, - elementKeys, + config, effectIds, sequenceIds, conditionIds, - controlIds, - styleSelectors, - interactionIds, effectIdReferences, sequenceIdReferences, - interactionKeyReferences, - effectKeyReferences, conditionReferences, - controlBindingReferences, - variableBindings, interactions, - controls: experience.controls, - cssVarUsage, + triggerEffectTuples, + keyframeNames, }; } diff --git a/packages/interact/src/validate/errors.ts b/packages/interact/src/validate/errors.ts index e3b14bd4..86553965 100644 --- a/packages/interact/src/validate/errors.ts +++ b/packages/interact/src/validate/errors.ts @@ -13,12 +13,12 @@ export type ValidationResult = { errors: ValidationError[]; }; -export class ExperienceValidationError extends Error { +export class InteractValidationError extends Error { readonly errors: ValidationError[]; constructor(errors: ValidationError[]) { - super(`Experience validation failed with ${errors.length} issue(s).`); - this.name = 'ExperienceValidationError'; + super(`Interact config validation failed with ${errors.length} issue(s).`); + this.name = 'InteractValidationError'; this.errors = errors; } } diff --git a/packages/interact/src/validate/index.ts b/packages/interact/src/validate/index.ts index efd98ce6..a05b08dd 100644 --- a/packages/interact/src/validate/index.ts +++ b/packages/interact/src/validate/index.ts @@ -1,7 +1,7 @@ -import type { Experience } from '../schema'; +import type { InteractConfig } from '../types/config'; import { buildContext } from './context'; import { - ExperienceValidationError, + InteractValidationError, type Severity, type ValidationError, type ValidationResult, @@ -9,9 +9,49 @@ import { import { validateSemantic } from './semantic'; import { validateStructural } from './structural'; -export { ExperienceValidationError }; +export { InteractValidationError }; export type { Severity, ValidationError, ValidationResult } from './errors'; +export { RULES, type Rule } from './rules'; + +// Zod schemas and sub-schemas for host-project schema composition +export { + InteractConfigSchema, + Interaction, + TriggerType, + ViewEnterParams, + PointerMoveParams, + AnimationEndParams, + TriggerParams, + SerializableEffect, + SerializableEffectRef, + SerializableEffectSource, + SerializableTimeEffect, + EffectBase, + NamedEffect, + SCRUB_FIELDS, + STATE_FIELDS, + TIME_FIELDS, + SerializableSequenceConfig, + SerializableSequenceConfigRef, + Keyframe, + LengthPercentage, + RangeOffset, + Condition, + MediaCondition, +} from './schema'; +export type { + InteractConfig, + ConditionDef, + SequenceOptionsConfig, + SequenceConfig, + SequenceConfigRef, + InteractionDef, + InteractionTrigger, + Effect, + EffectRef, +} from './schema'; + export type ValidateOptions = { strict?: boolean; severityOverrides?: Record; @@ -43,7 +83,7 @@ function finalize(errors: ValidationError[], opts: ValidateOptions): ValidationR return { valid, errors: next }; } -export function validateExperience( +export function validateInteractConfig( input: unknown, options: ValidateOptions = {}, ): ValidationResult { @@ -56,12 +96,12 @@ export function validateExperience( return finalize(layer2, options); } -export function assertValidExperience( +export function assertValidInteractConfig( input: unknown, options: ValidateOptions = {}, -): asserts input is Experience { - const result = validateExperience(input, options); +): asserts input is InteractConfig { + const result = validateInteractConfig(input, options); if (!result.valid) { - throw new ExperienceValidationError(result.errors); + throw new InteractValidationError(result.errors); } } diff --git a/packages/interact/src/validate/rules/conditions/validMediaQueries.ts b/packages/interact/src/validate/rules/conditions/validMediaQueries.ts index ce7f3264..54c16c3b 100644 --- a/packages/interact/src/validate/rules/conditions/validMediaQueries.ts +++ b/packages/interact/src/validate/rules/conditions/validMediaQueries.ts @@ -30,19 +30,21 @@ function isValidMediaQuery(query: string): boolean { export const validMediaQueries: Rule = { code: 'INVALID_MEDIA_QUERY', - defaultSeverity: 'error', + defaultSeverity: 'warning', run: (ctx) => { const errors: ValidationError[] = []; - (ctx.experience.disableWhen ?? []).forEach((condition, i) => { - if (!isValidMediaQuery(condition.mediaQuery)) { - errors.push({ - code: 'INVALID_MEDIA_QUERY' as const, - severity: 'error' as const, - path: ['disableWhen', i, 'mediaQuery'], - message: `Invalid media query: ${JSON.stringify(condition.mediaQuery)}.`, - }); + for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { + if (condition.type === 'media' && condition.predicate !== undefined) { + if (!isValidMediaQuery(condition.predicate)) { + errors.push({ + code: 'INVALID_MEDIA_QUERY', + severity: 'warning', + path: ['conditions', id, 'predicate'], + message: `Invalid media query: ${JSON.stringify(condition.predicate)}.`, + }); + } } - }); + } return errors; }, }; diff --git a/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts b/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts deleted file mode 100644 index e886a90c..00000000 --- a/packages/interact/src/validate/rules/controls/rangeDefaultWithinBounds.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const rangeDefaultWithinBounds: Rule = { - code: 'RANGE_DEFAULT_OUT_OF_BOUNDS', - defaultSeverity: 'error', - run: (ctx) => { - const errors: ValidationError[] = []; - ctx.controls.forEach((control, ci) => { - if (control.type !== 'range') return; - const { min, max } = control.constraints ?? {}; - const value = control.defaultValue; - if (typeof value !== 'number') return; - if (min !== undefined && value < min) { - errors.push({ - code: 'RANGE_DEFAULT_OUT_OF_BOUNDS' as const, - severity: 'error' as const, - path: ['controls', ci, 'defaultValue'], - message: `Range control "${control.id}" defaultValue ${value} is below min ${min}.`, - }); - } else if (max !== undefined && value > max) { - errors.push({ - code: 'RANGE_DEFAULT_OUT_OF_BOUNDS' as const, - severity: 'error' as const, - path: ['controls', ci, 'defaultValue'], - message: `Range control "${control.id}" defaultValue ${value} is above max ${max}.`, - }); - } - }); - return errors; - }, -}; diff --git a/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts b/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts deleted file mode 100644 index 8941ba98..00000000 --- a/packages/interact/src/validate/rules/controls/selectDefaultMatchesOption.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const selectDefaultMatchesOption: Rule = { - code: 'SELECT_DEFAULT_NOT_IN_OPTIONS', - defaultSeverity: 'error', - run: (ctx) => { - const errors: ValidationError[] = []; - ctx.controls.forEach((control, ci) => { - if (control.type !== 'select') return; - const options = control.constraints?.options ?? []; - if (!options.some((o) => o.value === control.defaultValue)) { - errors.push({ - code: 'SELECT_DEFAULT_NOT_IN_OPTIONS' as const, - severity: 'error' as const, - path: ['controls', ci, 'defaultValue'], - message: `Select control "${control.id}" defaultValue ${JSON.stringify(control.defaultValue)} does not match any option.`, - }); - } - }); - return errors; - }, -}; diff --git a/packages/interact/src/validate/rules/controls/selectMapCoverage.ts b/packages/interact/src/validate/rules/controls/selectMapCoverage.ts deleted file mode 100644 index 09289e4c..00000000 --- a/packages/interact/src/validate/rules/controls/selectMapCoverage.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const selectMapCoverage: Rule = { - code: 'MAP_MISSING_OPTION_ENTRY', - defaultSeverity: 'error', - run: (ctx) => { - const errors: ValidationError[] = []; - ctx.controls.forEach((control, ci) => { - if (control.type !== 'select') return; - const options = control.constraints?.options ?? []; - control.bindings.forEach((binding, bi) => { - if (binding.transform?.type !== 'map') return; - const entries = binding.transform.entries; - for (const option of options) { - if (!(String(option.value) in entries)) { - errors.push({ - code: 'MAP_MISSING_OPTION_ENTRY' as const, - severity: 'error' as const, - path: ['controls', ci, 'bindings', bi, 'transform', 'entries'], - message: `Map transform for control "${control.id}" is missing an entry for option ${JSON.stringify(option.value)}.`, - }); - } - } - }); - }); - return errors; - }, -}; diff --git a/packages/interact/src/validate/rules/controls/uniqueControlIds.ts b/packages/interact/src/validate/rules/controls/uniqueControlIds.ts deleted file mode 100644 index c87b0d24..00000000 --- a/packages/interact/src/validate/rules/controls/uniqueControlIds.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const uniqueControlIds: Rule = { - code: 'DUPLICATE_CONTROL_ID', - defaultSeverity: 'error', - run: (ctx) => { - const seen = new Set(); - const errors: ValidationError[] = []; - ctx.controls.forEach((control, ci) => { - if (seen.has(control.id)) { - errors.push({ - code: 'DUPLICATE_CONTROL_ID' as const, - severity: 'error' as const, - path: ['controls', ci, 'id'], - message: `Duplicate control id "${control.id}".`, - }); - } - seen.add(control.id); - }); - return errors; - }, -}; diff --git a/packages/interact/src/validate/rules/controls/validTransformTypes.ts b/packages/interact/src/validate/rules/controls/validTransformTypes.ts deleted file mode 100644 index 2d08bffb..00000000 --- a/packages/interact/src/validate/rules/controls/validTransformTypes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Rule } from '..'; - -const VALID = new Set(['direct', 'linear', 'inverse', 'map', 'template']); - -export const validTransformTypes: Rule = { - code: 'INVALID_TRANSFORM_TYPE', - defaultSeverity: 'error', - run: (ctx) => - ctx.controlBindingReferences - .filter(({ binding }) => binding.transform && !VALID.has(binding.transform.type)) - .map(({ path, binding }) => ({ - code: 'INVALID_TRANSFORM_TYPE', - severity: 'error' as const, - path: [...path, 'transform', 'type'], - message: `Invalid transform type "${binding.transform!.type}".`, - })), -}; diff --git a/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts b/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts deleted file mode 100644 index 9637b429..00000000 --- a/packages/interact/src/validate/rules/controls/variableUsageReferenced.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Rule } from '..'; - -export const variableUsageReferenced: Rule = { - code: 'VARIABLE_UNUSED', - defaultSeverity: 'warning', - run: (ctx) => - ctx.variableBindings - .filter(({ name }) => name.startsWith('--') && !ctx.cssVarUsage.has(name)) - .map(({ path, name, controlId }) => ({ - code: 'VARIABLE_UNUSED', - severity: 'warning' as const, - path, - message: `Variable "${name}" written by control "${controlId}" is not referenced via var() in any style.`, - hint: `Reference ${name} from elements[*].styles or styles[*].properties, or remove the binding.`, - })), -}; diff --git a/packages/interact/src/validate/rules/index.ts b/packages/interact/src/validate/rules/index.ts index 814f9b21..c1536318 100644 --- a/packages/interact/src/validate/rules/index.ts +++ b/packages/interact/src/validate/rules/index.ts @@ -1,24 +1,11 @@ import type { Severity, ValidationError } from '../errors'; import type { ValidationContext } from '../context'; -import { interactionKeysExist } from './referential/interactionKeysExist'; -import { effectKeyExistsInElements } from './referential/effectKeyExistsInElements'; import { effectIdsExist } from './referential/effectIdsExist'; import { sequenceIdsExist } from './referential/sequenceIdsExist'; import { animationEndEffectExists } from './referential/animationEndEffectExists'; import { conditionsExist } from './referential/conditionsExist'; import { interactionHasEffectsOrSequences } from './referential/interactionHasEffectsOrSequences'; -import { controlTargetsExist } from './referential/controlTargetsExist'; -import { styleBindingSelectorExists } from './referential/styleBindingSelectorExists'; -import { variableBindingIsCustomProperty } from './referential/variableBindingIsCustomProperty'; -import { bindingPropertyRequired } from './referential/bindingPropertyRequired'; - -import { rangeDefaultWithinBounds } from './controls/rangeDefaultWithinBounds'; -import { selectDefaultMatchesOption } from './controls/selectDefaultMatchesOption'; -import { uniqueControlIds } from './controls/uniqueControlIds'; -import { validTransformTypes } from './controls/validTransformTypes'; -import { selectMapCoverage } from './controls/selectMapCoverage'; -import { variableUsageReferenced } from './controls/variableUsageReferenced'; import { validMediaQueries } from './conditions/validMediaQueries'; @@ -29,24 +16,10 @@ export type Rule = { }; export const RULES: Rule[] = [ - interactionKeysExist, - effectKeyExistsInElements, effectIdsExist, sequenceIdsExist, animationEndEffectExists, conditionsExist, interactionHasEffectsOrSequences, - controlTargetsExist, - styleBindingSelectorExists, - variableBindingIsCustomProperty, - bindingPropertyRequired, - - rangeDefaultWithinBounds, - selectDefaultMatchesOption, - uniqueControlIds, - validTransformTypes, - selectMapCoverage, - variableUsageReferenced, - validMediaQueries, ]; diff --git a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts index 9b8c5798..18e9edeb 100644 --- a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts +++ b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts @@ -8,7 +8,7 @@ export const animationEndEffectExists: Rule = { const errors: ValidationError[] = []; for (const { path, interaction } of ctx.interactions) { if (interaction.trigger !== 'animationEnd' || !interaction.params) continue; - const effectId = interaction.params.effectId; + const effectId = (interaction.params as { effectId: string }).effectId; if (!ctx.effectIds.has(effectId)) { errors.push({ code: 'ANIMATION_END_EFFECT_NOT_FOUND', diff --git a/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts b/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts deleted file mode 100644 index eadb6ef9..00000000 --- a/packages/interact/src/validate/rules/referential/bindingPropertyRequired.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Rule } from '..'; - -const REQUIRES_PROPERTY = new Set(['effect', 'sequence', 'style', 'element', 'interaction']); - -export const bindingPropertyRequired: Rule = { - code: 'BINDING_PROPERTY_REQUIRED', - defaultSeverity: 'error', - run: (ctx) => - ctx.controlBindingReferences - .filter(({ binding }) => REQUIRES_PROPERTY.has(binding.target) && !binding.property) - .map(({ path, binding }) => ({ - code: 'BINDING_PROPERTY_REQUIRED', - severity: 'error' as const, - path, - message: `Binding to ${binding.target} "${binding.targetId}" requires a "property".`, - })), -}; diff --git a/packages/interact/src/validate/rules/referential/controlTargetsExist.ts b/packages/interact/src/validate/rules/referential/controlTargetsExist.ts deleted file mode 100644 index 24900219..00000000 --- a/packages/interact/src/validate/rules/referential/controlTargetsExist.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const controlTargetsExist: Rule = { - code: 'CONTROL_TARGET_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => { - const errors: ValidationError[] = []; - for (const { path, binding } of ctx.controlBindingReferences) { - switch (binding.target) { - case 'element': - if (!ctx.elementKeys.has(binding.targetId)) { - errors.push({ - code: 'CONTROL_TARGET_NOT_FOUND' as const, - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Element "${binding.targetId}" referenced by control binding is not defined.`, - }); - } - break; - case 'effect': - if (!ctx.effectIds.has(binding.targetId)) { - errors.push({ - code: 'CONTROL_TARGET_NOT_FOUND' as const, - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Effect "${binding.targetId}" referenced by control binding is not defined.`, - }); - } - break; - case 'sequence': - if (!ctx.sequenceIds.has(binding.targetId)) { - errors.push({ - code: 'CONTROL_TARGET_NOT_FOUND' as const, - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Sequence "${binding.targetId}" referenced by control binding is not defined.`, - }); - } - break; - case 'interaction': - if (!ctx.interactionIds.has(binding.targetId)) { - errors.push({ - code: 'CONTROL_TARGET_NOT_FOUND' as const, - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Interaction "${binding.targetId}" referenced by control binding is not defined.`, - }); - } - break; - case 'style': - case 'variable': - // Validated in dedicated rules below. - break; - } - } - return errors; - }, -}; diff --git a/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts b/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts deleted file mode 100644 index 3f17738b..00000000 --- a/packages/interact/src/validate/rules/referential/effectKeyExistsInElements.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Rule } from '..'; - -export const effectKeyExistsInElements: Rule = { - code: 'EFFECT_KEY_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.effectKeyReferences - .filter((ref) => !ctx.elementKeys.has(ref.key)) - .map((ref) => ({ - code: 'EFFECT_KEY_NOT_FOUND', - severity: 'error', - path: ref.path, - message: `Effect override key "${ref.key}" is not defined in elements.`, - hint: `Add "${ref.key}" to elements or remove the override.`, - })), -}; diff --git a/packages/interact/src/validate/rules/referential/interactionKeysExist.ts b/packages/interact/src/validate/rules/referential/interactionKeysExist.ts deleted file mode 100644 index c7978160..00000000 --- a/packages/interact/src/validate/rules/referential/interactionKeysExist.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Rule } from '..'; - -export const interactionKeysExist: Rule = { - code: 'INTERACTION_KEY_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.interactionKeyReferences - .filter((ref) => !ctx.elementKeys.has(ref.key)) - .map((ref) => ({ - code: 'INTERACTION_KEY_NOT_FOUND', - severity: 'error', - path: ref.path, - message: `Interaction targets element key "${ref.key}" which is not defined in elements.`, - hint: `Add "${ref.key}" to elements or fix the interaction key.`, - })), -}; diff --git a/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts b/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts deleted file mode 100644 index 8ef3f4b5..00000000 --- a/packages/interact/src/validate/rules/referential/styleBindingSelectorExists.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Rule } from '..'; - -export const styleBindingSelectorExists: Rule = { - code: 'STYLE_BINDING_SELECTOR_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.controlBindingReferences - .filter( - ({ binding }) => binding.target === 'style' && !ctx.styleSelectors.has(binding.targetId), - ) - .map(({ path, binding }) => ({ - code: 'STYLE_BINDING_SELECTOR_NOT_FOUND', - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Style binding selector "${binding.targetId}" does not match any styles[].selector.`, - hint: 'Add a matching entry to styles or fix the binding targetId.', - })), -}; diff --git a/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts b/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts deleted file mode 100644 index 399f058e..00000000 --- a/packages/interact/src/validate/rules/referential/variableBindingIsCustomProperty.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Rule } from '..'; - -export const variableBindingIsCustomProperty: Rule = { - code: 'VARIABLE_BINDING_INVALID_NAME', - defaultSeverity: 'error', - run: (ctx) => - ctx.variableBindings - .filter(({ name }) => !name.startsWith('--')) - .map(({ path, name }) => ({ - code: 'VARIABLE_BINDING_INVALID_NAME', - severity: 'error' as const, - path: [...path, 'targetId'], - message: `Variable binding "${name}" is not a valid CSS custom property name.`, - hint: 'CSS custom properties must start with "--".', - })), -}; diff --git a/packages/interact/src/validate/structural.ts b/packages/interact/src/validate/structural.ts index 2e29d144..0202c6a4 100644 --- a/packages/interact/src/validate/structural.ts +++ b/packages/interact/src/validate/structural.ts @@ -1,5 +1,6 @@ import type { ZodIssue } from 'zod'; -import { ExperienceSchema, type Experience } from '../schema'; +import { InteractConfigSchema } from './schema'; +import type { InteractConfig } from '../types/config'; import type { ValidationError } from './errors'; function mapZodCode(issue: ZodIssue): string { @@ -12,6 +13,8 @@ function mapZodCode(issue: ZodIssue): string { return 'SCHEMA_INVALID_UNION'; case 'invalid_value': return 'SCHEMA_INVALID_LITERAL'; + case 'too_small': + return 'SCHEMA_TOO_SMALL'; default: return 'SCHEMA_INVALID'; } @@ -19,12 +22,12 @@ function mapZodCode(issue: ZodIssue): string { export function validateStructural(input: unknown): { ok: boolean; - parsed?: Experience; + parsed?: InteractConfig; errors: ValidationError[]; } { - const result = ExperienceSchema.safeParse(input); + const result = InteractConfigSchema.safeParse(input); if (result.success) { - return { ok: true, parsed: result.data, errors: [] }; + return { ok: true, parsed: result.data as unknown as InteractConfig, errors: [] }; } const errors: ValidationError[] = result.error.issues.map((issue) => ({ code: mapZodCode(issue), From 6cc58369d9bf111b8fad0b384f258b6346932549 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 23:18:05 +0300 Subject: [PATCH 09/19] phases 4 --- ...tconfig_schema_validation_918ab0af.plan.md | 4 +-- packages/interact/src/validate/rules/index.ts | 14 ++++++++ .../referential/animationEndEffectExists.ts | 33 +++++++------------ .../rules/referential/conditionsExist.ts | 23 +++++-------- .../rules/referential/effectIdsExist.ts | 22 +++++-------- .../rules/referential/sequenceIdsExist.ts | 23 +++++-------- 6 files changed, 53 insertions(+), 66 deletions(-) diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index ee788120..47d34e65 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -16,10 +16,10 @@ todos: status: completed - id: phase4-rules-trim content: 'Phase 4a: Delete controls/* and 6 out-of-scope referential rules; add rules/_factory.ts with referenceRule() helper; rewrite 4 ID-existence rules as one-liners' - status: pending + status: completed - id: phase4-rules-add content: 'Phase 4b: Add 5 new semantic rules — triggerEffectCompatible (warning), numericBounds, conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions (warnings); update rules/index.ts RULES array' - status: pending + status: completed - id: phase5-tests content: 'Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)' status: pending diff --git a/packages/interact/src/validate/rules/index.ts b/packages/interact/src/validate/rules/index.ts index c1536318..41ace641 100644 --- a/packages/interact/src/validate/rules/index.ts +++ b/packages/interact/src/validate/rules/index.ts @@ -9,6 +9,12 @@ import { interactionHasEffectsOrSequences } from './referential/interactionHasEf import { validMediaQueries } from './conditions/validMediaQueries'; +import { triggerEffectCompatible } from './semantic/triggerEffectCompatible'; +import { numericBounds } from './semantic/numericBounds'; +import { conditionPredicateRequired } from './semantic/conditionPredicateRequired'; +import { uniqueDefinitionIds } from './semantic/uniqueDefinitionIds'; +import { unusedDefinitions } from './semantic/unusedDefinitions'; + export type Rule = { code: string; defaultSeverity: Severity; @@ -16,10 +22,18 @@ export type Rule = { }; export const RULES: Rule[] = [ + // Referential rules (errors) effectIdsExist, sequenceIdsExist, animationEndEffectExists, conditionsExist, interactionHasEffectsOrSequences, + // Condition rules (warnings) validMediaQueries, + // Semantic rules + triggerEffectCompatible, + numericBounds, + conditionPredicateRequired, + uniqueDefinitionIds, + unusedDefinitions, ]; diff --git a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts index 18e9edeb..f9d25e52 100644 --- a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts +++ b/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts @@ -1,24 +1,13 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; +import { referenceRule } from '../_factory'; -export const animationEndEffectExists: Rule = { +// effectIdReferences entries whose path contains 'params' come exclusively from +// animationEnd interactions (context.ts adds them at [...base, 'params', 'effectId']). +export const animationEndEffectExists = referenceRule({ code: 'ANIMATION_END_EFFECT_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => { - const errors: ValidationError[] = []; - for (const { path, interaction } of ctx.interactions) { - if (interaction.trigger !== 'animationEnd' || !interaction.params) continue; - const effectId = (interaction.params as { effectId: string }).effectId; - if (!ctx.effectIds.has(effectId)) { - errors.push({ - code: 'ANIMATION_END_EFFECT_NOT_FOUND', - severity: 'error' as const, - path: [...path, 'params', 'effectId'], - message: `animationEnd interaction references effect "${effectId}" which is not defined.`, - hint: 'Define the effect in interact.effects or fix the params.effectId.', - }); - } - } - return errors; - }, -}; + severity: 'error', + refs: (ctx) => ctx.effectIdReferences.filter((ref) => ref.path.includes('params')), + has: (ctx, ref) => ctx.effectIds.has(ref.effectId), + message: (ref) => + `animationEnd interaction references effect "${ref.effectId}" which is not defined.`, + hint: 'Define the effect in interact.effects or fix the params.effectId.', +}); diff --git a/packages/interact/src/validate/rules/referential/conditionsExist.ts b/packages/interact/src/validate/rules/referential/conditionsExist.ts index 268be269..b40f9e00 100644 --- a/packages/interact/src/validate/rules/referential/conditionsExist.ts +++ b/packages/interact/src/validate/rules/referential/conditionsExist.ts @@ -1,16 +1,11 @@ -import type { Rule } from '..'; +import { referenceRule } from '../_factory'; -export const conditionsExist: Rule = { +export const conditionsExist = referenceRule({ code: 'CONDITION_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.conditionReferences - .filter((ref) => !ctx.conditionIds.has(ref.conditionId)) - .map((ref) => ({ - code: 'CONDITION_NOT_FOUND', - severity: 'error', - path: ref.path, - message: `Condition "${ref.conditionId}" is referenced but not defined in interact.conditions.`, - hint: 'Add an entry to interact.conditions or remove the reference.', - })), -}; + severity: 'error', + refs: (ctx) => ctx.conditionReferences, + has: (ctx, ref) => ctx.conditionIds.has(ref.conditionId), + message: (ref) => + `Condition "${ref.conditionId}" is referenced but not defined in interact.conditions.`, + hint: 'Add an entry to interact.conditions or remove the reference.', +}); diff --git a/packages/interact/src/validate/rules/referential/effectIdsExist.ts b/packages/interact/src/validate/rules/referential/effectIdsExist.ts index 263f5502..66065e9c 100644 --- a/packages/interact/src/validate/rules/referential/effectIdsExist.ts +++ b/packages/interact/src/validate/rules/referential/effectIdsExist.ts @@ -1,16 +1,10 @@ -import type { Rule } from '..'; +import { referenceRule } from '../_factory'; -export const effectIdsExist: Rule = { +export const effectIdsExist = referenceRule({ code: 'EFFECT_ID_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.effectIdReferences - .filter((ref) => !ctx.effectIds.has(ref.effectId)) - .map((ref) => ({ - code: 'EFFECT_ID_NOT_FOUND', - severity: 'error', - path: ref.path, - message: `Effect "${ref.effectId}" is referenced but not defined in interact.effects.`, - hint: 'Add an entry to interact.effects or fix the reference.', - })), -}; + severity: 'error', + refs: (ctx) => ctx.effectIdReferences.filter((ref) => !ref.path.includes('params')), + has: (ctx, ref) => ctx.effectIds.has(ref.effectId), + message: (ref) => `Effect "${ref.effectId}" is referenced but not defined in interact.effects.`, + hint: 'Add an entry to interact.effects or fix the reference.', +}); diff --git a/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts b/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts index d98c23ea..69b88bb2 100644 --- a/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts +++ b/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts @@ -1,16 +1,11 @@ -import type { Rule } from '..'; +import { referenceRule } from '../_factory'; -export const sequenceIdsExist: Rule = { +export const sequenceIdsExist = referenceRule({ code: 'SEQUENCE_ID_NOT_FOUND', - defaultSeverity: 'error', - run: (ctx) => - ctx.sequenceIdReferences - .filter((ref) => !ctx.sequenceIds.has(ref.sequenceId)) - .map((ref) => ({ - code: 'SEQUENCE_ID_NOT_FOUND', - severity: 'error', - path: ref.path, - message: `Sequence "${ref.sequenceId}" is referenced but not defined in interact.sequences.`, - hint: 'Add an entry to interact.sequences or fix the reference.', - })), -}; + severity: 'error', + refs: (ctx) => ctx.sequenceIdReferences, + has: (ctx, ref) => ctx.sequenceIds.has(ref.sequenceId), + message: (ref) => + `Sequence "${ref.sequenceId}" is referenced but not defined in interact.sequences.`, + hint: 'Add an entry to interact.sequences or fix the reference.', +}); From 8cbe19fee77b530227a088c43af8e9cd136401df Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 1 Jun 2026 23:18:43 +0300 Subject: [PATCH 10/19] phases 4 --- .../interact/src/validate/rules/_factory.ts | 27 ++++ .../semantic/conditionPredicateRequired.ts | 24 ++++ .../validate/rules/semantic/numericBounds.ts | 120 ++++++++++++++++++ .../rules/semantic/triggerEffectCompatible.ts | 57 +++++++++ .../rules/semantic/uniqueDefinitionIds.ts | 30 +++++ .../rules/semantic/unusedDefinitions.ts | 54 ++++++++ 6 files changed, 312 insertions(+) create mode 100644 packages/interact/src/validate/rules/_factory.ts create mode 100644 packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts create mode 100644 packages/interact/src/validate/rules/semantic/numericBounds.ts create mode 100644 packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts create mode 100644 packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts create mode 100644 packages/interact/src/validate/rules/semantic/unusedDefinitions.ts diff --git a/packages/interact/src/validate/rules/_factory.ts b/packages/interact/src/validate/rules/_factory.ts new file mode 100644 index 00000000..6b2d922f --- /dev/null +++ b/packages/interact/src/validate/rules/_factory.ts @@ -0,0 +1,27 @@ +import type { Path, ValidationContext } from '../context'; +import type { Severity, ValidationError } from '../errors'; + +export function referenceRule(opts: { + code: string; + severity: Severity; + refs: (ctx: ValidationContext) => T[]; + has: (ctx: ValidationContext, ref: T) => boolean; + message: (ref: T) => string; + hint?: string; +}) { + return { + code: opts.code, + defaultSeverity: opts.severity, + run: (ctx: ValidationContext): ValidationError[] => + opts + .refs(ctx) + .filter((ref) => !opts.has(ctx, ref)) + .map((ref) => ({ + code: opts.code, + severity: opts.severity, + path: ref.path, + message: opts.message(ref), + ...(opts.hint !== undefined ? { hint: opts.hint } : {}), + })), + }; +} diff --git a/packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts b/packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts new file mode 100644 index 00000000..4ee7a299 --- /dev/null +++ b/packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts @@ -0,0 +1,24 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// The schema marks `predicate` as optional for simplicity, but `media` and +// `container` conditions require it to be meaningful at runtime. +export const conditionPredicateRequired: Rule = { + code: 'CONDITION_PREDICATE_REQUIRED', + defaultSeverity: 'error', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { + if ((condition.type === 'media' || condition.type === 'container') && !condition.predicate) { + errors.push({ + code: 'CONDITION_PREDICATE_REQUIRED', + severity: 'error', + path: ['conditions', id, 'predicate'], + message: `Condition "${id}" of type "${condition.type}" requires a "predicate".`, + hint: `Add a ${condition.type === 'media' ? 'CSS media query' : 'container query'} as the predicate.`, + }); + } + } + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/semantic/numericBounds.ts b/packages/interact/src/validate/rules/semantic/numericBounds.ts new file mode 100644 index 00000000..3e4450dd --- /dev/null +++ b/packages/interact/src/validate/rules/semantic/numericBounds.ts @@ -0,0 +1,120 @@ +import type { Rule } from '..'; +import type { Path } from '../../context'; +import type { ValidationError } from '../../errors'; + +function checkNonNegative( + value: number | undefined, + field: string, + code: string, + basePath: Path, + errors: ValidationError[], +): void { + if (value !== undefined && value < 0) { + errors.push({ + code, + severity: 'error', + path: [...basePath, field], + message: `"${field}" must be ≥ 0, got ${value}.`, + }); + } +} + +function checkEffectRecord( + entry: Record, + path: Path, + errors: ValidationError[], +): void { + checkNonNegative( + entry['duration'] as number | undefined, + 'duration', + 'NEGATIVE_DURATION', + path, + errors, + ); + checkNonNegative(entry['delay'] as number | undefined, 'delay', 'NEGATIVE_DELAY', path, errors); + checkNonNegative( + entry['iterations'] as number | undefined, + 'iterations', + 'NEGATIVE_ITERATIONS', + path, + errors, + ); +} + +function checkSequenceRecord( + entry: Record, + path: Path, + errors: ValidationError[], +): void { + checkNonNegative( + entry['offset'] as number | undefined, + 'offset', + 'NEGATIVE_OFFSET', + path, + errors, + ); + checkNonNegative(entry['delay'] as number | undefined, 'delay', 'NEGATIVE_DELAY', path, errors); + if (Array.isArray(entry['effects'])) { + (entry['effects'] as Record[]).forEach((e, i) => + checkEffectRecord(e, [...path, 'effects', i], errors), + ); + } +} + +export const numericBounds: Rule = { + code: 'NUMERIC_BOUNDS', + defaultSeverity: 'error', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + // Top-level effect definitions + for (const [id, effect] of Object.entries(ctx.config.effects ?? {})) { + checkEffectRecord(effect as unknown as Record, ['effects', id], errors); + } + + // Top-level sequence definitions + for (const [id, seq] of Object.entries(ctx.config.sequences ?? {})) { + checkSequenceRecord(seq as unknown as Record, ['sequences', id], errors); + } + + // Per-interaction checks + ctx.config.interactions.forEach((interaction, i) => { + const base: Path = ['interactions', i]; + const inter = interaction as unknown as Record; + + // viewEnter / pageVisible threshold ∈ [0, 1] + if ( + (interaction.trigger === 'viewEnter' || interaction.trigger === 'pageVisible') && + inter['params'] + ) { + const threshold = (inter['params'] as Record)['threshold'] as + | number + | undefined; + if (threshold !== undefined && (threshold < 0 || threshold > 1)) { + errors.push({ + code: 'THRESHOLD_OUT_OF_RANGE', + severity: 'error', + path: [...base, 'params', 'threshold'], + message: `"threshold" must be between 0 and 1, got ${threshold}.`, + }); + } + } + + // Inline effects + if (Array.isArray(inter['effects'])) { + (inter['effects'] as Record[]).forEach((e, ei) => + checkEffectRecord(e, [...base, 'effects', ei], errors), + ); + } + + // Inline sequences (and their effects) + if (Array.isArray(inter['sequences'])) { + (inter['sequences'] as Record[]).forEach((s, si) => + checkSequenceRecord(s, [...base, 'sequences', si], errors), + ); + } + }); + + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts b/packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts new file mode 100644 index 00000000..6d3c1628 --- /dev/null +++ b/packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts @@ -0,0 +1,57 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; +import { SCRUB_FIELDS, STATE_FIELDS, TIME_FIELDS } from '../../schema/effects'; + +// viewProgress and pointerMove are scrub triggers; all others are discrete (time-based). +const SCRUB_TRIGGERS = new Set(['viewProgress', 'pointerMove']); + +export const triggerEffectCompatible: Rule = { + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + for (const { trigger, effect, path } of ctx.triggerEffectTuples) { + const e = effect as Record; + const isScrub = SCRUB_TRIGGERS.has(trigger); + + if (isScrub) { + for (const field of TIME_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a time-effect field and is incompatible with the "${trigger}" scrub trigger.`, + hint: 'Use scrub fields (rangeStart, rangeEnd, etc.) for viewProgress and pointerMove triggers.', + }); + } + } + for (const field of STATE_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a state-effect field and is incompatible with the "${trigger}" scrub trigger.`, + }); + } + } + } else { + for (const field of SCRUB_FIELDS) { + if (e[field] !== undefined) { + errors.push({ + code: 'TRIGGER_EFFECT_INCOMPATIBLE', + severity: 'warning', + path: [...path, field], + message: `"${field}" is a scrub-effect field and is incompatible with the "${trigger}" trigger.`, + hint: 'Scrub fields (rangeStart, rangeEnd, etc.) are only valid on viewProgress and pointerMove triggers.', + }); + } + } + } + } + + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts b/packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts new file mode 100644 index 00000000..c59d5545 --- /dev/null +++ b/packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts @@ -0,0 +1,30 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Checks that keyframeEffect.name is unique across all effects. Object-key +// uniqueness for config.effects / config.sequences is guaranteed by JSON parsers. +export const uniqueDefinitionIds: Rule = { + code: 'DUPLICATE_KEYFRAME_NAME', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + const seen = new Map(); + + for (const { name, path } of ctx.keyframeNames) { + const first = seen.get(name); + if (first !== undefined) { + errors.push({ + code: 'DUPLICATE_KEYFRAME_NAME', + severity: 'warning', + path, + message: `Keyframe name "${name}" is already used at [${first.join(', ')}].`, + hint: 'Keyframe names must be unique across all effects.', + }); + } else { + seen.set(name, path); + } + } + + return errors; + }, +}; diff --git a/packages/interact/src/validate/rules/semantic/unusedDefinitions.ts b/packages/interact/src/validate/rules/semantic/unusedDefinitions.ts new file mode 100644 index 00000000..787e3d7b --- /dev/null +++ b/packages/interact/src/validate/rules/semantic/unusedDefinitions.ts @@ -0,0 +1,54 @@ +import type { Rule } from '..'; +import type { ValidationError } from '../../errors'; + +// Mirrors the referential rules in reverse: definitions that exist but are never +// referenced produce warnings so dead config can be cleaned up. +export const unusedDefinitions: Rule = { + code: 'UNUSED_DEFINITION', + defaultSeverity: 'warning', + run: (ctx): ValidationError[] => { + const errors: ValidationError[] = []; + + const referencedEffectIds = new Set(ctx.effectIdReferences.map((r) => r.effectId)); + const referencedSequenceIds = new Set(ctx.sequenceIdReferences.map((r) => r.sequenceId)); + const referencedConditionIds = new Set(ctx.conditionReferences.map((r) => r.conditionId)); + + for (const id of ctx.effectIds) { + if (!referencedEffectIds.has(id)) { + errors.push({ + code: 'UNUSED_EFFECT', + severity: 'warning', + path: ['effects', id], + message: `Effect "${id}" is defined but never referenced by any interaction.`, + hint: 'Remove the unused effect or reference it from an interaction.', + }); + } + } + + for (const id of ctx.sequenceIds) { + if (!referencedSequenceIds.has(id)) { + errors.push({ + code: 'UNUSED_SEQUENCE', + severity: 'warning', + path: ['sequences', id], + message: `Sequence "${id}" is defined but never referenced by any interaction.`, + hint: 'Remove the unused sequence or reference it from an interaction.', + }); + } + } + + for (const id of ctx.conditionIds) { + if (!referencedConditionIds.has(id)) { + errors.push({ + code: 'UNUSED_CONDITION', + severity: 'warning', + path: ['conditions', id], + message: `Condition "${id}" is defined but never referenced.`, + hint: 'Remove the unused condition or reference it from an interaction or effect.', + }); + } + } + + return errors; + }, +}; From 48eaccd5c53091231f08a3cd333433a4eec787d1 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 00:03:32 +0300 Subject: [PATCH 11/19] phase 5 --- ...tconfig_schema_validation_918ab0af.plan.md | 2 +- packages/interact/src/schema/controls.ts | 82 --------- packages/interact/src/schema/effects.ts | 169 ------------------ packages/interact/src/schema/experience.ts | 27 --- packages/interact/src/schema/index.ts | 125 ------------- packages/interact/src/schema/interactions.ts | 104 ----------- packages/interact/src/schema/primitives.ts | 64 ------- packages/interact/src/schema/sequences.ts | 25 --- .../interact/test/validate/bundle.spec.ts | 34 ++++ .../rules/animationEndEffectExists.spec.ts | 38 ++++ .../rules/conditionPredicateRequired.spec.ts | 85 +++++++++ .../validate/rules/conditionsExist.spec.ts | 49 +++++ .../validate/rules/effectIdsExist.spec.ts | 35 ++++ .../interactionHasEffectsOrSequences.spec.ts | 48 +++++ .../test/validate/rules/numericBounds.spec.ts | 163 +++++++++++++++++ .../validate/rules/sequenceIdsExist.spec.ts | 22 +++ .../rules/triggerEffectCompatible.spec.ts | 144 +++++++++++++++ .../rules/uniqueDefinitionIds.spec.ts | 73 ++++++++ .../validate/rules/unusedDefinitions.spec.ts | 122 +++++++++++++ .../validate/rules/validMediaQueries.spec.ts | 78 ++++++++ .../interact/test/validate/structural.spec.ts | 84 +++++++++ .../test/validate/type-parity.spec.ts | 54 ++++++ .../interact/test/validate/validate.spec.ts | 135 ++++++++++++++ 23 files changed, 1165 insertions(+), 597 deletions(-) delete mode 100644 packages/interact/src/schema/controls.ts delete mode 100644 packages/interact/src/schema/effects.ts delete mode 100644 packages/interact/src/schema/experience.ts delete mode 100644 packages/interact/src/schema/index.ts delete mode 100644 packages/interact/src/schema/interactions.ts delete mode 100644 packages/interact/src/schema/primitives.ts delete mode 100644 packages/interact/src/schema/sequences.ts create mode 100644 packages/interact/test/validate/bundle.spec.ts create mode 100644 packages/interact/test/validate/rules/animationEndEffectExists.spec.ts create mode 100644 packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts create mode 100644 packages/interact/test/validate/rules/conditionsExist.spec.ts create mode 100644 packages/interact/test/validate/rules/effectIdsExist.spec.ts create mode 100644 packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts create mode 100644 packages/interact/test/validate/rules/numericBounds.spec.ts create mode 100644 packages/interact/test/validate/rules/sequenceIdsExist.spec.ts create mode 100644 packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts create mode 100644 packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts create mode 100644 packages/interact/test/validate/rules/unusedDefinitions.spec.ts create mode 100644 packages/interact/test/validate/rules/validMediaQueries.spec.ts create mode 100644 packages/interact/test/validate/structural.spec.ts create mode 100644 packages/interact/test/validate/type-parity.spec.ts create mode 100644 packages/interact/test/validate/validate.spec.ts diff --git a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md index 47d34e65..4b3c5e77 100644 --- a/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md +++ b/.cursor/plans/interactconfig_schema_validation_918ab0af.plan.md @@ -22,7 +22,7 @@ todos: status: completed - id: phase5-tests content: 'Phase 5: Unit tests per rule (valid config + per-code fixture); structural tests; type-parity test (expectTypeOf); bundle test (CI grep for zod)' - status: pending + status: completed - id: phase6-docs content: 'Phase 6: README section for @wix/interact/validate; error-code table (§7); llms.txt entry' status: pending diff --git a/packages/interact/src/schema/controls.ts b/packages/interact/src/schema/controls.ts deleted file mode 100644 index 63b360d9..00000000 --- a/packages/interact/src/schema/controls.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { z } from 'zod'; - -export const ControlType = z.enum(['range', 'select', 'color', 'toggle', 'text']); - -export const ControlValue = z.union([z.number(), z.string(), z.boolean()]); - -export const ControlOption = z - .object({ - value: z.union([z.string(), z.number()]), - label: z.string(), - }) - .strict(); - -export const ControlConstraints = z - .object({ - min: z.number().optional(), - max: z.number().optional(), - step: z.number().optional(), - unit: z.string().optional(), - options: z.array(ControlOption).optional(), - }) - .strict(); - -export const BindingTarget = z.enum([ - 'effect', - 'sequence', - 'style', - 'element', - 'interaction', - 'variable', -]); - -export const ValueTransform = z.union([ - z.object({ type: z.literal('direct') }).strict(), - z - .object({ - type: z.literal('linear'), - factor: z.number(), - offset: z.number().optional(), - }) - .strict(), - z - .object({ - type: z.literal('inverse'), - numerator: z.number(), - }) - .strict(), - z - .object({ - type: z.literal('map'), - entries: z.record(z.string(), ControlValue), - }) - .strict(), - z - .object({ - type: z.literal('template'), - template: z.string(), - }) - .strict(), -]); - -export const ControlBinding = z - .object({ - target: BindingTarget, - targetId: z.string().min(1), - property: z.string().optional(), - transform: ValueTransform.optional(), - }) - .strict(); - -export const Control = z - .object({ - id: z.string().min(1), - label: z.string().min(1), - description: z.string().optional(), - group: z.string().optional(), - type: ControlType, - defaultValue: ControlValue, - constraints: ControlConstraints.optional(), - bindings: z.array(ControlBinding), - }) - .strict(); diff --git a/packages/interact/src/schema/effects.ts b/packages/interact/src/schema/effects.ts deleted file mode 100644 index da52f57a..00000000 --- a/packages/interact/src/schema/effects.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { z } from 'zod'; -import { Keyframe, RangeOffset } from './primitives'; - -const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); - -export const NamedEffect = z.object({ type: z.string().min(1) }).catchall(z.unknown()); - -const KeyframeEffectInline = z - .object({ - name: z.string().min(1), - keyframes: z.array(Keyframe).min(1), - }) - .strict(); - -export const EffectBase = z.object({ - key: z.string().optional(), - effectId: z.string().optional(), - selector: z.string().optional(), - listContainer: z.string().optional(), - listItemSelector: z.string().optional(), - conditions: z.array(z.string()).optional(), -}); - -export const SerializableEffectRef = EffectBase.extend({ - effectId: z.string().min(1), -}).strict(); - -const TimeEffectFields = { - duration: z.number().optional(), - easing: z.string().optional(), - iterations: z.number().optional(), - alternate: z.boolean().optional(), - reversed: z.boolean().optional(), - delay: z.number().optional(), - fill: z.enum(['none', 'forwards', 'backwards', 'both']).optional(), - composite: z.enum(['replace', 'add', 'accumulate']).optional(), - triggerType: TriggerType.optional(), -}; - -const ScrubEffectFields = { - rangeStart: RangeOffset.optional(), - rangeEnd: RangeOffset.optional(), - centeredToTarget: z.boolean().optional(), - transitionDuration: z.number().optional(), - transitionDelay: z.number().optional(), - transitionEasing: z.enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']).optional(), -}; - -const StateEffectFields = { - stateAction: z.enum(['add', 'remove', 'toggle', 'clear']).optional(), - transition: z - .object({ - duration: z.number().optional(), - delay: z.number().optional(), - easing: z.string().optional(), - styleProperties: z.array(z.object({ name: z.string(), value: z.string() })), - }) - .optional(), - transitionProperties: z - .array( - z.object({ - name: z.string(), - value: z.string(), - duration: z.number().optional(), - delay: z.number().optional(), - easing: z.string().optional(), - }), - ) - .optional(), -}; - -const SourceFields = { - namedEffect: NamedEffect.optional(), - keyframeEffect: KeyframeEffectInline.optional(), -}; - -export const SerializableEffectSource = z - .object(SourceFields) - .strict() - .refine((v) => (v.namedEffect ? 1 : 0) + (v.keyframeEffect ? 1 : 0) === 1, { - message: 'Effect source must define exactly one of namedEffect or keyframeEffect', - }); - -const EffectShape = EffectBase.extend({ - ...SourceFields, - ...TimeEffectFields, - ...ScrubEffectFields, - ...StateEffectFields, -}).strict(); - -export const SerializableEffect = EffectShape.superRefine((v, ctx) => { - const hasNamed = v.namedEffect !== undefined; - const hasKeyframe = v.keyframeEffect !== undefined; - const hasSource = hasNamed || hasKeyframe; - const hasState = - v.stateAction !== undefined || - v.transition !== undefined || - v.transitionProperties !== undefined; - - if (hasNamed && hasKeyframe) { - ctx.addIssue({ - code: 'custom', - message: 'Effect cannot define both namedEffect and keyframeEffect.', - path: ['keyframeEffect'], - }); - } - if (hasSource && hasState) { - ctx.addIssue({ - code: 'custom', - message: - 'Effect source fields (namedEffect or keyframeEffect) cannot be combined with state effect fields.', - path: [], - }); - } - if (!hasSource && !hasState) { - ctx.addIssue({ - code: 'custom', - message: - 'Effect must define an effect source (namedEffect or keyframeEffect) or be a state effect (stateAction / transition / transitionProperties).', - path: [], - }); - } -}); - -// Time effects are the only variant allowed inside a sequence: -// must have an effect source, and must not carry scrub or state fields. -const SCRUB_FIELDS = [ - 'rangeStart', - 'rangeEnd', - 'centeredToTarget', - 'transitionDuration', - 'transitionDelay', - 'transitionEasing', -] as const; - -const STATE_FIELDS = ['stateAction', 'transition', 'transitionProperties'] as const; - -export const SerializableTimeEffect = SerializableEffect.superRefine((v, ctx) => { - if (v.namedEffect === undefined && v.keyframeEffect === undefined) { - ctx.addIssue({ - code: 'custom', - message: 'Time effect must define an effect source (namedEffect or keyframeEffect).', - path: [], - }); - } - for (const field of SCRUB_FIELDS) { - if ((v as Record)[field] !== undefined) { - ctx.addIssue({ - code: 'custom', - message: `"${field}" is a scrub-effect field and is not allowed on a time effect.`, - path: [field], - }); - } - } - for (const field of STATE_FIELDS) { - if ((v as Record)[field] !== undefined) { - ctx.addIssue({ - code: 'custom', - message: `"${field}" is a state-effect field and is not allowed on a time effect.`, - path: [field], - }); - } - } -}); - -// Aliases preserved for the schema barrel; the unified SerializableEffect is the -// runtime for interaction-level effects (which may be time, scrub, or state). -export const SerializableScrubEffect = SerializableEffect; -export const SerializableStateEffect = SerializableEffect; diff --git a/packages/interact/src/schema/experience.ts b/packages/interact/src/schema/experience.ts deleted file mode 100644 index 18654f3e..00000000 --- a/packages/interact/src/schema/experience.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; -import { - ElementEntry, - ExperienceMeta, - ExperienceSchemaVersion, - MediaCondition, - StyleRule, -} from './primitives'; -import { ExperienceInteractConfig } from './interactions'; -import { Control } from './controls'; - -export const ExperienceSchema = z - .object({ - $schema: ExperienceSchemaVersion, - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - elements: z.record(z.string().min(1), ElementEntry), - styles: z.array(StyleRule).optional(), - interact: ExperienceInteractConfig, - controls: z.array(Control), - disableWhen: z.array(MediaCondition).optional(), - meta: ExperienceMeta.optional(), - }) - .strict(); - -export type Experience = z.infer; diff --git a/packages/interact/src/schema/index.ts b/packages/interact/src/schema/index.ts deleted file mode 100644 index 973779f5..00000000 --- a/packages/interact/src/schema/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { z } from 'zod'; - -import { - Condition as ConditionSchema, - ElementEntry as ElementEntrySchema, - ExperienceMeta as ExperienceMetaSchema, - ExperienceSchemaVersion as ExperienceSchemaVersionSchema, - Keyframe as KeyframeSchema, - LengthPercentage as LengthPercentageSchema, - MediaCondition as MediaConditionSchema, - RangeOffset as RangeOffsetSchema, - StyleRule as StyleRuleSchema, -} from './primitives'; -import { - EffectBase as EffectBaseSchema, - NamedEffect as NamedEffectSchema, - SerializableEffect as SerializableEffectSchema, - SerializableEffectRef as SerializableEffectRefSchema, - SerializableEffectSource as SerializableEffectSourceSchema, - SerializableScrubEffect as SerializableScrubEffectSchema, - SerializableStateEffect as SerializableStateEffectSchema, - SerializableTimeEffect as SerializableTimeEffectSchema, -} from './effects'; -import { - SerializableSequenceConfig as SerializableSequenceConfigSchema, - SerializableSequenceConfigRef as SerializableSequenceConfigRefSchema, -} from './sequences'; -import { - AnimationEndParams as AnimationEndParamsSchema, - ExperienceInteractConfig as ExperienceInteractConfigSchema, - ExperienceInteraction as ExperienceInteractionSchema, - PointerMoveParams as PointerMoveParamsSchema, - TriggerParams as TriggerParamsSchema, - TriggerType as TriggerTypeSchema, - ViewEnterParams as ViewEnterParamsSchema, -} from './interactions'; -import { - BindingTarget as BindingTargetSchema, - Control as ControlSchema, - ControlBinding as ControlBindingSchema, - ControlConstraints as ControlConstraintsSchema, - ControlOption as ControlOptionSchema, - ControlType as ControlTypeSchema, - ControlValue as ControlValueSchema, - ValueTransform as ValueTransformSchema, -} from './controls'; - -export { - ConditionSchema as Condition, - ElementEntrySchema as ElementEntry, - ExperienceMetaSchema as ExperienceMeta, - ExperienceSchemaVersionSchema as ExperienceSchemaVersion, - KeyframeSchema as Keyframe, - LengthPercentageSchema as LengthPercentage, - MediaConditionSchema as MediaCondition, - RangeOffsetSchema as RangeOffset, - StyleRuleSchema as StyleRule, - EffectBaseSchema as EffectBase, - NamedEffectSchema as NamedEffect, - SerializableEffectSchema as SerializableEffect, - SerializableEffectRefSchema as SerializableEffectRef, - SerializableEffectSourceSchema as SerializableEffectSource, - SerializableScrubEffectSchema as SerializableScrubEffect, - SerializableStateEffectSchema as SerializableStateEffect, - SerializableTimeEffectSchema as SerializableTimeEffect, - SerializableSequenceConfigSchema as SerializableSequenceConfig, - SerializableSequenceConfigRefSchema as SerializableSequenceConfigRef, - AnimationEndParamsSchema as AnimationEndParams, - ExperienceInteractConfigSchema as ExperienceInteractConfig, - ExperienceInteractionSchema as ExperienceInteraction, - PointerMoveParamsSchema as PointerMoveParams, - TriggerParamsSchema as TriggerParams, - TriggerTypeSchema as TriggerType, - ViewEnterParamsSchema as ViewEnterParams, - BindingTargetSchema as BindingTarget, - ControlSchema as Control, - ControlBindingSchema as ControlBinding, - ControlConstraintsSchema as ControlConstraints, - ControlOptionSchema as ControlOption, - ControlTypeSchema as ControlType, - ControlValueSchema as ControlValue, - ValueTransformSchema as ValueTransform, -}; - -export { ExperienceSchema } from './experience'; -export type { Experience } from './experience'; - -export type Condition = z.infer; -export type ElementEntry = z.infer; -export type ExperienceMeta = z.infer; -export type ExperienceSchemaVersion = z.infer; -export type Keyframe = z.infer; -export type LengthPercentage = z.infer; -export type MediaCondition = z.infer; -export type RangeOffset = z.infer; -export type StyleRule = z.infer; - -export type EffectBase = z.infer; -export type NamedEffect = z.infer; -export type SerializableEffect = z.infer; -export type SerializableEffectRef = z.infer; -export type SerializableEffectSource = z.infer; -export type SerializableScrubEffect = z.infer; -export type SerializableStateEffect = z.infer; -export type SerializableTimeEffect = z.infer; - -export type SerializableSequenceConfig = z.infer; -export type SerializableSequenceConfigRef = z.infer; - -export type AnimationEndParams = z.infer; -export type ExperienceInteractConfig = z.infer; -export type ExperienceInteraction = z.infer; -export type PointerMoveParams = z.infer; -export type TriggerParams = z.infer; -export type TriggerType = z.infer; -export type ViewEnterParams = z.infer; - -export type BindingTarget = z.infer; -export type Control = z.infer; -export type ControlBinding = z.infer; -export type ControlConstraints = z.infer; -export type ControlOption = z.infer; -export type ControlType = z.infer; -export type ControlValue = z.infer; -export type ValueTransform = z.infer; diff --git a/packages/interact/src/schema/interactions.ts b/packages/interact/src/schema/interactions.ts deleted file mode 100644 index b0ab6071..00000000 --- a/packages/interact/src/schema/interactions.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from 'zod'; -import { SerializableEffect, SerializableEffectRef } from './effects'; -import { SerializableSequenceConfig, SerializableSequenceConfigRef } from './sequences'; - -export const TriggerType = z.enum([ - 'hover', - 'click', - 'interest', - 'activate', - 'viewEnter', - 'viewProgress', - 'pointerMove', - 'animationEnd', - 'pageVisible', -]); - -export const ViewEnterParams = z - .object({ - threshold: z.number().optional(), - inset: z.string().optional(), - useSafeViewEnter: z.boolean().optional(), - }) - .strict(); - -export const PointerMoveParams = z - .object({ - hitArea: z.enum(['root', 'self']).optional(), - axis: z.enum(['x', 'y']).optional(), - }) - .strict(); - -export const AnimationEndParams = z - .object({ - effectId: z.string().min(1), - }) - .strict(); - -export const TriggerParams = z.union([ViewEnterParams, PointerMoveParams, AnimationEndParams]); - -const InteractionBase = { - id: z.string().optional(), - key: z.string().min(1), - selector: z.string().optional(), - listContainer: z.string().optional(), - listItemSelector: z.string().optional(), - conditions: z.array(z.string()).optional(), - effects: z.array(z.union([SerializableEffect, SerializableEffectRef])).optional(), - sequences: z - .array(z.union([SerializableSequenceConfig, SerializableSequenceConfigRef])) - .optional(), -}; - -const ViewEnterInteraction = z - .object({ - ...InteractionBase, - trigger: z.enum(['viewEnter', 'pageVisible']), - params: ViewEnterParams.optional(), - }) - .strict(); - -const PointerMoveInteraction = z - .object({ - ...InteractionBase, - trigger: z.literal('pointerMove'), - params: PointerMoveParams.optional(), - }) - .strict(); - -const AnimationEndInteraction = z - .object({ - ...InteractionBase, - trigger: z.literal('animationEnd'), - params: AnimationEndParams, - }) - .strict(); - -const SimpleInteraction = z - .object({ - ...InteractionBase, - trigger: z.enum(['hover', 'click', 'interest', 'activate', 'viewProgress']), - }) - .strict(); - -export const ExperienceInteraction = z.union([ - ViewEnterInteraction, - PointerMoveInteraction, - AnimationEndInteraction, - SimpleInteraction, -]); - -export const ExperienceInteractConfig = z.object({ - effects: z.record(z.string().min(1), SerializableEffect), - sequences: z.record(z.string().min(1), SerializableSequenceConfig).optional(), - conditions: z - .record( - z.string().min(1), - z.object({ - type: z.enum(['media', 'selector']), - predicate: z.string().optional(), - }), - ) - .optional(), - interactions: z.array(ExperienceInteraction), -}); diff --git a/packages/interact/src/schema/primitives.ts b/packages/interact/src/schema/primitives.ts deleted file mode 100644 index 42aa2fd7..00000000 --- a/packages/interact/src/schema/primitives.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from 'zod'; - -export const ExperienceSchemaVersion = z.literal('interact-experience/1.0'); - -export const ElementEntry = z - .object({ - selector: z.string().min(1), - styles: z.record(z.string(), z.string()).optional(), - }) - .strict(); - -export const StyleRule = z - .object({ - selector: z.string().min(1), - properties: z.record(z.string(), z.string()), - mediaQuery: z.string().optional(), - }) - .strict(); - -export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()])); - -export const LengthPercentage = z.union([ - z.object({ - value: z.number(), - unit: z.enum(['px', 'em', 'rem', 'vh', 'vw', 'vmin', 'vmax']), - }), - z.object({ - value: z.number(), - unit: z.literal('percentage'), - }), -]); - -export const RangeOffset = z - .object({ - name: z - .enum(['entry', 'exit', 'contain', 'cover', 'entry-crossing', 'exit-crossing']) - .optional(), - offset: LengthPercentage.optional(), - }) - .strict(); - -export const Condition = z - .object({ - type: z.enum(['media', 'container', 'selector']), - predicate: z.string().optional(), - }) - .strict(); - -export const MediaCondition = z - .object({ - mediaQuery: z.string().min(1), - label: z.string().optional(), - }) - .strict(); - -export const ExperienceMeta = z - .object({ - category: z.string().optional(), - tags: z.array(z.string()).optional(), - previewUrl: z.string().optional(), - author: z.string().optional(), - createdAt: z.string().optional(), - }) - .strict(); diff --git a/packages/interact/src/schema/sequences.ts b/packages/interact/src/schema/sequences.ts deleted file mode 100644 index c4732cbc..00000000 --- a/packages/interact/src/schema/sequences.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; -import { SerializableEffectRef, SerializableTimeEffect } from './effects'; - -const TriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); - -export const SerializableSequenceConfig = z.object({ - effects: z.array(z.union([SerializableTimeEffect, SerializableEffectRef])), - delay: z.number().optional(), - offset: z.number().optional(), - offsetEasing: z.string().optional(), - triggerType: TriggerType.optional(), - sequenceId: z.string().optional(), - conditions: z.array(z.string()).optional(), -}); - -export const SerializableSequenceConfigRef = z - .object({ - sequenceId: z.string().min(1), - delay: z.number().optional(), - offset: z.number().optional(), - offsetEasing: z.string().optional(), - triggerType: TriggerType.optional(), - conditions: z.array(z.string()).optional(), - }) - .strict(); diff --git a/packages/interact/test/validate/bundle.spec.ts b/packages/interact/test/validate/bundle.spec.ts new file mode 100644 index 00000000..ffab0b7d --- /dev/null +++ b/packages/interact/test/validate/bundle.spec.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// fileURLToPath + dirname gives us the directory of this test file, +// which works correctly in vitest's jsdom environment. +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const INDEX_BUNDLE = path.resolve(__dirname, '../../dist/es/index.js'); +const VALIDATE_BUNDLE = path.resolve(__dirname, '../../dist/es/validate.js'); + +describe('bundle isolation', () => { + it('dist/es/index.js does not contain a zod import (zod must be tree-shaken from main bundle)', () => { + if (!existsSync(INDEX_BUNDLE)) { + console.warn( + '[bundle test] dist/es/index.js not found — run `yarn build` first to enable this check', + ); + return; + } + const content = readFileSync(INDEX_BUNDLE, 'utf8'); + // zod should only appear in dist/es/validate.js, never in the main bundle + expect(content).not.toMatch(/["']zod["']/); + }); + + it('dist/es/validate.js exists after build (validate entry was compiled)', () => { + if (!existsSync(INDEX_BUNDLE)) { + console.warn('[bundle test] dist/ not found — run `yarn build` first to enable this check'); + return; + } + expect(existsSync(VALIDATE_BUNDLE)).toBe(true); + }); +}); diff --git a/packages/interact/test/validate/rules/animationEndEffectExists.spec.ts b/packages/interact/test/validate/rules/animationEndEffectExists.spec.ts new file mode 100644 index 00000000..a36fa883 --- /dev/null +++ b/packages/interact/test/validate/rules/animationEndEffectExists.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('animationEndEffectExists — ANIMATION_END_EFFECT_NOT_FOUND', () => { + it('emits no errors when the animationEnd params.effectId resolves to a defined effect', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { + key: 'el', + trigger: 'animationEnd', + params: { effectId: 'fade' }, + effects: [{ namedEffect: { type: 'SlideIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'ANIMATION_END_EFFECT_NOT_FOUND')).toHaveLength( + 0, + ); + }); + + it('emits ANIMATION_END_EFFECT_NOT_FOUND when params.effectId is not defined', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'animationEnd', + params: { effectId: 'ghost' }, + effects: [{ namedEffect: { type: 'SlideIn' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'ANIMATION_END_EFFECT_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('params'); + }); +}); diff --git a/packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts b/packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts new file mode 100644 index 00000000..cfe9b73d --- /dev/null +++ b/packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('conditionPredicateRequired — CONDITION_PREDICATE_REQUIRED', () => { + it('emits no errors for a media condition with a predicate', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); + }); + + it('emits no errors for a container condition with a predicate', () => { + const result = validateInteractConfig({ + conditions: { cont: { type: 'container', predicate: '(min-width: 200px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['cont'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); + }); + + it('emits no errors for a selector condition (predicate is optional for selector)', () => { + const result = validateInteractConfig({ + conditions: { sel: { type: 'selector' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['sel'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); + }); + + it('emits CONDITION_PREDICATE_REQUIRED for a media condition without a predicate', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'CONDITION_PREDICATE_REQUIRED'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('error'); + expect(err?.path).toEqual(['conditions', 'mq', 'predicate']); + expect(err?.message).toContain('"mq"'); + }); + + it('emits CONDITION_PREDICATE_REQUIRED for a container condition without a predicate', () => { + const result = validateInteractConfig({ + conditions: { cont: { type: 'container' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['cont'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'CONDITION_PREDICATE_REQUIRED'); + expect(err).toBeDefined(); + expect(err?.path).toEqual(['conditions', 'cont', 'predicate']); + }); +}); diff --git a/packages/interact/test/validate/rules/conditionsExist.spec.ts b/packages/interact/test/validate/rules/conditionsExist.spec.ts new file mode 100644 index 00000000..fd8f16c6 --- /dev/null +++ b/packages/interact/test/validate/rules/conditionsExist.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('conditionsExist — CONDITION_NOT_FOUND', () => { + it('emits no errors when all condition references resolve to defined conditions', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'CONDITION_NOT_FOUND')).toHaveLength(0); + }); + + it('emits CONDITION_NOT_FOUND when a condition reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['ghost'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'CONDITION_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('conditions'); + }); + + it('emits CONDITION_NOT_FOUND for a missing condition referenced on an effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, conditions: ['noSuchCondition'] }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'CONDITION_NOT_FOUND')).toBe(true); + }); +}); diff --git a/packages/interact/test/validate/rules/effectIdsExist.spec.ts b/packages/interact/test/validate/rules/effectIdsExist.spec.ts new file mode 100644 index 00000000..8fb6f82c --- /dev/null +++ b/packages/interact/test/validate/rules/effectIdsExist.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('effectIdsExist — EFFECT_ID_NOT_FOUND', () => { + it('emits no errors when an effectId reference resolves to a defined effect', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toHaveLength(0); + }); + + it('emits EFFECT_ID_NOT_FOUND when an effectId reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] }], + }); + const errs = result.errors.filter((e) => e.code === 'EFFECT_ID_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('effectId'); + }); + + it('emits EFFECT_ID_NOT_FOUND for an effectId inside a sequence', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ effects: [{ effectId: 'ghost' }] }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toBe(true); + }); +}); diff --git a/packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts b/packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts new file mode 100644 index 00000000..5e9675a0 --- /dev/null +++ b/packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('interactionHasEffectsOrSequences — INTERACTION_EMPTY', () => { + it('emits no errors when an interaction has at least one effect', () => { + const result = validateInteractConfig({ + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.errors.filter((e) => e.code === 'INTERACTION_EMPTY')).toHaveLength(0); + }); + + it('emits no errors when an interaction has at least one sequence', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + sequences: [{ effects: [{ namedEffect: { type: 'FadeIn' } }] }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'INTERACTION_EMPTY')).toHaveLength(0); + }); + + it('emits INTERACTION_EMPTY when an interaction has neither effects nor sequences', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter' }], + }); + const errs = result.errors.filter((e) => e.code === 'INTERACTION_EMPTY'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toEqual(['interactions', 0]); + }); + + it('emits one INTERACTION_EMPTY per offending interaction', () => { + const result = validateInteractConfig({ + interactions: [ + { key: 'a', trigger: 'viewEnter' }, + { key: 'b', trigger: 'click', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + { key: 'c', trigger: 'hover' }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'INTERACTION_EMPTY'); + expect(errs).toHaveLength(2); + }); +}); diff --git a/packages/interact/test/validate/rules/numericBounds.spec.ts b/packages/interact/test/validate/rules/numericBounds.spec.ts new file mode 100644 index 00000000..c7c5cf65 --- /dev/null +++ b/packages/interact/test/validate/rules/numericBounds.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('numericBounds', () => { + describe('valid configs', () => { + it('emits no numeric errors for non-negative effect fields', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, delay: 0, iterations: 1 }], + }, + ], + }); + const numericCodes = [ + 'NEGATIVE_DURATION', + 'NEGATIVE_DELAY', + 'NEGATIVE_ITERATIONS', + 'THRESHOLD_OUT_OF_RANGE', + 'NEGATIVE_OFFSET', + ]; + expect(result.errors.filter((e) => numericCodes.includes(e.code))).toHaveLength(0); + }); + + it('emits no errors for a top-level effect with valid fields', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: 600, delay: 50 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'NEGATIVE_DURATION')).toHaveLength(0); + }); + }); + + describe('NEGATIVE_DURATION', () => { + it('emits NEGATIVE_DURATION for a negative duration on an inline effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: -1 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_DURATION'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('error'); + expect(err?.path).toContain('duration'); + }); + + it('emits NEGATIVE_DURATION for a negative duration in a top-level effect definition', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: -100 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.some((e) => e.code === 'NEGATIVE_DURATION')).toBe(true); + }); + }); + + describe('NEGATIVE_DELAY', () => { + it('emits NEGATIVE_DELAY for a negative delay on an inline effect', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, delay: -50 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_DELAY'); + expect(err).toBeDefined(); + expect(err?.path).toContain('delay'); + }); + + it('emits NEGATIVE_DELAY for a negative delay in a top-level sequence definition', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }], delay: -10 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.some((e) => e.code === 'NEGATIVE_DELAY')).toBe(true); + }); + }); + + describe('NEGATIVE_ITERATIONS', () => { + it('emits NEGATIVE_ITERATIONS for a negative iterations value', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, iterations: -2 }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_ITERATIONS'); + expect(err).toBeDefined(); + expect(err?.path).toContain('iterations'); + }); + }); + + describe('NEGATIVE_OFFSET', () => { + it('emits NEGATIVE_OFFSET for a negative offset on a top-level sequence', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }], offset: -5 } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + const err = result.errors.find((e) => e.code === 'NEGATIVE_OFFSET'); + expect(err).toBeDefined(); + expect(err?.path).toContain('offset'); + }); + }); + + describe('THRESHOLD_OUT_OF_RANGE', () => { + it('emits THRESHOLD_OUT_OF_RANGE for threshold > 1 on viewEnter', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + params: { threshold: 1.5 }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + const err = result.errors.find((e) => e.code === 'THRESHOLD_OUT_OF_RANGE'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('error'); + expect(err?.path).toContain('threshold'); + }); + + it('emits THRESHOLD_OUT_OF_RANGE for threshold < 0 on pageVisible', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pageVisible', + params: { threshold: -0.1 }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.some((e) => e.code === 'THRESHOLD_OUT_OF_RANGE')).toBe(true); + }); + + it('emits no errors for threshold = 0 and threshold = 1 (boundary values)', () => { + for (const threshold of [0, 1]) { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + params: { threshold }, + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'THRESHOLD_OUT_OF_RANGE')).toHaveLength(0); + } + }); + }); +}); diff --git a/packages/interact/test/validate/rules/sequenceIdsExist.spec.ts b/packages/interact/test/validate/rules/sequenceIdsExist.spec.ts new file mode 100644 index 00000000..0fe7a15c --- /dev/null +++ b/packages/interact/test/validate/rules/sequenceIdsExist.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('sequenceIdsExist — SEQUENCE_ID_NOT_FOUND', () => { + it('emits no errors when a sequenceId reference resolves to a defined sequence', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'SEQUENCE_ID_NOT_FOUND')).toHaveLength(0); + }); + + it('emits SEQUENCE_ID_NOT_FOUND when a sequenceId reference has no matching definition', () => { + const result = validateInteractConfig({ + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'missing' }] }], + }); + const errs = result.errors.filter((e) => e.code === 'SEQUENCE_ID_NOT_FOUND'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('error'); + expect(errs[0].path).toContain('sequenceId'); + }); +}); diff --git a/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts b/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts new file mode 100644 index 00000000..ebfed376 --- /dev/null +++ b/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('triggerEffectCompatible — TRIGGER_EFFECT_INCOMPATIBLE', () => { + describe('valid combinations', () => { + it('emits no errors for a time effect on a discrete trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, delay: 100 }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + + it('emits no errors for a scrub effect on a viewProgress trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeIn' }, rangeStart: { name: 'entry' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + + it('emits no errors for a scrub effect on a pointerMove trigger', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pointerMove', + effects: [{ namedEffect: { type: 'ParallaxMove' }, rangeEnd: { name: 'exit' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + }); + + describe('time fields on scrub trigger', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for duration on viewProgress', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 500 }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE'); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].severity).toBe('warning'); + expect(errs[0].path).toContain('duration'); + }); + + it('emits TRIGGER_EFFECT_INCOMPATIBLE for delay on pointerMove', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'pointerMove', + effects: [{ namedEffect: { type: 'FadeIn' }, delay: 200 }], + }, + ], + }); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('delay'), + ), + ).toBe(true); + }); + }); + + describe('state fields on scrub trigger', () => { + // A pure state effect (stateAction, transition, transitionProperties with no source field) is + // classified as an EffectRef by isEffectRef() because it lacks namedEffect/keyframeEffect/ + // customEffect. It is therefore never added to triggerEffectTuples, so TRIGGER_EFFECT_INCOMPATIBLE + // cannot fire for it. Instead it is caught by EFFECT_ID_NOT_FOUND (effectId is undefined). + // Mixing a source field WITH state fields is rejected by the schema (SCHEMA_INVALID), so + // STATE_FIELDS checks via triggerEffectCompatible are effectively a forward-compatibility guard. + it('does not emit TRIGGER_EFFECT_INCOMPATIBLE for a pure state effect (classified as effectRef)', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [{ stateAction: 'add' }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); + }); + + describe('scrub fields on discrete trigger', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for rangeStart on viewEnter', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, rangeStart: { name: 'entry' } }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE'); + expect(errs.length).toBeGreaterThan(0); + expect(errs[0].path).toContain('rangeStart'); + }); + + it('emits TRIGGER_EFFECT_INCOMPATIBLE for transitionDuration on click', () => { + const result = validateInteractConfig({ + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ namedEffect: { type: 'FadeIn' }, transitionDuration: 300 }], + }, + ], + }); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('transitionDuration'), + ), + ).toBe(true); + }); + }); + + it('does not flag effectId references (only inline effects are checked)', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' }, duration: 300 } }, + interactions: [{ key: 'el', trigger: 'viewProgress', effects: [{ effectId: 'fade' }] }], + }); + // effectId refs are not in triggerEffectTuples, so no TRIGGER_EFFECT_INCOMPATIBLE + expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + }); +}); diff --git a/packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts b/packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts new file mode 100644 index 00000000..6a711517 --- /dev/null +++ b/packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +const KEYFRAME_EFFECT_A = { name: 'anim1', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; +const KEYFRAME_EFFECT_B = { name: 'anim2', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; + +describe('uniqueDefinitionIds — DUPLICATE_KEYFRAME_NAME', () => { + it('emits no errors when all keyframe names are unique', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: KEYFRAME_EFFECT_A }, + b: { keyframeEffect: KEYFRAME_EFFECT_B }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'a' }, { effectId: 'b' }] }, + ], + }); + expect(result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME')).toHaveLength(0); + }); + + it('emits no errors when effects use different source types', () => { + const result = validateInteractConfig({ + effects: { + named: { namedEffect: { type: 'FadeIn' } }, + keyframed: { keyframeEffect: KEYFRAME_EFFECT_A }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ effectId: 'named' }, { effectId: 'keyframed' }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME')).toHaveLength(0); + }); + + it('emits DUPLICATE_KEYFRAME_NAME when two top-level effects share a keyframe name', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: { name: 'shared', keyframes: [{ opacity: '0' }] } }, + b: { keyframeEffect: { name: 'shared', keyframes: [{ opacity: '1' }] } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'a' }, { effectId: 'b' }] }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME'); + expect(errs).toHaveLength(1); + expect(errs[0].severity).toBe('warning'); + expect(errs[0].path).toContain('name'); + expect(errs[0].message).toContain('"shared"'); + }); + + it('emits one DUPLICATE_KEYFRAME_NAME per extra duplicate (first occurrence is the baseline)', () => { + const result = validateInteractConfig({ + effects: { + a: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '0' }] } }, + b: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '0.5' }] } }, + c: { keyframeEffect: { name: 'dup', keyframes: [{ opacity: '1' }] } }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ effectId: 'a' }, { effectId: 'b' }, { effectId: 'c' }], + }, + ], + }); + const errs = result.errors.filter((e) => e.code === 'DUPLICATE_KEYFRAME_NAME'); + expect(errs).toHaveLength(2); + }); +}); diff --git a/packages/interact/test/validate/rules/unusedDefinitions.spec.ts b/packages/interact/test/validate/rules/unusedDefinitions.spec.ts new file mode 100644 index 00000000..7c9b7267 --- /dev/null +++ b/packages/interact/test/validate/rules/unusedDefinitions.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +describe('unusedDefinitions', () => { + it('emits no errors when all definitions are referenced', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + sequences: { seq: { effects: [{ namedEffect: { type: 'SlideIn' } }] } }, + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ effectId: 'fade' }], + sequences: [{ sequenceId: 'seq' }], + }, + ], + }); + const unusedCodes = ['UNUSED_EFFECT', 'UNUSED_SEQUENCE', 'UNUSED_CONDITION']; + expect(result.errors.filter((e) => unusedCodes.includes(e.code))).toHaveLength(0); + }); + + describe('UNUSED_EFFECT', () => { + it('emits UNUSED_EFFECT for a defined effect that no interaction references', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_EFFECT'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['effects', 'fade']); + }); + + it('does not emit UNUSED_EFFECT for an effect referenced via effectId', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'fade' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + + it('does not emit UNUSED_EFFECT for an effect referenced inside a sequence', () => { + const result = validateInteractConfig({ + effects: { fade: { namedEffect: { type: 'FadeIn' } } }, + sequences: { seq: { effects: [{ effectId: 'fade' }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + }); + + describe('UNUSED_SEQUENCE', () => { + it('emits UNUSED_SEQUENCE for a defined sequence that no interaction references', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_SEQUENCE'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['sequences', 'seq']); + }); + + it('does not emit UNUSED_SEQUENCE for a sequence referenced via sequenceId', () => { + const result = validateInteractConfig({ + sequences: { seq: { effects: [{ namedEffect: { type: 'FadeIn' } }] } }, + interactions: [{ key: 'el', trigger: 'viewEnter', sequences: [{ sequenceId: 'seq' }] }], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_SEQUENCE')).toHaveLength(0); + }); + }); + + describe('UNUSED_CONDITION', () => { + it('emits UNUSED_CONDITION for a defined condition that nothing references', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + const err = result.errors.find((e) => e.code === 'UNUSED_CONDITION'); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['conditions', 'mq']); + }); + + it('does not emit UNUSED_CONDITION when the condition is referenced by an interaction', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['mq'], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_CONDITION')).toHaveLength(0); + }); + + it('does not emit UNUSED_CONDITION when the condition is referenced by an inline effect', () => { + const result = validateInteractConfig({ + conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [{ namedEffect: { type: 'FadeIn' }, conditions: ['mq'] }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_CONDITION')).toHaveLength(0); + }); + }); +}); diff --git a/packages/interact/test/validate/rules/validMediaQueries.spec.ts b/packages/interact/test/validate/rules/validMediaQueries.spec.ts new file mode 100644 index 00000000..6fd07fa5 --- /dev/null +++ b/packages/interact/test/validate/rules/validMediaQueries.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { validateInteractConfig } from '../../../src/validate'; + +// jsdom defines window.matchMedia but its stub returns `media: ''` for all +// queries because jsdom does not implement CSS parsing. Provide a minimal +// replacement so the rule behaves like a real browser: +// - known-valid queries get back their own string as `media` (truthy → valid) +// - everything else gets back `''` (falsy → invalid) +function stubMatchMedia(validQueries: string[]) { + vi.stubGlobal('matchMedia', (q: string) => ({ + media: validQueries.includes(q.trim()) ? q.trim() : '', + matches: false, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// Helper: a config that references the named condition so no UNUSED_CONDITION fires. +function configWithCondition( + id: string, + predicate?: string, + type: 'media' | 'container' | 'selector' = 'media', +) { + return { + conditions: { [id]: predicate !== undefined ? { type, predicate } : { type } }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter' as const, + conditions: [id], + effects: [{ namedEffect: { type: 'FadeIn' } }], + }, + ], + }; +} + +describe('validMediaQueries — INVALID_MEDIA_QUERY', () => { + it('emits no errors for a syntactically valid media query', () => { + stubMatchMedia(['(min-width: 768px)']); + const result = validateInteractConfig(configWithCondition('mq', '(min-width: 768px)')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits no errors for a condition type other than media', () => { + const result = validateInteractConfig(configWithCondition('sel', '.my-class', 'selector')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits no errors when the media condition has no predicate (handled by conditionPredicateRequired)', () => { + // validMediaQueries only fires when predicate is defined; the missing-predicate + // case is owned by conditionPredicateRequired. + const result = validateInteractConfig(configWithCondition('mq')); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); + }); + + it('emits INVALID_MEDIA_QUERY for an empty predicate string', () => { + // Empty string fails the first guard in isValidMediaQuery: `if (!q) return false` + const result = validateInteractConfig(configWithCondition('mq', '')); + expect(result.errors.some((e) => e.code === 'INVALID_MEDIA_QUERY')).toBe(true); + const err = result.errors.find((e) => e.code === 'INVALID_MEDIA_QUERY'); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['conditions', 'mq', 'predicate']); + }); + + it('emits INVALID_MEDIA_QUERY for a query that matchMedia reports as invalid', () => { + stubMatchMedia([]); // nothing is valid → matchMedia returns media: '' for everything + const result = validateInteractConfig(configWithCondition('mq', '@bad##query')); + expect(result.errors.some((e) => e.code === 'INVALID_MEDIA_QUERY')).toBe(true); + }); +}); diff --git a/packages/interact/test/validate/structural.spec.ts b/packages/interact/test/validate/structural.spec.ts new file mode 100644 index 00000000..82f2e9eb --- /dev/null +++ b/packages/interact/test/validate/structural.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { validateStructural } from '../../src/validate/structural'; + +const VALID_CONFIG = { + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], +}; + +describe('validateStructural', () => { + it('returns ok=true and no errors for a valid config', () => { + const result = validateStructural(VALID_CONFIG); + expect(result.ok).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.parsed).toBeDefined(); + }); + + it('emits SCHEMA_INVALID_TYPE when interactions is missing', () => { + const result = validateStructural({}); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID_TYPE')).toBe(true); + }); + + it('emits SCHEMA_INVALID_TYPE when interactions is not an array', () => { + const result = validateStructural({ interactions: 'not-an-array' }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID_TYPE')).toBe(true); + }); + + it('emits SCHEMA_UNRECOGNIZED_KEYS for an unknown root key', () => { + const result = validateStructural({ ...VALID_CONFIG, unknownField: true }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_UNRECOGNIZED_KEYS')).toBe(true); + }); + + it('emits SCHEMA_TOO_SMALL when interaction key is an empty string', () => { + const result = validateStructural({ + interactions: [ + { key: '', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_TOO_SMALL')).toBe(true); + }); + + it('emits errors for an unrecognised trigger value', () => { + const result = validateStructural({ + interactions: [ + { key: 'el', trigger: 'invalidTrigger', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('emits SCHEMA_INVALID when an effect defines multiple sources', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + namedEffect: { type: 'FadeIn' }, + keyframeEffect: { name: 'k', keyframes: [{ opacity: 0 }] }, + }, + ], + }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_INVALID')).toBe(true); + }); + + it('returns the parsed config on success', () => { + const result = validateStructural(VALID_CONFIG); + expect(result.parsed).toMatchObject({ interactions: expect.any(Array) }); + }); + + it('exposes path information in errors', () => { + const result = validateStructural({ interactions: 'bad' }); + expect(result.errors[0].path).toBeDefined(); + }); +}); diff --git a/packages/interact/test/validate/type-parity.spec.ts b/packages/interact/test/validate/type-parity.spec.ts new file mode 100644 index 00000000..5d5bdb18 --- /dev/null +++ b/packages/interact/test/validate/type-parity.spec.ts @@ -0,0 +1,54 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { z } from 'zod'; +import type { InteractConfig, Condition as ConditionDef } from '../../src/types/config'; +import { InteractConfigSchema, Condition } from '../../src/validate/schema'; + +type InferredConfig = z.infer; +type InferredCondition = z.infer; + +// --------------------------------------------------------------------------- +// These tests are compile-time drift guards. They fail at TypeScript type- +// checking time (yarn lint / tsc --noEmit) if the zod schemas diverge from +// the hand-written types in src/types/. At runtime they are no-ops. +// --------------------------------------------------------------------------- + +describe('schema type parity (drift guard)', () => { + it('Condition schema type field is identical to the hand-written ConditionDef type field', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('Condition schema predicate field is identical to the hand-written ConditionDef predicate field', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('InteractConfigSchema has required interactions (mirrors InteractConfig)', () => { + type Interactions = InferredConfig['interactions']; + // interactions must be a required array + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + }); + + it('InteractConfigSchema has optional effects (mirrors InteractConfig)', () => { + // Both types declare effects as optional + expectTypeOf().toMatchTypeOf | undefined>(); + expectTypeOf().toMatchTypeOf | undefined>(); + }); + + it('InteractConfigSchema has optional sequences (mirrors InteractConfig)', () => { + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + }); + + it('InteractConfigSchema has optional conditions (mirrors InteractConfig)', () => { + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + expectTypeOf().toMatchTypeOf< + Record | undefined + >(); + }); +}); diff --git a/packages/interact/test/validate/validate.spec.ts b/packages/interact/test/validate/validate.spec.ts new file mode 100644 index 00000000..b5a9fc24 --- /dev/null +++ b/packages/interact/test/validate/validate.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + validateInteractConfig, + assertValidInteractConfig, + InteractValidationError, +} from '../../src/validate'; + +const VALID_CONFIG = { + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, + ], +}; + +// Unused effects produce UNUSED_EFFECT warnings (via unusedDefinitions rule, rule.code = 'UNUSED_DEFINITION') +const CONFIG_WITH_WARNING = { + effects: { unused: { namedEffect: { type: 'FadeIn' } } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'SlideIn' } }] }, + ], +}; + +// Missing effectId reference → EFFECT_ID_NOT_FOUND (error) +const CONFIG_WITH_ERROR = { + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'missing' }] }], +}; + +describe('validateInteractConfig', () => { + it('returns valid=true with no errors for a valid config', () => { + const result = validateInteractConfig(VALID_CONFIG); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns valid=false for a config with structural errors', () => { + const result = validateInteractConfig({ interactions: 'not-an-array' }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('returns valid=true (warnings only) for a config with only warnings', () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING); + expect(result.valid).toBe(true); + expect(result.errors.some((e) => e.severity === 'warning')).toBe(true); + }); + + it('returns valid=false for a config with semantic errors', () => { + const result = validateInteractConfig(CONFIG_WITH_ERROR); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.code === 'EFFECT_ID_NOT_FOUND')).toBe(true); + }); + + it('sorts errors by path lexicographically', () => { + const config = { + effects: { + a: { namedEffect: { type: 'Foo' } }, + b: { namedEffect: { type: 'Bar' } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'Baz' } }] }, + ], + }; + const result = validateInteractConfig(config); + const unusedErrors = result.errors.filter((e) => e.code === 'UNUSED_EFFECT'); + expect(unusedErrors).toHaveLength(2); + expect(unusedErrors[0].path).toEqual(['effects', 'a']); + expect(unusedErrors[1].path).toEqual(['effects', 'b']); + }); +}); + +describe('ValidateOptions', () => { + it('strict promotes warnings to errors, making valid=false', () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING, { strict: true }); + expect(result.valid).toBe(false); + expect(result.errors.every((e) => e.severity === 'error')).toBe(true); + }); + + it("severityOverrides with 'off' skips the rule entirely", () => { + // 'UNUSED_DEFINITION' is the rule.code for the unusedDefinitions rule + const result = validateInteractConfig(CONFIG_WITH_WARNING, { + severityOverrides: { UNUSED_DEFINITION: 'off' }, + }); + expect(result.errors.filter((e) => e.code === 'UNUSED_EFFECT')).toHaveLength(0); + }); + + it("severityOverrides can promote a warning to 'error'", () => { + const result = validateInteractConfig(CONFIG_WITH_WARNING, { + severityOverrides: { UNUSED_DEFINITION: 'error' }, + }); + const unusedErr = result.errors.find((e) => e.code === 'UNUSED_EFFECT'); + expect(unusedErr?.severity).toBe('error'); + }); + + it('max truncates the returned error list', () => { + const config = { + effects: { + a: { namedEffect: { type: 'Foo' } }, + b: { namedEffect: { type: 'Bar' } }, + c: { namedEffect: { type: 'Baz' } }, + }, + interactions: [ + { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'Qux' } }] }, + ], + }; + const result = validateInteractConfig(config, { max: 1 }); + expect(result.errors).toHaveLength(1); + }); +}); + +describe('assertValidInteractConfig', () => { + it('does not throw for a valid config', () => { + expect(() => assertValidInteractConfig(VALID_CONFIG)).not.toThrow(); + }); + + it('throws InteractValidationError for an invalid config', () => { + expect(() => assertValidInteractConfig(CONFIG_WITH_ERROR)).toThrow(InteractValidationError); + }); + + it('thrown error carries the validation errors array', () => { + try { + assertValidInteractConfig(CONFIG_WITH_ERROR); + } catch (e) { + expect(e).toBeInstanceOf(InteractValidationError); + expect((e as InteractValidationError).errors.length).toBeGreaterThan(0); + expect((e as InteractValidationError).errors[0].code).toBe('EFFECT_ID_NOT_FOUND'); + } + }); + + it('narrows the type to InteractConfig after assertion', () => { + const config: unknown = VALID_CONFIG; + assertValidInteractConfig(config); + // TypeScript now knows config: InteractConfig — no runtime assertion needed, + // the fact that no throw occurred is the guarantee. + expect(config).toBeDefined(); + }); +}); From 80f80ac1e7ff2b443f4f928056d170715198bca1 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 00:13:54 +0300 Subject: [PATCH 12/19] tests revealed fix --- packages/interact/src/validate/context.ts | 2 +- .../validate/rules/triggerEffectCompatible.spec.ts | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/interact/src/validate/context.ts b/packages/interact/src/validate/context.ts index d961aba2..011941d5 100644 --- a/packages/interact/src/validate/context.ts +++ b/packages/interact/src/validate/context.ts @@ -41,7 +41,7 @@ export type ValidationContext = { }; function isEffectRef(entry: Effect | EffectRef): entry is EffectRef { - return !('keyframeEffect' in entry) && !('namedEffect' in entry) && !('customEffect' in entry); + return typeof (entry as Record)['effectId'] === 'string'; } function isSequenceRef(entry: SequenceConfig | SequenceConfigRef): entry is SequenceConfigRef { diff --git a/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts b/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts index ebfed376..bbd6fc6f 100644 --- a/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts +++ b/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts @@ -79,13 +79,7 @@ describe('triggerEffectCompatible — TRIGGER_EFFECT_INCOMPATIBLE', () => { }); describe('state fields on scrub trigger', () => { - // A pure state effect (stateAction, transition, transitionProperties with no source field) is - // classified as an EffectRef by isEffectRef() because it lacks namedEffect/keyframeEffect/ - // customEffect. It is therefore never added to triggerEffectTuples, so TRIGGER_EFFECT_INCOMPATIBLE - // cannot fire for it. Instead it is caught by EFFECT_ID_NOT_FOUND (effectId is undefined). - // Mixing a source field WITH state fields is rejected by the schema (SCHEMA_INVALID), so - // STATE_FIELDS checks via triggerEffectCompatible are effectively a forward-compatibility guard. - it('does not emit TRIGGER_EFFECT_INCOMPATIBLE for a pure state effect (classified as effectRef)', () => { + it('emits TRIGGER_EFFECT_INCOMPATIBLE for stateAction on viewProgress', () => { const result = validateInteractConfig({ interactions: [ { @@ -95,7 +89,11 @@ describe('triggerEffectCompatible — TRIGGER_EFFECT_INCOMPATIBLE', () => { }, ], }); - expect(result.errors.filter((e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE')).toHaveLength(0); + expect( + result.errors.some( + (e) => e.code === 'TRIGGER_EFFECT_INCOMPATIBLE' && e.path.includes('stateAction'), + ), + ).toBe(true); }); }); From 4c858ffddf41c926701c23e7f78b6c49340681db Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 13:05:03 +0300 Subject: [PATCH 13/19] moving to a different package --- .yarnrc.yml | 2 + packages/interact-validate/package.json | 62 +++++++++++++++++++ .../src}/context.ts | 9 +-- .../src}/errors.ts | 0 .../src}/index.ts | 2 +- .../src}/rules/_factory.ts | 0 .../rules/conditions/validMediaQueries.ts | 0 .../src}/rules/index.ts | 3 - .../referential/animationEndEffectExists.ts | 0 .../src}/rules/referential/conditionsExist.ts | 0 .../src}/rules/referential/effectIdsExist.ts | 0 .../interactionHasEffectsOrSequences.ts | 0 .../rules/referential/sequenceIdsExist.ts | 0 .../semantic/conditionPredicateRequired.ts | 0 .../src}/rules/semantic/numericBounds.ts | 0 .../rules/semantic/triggerEffectCompatible.ts | 0 .../rules/semantic/uniqueDefinitionIds.ts | 0 .../src}/rules/semantic/unusedDefinitions.ts | 0 .../src}/schema/effects.ts | 0 .../src}/schema/index.ts | 6 +- .../src}/schema/interactions.ts | 0 .../src}/schema/primitives.ts | 0 .../src}/schema/sequences.ts | 0 .../src}/semantic.ts | 0 .../src}/structural.ts | 2 +- .../rules/animationEndEffectExists.spec.ts | 2 +- .../rules/conditionPredicateRequired.spec.ts | 2 +- .../test}/rules/conditionsExist.spec.ts | 2 +- .../test}/rules/effectIdsExist.spec.ts | 2 +- .../interactionHasEffectsOrSequences.spec.ts | 2 +- .../test}/rules/numericBounds.spec.ts | 2 +- .../test}/rules/sequenceIdsExist.spec.ts | 2 +- .../rules/triggerEffectCompatible.spec.ts | 2 +- .../test}/rules/uniqueDefinitionIds.spec.ts | 2 +- .../test}/rules/unusedDefinitions.spec.ts | 2 +- .../test}/rules/validMediaQueries.spec.ts | 2 +- .../test}/structural.spec.ts | 2 +- .../test}/type-parity.spec.ts | 6 +- .../test}/validate.spec.ts | 6 +- .../interact-validate/tsconfig.build.json | 21 +++++++ packages/interact-validate/tsconfig.json | 17 +++++ packages/interact-validate/vite.config.ts | 25 ++++++++ packages/interact-validate/vitest.config.ts | 7 +++ packages/interact/package.json | 8 +-- packages/interact/src/types/external.ts | 1 + .../interact/test/validate/bundle.spec.ts | 34 ---------- packages/interact/vite.config.ts | 3 +- 47 files changed, 159 insertions(+), 79 deletions(-) create mode 100644 packages/interact-validate/package.json rename packages/{interact/src/validate => interact-validate/src}/context.ts (96%) rename packages/{interact/src/validate => interact-validate/src}/errors.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/index.ts (98%) rename packages/{interact/src/validate => interact-validate/src}/rules/_factory.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/conditions/validMediaQueries.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/index.ts (94%) rename packages/{interact/src/validate => interact-validate/src}/rules/referential/animationEndEffectExists.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/referential/conditionsExist.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/referential/effectIdsExist.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/referential/interactionHasEffectsOrSequences.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/referential/sequenceIdsExist.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/semantic/conditionPredicateRequired.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/semantic/numericBounds.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/semantic/triggerEffectCompatible.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/semantic/uniqueDefinitionIds.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/rules/semantic/unusedDefinitions.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/schema/effects.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/schema/index.ts (80%) rename packages/{interact/src/validate => interact-validate/src}/schema/interactions.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/schema/primitives.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/schema/sequences.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/semantic.ts (100%) rename packages/{interact/src/validate => interact-validate/src}/structural.ts (95%) rename packages/{interact/test/validate => interact-validate/test}/rules/animationEndEffectExists.spec.ts (95%) rename packages/{interact/test/validate => interact-validate/test}/rules/conditionPredicateRequired.spec.ts (97%) rename packages/{interact/test/validate => interact-validate/test}/rules/conditionsExist.spec.ts (96%) rename packages/{interact/test/validate => interact-validate/test}/rules/effectIdsExist.spec.ts (95%) rename packages/{interact/test/validate => interact-validate/test}/rules/interactionHasEffectsOrSequences.spec.ts (96%) rename packages/{interact/test/validate => interact-validate/test}/rules/numericBounds.spec.ts (98%) rename packages/{interact/test/validate => interact-validate/test}/rules/sequenceIdsExist.spec.ts (93%) rename packages/{interact/test/validate => interact-validate/test}/rules/triggerEffectCompatible.spec.ts (98%) rename packages/{interact/test/validate => interact-validate/test}/rules/uniqueDefinitionIds.spec.ts (97%) rename packages/{interact/test/validate => interact-validate/test}/rules/unusedDefinitions.spec.ts (98%) rename packages/{interact/test/validate => interact-validate/test}/rules/validMediaQueries.spec.ts (98%) rename packages/{interact/test/validate => interact-validate/test}/structural.spec.ts (97%) rename packages/{interact/test/validate => interact-validate/test}/type-parity.spec.ts (93%) rename packages/{interact/test/validate => interact-validate/test}/validate.spec.ts (97%) create mode 100644 packages/interact-validate/tsconfig.build.json create mode 100644 packages/interact-validate/tsconfig.json create mode 100644 packages/interact-validate/vite.config.ts create mode 100644 packages/interact-validate/vitest.config.ts delete mode 100644 packages/interact/test/validate/bundle.spec.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index d06c71c9..2a849f0a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,6 +2,8 @@ compressionLevel: mixed enableGlobalCache: true +enableNetwork: true + enableTelemetry: false nodeLinker: node-modules diff --git a/packages/interact-validate/package.json b/packages/interact-validate/package.json new file mode 100644 index 00000000..f44b06dd --- /dev/null +++ b/packages/interact-validate/package.json @@ -0,0 +1,62 @@ +{ + "name": "@wix/interact-validate", + "version": "1.0.0", + "description": "Schema + referential + semantic validation for @wix/interact's InteractConfig, powered by zod.", + "license": "MIT", + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/es/index.js", + "require": "./dist/cjs/index.js" + } + }, + "files": [ + "dist", + "rules" + ], + "sideEffects": false, + "scripts": { + "build": "rimraf dist && vite build && npm run build:types", + "build:types": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "keywords": [ + "animation", + "interaction", + "validation", + "zod", + "schema", + "interact", + "wix" + ], + "author": { + "name": "wow!Team", + "email": "wow-dev@wix.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wix/interact.git" + }, + "bugs": { + "url": "https://github.com/wix/interact/issues" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "peerDependencies": { + "@wix/interact": "^2.4.0" + }, + "devDependencies": { + "@wix/interact": "^2.4.0", + "@vitest/coverage-v8": "^4.0.14", + "rimraf": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.14" + } +} diff --git a/packages/interact/src/validate/context.ts b/packages/interact-validate/src/context.ts similarity index 96% rename from packages/interact/src/validate/context.ts rename to packages/interact-validate/src/context.ts index 011941d5..7fc499a1 100644 --- a/packages/interact/src/validate/context.ts +++ b/packages/interact-validate/src/context.ts @@ -1,10 +1,5 @@ -import type { - InteractConfig, - SequenceConfig, - SequenceConfigRef, - Interaction, -} from '../types/config'; -import type { Effect, EffectRef } from '../types/effects'; +import type { InteractConfig, SequenceConfig, SequenceConfigRef, Interaction } from '@wix/interact'; +import type { Effect, EffectRef } from '@wix/interact'; export type Path = (string | number)[]; diff --git a/packages/interact/src/validate/errors.ts b/packages/interact-validate/src/errors.ts similarity index 100% rename from packages/interact/src/validate/errors.ts rename to packages/interact-validate/src/errors.ts diff --git a/packages/interact/src/validate/index.ts b/packages/interact-validate/src/index.ts similarity index 98% rename from packages/interact/src/validate/index.ts rename to packages/interact-validate/src/index.ts index a05b08dd..23fdeca9 100644 --- a/packages/interact/src/validate/index.ts +++ b/packages/interact-validate/src/index.ts @@ -1,4 +1,4 @@ -import type { InteractConfig } from '../types/config'; +import type { InteractConfig } from '@wix/interact'; import { buildContext } from './context'; import { InteractValidationError, diff --git a/packages/interact/src/validate/rules/_factory.ts b/packages/interact-validate/src/rules/_factory.ts similarity index 100% rename from packages/interact/src/validate/rules/_factory.ts rename to packages/interact-validate/src/rules/_factory.ts diff --git a/packages/interact/src/validate/rules/conditions/validMediaQueries.ts b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts similarity index 100% rename from packages/interact/src/validate/rules/conditions/validMediaQueries.ts rename to packages/interact-validate/src/rules/conditions/validMediaQueries.ts diff --git a/packages/interact/src/validate/rules/index.ts b/packages/interact-validate/src/rules/index.ts similarity index 94% rename from packages/interact/src/validate/rules/index.ts rename to packages/interact-validate/src/rules/index.ts index 41ace641..3cc6dc84 100644 --- a/packages/interact/src/validate/rules/index.ts +++ b/packages/interact-validate/src/rules/index.ts @@ -22,15 +22,12 @@ export type Rule = { }; export const RULES: Rule[] = [ - // Referential rules (errors) effectIdsExist, sequenceIdsExist, animationEndEffectExists, conditionsExist, interactionHasEffectsOrSequences, - // Condition rules (warnings) validMediaQueries, - // Semantic rules triggerEffectCompatible, numericBounds, conditionPredicateRequired, diff --git a/packages/interact/src/validate/rules/referential/animationEndEffectExists.ts b/packages/interact-validate/src/rules/referential/animationEndEffectExists.ts similarity index 100% rename from packages/interact/src/validate/rules/referential/animationEndEffectExists.ts rename to packages/interact-validate/src/rules/referential/animationEndEffectExists.ts diff --git a/packages/interact/src/validate/rules/referential/conditionsExist.ts b/packages/interact-validate/src/rules/referential/conditionsExist.ts similarity index 100% rename from packages/interact/src/validate/rules/referential/conditionsExist.ts rename to packages/interact-validate/src/rules/referential/conditionsExist.ts diff --git a/packages/interact/src/validate/rules/referential/effectIdsExist.ts b/packages/interact-validate/src/rules/referential/effectIdsExist.ts similarity index 100% rename from packages/interact/src/validate/rules/referential/effectIdsExist.ts rename to packages/interact-validate/src/rules/referential/effectIdsExist.ts diff --git a/packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts b/packages/interact-validate/src/rules/referential/interactionHasEffectsOrSequences.ts similarity index 100% rename from packages/interact/src/validate/rules/referential/interactionHasEffectsOrSequences.ts rename to packages/interact-validate/src/rules/referential/interactionHasEffectsOrSequences.ts diff --git a/packages/interact/src/validate/rules/referential/sequenceIdsExist.ts b/packages/interact-validate/src/rules/referential/sequenceIdsExist.ts similarity index 100% rename from packages/interact/src/validate/rules/referential/sequenceIdsExist.ts rename to packages/interact-validate/src/rules/referential/sequenceIdsExist.ts diff --git a/packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts b/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts similarity index 100% rename from packages/interact/src/validate/rules/semantic/conditionPredicateRequired.ts rename to packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts diff --git a/packages/interact/src/validate/rules/semantic/numericBounds.ts b/packages/interact-validate/src/rules/semantic/numericBounds.ts similarity index 100% rename from packages/interact/src/validate/rules/semantic/numericBounds.ts rename to packages/interact-validate/src/rules/semantic/numericBounds.ts diff --git a/packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts b/packages/interact-validate/src/rules/semantic/triggerEffectCompatible.ts similarity index 100% rename from packages/interact/src/validate/rules/semantic/triggerEffectCompatible.ts rename to packages/interact-validate/src/rules/semantic/triggerEffectCompatible.ts diff --git a/packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts b/packages/interact-validate/src/rules/semantic/uniqueDefinitionIds.ts similarity index 100% rename from packages/interact/src/validate/rules/semantic/uniqueDefinitionIds.ts rename to packages/interact-validate/src/rules/semantic/uniqueDefinitionIds.ts diff --git a/packages/interact/src/validate/rules/semantic/unusedDefinitions.ts b/packages/interact-validate/src/rules/semantic/unusedDefinitions.ts similarity index 100% rename from packages/interact/src/validate/rules/semantic/unusedDefinitions.ts rename to packages/interact-validate/src/rules/semantic/unusedDefinitions.ts diff --git a/packages/interact/src/validate/schema/effects.ts b/packages/interact-validate/src/schema/effects.ts similarity index 100% rename from packages/interact/src/validate/schema/effects.ts rename to packages/interact-validate/src/schema/effects.ts diff --git a/packages/interact/src/validate/schema/index.ts b/packages/interact-validate/src/schema/index.ts similarity index 80% rename from packages/interact/src/validate/schema/index.ts rename to packages/interact-validate/src/schema/index.ts index 0012f4de..928b30bc 100644 --- a/packages/interact/src/validate/schema/index.ts +++ b/packages/interact-validate/src/schema/index.ts @@ -26,8 +26,6 @@ export { SerializableSequenceConfig, SerializableSequenceConfigRef } from './seq export { Keyframe, LengthPercentage, RangeOffset, Condition, MediaCondition } from './primitives'; // Canonical types — single source of truth, no z.infer<> re-derivation. -// Names that collide with a zod schema value above are exported with a -// `Def` suffix; unambiguous names are exported as-is. export type { InteractConfig, Condition as ConditionDef, @@ -36,6 +34,6 @@ export type { SequenceConfigRef, Interaction as InteractionDef, InteractionTrigger, -} from '../../types/config'; +} from '@wix/interact'; -export type { Effect, EffectRef } from '../../types/effects'; +export type { Effect, EffectRef } from '@wix/interact'; diff --git a/packages/interact/src/validate/schema/interactions.ts b/packages/interact-validate/src/schema/interactions.ts similarity index 100% rename from packages/interact/src/validate/schema/interactions.ts rename to packages/interact-validate/src/schema/interactions.ts diff --git a/packages/interact/src/validate/schema/primitives.ts b/packages/interact-validate/src/schema/primitives.ts similarity index 100% rename from packages/interact/src/validate/schema/primitives.ts rename to packages/interact-validate/src/schema/primitives.ts diff --git a/packages/interact/src/validate/schema/sequences.ts b/packages/interact-validate/src/schema/sequences.ts similarity index 100% rename from packages/interact/src/validate/schema/sequences.ts rename to packages/interact-validate/src/schema/sequences.ts diff --git a/packages/interact/src/validate/semantic.ts b/packages/interact-validate/src/semantic.ts similarity index 100% rename from packages/interact/src/validate/semantic.ts rename to packages/interact-validate/src/semantic.ts diff --git a/packages/interact/src/validate/structural.ts b/packages/interact-validate/src/structural.ts similarity index 95% rename from packages/interact/src/validate/structural.ts rename to packages/interact-validate/src/structural.ts index 0202c6a4..e3b4d438 100644 --- a/packages/interact/src/validate/structural.ts +++ b/packages/interact-validate/src/structural.ts @@ -1,6 +1,6 @@ import type { ZodIssue } from 'zod'; import { InteractConfigSchema } from './schema'; -import type { InteractConfig } from '../types/config'; +import type { InteractConfig } from '@wix/interact'; import type { ValidationError } from './errors'; function mapZodCode(issue: ZodIssue): string { diff --git a/packages/interact/test/validate/rules/animationEndEffectExists.spec.ts b/packages/interact-validate/test/rules/animationEndEffectExists.spec.ts similarity index 95% rename from packages/interact/test/validate/rules/animationEndEffectExists.spec.ts rename to packages/interact-validate/test/rules/animationEndEffectExists.spec.ts index a36fa883..6b6b9a37 100644 --- a/packages/interact/test/validate/rules/animationEndEffectExists.spec.ts +++ b/packages/interact-validate/test/rules/animationEndEffectExists.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('animationEndEffectExists — ANIMATION_END_EFFECT_NOT_FOUND', () => { it('emits no errors when the animationEnd params.effectId resolves to a defined effect', () => { diff --git a/packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts b/packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts similarity index 97% rename from packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts rename to packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts index cfe9b73d..04a37284 100644 --- a/packages/interact/test/validate/rules/conditionPredicateRequired.spec.ts +++ b/packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('conditionPredicateRequired — CONDITION_PREDICATE_REQUIRED', () => { it('emits no errors for a media condition with a predicate', () => { diff --git a/packages/interact/test/validate/rules/conditionsExist.spec.ts b/packages/interact-validate/test/rules/conditionsExist.spec.ts similarity index 96% rename from packages/interact/test/validate/rules/conditionsExist.spec.ts rename to packages/interact-validate/test/rules/conditionsExist.spec.ts index fd8f16c6..3faaf055 100644 --- a/packages/interact/test/validate/rules/conditionsExist.spec.ts +++ b/packages/interact-validate/test/rules/conditionsExist.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('conditionsExist — CONDITION_NOT_FOUND', () => { it('emits no errors when all condition references resolve to defined conditions', () => { diff --git a/packages/interact/test/validate/rules/effectIdsExist.spec.ts b/packages/interact-validate/test/rules/effectIdsExist.spec.ts similarity index 95% rename from packages/interact/test/validate/rules/effectIdsExist.spec.ts rename to packages/interact-validate/test/rules/effectIdsExist.spec.ts index 8fb6f82c..86de1d8c 100644 --- a/packages/interact/test/validate/rules/effectIdsExist.spec.ts +++ b/packages/interact-validate/test/rules/effectIdsExist.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('effectIdsExist — EFFECT_ID_NOT_FOUND', () => { it('emits no errors when an effectId reference resolves to a defined effect', () => { diff --git a/packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts b/packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts similarity index 96% rename from packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts rename to packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts index 5e9675a0..9c028e3e 100644 --- a/packages/interact/test/validate/rules/interactionHasEffectsOrSequences.spec.ts +++ b/packages/interact-validate/test/rules/interactionHasEffectsOrSequences.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('interactionHasEffectsOrSequences — INTERACTION_EMPTY', () => { it('emits no errors when an interaction has at least one effect', () => { diff --git a/packages/interact/test/validate/rules/numericBounds.spec.ts b/packages/interact-validate/test/rules/numericBounds.spec.ts similarity index 98% rename from packages/interact/test/validate/rules/numericBounds.spec.ts rename to packages/interact-validate/test/rules/numericBounds.spec.ts index c7c5cf65..83a338f6 100644 --- a/packages/interact/test/validate/rules/numericBounds.spec.ts +++ b/packages/interact-validate/test/rules/numericBounds.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('numericBounds', () => { describe('valid configs', () => { diff --git a/packages/interact/test/validate/rules/sequenceIdsExist.spec.ts b/packages/interact-validate/test/rules/sequenceIdsExist.spec.ts similarity index 93% rename from packages/interact/test/validate/rules/sequenceIdsExist.spec.ts rename to packages/interact-validate/test/rules/sequenceIdsExist.spec.ts index 0fe7a15c..faafc86d 100644 --- a/packages/interact/test/validate/rules/sequenceIdsExist.spec.ts +++ b/packages/interact-validate/test/rules/sequenceIdsExist.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('sequenceIdsExist — SEQUENCE_ID_NOT_FOUND', () => { it('emits no errors when a sequenceId reference resolves to a defined sequence', () => { diff --git a/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts b/packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts similarity index 98% rename from packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts rename to packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts index bbd6fc6f..ddc74046 100644 --- a/packages/interact/test/validate/rules/triggerEffectCompatible.spec.ts +++ b/packages/interact-validate/test/rules/triggerEffectCompatible.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('triggerEffectCompatible — TRIGGER_EFFECT_INCOMPATIBLE', () => { describe('valid combinations', () => { diff --git a/packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts b/packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts similarity index 97% rename from packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts rename to packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts index 6a711517..b2cded7c 100644 --- a/packages/interact/test/validate/rules/uniqueDefinitionIds.spec.ts +++ b/packages/interact-validate/test/rules/uniqueDefinitionIds.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; const KEYFRAME_EFFECT_A = { name: 'anim1', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; const KEYFRAME_EFFECT_B = { name: 'anim2', keyframes: [{ opacity: '0' }, { opacity: '1' }] }; diff --git a/packages/interact/test/validate/rules/unusedDefinitions.spec.ts b/packages/interact-validate/test/rules/unusedDefinitions.spec.ts similarity index 98% rename from packages/interact/test/validate/rules/unusedDefinitions.spec.ts rename to packages/interact-validate/test/rules/unusedDefinitions.spec.ts index 7c9b7267..9d1fa4be 100644 --- a/packages/interact/test/validate/rules/unusedDefinitions.spec.ts +++ b/packages/interact-validate/test/rules/unusedDefinitions.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; describe('unusedDefinitions', () => { it('emits no errors when all definitions are referenced', () => { diff --git a/packages/interact/test/validate/rules/validMediaQueries.spec.ts b/packages/interact-validate/test/rules/validMediaQueries.spec.ts similarity index 98% rename from packages/interact/test/validate/rules/validMediaQueries.spec.ts rename to packages/interact-validate/test/rules/validMediaQueries.spec.ts index 6fd07fa5..4181e32f 100644 --- a/packages/interact/test/validate/rules/validMediaQueries.spec.ts +++ b/packages/interact-validate/test/rules/validMediaQueries.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { validateInteractConfig } from '../../../src/validate'; +import { validateInteractConfig } from '../../src'; // jsdom defines window.matchMedia but its stub returns `media: ''` for all // queries because jsdom does not implement CSS parsing. Provide a minimal diff --git a/packages/interact/test/validate/structural.spec.ts b/packages/interact-validate/test/structural.spec.ts similarity index 97% rename from packages/interact/test/validate/structural.spec.ts rename to packages/interact-validate/test/structural.spec.ts index 82f2e9eb..29ebae02 100644 --- a/packages/interact/test/validate/structural.spec.ts +++ b/packages/interact-validate/test/structural.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateStructural } from '../../src/validate/structural'; +import { validateStructural } from '../src/structural'; const VALID_CONFIG = { interactions: [ diff --git a/packages/interact/test/validate/type-parity.spec.ts b/packages/interact-validate/test/type-parity.spec.ts similarity index 93% rename from packages/interact/test/validate/type-parity.spec.ts rename to packages/interact-validate/test/type-parity.spec.ts index 5d5bdb18..8d318ff7 100644 --- a/packages/interact/test/validate/type-parity.spec.ts +++ b/packages/interact-validate/test/type-parity.spec.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { z } from 'zod'; -import type { InteractConfig, Condition as ConditionDef } from '../../src/types/config'; -import { InteractConfigSchema, Condition } from '../../src/validate/schema'; +import type { InteractConfig, Condition as ConditionDef } from '@wix/interact'; +import { InteractConfigSchema, Condition } from '../src/schema'; type InferredConfig = z.infer; type InferredCondition = z.infer; @@ -9,7 +9,7 @@ type InferredCondition = z.infer; // --------------------------------------------------------------------------- // These tests are compile-time drift guards. They fail at TypeScript type- // checking time (yarn lint / tsc --noEmit) if the zod schemas diverge from -// the hand-written types in src/types/. At runtime they are no-ops. +// the hand-written types in @wix/interact. At runtime they are no-ops. // --------------------------------------------------------------------------- describe('schema type parity (drift guard)', () => { diff --git a/packages/interact/test/validate/validate.spec.ts b/packages/interact-validate/test/validate.spec.ts similarity index 97% rename from packages/interact/test/validate/validate.spec.ts rename to packages/interact-validate/test/validate.spec.ts index b5a9fc24..424d5867 100644 --- a/packages/interact/test/validate/validate.spec.ts +++ b/packages/interact-validate/test/validate.spec.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - validateInteractConfig, - assertValidInteractConfig, - InteractValidationError, -} from '../../src/validate'; +import { validateInteractConfig, assertValidInteractConfig, InteractValidationError } from '../src'; const VALID_CONFIG = { interactions: [ diff --git a/packages/interact-validate/tsconfig.build.json b/packages/interact-validate/tsconfig.build.json new file mode 100644 index 00000000..7d8c0867 --- /dev/null +++ b/packages/interact-validate/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationDir": "dist/types", + "emitDeclarationOnly": true, + "noEmit": false, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"], + "references": [ + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-validate/tsconfig.json b/packages/interact-validate/tsconfig.json new file mode 100644 index 00000000..8b9d3a15 --- /dev/null +++ b/packages/interact-validate/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist/es", + "declarationDir": "dist/types", + "declaration": true, + "composite": true, + "baseUrl": "." + }, + "include": ["src/**/*", "src"], + "references": [ + { + "path": "../interact" + } + ] +} diff --git a/packages/interact-validate/vite.config.ts b/packages/interact-validate/vite.config.ts new file mode 100644 index 00000000..ec303df5 --- /dev/null +++ b/packages/interact-validate/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default defineConfig({ + build: { + lib: { + entry: { + index: path.resolve(__dirname, 'src/index.ts'), + }, + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: ['zod', '@wix/interact'], + output: { + entryFileNames: '[format]/[name].js', + compact: true, + }, + }, + }, +}); diff --git a/packages/interact-validate/vitest.config.ts b/packages/interact-validate/vitest.config.ts new file mode 100644 index 00000000..647a9e54 --- /dev/null +++ b/packages/interact-validate/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}); diff --git a/packages/interact/package.json b/packages/interact/package.json index aa1ad503..181dcca2 100644 --- a/packages/interact/package.json +++ b/packages/interact/package.json @@ -23,11 +23,6 @@ "types": "./dist/types/web/index.d.ts", "import": "./dist/es/web.js", "require": "./dist/cjs/web.js" - }, - "./validate": { - "types": "./dist/types/validate/index.d.ts", - "import": "./dist/es/validate.js", - "require": "./dist/cjs/validate.js" } }, "files": [ @@ -76,8 +71,7 @@ "@wix/motion": "^2.1.7", "fastdom": "^1.0.12", "fizban": "^0.7.2", - "kuliso": "^0.4.13", - "zod": "^4.0.0" + "kuliso": "^0.4.13" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", diff --git a/packages/interact/src/types/external.ts b/packages/interact/src/types/external.ts index adefdbf0..3915e2ed 100644 --- a/packages/interact/src/types/external.ts +++ b/packages/interact/src/types/external.ts @@ -27,6 +27,7 @@ export type { // Config export type { Condition, + SequenceOptionsConfig, SequenceConfig, SequenceConfigRef, InteractionTrigger, diff --git a/packages/interact/test/validate/bundle.spec.ts b/packages/interact/test/validate/bundle.spec.ts deleted file mode 100644 index ffab0b7d..00000000 --- a/packages/interact/test/validate/bundle.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; - -// fileURLToPath + dirname gives us the directory of this test file, -// which works correctly in vitest's jsdom environment. -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const INDEX_BUNDLE = path.resolve(__dirname, '../../dist/es/index.js'); -const VALIDATE_BUNDLE = path.resolve(__dirname, '../../dist/es/validate.js'); - -describe('bundle isolation', () => { - it('dist/es/index.js does not contain a zod import (zod must be tree-shaken from main bundle)', () => { - if (!existsSync(INDEX_BUNDLE)) { - console.warn( - '[bundle test] dist/es/index.js not found — run `yarn build` first to enable this check', - ); - return; - } - const content = readFileSync(INDEX_BUNDLE, 'utf8'); - // zod should only appear in dist/es/validate.js, never in the main bundle - expect(content).not.toMatch(/["']zod["']/); - }); - - it('dist/es/validate.js exists after build (validate entry was compiled)', () => { - if (!existsSync(INDEX_BUNDLE)) { - console.warn('[bundle test] dist/ not found — run `yarn build` first to enable this check'); - return; - } - expect(existsSync(VALIDATE_BUNDLE)).toBe(true); - }); -}); diff --git a/packages/interact/vite.config.ts b/packages/interact/vite.config.ts index 1a7ba9aa..6c383a2d 100644 --- a/packages/interact/vite.config.ts +++ b/packages/interact/vite.config.ts @@ -15,13 +15,12 @@ export default defineConfig(({ command }) => { index: path.resolve(__dirname, 'src/index.ts'), react: path.resolve(__dirname, 'src/react/index.ts'), web: path.resolve(__dirname, 'src/web/index.ts'), - validate: path.resolve(__dirname, 'src/validate/index.ts'), }, formats: ['es', 'cjs'], }, sourcemap: true, rollupOptions: { - external: ['react', 'react-dom', 'zod'], + external: ['react', 'react-dom'], output: { entryFileNames: '[format]/[name].js', compact: true, From 433dc0c5938fe83ad27a5b04e9513d6d01fd2bb5 Mon Sep 17 00:00:00 2001 From: Ameer Abu-Fraiha Date: Tue, 2 Jun 2026 10:15:18 +0000 Subject: [PATCH 14/19] yarn install --- .yarnrc.yml | 2 -- packages/interact-validate/package.json | 2 +- yarn.lock | 17 ++++++++++++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 2a849f0a..d06c71c9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,8 +2,6 @@ compressionLevel: mixed enableGlobalCache: true -enableNetwork: true - enableTelemetry: false nodeLinker: node-modules diff --git a/packages/interact-validate/package.json b/packages/interact-validate/package.json index f44b06dd..83034ff2 100644 --- a/packages/interact-validate/package.json +++ b/packages/interact-validate/package.json @@ -52,8 +52,8 @@ "@wix/interact": "^2.4.0" }, "devDependencies": { - "@wix/interact": "^2.4.0", "@vitest/coverage-v8": "^4.0.14", + "@wix/interact": "^2.4.0", "rimraf": "^6.0.1", "typescript": "^5.9.3", "vite": "^7.2.2", diff --git a/yarn.lock b/yarn.lock index 4a27e63e..6f0b8df1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1396,6 +1396,22 @@ __metadata: languageName: unknown linkType: soft +"@wix/interact-validate@workspace:packages/interact-validate": + version: 0.0.0-use.local + resolution: "@wix/interact-validate@workspace:packages/interact-validate" + dependencies: + "@vitest/coverage-v8": "npm:^4.0.14" + "@wix/interact": "npm:^2.4.0" + rimraf: "npm:^6.0.1" + typescript: "npm:^5.9.3" + vite: "npm:^7.2.2" + vitest: "npm:^4.0.14" + zod: "npm:^4.0.0" + peerDependencies: + "@wix/interact": ^2.4.0 + languageName: unknown + linkType: soft + "@wix/interact-website@workspace:apps/website": version: 0.0.0-use.local resolution: "@wix/interact-website@workspace:apps/website" @@ -1423,7 +1439,6 @@ __metadata: typescript: "npm:^5.9.3" vite: "npm:^7.2.2" vitest: "npm:^4.0.14" - zod: "npm:^4.0.0" peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 From bf49eee31c8ac5b99bec6433a01e72e77fdf2ed3 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 13:26:07 +0300 Subject: [PATCH 15/19] fixing build --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b98ee690..2487a275 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "index.js", "packageManager": "yarn@4.10.3", "scripts": { - "build": "yarn workspaces foreach --all --topological --include 'packages/*' run build && yarn workspaces foreach --all --include 'apps/*' run build", + "build": "yarn workspaces foreach --all --topological-dev --include 'packages/*' run build && yarn workspaces foreach --all --include 'apps/*' run build", "lint": "eslint . --max-warnings=0", "test": "yarn workspaces foreach --all --include 'packages/*' run test", "dev:website": "yarn workspace @wix/interact-website run dev", From f80042510332fa8a2118f51ccccf2058777bb1cf Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 15:24:12 +0300 Subject: [PATCH 16/19] predicate is required --- .../inspector/pg-condition-editor.ts | 4 +- .../src/rules/conditions/validMediaQueries.ts | 2 +- .../semantic/conditionPredicateRequired.ts | 4 +- .../src/schema/primitives.ts | 2 +- .../test/rules/validMediaQueries.spec.ts | 2 +- packages/interact/docs/api/types.md | 2 +- .../interact/docs/guides/state-management.md | 41 ------------------- packages/interact/src/types/config.ts | 2 +- 8 files changed, 8 insertions(+), 51 deletions(-) diff --git a/apps/playground/src/components/inspector/pg-condition-editor.ts b/apps/playground/src/components/inspector/pg-condition-editor.ts index fa0a1b28..49adae0a 100644 --- a/apps/playground/src/components/inspector/pg-condition-editor.ts +++ b/apps/playground/src/components/inspector/pg-condition-editor.ts @@ -236,7 +236,7 @@ export class PgConditionEditor extends BaseComponent {
@@ -350,7 +350,7 @@ export class PgConditionEditor extends BaseComponent { this.store.dispatch( updateCondition(condId, { ...current, - predicate: input.value || undefined, + predicate: input.value || '', }), ); } diff --git a/packages/interact-validate/src/rules/conditions/validMediaQueries.ts b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts index 54c16c3b..1ff47f03 100644 --- a/packages/interact-validate/src/rules/conditions/validMediaQueries.ts +++ b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts @@ -34,7 +34,7 @@ export const validMediaQueries: Rule = { run: (ctx) => { const errors: ValidationError[] = []; for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { - if (condition.type === 'media' && condition.predicate !== undefined) { + if (condition.type === 'media') { if (!isValidMediaQuery(condition.predicate)) { errors.push({ code: 'INVALID_MEDIA_QUERY', diff --git a/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts b/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts index 4ee7a299..ed4f2e64 100644 --- a/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts +++ b/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts @@ -1,15 +1,13 @@ import type { Rule } from '..'; import type { ValidationError } from '../../errors'; -// The schema marks `predicate` as optional for simplicity, but `media` and -// `container` conditions require it to be meaningful at runtime. export const conditionPredicateRequired: Rule = { code: 'CONDITION_PREDICATE_REQUIRED', defaultSeverity: 'error', run: (ctx): ValidationError[] => { const errors: ValidationError[] = []; for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { - if ((condition.type === 'media' || condition.type === 'container') && !condition.predicate) { + if (!condition.predicate) { errors.push({ code: 'CONDITION_PREDICATE_REQUIRED', severity: 'error', diff --git a/packages/interact-validate/src/schema/primitives.ts b/packages/interact-validate/src/schema/primitives.ts index c69904e5..91a20d0d 100644 --- a/packages/interact-validate/src/schema/primitives.ts +++ b/packages/interact-validate/src/schema/primitives.ts @@ -25,7 +25,7 @@ export const RangeOffset = z export const Condition = z .object({ type: z.enum(['media', 'container', 'selector']), - predicate: z.string().optional(), + predicate: z.string(), }) .strict(); diff --git a/packages/interact-validate/test/rules/validMediaQueries.spec.ts b/packages/interact-validate/test/rules/validMediaQueries.spec.ts index 4181e32f..45360c53 100644 --- a/packages/interact-validate/test/rules/validMediaQueries.spec.ts +++ b/packages/interact-validate/test/rules/validMediaQueries.spec.ts @@ -30,7 +30,7 @@ function configWithCondition( type: 'media' | 'container' | 'selector' = 'media', ) { return { - conditions: { [id]: predicate !== undefined ? { type, predicate } : { type } }, + conditions: { [id]: { type, predicate: predicate || '' } }, interactions: [ { key: 'el', diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index ba342652..64f40182 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -873,7 +873,7 @@ Defines conditional logic for interactions. ```typescript type Condition = { type: 'media' | 'container' | 'selector'; - predicate?: string; + predicate: string; }; ``` diff --git a/packages/interact/docs/guides/state-management.md b/packages/interact/docs/guides/state-management.md index 081a1573..37bf9692 100644 --- a/packages/interact/docs/guides/state-management.md +++ b/packages/interact/docs/guides/state-management.md @@ -405,47 +405,6 @@ observer.observe(interactElement, { }); ``` -### State-Based Conditional Logic - -Use states to control other interactions: - -```typescript -const conditionalConfig = { - conditions: { - 'menu-open': { - type: 'custom', - predicate: () => { - const element = document.querySelector('interact-element'); - return hasState('menu-open'); - }, - }, - }, - interactions: [ - // Close menu when clicking outside - { - key: 'page-body', - trigger: 'click', - conditions: ['menu-open'], - effects: [ - { - key: 'mobile-menu', - effectId: 'menu-close', - keyframeEffect: { - name: 'slide-out', - keyframes: [ - { opacity: '1', transform: 'translateX(0)' }, - { opacity: '0', transform: 'translateX(100%)' }, - ], - }, - duration: 300, - easing: 'ease-in', - }, - ], - }, - ], -}; -``` - ## State Management Patterns ### State Machine Pattern diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index 763a4c37..c505dcef 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -3,7 +3,7 @@ import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from export type Condition = { type: 'media' | 'container' | 'selector'; - predicate?: string; + predicate: string; }; export type SequenceOptionsConfig = { From 4d46fc20bffd1e5d7a0ccaa96ce8fb77e3d4f23b Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 16:51:20 +0300 Subject: [PATCH 17/19] fixing predicate --- .../src/rules/conditions/validMediaQueries.ts | 2 +- packages/interact-validate/src/rules/index.ts | 2 - .../semantic/conditionPredicateRequired.ts | 22 ----- .../src/schema/primitives.ts | 2 +- .../rules/conditionPredicateRequired.spec.ts | 85 ------------------- .../interact-validate/test/structural.spec.ts | 15 ++++ 6 files changed, 17 insertions(+), 111 deletions(-) delete mode 100644 packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts delete mode 100644 packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts diff --git a/packages/interact-validate/src/rules/conditions/validMediaQueries.ts b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts index 1ff47f03..ba096a07 100644 --- a/packages/interact-validate/src/rules/conditions/validMediaQueries.ts +++ b/packages/interact-validate/src/rules/conditions/validMediaQueries.ts @@ -34,7 +34,7 @@ export const validMediaQueries: Rule = { run: (ctx) => { const errors: ValidationError[] = []; for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { - if (condition.type === 'media') { + if (condition.type === 'media' && condition.predicate) { if (!isValidMediaQuery(condition.predicate)) { errors.push({ code: 'INVALID_MEDIA_QUERY', diff --git a/packages/interact-validate/src/rules/index.ts b/packages/interact-validate/src/rules/index.ts index 3cc6dc84..2a699395 100644 --- a/packages/interact-validate/src/rules/index.ts +++ b/packages/interact-validate/src/rules/index.ts @@ -11,7 +11,6 @@ import { validMediaQueries } from './conditions/validMediaQueries'; import { triggerEffectCompatible } from './semantic/triggerEffectCompatible'; import { numericBounds } from './semantic/numericBounds'; -import { conditionPredicateRequired } from './semantic/conditionPredicateRequired'; import { uniqueDefinitionIds } from './semantic/uniqueDefinitionIds'; import { unusedDefinitions } from './semantic/unusedDefinitions'; @@ -30,7 +29,6 @@ export const RULES: Rule[] = [ validMediaQueries, triggerEffectCompatible, numericBounds, - conditionPredicateRequired, uniqueDefinitionIds, unusedDefinitions, ]; diff --git a/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts b/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts deleted file mode 100644 index ed4f2e64..00000000 --- a/packages/interact-validate/src/rules/semantic/conditionPredicateRequired.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Rule } from '..'; -import type { ValidationError } from '../../errors'; - -export const conditionPredicateRequired: Rule = { - code: 'CONDITION_PREDICATE_REQUIRED', - defaultSeverity: 'error', - run: (ctx): ValidationError[] => { - const errors: ValidationError[] = []; - for (const [id, condition] of Object.entries(ctx.config.conditions ?? {})) { - if (!condition.predicate) { - errors.push({ - code: 'CONDITION_PREDICATE_REQUIRED', - severity: 'error', - path: ['conditions', id, 'predicate'], - message: `Condition "${id}" of type "${condition.type}" requires a "predicate".`, - hint: `Add a ${condition.type === 'media' ? 'CSS media query' : 'container query'} as the predicate.`, - }); - } - } - return errors; - }, -}; diff --git a/packages/interact-validate/src/schema/primitives.ts b/packages/interact-validate/src/schema/primitives.ts index 91a20d0d..bb153ade 100644 --- a/packages/interact-validate/src/schema/primitives.ts +++ b/packages/interact-validate/src/schema/primitives.ts @@ -25,7 +25,7 @@ export const RangeOffset = z export const Condition = z .object({ type: z.enum(['media', 'container', 'selector']), - predicate: z.string(), + predicate: z.string().min(1), }) .strict(); diff --git a/packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts b/packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts deleted file mode 100644 index 04a37284..00000000 --- a/packages/interact-validate/test/rules/conditionPredicateRequired.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { validateInteractConfig } from '../../src'; - -describe('conditionPredicateRequired — CONDITION_PREDICATE_REQUIRED', () => { - it('emits no errors for a media condition with a predicate', () => { - const result = validateInteractConfig({ - conditions: { mq: { type: 'media', predicate: '(min-width: 768px)' } }, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - conditions: ['mq'], - effects: [{ namedEffect: { type: 'FadeIn' } }], - }, - ], - }); - expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); - }); - - it('emits no errors for a container condition with a predicate', () => { - const result = validateInteractConfig({ - conditions: { cont: { type: 'container', predicate: '(min-width: 200px)' } }, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - conditions: ['cont'], - effects: [{ namedEffect: { type: 'FadeIn' } }], - }, - ], - }); - expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); - }); - - it('emits no errors for a selector condition (predicate is optional for selector)', () => { - const result = validateInteractConfig({ - conditions: { sel: { type: 'selector' } }, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - conditions: ['sel'], - effects: [{ namedEffect: { type: 'FadeIn' } }], - }, - ], - }); - expect(result.errors.filter((e) => e.code === 'CONDITION_PREDICATE_REQUIRED')).toHaveLength(0); - }); - - it('emits CONDITION_PREDICATE_REQUIRED for a media condition without a predicate', () => { - const result = validateInteractConfig({ - conditions: { mq: { type: 'media' } }, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - conditions: ['mq'], - effects: [{ namedEffect: { type: 'FadeIn' } }], - }, - ], - }); - const err = result.errors.find((e) => e.code === 'CONDITION_PREDICATE_REQUIRED'); - expect(err).toBeDefined(); - expect(err?.severity).toBe('error'); - expect(err?.path).toEqual(['conditions', 'mq', 'predicate']); - expect(err?.message).toContain('"mq"'); - }); - - it('emits CONDITION_PREDICATE_REQUIRED for a container condition without a predicate', () => { - const result = validateInteractConfig({ - conditions: { cont: { type: 'container' } }, - interactions: [ - { - key: 'el', - trigger: 'viewEnter', - conditions: ['cont'], - effects: [{ namedEffect: { type: 'FadeIn' } }], - }, - ], - }); - const err = result.errors.find((e) => e.code === 'CONDITION_PREDICATE_REQUIRED'); - expect(err).toBeDefined(); - expect(err?.path).toEqual(['conditions', 'cont', 'predicate']); - }); -}); diff --git a/packages/interact-validate/test/structural.spec.ts b/packages/interact-validate/test/structural.spec.ts index 29ebae02..e6426d2f 100644 --- a/packages/interact-validate/test/structural.spec.ts +++ b/packages/interact-validate/test/structural.spec.ts @@ -5,6 +5,7 @@ const VALID_CONFIG = { interactions: [ { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, ], + conditions: {'condition-id': { type: 'media', predicate: '(min-width: 768px)' }}, }; describe('validateStructural', () => { @@ -81,4 +82,18 @@ describe('validateStructural', () => { const result = validateStructural({ interactions: 'bad' }); expect(result.errors[0].path).toBeDefined(); }); + + it('emits SCHEMA_TOO_SMALL when condition predicate is an empty string', () => { + const result = validateStructural({ + interactions: [], + conditions: { + 'condition-id': { + type: 'media', + predicate: '', + }, + }, + }); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'SCHEMA_TOO_SMALL')).toBe(true); + }); }); From 91b3b9b60e62d55910d959221b954b23835ce58e Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 16:53:57 +0300 Subject: [PATCH 18/19] fixing predicate --- packages/interact-validate/test/structural.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interact-validate/test/structural.spec.ts b/packages/interact-validate/test/structural.spec.ts index e6426d2f..cf02b713 100644 --- a/packages/interact-validate/test/structural.spec.ts +++ b/packages/interact-validate/test/structural.spec.ts @@ -5,7 +5,7 @@ const VALID_CONFIG = { interactions: [ { key: 'el', trigger: 'viewEnter', effects: [{ namedEffect: { type: 'FadeIn' } }] }, ], - conditions: {'condition-id': { type: 'media', predicate: '(min-width: 768px)' }}, + conditions: { 'condition-id': { type: 'media', predicate: '(min-width: 768px)' } }, }; describe('validateStructural', () => { From c0a9a23a5c66bef510517593dda1288908cc52ac Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Tue, 2 Jun 2026 17:01:50 +0300 Subject: [PATCH 19/19] fixing predicate --- .../interact-validate/test/rules/validMediaQueries.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/interact-validate/test/rules/validMediaQueries.spec.ts b/packages/interact-validate/test/rules/validMediaQueries.spec.ts index 45360c53..3afea6f2 100644 --- a/packages/interact-validate/test/rules/validMediaQueries.spec.ts +++ b/packages/interact-validate/test/rules/validMediaQueries.spec.ts @@ -61,13 +61,10 @@ describe('validMediaQueries — INVALID_MEDIA_QUERY', () => { expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); }); - it('emits INVALID_MEDIA_QUERY for an empty predicate string', () => { + it('emits no errors for an empty predicate string', () => { // Empty string fails the first guard in isValidMediaQuery: `if (!q) return false` const result = validateInteractConfig(configWithCondition('mq', '')); - expect(result.errors.some((e) => e.code === 'INVALID_MEDIA_QUERY')).toBe(true); - const err = result.errors.find((e) => e.code === 'INVALID_MEDIA_QUERY'); - expect(err?.severity).toBe('warning'); - expect(err?.path).toEqual(['conditions', 'mq', 'predicate']); + expect(result.errors.filter((e) => e.code === 'INVALID_MEDIA_QUERY')).toHaveLength(0); }); it('emits INVALID_MEDIA_QUERY for a query that matchMedia reports as invalid', () => {