From c201c4ae03f2613365844b23af171da74e7f3774 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 29 Jun 2026 12:36:56 +0300 Subject: [PATCH 01/62] added plan for validator Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-interact-validator-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-interact-validator-design.md diff --git a/docs/superpowers/specs/2026-06-28-interact-validator-design.md b/docs/superpowers/specs/2026-06-28-interact-validator-design.md new file mode 100644 index 0000000..329dee2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-interact-validator-design.md @@ -0,0 +1,179 @@ +# Interact Validator — Design Spec + +**Date:** 2026-06-28 +**Status:** Approved (brainstorm), pending implementation plan +**Author:** Hassan Kettany + Claude Code + +## Problem + +The `interact-examples` repo holds ~130 standalone `@wix/interact` animation HTML files, +contributed over a long period by multiple people (some non-technical). They have drifted: + +- **10 different `@wix/interact` versions** are imported across files (1.78 → 2.4.0). +- Some files **don't use interact at all**. +- Some use **`customEffect`** where a `namedEffect`/`keyframeEffect` would be idiomatic. +- Some mix interact with **extra hand-written JavaScript** (manual listeners, observers, `.animate`). +- Some use **outdated syntax** from early v2 (pre-2.2.0). + +Manually checking each file for version, correctness, and idiom is not sustainable. + +## Goal + +A **local validator tool with a UI** that: + +1. Lists all animation files (with code view + live preview). +2. **Scans/diagnoses** every file (static analysis) and shows a categorized summary. +3. Lets the user **select files + fix options** (or a freeform prompt) and have a + Claude agent rewrite them to use interact correctly, on the latest version, + without extra JS (unless allowed). +4. Shows **diffs of drafts**, with live preview, before the user **applies** changes + to the real files. + +## Canonical reference facts (verified against github.com/wix/interact `master`) + +- **Latest version: `2.4.0`** (pin as `LATEST`). Current major is v2.x. +- Custom element tag is **``** (the repo's + files using `wix-interact-element` are outdated — this is a detectable marker). +- `Interact.create({ interactions, effects?, sequences?, conditions? })`, called once. +- Three effect sources (exactly one per effect), preference order: + `namedEffect` → `keyframeEffect` → `customEffect`. +- Named presets require `Interact.registerEffects(presets)` from `@wix/motion-presets`. +- **Key breaking change (2.2.0):** play-mode moved off `Interaction.params` onto the effect + and was renamed — `params.type` → `triggerType` (on `TimeEffect`), + `params.method` → `stateAction` (on `StateEffect`). These are mutually exclusive. +- **Range offset rename (2.1.0):** `{value, type}` → `{value, unit}`. +- **Typo fix (2.2.0):** `useCutsomElement` → `useCustomElement`. +- Official validator package exists: `@wix/interact-validate` (zod-based, + `validateInteractConfig(config)`), reserved for a **phase-2 add-on**. +- Canonical CDN import: `https://esm.sh/@wix/interact@2.4.0` + (+ `https://esm.sh/@wix/motion-presets`). +- Full API reference: project's `full-lean.md` (matches interact's `rules/full-lean.md`, + current for 2.4.0 and already uses the new syntax — safe target spec). + +## Design decisions (from brainstorm) + +| Decision | Choice | +|---|---| +| Agent backend | Claude **Agent SDK headless**, using existing Claude Code auth (no API key) | +| UI host | **Separate standalone app** in a new `validator/` dir; `explorer.html` untouched | +| Scan engine | **Static analysis only** in v1 (`@wix/interact-validate` = phase 2) | +| Draft/apply | **Sidecar drafts** in `.drafts/`, **git as the undo**; drafts cleared on apply | + +## Architecture + +``` +┌─ Validator UI (browser) ─────────────┐ +│ file list · code view · live preview │ +│ scan dashboard · fix options · diffs │ +└───────────────┬───────────────────────┘ + │ REST (localhost) +┌───────────────▼───────────────────────┐ +│ Node backend (server.js) │ +│ ├─ detect.js (static analysis) │ ← instant, free, deterministic +│ ├─ fix.js (Agent SDK orchestr.) │ ← Claude rewrites → .drafts/ +│ └─ apply/diff/discard (fs + git) │ +└────────────────────────────────────────┘ +``` + +All new code lives under `validator/`. The live-preview iframe reuses explorer's +``-injection + `srcdoc` technique, extracted into a small shared helper. + +### Components + +**1. Node backend (`validator/server.js`)** — serves the UI + REST API: + +| Endpoint | Purpose | +|---|---| +| `GET /api/files` | Enumerate animation HTML files across known dirs; return metadata | +| `GET /api/file?path=` | Raw source for code view | +| `POST /api/scan` | Run `detect.js` over all/selected files; return per-file diagnosis + aggregate summary | +| `POST /api/fix` | Given files + options + custom prompt, run Agent SDK (bounded concurrency) → write `.drafts/`; report per-file progress/status | +| `GET /api/diff?path=` | Original vs draft diff | +| `GET /api/draft?path=` | Draft source (for live preview) | +| `POST /api/apply` | Overwrite original(s) from draft(s); clear applied drafts | +| `POST /api/discard` | Delete draft(s) | + +Path-safety: every `path` is validated to resolve inside the repo root (no traversal). + +**2. Static detection (`validator/detect.js`)** — pure `(path, source) → Diagnosis`: + +``` +Diagnosis = { + usesInteract: bool, // imports @wix/interact + version: string|null, // parsed from import + isLatest: bool, // version === LATEST (2.4.0) + usesCustomEffect:bool, // 'customEffect:' present + usesExtraJs: bool, + extraJsSignals: string[], // addEventListener(scroll|mousemove|pointermove|click), + // IntersectionObserver, direct .animate(, rAF/setInterval loops + oldSyntaxMarkers:string[], // params.type/method as play-mode, wix-interact-element tag, + // {value,type} range offset, useCutsomElement typo + category: enum, // Not using interact | Outdated version | Uses customEffect + // | Uses extra JS | Clean & current +} +``` + +Drives per-file badges and the aggregate dashboard (counts + percentages per category). + +**3. Fix orchestrator (`validator/fix.js`)** — for each selected file builds an agent +prompt from: chosen preset fragments + the file's static `Diagnosis` + canonical spec +context (`full-lean.md`) + freeform prompt. Invokes the Agent SDK (read original, +write draft only). Bounded concurrency (default 4). **Post-fix self-check:** re-run +`detect.js` on the draft; if still problematic, flag `needsReview` (draft still shown). + +Preset fix options (each maps to a hidden prompt fragment, all spec-anchored): + +- **Update to latest version** — bump imports to `@wix/interact@2.4.0` (+ motion-presets), + migrate version-specific syntax. +- **Migrate old syntax** — `params.type/method` → `triggerType`/`stateAction`; + `{value,type}`→`{value,unit}`; `wix-interact-element`→`interact-element`; fix `useCutsomElement`. +- **Convert customEffect → preset/keyframe** — when the customEffect maps to a known + `namedEffect`/`keyframeEffect`. +- **Remove extra JavaScript** — replace manual listeners/observers/`.animate` with interact + triggers/effects. **Defaults OFF** (the "unless I say so" lever). +- **Convert non-interact → interact** — rewrite a file that doesn't use interact at all. +- **Custom prompt box** — freeform, always appended. + +**4. Validator UI (`validator/index.html` + js, vanilla)** — +- File list grouped by directory, with category badges, code-view toggle, live iframe preview. +- **Scan/Diagnose** button → dashboard (counts/percentages/category breakdown) + per-file diagnosis. +- Selection: checkboxes / select-all / filter-by-category. +- Fix options panel (preset toggles + freeform prompt box). +- **Run** → per-file progress. +- Per-file: side-by-side **diff** + **live preview of the draft**. +- **Apply** / **Discard**, per-file or batch. + +### Data flow + +UI → backend REST. Scan path is synchronous (`detect.js`, instant). Fix path is async: +Agent SDK → `.drafts/.html`. Diff computed backend-side. Apply = copy +draft→original then remove draft. Git is the undo. + +### Error handling + +- Agent failure on a file → mark `fixFailed` with the error message; continue other files. +- Draft failing the post-fix self-check → flagged `needsReview` but still shown for manual diff. +- Backend rejects any path outside the repo root. +- Apply refuses when the draft is missing. + +## Testing + +- **`detect.js`** (pure) → unit tests against fixture HTML snippets: known outdated-version, + customEffect, extra-JS, non-interact, and clean-current samples; assert each `Diagnosis` field. +- **Backend endpoints** → integration tests against a temp fixture dir (scan, diff, apply, discard, + path-traversal rejection, apply-without-draft rejection). +- **Fix step** → unit-test the prompt-assembly function (`Diagnosis` + options → expected prompt), + with the Agent SDK mocked. The agent's creative output is not deterministically testable; + we test what is. + +## Tech stack + +- Node + minimal deps: `http`/Express, `@anthropic-ai/claude-agent-sdk`, a `diff` library. +- UI in vanilla JS (matches explorer's style; no framework). + +## Out of scope (v1) + +- `@wix/interact-validate` zod integration (phase 2). +- Modifying `explorer.html` or the `analysis/` files. +- Multi-user / remote hosting — local-only tool. +- Auto-commit on apply (git is the manual undo; user commits when ready). From 3102ac70a013966bd155a443a2ebb6a6c7c7ab6d Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 29 Jun 2026 17:08:04 +0300 Subject: [PATCH 02/62] add implementation plan for validator Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-29-interact-validator.md | 1392 +++++++++++++++++ 1 file changed, 1392 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-interact-validator.md diff --git a/docs/superpowers/plans/2026-06-29-interact-validator.md b/docs/superpowers/plans/2026-06-29-interact-validator.md new file mode 100644 index 0000000..186d3d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-interact-validator.md @@ -0,0 +1,1392 @@ +# Interact Validator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local web tool that scans every `@wix/interact` animation HTML file, categorizes it by static analysis, and uses the Claude Agent SDK to rewrite selected files to the latest interact syntax — with draft diffs the user previews before applying. + +**Architecture:** A Node (Express, ESM) backend serves a vanilla-JS UI and a REST API. Pure-function library modules (`detect`, `files`, `prompt`, `drafts`) do deterministic work; an `agent` module wraps the Claude Agent SDK for one-shot HTML rewrites; a `fix` orchestrator ties detect→prompt→agent→draft together with bounded concurrency. Fixes are written to sidecar `.drafts/` files, diffed/previewed, then applied over originals (git is the undo). + +**Tech Stack:** Node 18+ (ESM), Express, the `diff` npm package, `@anthropic-ai/claude-agent-sdk`. Tests use Node's built-in `node:test` + `node:assert/strict` (zero extra deps). UI is vanilla HTML/JS/CSS. + +## Global Constraints + +- All new code lives under `validator/`. Do **NOT** modify `explorer.html`, the `analysis/` directory, or any animation HTML file by hand. +- **Latest interact version = `2.4.0`** (the `LATEST_VERSION` constant). Canonical CDN import: `https://esm.sh/@wix/interact@2.4.0` (+ `https://esm.sh/@wix/motion-presets`). +- Correct custom-element tag is ``. `wix-interact-element` is an outdated marker. +- Agent SDK package is exactly `@anthropic-ai/claude-agent-sdk`; it uses the local Claude Code login by default (no `ANTHROPIC_API_KEY` required). `query({ prompt, options })` returns an `AsyncGenerator`; the final text is on the message where `msg.type === 'result' && msg.subtype === 'success'` as `msg.result`. +- Backend must reject any request `path` that resolves outside the repo root (no path traversal). +- "Remove extra JavaScript" fix option defaults **OFF**. +- ESM everywhere (`"type": "module"` in package.json). Run tests with `node --test`. + +--- + +### Task 1: Project scaffold + constants + file enumeration + +**Files:** +- Create: `validator/package.json` +- Create: `validator/lib/constants.js` +- Create: `validator/lib/files.js` +- Test: `validator/test/files.test.js` + +**Interfaces:** +- Produces: `LATEST_VERSION`, `INTERACT_CDN`, `PRESETS_CDN`, `IGNORED_DIRS`, `DRAFTS_DIR` (from `constants.js`); `listAnimationFiles(rootDir) -> Array<{ path: string, dir: string, file: string }>` where `path` is a POSIX-relative path from `rootDir` (from `files.js`). + +- [ ] **Step 1: Create `validator/package.json`** + +```json +{ + "name": "interact-validator", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "diff": "^7.0.0", + "express": "^4.21.0" + } +} +``` + +- [ ] **Step 2: Install dependencies** + +Run: `cd validator && npm install` +Expected: `node_modules/` created, no errors. (If `@anthropic-ai/claude-agent-sdk` version `^0.1.0` is unavailable, run `npm install @anthropic-ai/claude-agent-sdk@latest` and keep the resolved version.) + +- [ ] **Step 3: Create `validator/lib/constants.js`** + +```js +export const LATEST_VERSION = '2.4.0'; +export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}`; +export const PRESETS_CDN = 'https://esm.sh/@wix/motion-presets'; +export const DRAFTS_DIR = '.drafts'; + +// Directories never scanned for animation files. +export const IGNORED_DIRS = new Set([ + 'node_modules', '.git', '.drafts', '.backups', + 'analysis', 'explorer-screenshots', 'docs', 'validator', '.cursor', +]); + +// Files at any level that are not animations. +export const IGNORED_FILES = new Set(['explorer.html']); +``` + +- [ ] **Step 4: Write the failing test for `listAnimationFiles`** + +```js +// validator/test/files.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { listAnimationFiles } from '../lib/files.js'; + +async function makeRepo() { + const root = await mkdtemp(join(tmpdir(), 'iv-files-')); + await mkdir(join(root, 'Gallery-and-Carousel'), { recursive: true }); + await mkdir(join(root, 'analysis'), { recursive: true }); + await mkdir(join(root, 'node_modules', 'x'), { recursive: true }); + await writeFile(join(root, 'explorer.html'), ''); + await writeFile(join(root, 'Gallery-and-Carousel', 'A.html'), ''); + await writeFile(join(root, 'Gallery-and-Carousel', 'notes.txt'), 'x'); + await writeFile(join(root, 'analysis', 'B.html'), ''); + await writeFile(join(root, 'node_modules', 'x', 'C.html'), ''); + return root; +} + +test('lists html animations and ignores excluded dirs/files', async () => { + const root = await makeRepo(); + const files = await listAnimationFiles(root); + const paths = files.map((f) => f.path).sort(); + assert.deepEqual(paths, ['Gallery-and-Carousel/A.html']); + assert.equal(files[0].dir, 'Gallery-and-Carousel'); + assert.equal(files[0].file, 'A.html'); +}); +``` + +- [ ] **Step 5: Run the test to verify it fails** + +Run: `cd validator && node --test test/files.test.js` +Expected: FAIL — `Cannot find module '../lib/files.js'`. + +- [ ] **Step 6: Implement `validator/lib/files.js`** + +```js +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; +import { IGNORED_DIRS, IGNORED_FILES } from './constants.js'; + +export async function listAnimationFiles(rootDir) { + const out = []; + async function walk(absDir) { + const entries = await readdir(absDir, { withFileTypes: true }); + for (const entry of entries) { + const abs = join(absDir, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name)) continue; + await walk(abs); + } else if (entry.isFile() && entry.name.endsWith('.html')) { + if (IGNORED_FILES.has(entry.name)) continue; + const rel = relative(rootDir, abs).split(sep).join('/'); + const slash = rel.lastIndexOf('/'); + out.push({ + path: rel, + dir: slash === -1 ? '' : rel.slice(0, slash), + file: slash === -1 ? rel : rel.slice(slash + 1), + }); + } + } + } + await walk(rootDir); + return out.sort((a, b) => a.path.localeCompare(b.path)); +} +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `cd validator && node --test test/files.test.js` +Expected: PASS (1 test). + +- [ ] **Step 8: Commit** + +```bash +git add validator/package.json validator/package-lock.json validator/lib/constants.js validator/lib/files.js validator/test/files.test.js +git commit -m "feat(validator): scaffold + animation file enumeration" +``` + +--- + +### Task 2: Static detection engine + +**Files:** +- Create: `validator/lib/detect.js` +- Test: `validator/test/detect.test.js` + +**Interfaces:** +- Consumes: `LATEST_VERSION` from `constants.js`. +- Produces: `detect(filePath, source) -> Diagnosis`, where + `Diagnosis = { path, usesInteract, version, isLatest, usesCustomEffect, usesExtraJs, extraJsSignals: string[], oldSyntaxMarkers: string[], category }` + and `category ∈ { 'Not using interact', 'Outdated version', 'Uses extra JS', 'Uses customEffect', 'Clean & current' }`. + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/detect.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { detect } from '../lib/detect.js'; + +const clean = ` + +
x
`; + +test('clean current file', () => { + const d = detect('X.html', clean); + assert.equal(d.usesInteract, true); + assert.equal(d.version, '2.4.0'); + assert.equal(d.isLatest, true); + assert.equal(d.usesCustomEffect, false); + assert.equal(d.usesExtraJs, false); + assert.deepEqual(d.oldSyntaxMarkers, []); + assert.equal(d.category, 'Clean & current'); +}); + +test('outdated version', () => { + const d = detect('Y.html', `import { Interact } from 'https://esm.sh/@wix/interact@1.79.0';`); + assert.equal(d.usesInteract, true); + assert.equal(d.version, '1.79.0'); + assert.equal(d.isLatest, false); + assert.equal(d.category, 'Outdated version'); +}); + +test('not using interact', () => { + const d = detect('Z.html', ``); + assert.equal(d.usesInteract, false); + assert.equal(d.version, null); + assert.equal(d.category, 'Not using interact'); +}); + +test('old syntax markers flag a latest-version file as outdated', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + params:{ method:'toggle' }, effects:[{ customEffect:()=>{} }] }] }); + `; + const d = detect('W.html', src); + assert.ok(d.oldSyntaxMarkers.some((m) => m.includes('wix-interact-element'))); + assert.ok(d.oldSyntaxMarkers.some((m) => m.includes('method'))); + assert.equal(d.category, 'Outdated version'); +}); + +test('extra js detection', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + window.addEventListener('scroll', () => {}); + new IntersectionObserver(() => {}); + el.animate([], 300);`; + const d = detect('V.html', src); + assert.equal(d.usesExtraJs, true); + assert.ok(d.extraJsSignals.includes('addEventListener(scroll)')); + assert.ok(d.extraJsSignals.includes('IntersectionObserver')); + assert.ok(d.extraJsSignals.includes('Element.animate()')); + assert.equal(d.category, 'Uses extra JS'); +}); + +test('customEffect on a latest, no-extra-js file', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'pointerMove', + effects:[{ customEffect:(el,p)=>{} }] }] });`; + const d = detect('U.html', src); + assert.equal(d.usesCustomEffect, true); + assert.equal(d.usesExtraJs, false); + assert.equal(d.category, 'Uses customEffect'); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd validator && node --test test/detect.test.js` +Expected: FAIL — `Cannot find module '../lib/detect.js'`. + +- [ ] **Step 3: Implement `validator/lib/detect.js`** + +```js +import { LATEST_VERSION } from './constants.js'; + +const EXTRA_JS_PATTERNS = [ + { re: /addEventListener\(\s*['"`](scroll|wheel|mousemove|pointermove|pointerdown|touchmove)['"`]/g, + label: (m) => `addEventListener(${m[1]})` }, + { re: /\bIntersectionObserver\b/, label: () => 'IntersectionObserver' }, + { re: /\.animate\s*\(/, label: () => 'Element.animate()' }, + { re: /\brequestAnimationFrame\b/, label: () => 'requestAnimationFrame loop' }, + { re: /\bsetInterval\b/, label: () => 'setInterval loop' }, +]; + +function findExtraJs(source) { + const signals = []; + for (const { re, label } of EXTRA_JS_PATTERNS) { + if (re.global) { + let m; + const r = new RegExp(re.source, re.flags); + while ((m = r.exec(source)) !== null) { + const s = label(m); + if (!signals.includes(s)) signals.push(s); + } + } else if (re.test(source)) { + signals.push(label()); + } + } + return signals; +} + +function findOldSyntaxMarkers(source) { + const markers = []; + if (/wix-interact-element/.test(source)) markers.push('wix-interact-element tag (use interact-element)'); + if (/\bmethod\s*:/.test(source)) markers.push('params.method (use stateAction on the effect)'); + if (/\btype\s*:\s*['"`](once|repeat|alternate|state)['"`]/.test(source)) markers.push('params.type play-mode (use triggerType on the effect)'); + if (/\btype\s*:\s*['"`](percentage|px|vh|vw|vmin|vmax|em|rem)['"`]/.test(source)) markers.push('range offset {value,type} (use unit)'); + if (/useCutsomElement/.test(source)) markers.push('useCutsomElement typo (use useCustomElement)'); + return markers; +} + +export function detect(filePath, source) { + const usesInteract = /@wix\/interact/.test(source); + const versionMatch = source.match(/@wix\/interact@(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : null; + const isLatest = version === LATEST_VERSION; + const usesCustomEffect = /customEffect\s*:/.test(source); + const extraJsSignals = findExtraJs(source); + const usesExtraJs = extraJsSignals.length > 0; + const oldSyntaxMarkers = findOldSyntaxMarkers(source); + + let category; + if (!usesInteract) category = 'Not using interact'; + else if (!isLatest || oldSyntaxMarkers.length > 0) category = 'Outdated version'; + else if (usesExtraJs) category = 'Uses extra JS'; + else if (usesCustomEffect) category = 'Uses customEffect'; + else category = 'Clean & current'; + + return { path: filePath, usesInteract, version, isLatest, usesCustomEffect, + usesExtraJs, extraJsSignals, oldSyntaxMarkers, category }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd validator && node --test test/detect.test.js` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/detect.js validator/test/detect.test.js +git commit -m "feat(validator): static detection engine" +``` + +--- + +### Task 3: Draft store (path safety, write/read, diff, apply, discard) + +**Files:** +- Create: `validator/lib/drafts.js` +- Test: `validator/test/drafts.test.js` + +**Interfaces:** +- Consumes: `DRAFTS_DIR` from `constants.js`; `diffLines` from the `diff` package. +- Produces: + - `resolveSafe(rootDir, relPath) -> string` (absolute path; throws `Error('path escapes root')` if outside root) + - `draftAbsPath(rootDir, relPath) -> string` + - `writeDraft(rootDir, relPath, content) -> Promise` + - `readDraft(rootDir, relPath) -> Promise` + - `readOriginal(rootDir, relPath) -> Promise` + - `computeDiff(original, draft) -> Array<{ value: string, added?: boolean, removed?: boolean }>` + - `applyDraft(rootDir, relPath) -> Promise` (throws `Error('no draft')` if draft missing) + - `discardDraft(rootDir, relPath) -> Promise` + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/drafts.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveSafe, writeDraft, readDraft, readOriginal, + computeDiff, applyDraft, discardDraft } from '../lib/drafts.js'; + +async function repo() { + const root = await mkdtemp(join(tmpdir(), 'iv-drafts-')); + await mkdir(join(root, 'Gallery-and-Carousel'), { recursive: true }); + await writeFile(join(root, 'Gallery-and-Carousel', 'A.html'), 'ORIGINAL\n'); + return root; +} + +test('resolveSafe rejects traversal', async () => { + const root = await repo(); + assert.throws(() => resolveSafe(root, '../escape.html'), /escapes root/); + assert.doesNotThrow(() => resolveSafe(root, 'Gallery-and-Carousel/A.html')); +}); + +test('write/read draft round trip', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/missing.html'), null); +}); + +test('computeDiff marks added and removed lines', async () => { + const parts = computeDiff('ORIGINAL\n', 'FIXED\n'); + assert.ok(parts.some((p) => p.removed && p.value.includes('ORIGINAL'))); + assert.ok(parts.some((p) => p.added && p.value.includes('FIXED'))); +}); + +test('applyDraft overwrites original and clears draft', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + await applyDraft(root, 'Gallery-and-Carousel/A.html'); + assert.equal(await readOriginal(root, 'Gallery-and-Carousel/A.html'), 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), null); +}); + +test('applyDraft throws when no draft', async () => { + const root = await repo(); + await assert.rejects(() => applyDraft(root, 'Gallery-and-Carousel/A.html'), /no draft/); +}); + +test('discardDraft removes draft only', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + await discardDraft(root, 'Gallery-and-Carousel/A.html'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), null); + assert.equal(await readOriginal(root, 'Gallery-and-Carousel/A.html'), 'ORIGINAL\n'); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd validator && node --test test/drafts.test.js` +Expected: FAIL — `Cannot find module '../lib/drafts.js'`. + +- [ ] **Step 3: Implement `validator/lib/drafts.js`** + +```js +import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; +import { resolve, sep, dirname } from 'node:path'; +import { diffLines } from 'diff'; +import { DRAFTS_DIR } from './constants.js'; + +export function resolveSafe(rootDir, relPath) { + const root = resolve(rootDir); + const abs = resolve(root, relPath); + if (abs !== root && !abs.startsWith(root + sep)) { + throw new Error('path escapes root'); + } + return abs; +} + +export function draftAbsPath(rootDir, relPath) { + // Validate relPath is in-root, then place it under DRAFTS_DIR. + resolveSafe(rootDir, relPath); + return resolve(rootDir, DRAFTS_DIR, relPath); +} + +export async function writeDraft(rootDir, relPath, content) { + const abs = draftAbsPath(rootDir, relPath); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, 'utf8'); +} + +export async function readDraft(rootDir, relPath) { + try { + return await readFile(draftAbsPath(rootDir, relPath), 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +export async function readOriginal(rootDir, relPath) { + return readFile(resolveSafe(rootDir, relPath), 'utf8'); +} + +export function computeDiff(original, draft) { + return diffLines(original, draft); +} + +export async function applyDraft(rootDir, relPath) { + const draft = await readDraft(rootDir, relPath); + if (draft === null) throw new Error('no draft'); + await writeFile(resolveSafe(rootDir, relPath), draft, 'utf8'); + await discardDraft(rootDir, relPath); +} + +export async function discardDraft(rootDir, relPath) { + await rm(draftAbsPath(rootDir, relPath), { force: true }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd validator && node --test test/drafts.test.js` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/drafts.js validator/test/drafts.test.js +git commit -m "feat(validator): draft store with diff/apply/discard and path safety" +``` + +--- + +### Task 4: Prompt assembly + +**Files:** +- Create: `validator/lib/prompt.js` +- Test: `validator/test/prompt.test.js` + +**Interfaces:** +- Consumes: `INTERACT_CDN`, `PRESETS_CDN`, `LATEST_VERSION` from `constants.js`; a `Diagnosis` from `detect.js`. +- Produces: + - `FIX_OPTIONS: Array<{ id, label, default: boolean, fragment: string }>` with ids `updateVersion`, `migrateSyntax`, `convertCustomEffect`, `removeExtraJs`, `convertToInteract`. + - `buildPrompt({ diagnosis, source, optionIds: string[], customPrompt: string, specText: string }) -> { system: string, user: string }`. + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/prompt.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FIX_OPTIONS, buildPrompt } from '../lib/prompt.js'; + +test('FIX_OPTIONS has expected ids and removeExtraJs defaults off', () => { + const ids = FIX_OPTIONS.map((o) => o.id); + assert.deepEqual(ids, ['updateVersion', 'migrateSyntax', 'convertCustomEffect', 'removeExtraJs', 'convertToInteract']); + assert.equal(FIX_OPTIONS.find((o) => o.id === 'removeExtraJs').default, false); + assert.equal(FIX_OPTIONS.find((o) => o.id === 'updateVersion').default, true); +}); + +test('buildPrompt embeds selected fragments, custom prompt, spec, and source', () => { + const diagnosis = { path: 'A.html', version: '1.79.0', category: 'Outdated version', oldSyntaxMarkers: ['x'] }; + const { system, user } = buildPrompt({ + diagnosis, source: 'SRC', + optionIds: ['updateVersion', 'migrateSyntax'], + customPrompt: 'keep the colors', specText: 'SPEC-RULES', + }); + assert.match(system, /SPEC-RULES/); + assert.match(system, /ONLY the complete rewritten HTML/i); + assert.match(user, /2\.4\.0/); // updateVersion fragment mentions target version + assert.match(user, /triggerType|stateAction/); // migrateSyntax fragment mentions renames + assert.match(user, /keep the colors/); + assert.match(user, /SRC/); + assert.match(user, /Outdated version/); // diagnosis included +}); + +test('buildPrompt ignores unknown option ids and tolerates empty custom prompt', () => { + const { user } = buildPrompt({ + diagnosis: { path: 'A.html', category: 'Clean & current', oldSyntaxMarkers: [] }, + source: 'x', optionIds: ['bogus'], customPrompt: '', specText: 's', + }); + assert.doesNotMatch(user, /undefined/); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd validator && node --test test/prompt.test.js` +Expected: FAIL — `Cannot find module '../lib/prompt.js'`. + +- [ ] **Step 3: Implement `validator/lib/prompt.js`** + +```js +import { INTERACT_CDN, PRESETS_CDN, LATEST_VERSION } from './constants.js'; + +export const FIX_OPTIONS = [ + { id: 'updateVersion', label: 'Update to latest version', default: true, + fragment: `Update all @wix/interact imports to version ${LATEST_VERSION} using "${INTERACT_CDN}" (and "${PRESETS_CDN}" for named presets). Migrate any version-specific syntax that the new version requires.` }, + { id: 'migrateSyntax', label: 'Migrate old syntax', default: true, + fragment: `Migrate outdated syntax to the current API: move play-mode off Interaction.params onto the effect and rename params.type -> triggerType (on TimeEffect) and params.method -> stateAction (on StateEffect); rename range-offset {value,type} -> {value,unit}; rename the custom element tag wix-interact-element -> interact-element; fix the useCutsomElement -> useCustomElement typo.` }, + { id: 'convertCustomEffect', label: 'Convert customEffect → preset/keyframe', default: false, + fragment: `Where a customEffect merely maps to a known namedEffect (from @wix/motion-presets) or a keyframeEffect, replace it with that idiomatic effect. Only keep customEffect when the behavior genuinely requires per-frame DOM manipulation or randomness.` }, + { id: 'removeExtraJs', label: 'Remove extra JavaScript', default: false, + fragment: `Remove hand-written JavaScript (manual addEventListener, IntersectionObserver, direct Element.animate, requestAnimationFrame/setInterval animation loops) and express the same behavior through @wix/interact triggers and effects instead.` }, + { id: 'convertToInteract', label: 'Convert non-interact → interact', default: false, + fragment: `This file does not currently use @wix/interact. Rewrite it so the animation is driven by @wix/interact (import it, wrap targets in , and call Interact.create once), preserving the original visual result.` }, +]; + +const SYSTEM = (specText) => `You are an expert at the @wix/interact animation library. You rewrite standalone HTML animation files so they use @wix/interact correctly on the latest version. + +Follow this canonical reference exactly: +${specText} + +OUTPUT CONTRACT: Return ONLY the complete rewritten HTML file. No markdown code fences, no commentary, no explanation — just the raw HTML from (or the file's first line) to its end. Preserve the original visual design, layout, copy, and asset URLs unless a requested fix requires changing them.`; + +export function buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }) { + const chosen = FIX_OPTIONS.filter((o) => optionIds.includes(o.id)); + const fixList = chosen.length + ? chosen.map((o) => `- ${o.label}: ${o.fragment}`).join('\n') + : '- Apply only the custom instructions below.'; + const custom = customPrompt && customPrompt.trim() + ? `\nCustom instructions (highest priority):\n${customPrompt.trim()}\n` + : ''; + const user = `File: ${diagnosis.path} +Static diagnosis: ${JSON.stringify(diagnosis)} + +Requested fixes: +${fixList} +${custom} +--- ORIGINAL SOURCE --- +${source}`; + return { system: SYSTEM(specText), user }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd validator && node --test test/prompt.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/prompt.js validator/test/prompt.test.js +git commit -m "feat(validator): fix-option prompt assembly" +``` + +--- + +### Task 5: Agent wrapper (Claude Agent SDK) + +**Files:** +- Create: `validator/lib/agent.js` +- Test: `validator/test/agent.test.js` + +**Interfaces:** +- Consumes: `query` from `@anthropic-ai/claude-agent-sdk`. +- Produces: + - `extractHtml(text) -> string` (strips ```html / ``` fences and surrounding whitespace). + - `runAgent(system, user, { model } = {}) -> Promise` (one-shot; returns final result text). Uses `maxTurns: 1`, `allowedTools: []` so no tools/loop. + +- [ ] **Step 1: Write the failing test for `extractHtml` (pure, no SDK)** + +```js +// validator/test/agent.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractHtml } from '../lib/agent.js'; + +test('extractHtml strips html code fences', () => { + assert.equal(extractHtml('```html\n
x
\n```'), '
x
'); +}); +test('extractHtml strips bare fences', () => { + assert.equal(extractHtml('```\n
x
\n```'), '
x
'); +}); +test('extractHtml passes through plain html', () => { + assert.equal(extractHtml('\n'), '\n'); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd validator && node --test test/agent.test.js` +Expected: FAIL — `Cannot find module '../lib/agent.js'`. + +- [ ] **Step 3: Implement `validator/lib/agent.js`** + +```js +import { query } from '@anthropic-ai/claude-agent-sdk'; + +export function extractHtml(text) { + let t = String(text).trim(); + const fence = t.match(/^```(?:html)?\s*\n([\s\S]*?)\n```$/i); + if (fence) t = fence[1]; + return t.trim(); +} + +export async function runAgent(system, user, { model } = {}) { + const options = { + systemPrompt: system, + allowedTools: [], + maxTurns: 1, + permissionMode: 'default', + }; + if (model) options.model = model; + + let resultText = ''; + let assistantText = ''; + for await (const msg of query({ prompt: user, options })) { + if (msg.type === 'assistant') { + for (const block of msg.message.content) { + if (block.type === 'text') assistantText += block.text; + } + } else if (msg.type === 'result') { + if (msg.subtype === 'success') resultText = msg.result; + else throw new Error(`agent error: ${msg.subtype}`); + } + } + return resultText || assistantText; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd validator && node --test test/agent.test.js` +Expected: PASS (3 tests). (`runAgent` is exercised live in Task 8's manual smoke test, not unit-tested, since it depends on Claude.) + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/agent.js validator/test/agent.test.js +git commit -m "feat(validator): Claude Agent SDK wrapper + html extraction" +``` + +--- + +### Task 6: Fix orchestrator (bounded concurrency + self-check) + +**Files:** +- Create: `validator/lib/fix.js` +- Test: `validator/test/fix.test.js` + +**Interfaces:** +- Consumes: `detect` (detect.js), `buildPrompt` (prompt.js), `writeDraft` (drafts.js), `extractHtml` (agent.js). +- Produces: + - `mapLimit(items, limit, fn) -> Promise` (preserves input order). + - `fixFile(rootDir, relPath, { source, optionIds, customPrompt, specText, runAgent, model }) -> Promise` where `Result = { path, status: 'fixed'|'needsReview'|'fixFailed', error?: string, recheck?: Diagnosis }`. `runAgent` is injected (defaults to the real one) so tests can mock it. + - `runFix(rootDir, files: Array<{path, source}>, { optionIds, customPrompt, specText, runAgent, model, concurrency }) -> Promise`. + +- [ ] **Step 1: Write the failing tests (mocked agent — no live calls)** + +```js +// validator/test/fix.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mapLimit, fixFile, runFix } from '../lib/fix.js'; +import { readDraft } from '../lib/drafts.js'; + +const root = () => mkdtemp(join(tmpdir(), 'iv-fix-')); +const SPEC = 'spec'; + +test('mapLimit preserves order and caps concurrency', async () => { + let active = 0, max = 0; + const fn = async (n) => { + active++; max = Math.max(max, active); + await new Promise((r) => setTimeout(r, 5)); + active--; return n * 2; + }; + const out = await mapLimit([1, 2, 3, 4, 5], 2, fn); + assert.deepEqual(out, [2, 4, 6, 8, 10]); + assert.ok(max <= 2); +}); + +test('fixFile writes a draft and reports fixed when recheck is clean', async () => { + const r = await root(); + const good = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; + const res = await fixFile(r, 'A.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => good, + }); + assert.equal(res.status, 'fixed'); + assert.equal(await readDraft(r, 'A.html'), good); +}); + +test('fixFile reports needsReview when draft still diagnoses as problematic', async () => { + const r = await root(); + const res = await fixFile(r, 'B.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`, + }); + assert.equal(res.status, 'needsReview'); +}); + +test('fixFile reports fixFailed and writes no draft when agent throws', async () => { + const r = await root(); + const res = await fixFile(r, 'C.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => { throw new Error('boom'); }, + }); + assert.equal(res.status, 'fixFailed'); + assert.match(res.error, /boom/); + assert.equal(await readDraft(r, 'C.html'), null); +}); + +test('runFix processes a batch', async () => { + const r = await root(); + const results = await runFix(r, + [{ path: 'A.html', source: 'x' }, { path: 'B.html', source: 'y' }], + { optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => 'import "https://esm.sh/@wix/interact@2.4.0";', concurrency: 2 }); + assert.equal(results.length, 2); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd validator && node --test test/fix.test.js` +Expected: FAIL — `Cannot find module '../lib/fix.js'`. + +- [ ] **Step 3: Implement `validator/lib/fix.js`** + +```js +import { detect } from './detect.js'; +import { buildPrompt } from './prompt.js'; +import { writeDraft } from './drafts.js'; +import { extractHtml, runAgent as realRunAgent } from './agent.js'; + +export async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + } + const workers = Array.from({ length: Math.min(limit, items.length) }, worker); + await Promise.all(workers); + return results; +} + +export async function fixFile(rootDir, relPath, opts) { + const { source, optionIds, customPrompt, specText, model, runAgent = realRunAgent } = opts; + try { + const diagnosis = detect(relPath, source); + const { system, user } = buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }); + const html = extractHtml(await runAgent(system, user, { model })); + await writeDraft(rootDir, relPath, html); + const recheck = detect(relPath, html); + const clean = recheck.category === 'Clean & current' + || (recheck.isLatest && recheck.oldSyntaxMarkers.length === 0); + return { path: relPath, status: clean ? 'fixed' : 'needsReview', recheck }; + } catch (err) { + return { path: relPath, status: 'fixFailed', error: String(err.message || err) }; + } +} + +export async function runFix(rootDir, files, opts) { + const { concurrency = 4, ...rest } = opts; + return mapLimit(files, concurrency, (f) => + fixFile(rootDir, f.path, { ...rest, source: f.source })); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd validator && node --test test/fix.test.js` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/fix.js validator/test/fix.test.js +git commit -m "feat(validator): fix orchestrator with bounded concurrency + self-check" +``` + +--- + +### Task 7: Express server + REST API + +**Files:** +- Create: `validator/server.js` +- Create: `validator/lib/spec.js` +- Test: `validator/test/server.test.js` + +**Interfaces:** +- Consumes: every lib module above. +- Produces: `createApp(rootDir) -> express.Application` (exported from `server.js` for tests); the file also self-starts a listener when run directly. `loadSpecText(rootDir) -> Promise` from `spec.js` (reads `full-lean.md`). +- Endpoints: `GET /api/files`, `GET /api/file?path=`, `POST /api/scan` `{paths?}`, `POST /api/fix` `{paths, optionIds, customPrompt}`, `GET /api/diff?path=`, `GET /api/draft?path=`, `POST /api/apply` `{paths}`, `POST /api/discard` `{paths}`. Static UI served from `validator/public`. + +- [ ] **Step 1: Implement `validator/lib/spec.js`** + +```js +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function loadSpecText(rootDir) { + try { + return await readFile(join(rootDir, 'full-lean.md'), 'utf8'); + } catch { + return 'Use @wix/interact 2.4.0. Tag: . ' + + 'Effects: namedEffect | keyframeEffect | customEffect. ' + + 'Play-mode: triggerType (TimeEffect) / stateAction (StateEffect).'; + } +} +``` + +- [ ] **Step 2: Write the failing integration tests** + +```js +// validator/test/server.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../server.js'; + +async function repo() { + const root = await mkdtemp(join(tmpdir(), 'iv-srv-')); + await mkdir(join(root, 'G'), { recursive: true }); + await writeFile(join(root, 'G', 'A.html'), + `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`); + return root; +} + +async function start(root) { + const app = createApp(root); + const server = app.listen(0); + await new Promise((r) => server.once('listening', r)); + const base = `http://127.0.0.1:${server.address().port}`; + return { base, server }; +} + +test('GET /api/files lists animations', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/files`); + const body = await res.json(); + assert.equal(res.status, 200); + assert.ok(body.files.some((f) => f.path === 'G/A.html')); + server.close(); +}); + +test('POST /api/scan returns per-file diagnosis and a summary', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/scan`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + const body = await res.json(); + assert.equal(body.results[0].category, 'Outdated version'); + assert.equal(body.summary['Outdated version'], 1); + server.close(); +}); + +test('GET /api/file rejects path traversal', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/file?path=${encodeURIComponent('../../etc/passwd')}`); + assert.equal(res.status, 400); + server.close(); +}); + +test('apply flow: seed a draft via discard/apply endpoints', async () => { + const root = await repo(); + const { base, server } = await start(root); + // Write a draft directly through the lib to simulate a completed fix. + const { writeDraft } = await import('../lib/drafts.js'); + await writeDraft(root, 'G/A.html', 'FIXED'); + const diff = await (await fetch(`${base}/api/diff?path=${encodeURIComponent('G/A.html')}`)).json(); + assert.ok(diff.parts.some((p) => p.added && p.value.includes('FIXED'))); + const apply = await fetch(`${base}/api/apply`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths: ['G/A.html'] }) }); + assert.equal(apply.status, 200); + const after = await (await fetch(`${base}/api/file?path=${encodeURIComponent('G/A.html')}`)).json(); + assert.equal(after.source, 'FIXED'); + server.close(); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cd validator && node --test test/server.test.js` +Expected: FAIL — `Cannot find module '../server.js'`. + +- [ ] **Step 4: Implement `validator/server.js`** + +```js +import express from 'express'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { listAnimationFiles } from './lib/files.js'; +import { detect } from './lib/detect.js'; +import { readOriginal, readDraft, computeDiff, applyDraft, discardDraft } from './lib/drafts.js'; +import { runFix } from './lib/fix.js'; +import { FIX_OPTIONS } from './lib/prompt.js'; +import { loadSpecText } from './lib/spec.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function createApp(rootDir) { + const root = resolve(rootDir); + const app = express(); + app.use(express.json({ limit: '5mb' })); + app.use(express.static(join(__dirname, 'public'))); + + const bad = (res, msg) => res.status(400).json({ error: msg }); + + app.get('/api/options', (_req, res) => { + res.json({ options: FIX_OPTIONS.map(({ id, label, default: d }) => ({ id, label, default: d })) }); + }); + + app.get('/api/files', async (_req, res) => { + res.json({ files: await listAnimationFiles(root) }); + }); + + app.get('/api/file', async (req, res) => { + try { + res.json({ source: await readOriginal(root, String(req.query.path)) }); + } catch (err) { + bad(res, String(err.message || err)); + } + }); + + app.get('/api/draft', async (req, res) => { + try { + const source = await readDraft(root, String(req.query.path)); + if (source === null) return res.status(404).json({ error: 'no draft' }); + res.json({ source }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/scan', async (req, res) => { + try { + const all = await listAnimationFiles(root); + const wanted = Array.isArray(req.body.paths) && req.body.paths.length + ? all.filter((f) => req.body.paths.includes(f.path)) : all; + const results = []; + for (const f of wanted) { + results.push(detect(f.path, await readOriginal(root, f.path))); + } + const summary = {}; + for (const r of results) summary[r.category] = (summary[r.category] || 0) + 1; + res.json({ results, summary, total: results.length }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/fix', async (req, res) => { + try { + const { paths, optionIds = [], customPrompt = '' } = req.body; + if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); + const specText = await loadSpecText(root); + const files = []; + for (const p of paths) files.push({ path: p, source: await readOriginal(root, p) }); + const results = await runFix(root, files, { optionIds, customPrompt, specText }); + res.json({ results }); + } catch (err) { res.status(500).json({ error: String(err.message || err) }); } + }); + + app.get('/api/diff', async (req, res) => { + try { + const p = String(req.query.path); + const draft = await readDraft(root, p); + if (draft === null) return res.status(404).json({ error: 'no draft' }); + const original = await readOriginal(root, p); + res.json({ parts: computeDiff(original, draft) }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/apply', async (req, res) => { + try { + for (const p of req.body.paths || []) await applyDraft(root, p); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/discard', async (req, res) => { + try { + for (const p of req.body.paths || []) await discardDraft(root, p); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + return app; +} + +// Self-start when run directly (repo root is the parent of validator/). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const root = resolve(__dirname, '..'); + const port = process.env.PORT || 4500; + createApp(root).listen(port, () => { + console.log(`Interact Validator on http://localhost:${port} (root: ${root})`); + }); +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd validator && node --test test/server.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 6: Run the full suite** + +Run: `cd validator && node --test` +Expected: PASS — all tests from Tasks 1–7 green. + +- [ ] **Step 7: Commit** + +```bash +git add validator/server.js validator/lib/spec.js validator/test/server.test.js +git commit -m "feat(validator): express server + REST API" +``` + +--- + +### Task 8: UI (list, scan dashboard, code/preview, fix panel, diff/apply) + +**Files:** +- Create: `validator/public/index.html` +- Create: `validator/public/app.js` +- Create: `validator/public/styles.css` +- Create: `validator/public/preview.js` +- Test: `validator/test/preview.test.js` + +**Interfaces:** +- Consumes: all `/api/*` endpoints from Task 7. +- Produces: `injectBase(html, baseHref) -> string` (in `preview.js`, ESM, used by both the browser and the unit test) — injects a `` so iframe-previewed animations resolve relative asset URLs against the original file's directory. + +- [ ] **Step 1: Write the failing test for `injectBase`** + +```js +// validator/test/preview.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { injectBase } from '../public/preview.js'; + +test('injectBase inserts a base tag after ', () => { + const out = injectBase('\nx', '/G/'); + assert.match(out, /\s*\n/); +}); +test('injectBase prepends when no head', () => { + assert.match(injectBase('
x
', '/G/'), /^/); +}); +test('injectBase leaves an existing base alone', () => { + const html = ''; + assert.equal(injectBase(html, '/G/'), html); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd validator && node --test test/preview.test.js` +Expected: FAIL — `Cannot find module '../public/preview.js'`. + +- [ ] **Step 3: Implement `validator/public/preview.js`** + +```js +// Injects a so relative asset URLs in a previewed animation +// resolve against its original directory (same technique explorer.html uses). +export function injectBase(html, baseHref) { + if (/]*>/i.test(html)) { + return html.replace(/]*>/i, (m) => `${m}\n`); + } + return `\n${html}`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd validator && node --test test/preview.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create `validator/public/index.html`** + +```html + + + + + + Interact Validator + + + +
+

Interact Validator

+
+ + + +
+
+
+
+
    +
    +
    +
    + + + +
    + + + +
    + +
    + + + +``` + +- [ ] **Step 6: Create `validator/public/styles.css`** + +```css +* { box-sizing: border-box; } +body { margin: 0; font: 14px/1.4 system-ui, sans-serif; color: #1a1a1a; } +header { display: flex; justify-content: space-between; align-items: center; + padding: 10px 16px; border-bottom: 1px solid #ddd; } +header h1 { font-size: 16px; margin: 0; } +.actions { display: flex; gap: 8px; align-items: center; } +.summary { color: #555; font-size: 12px; } +button { cursor: pointer; padding: 6px 10px; border: 1px solid #ccc; + background: #f7f7f7; border-radius: 6px; } +main { display: grid; grid-template-columns: 320px 1fr 300px; height: calc(100vh - 53px); } +#listPane { overflow: auto; border-right: 1px solid #eee; } +#fileList { list-style: none; margin: 0; padding: 0; } +#fileList li { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; + display: flex; gap: 8px; align-items: center; } +#fileList li.active { background: #eef4ff; } +.badge { font-size: 11px; padding: 1px 6px; border-radius: 10px; white-space: nowrap; } +.badge.outdated { background: #ffe6cc; } +.badge.nointeract { background: #ffd6d6; } +.badge.extrajs { background: #fff2b3; } +.badge.custom { background: #e0d6ff; } +.badge.clean { background: #cdeccd; } +.badge.draft { background: #cfe9ff; } +#detailPane { display: flex; flex-direction: column; } +.tabs { display: flex; gap: 4px; padding: 6px; border-bottom: 1px solid #eee; } +.tab.active { background: #1a1a1a; color: #fff; } +#preview { flex: 1; border: 0; width: 100%; } +#code, #diff { flex: 1; overflow: auto; margin: 0; padding: 12px; + white-space: pre-wrap; font-family: ui-monospace, monospace; } +#diff ins { background: #d6f5d6; text-decoration: none; display: block; } +#diff del { background: #f8d6d6; text-decoration: none; display: block; } +#fixPane { border-left: 1px solid #eee; padding: 12px; overflow: auto; + display: flex; flex-direction: column; gap: 10px; } +#customPrompt { width: 100%; min-height: 80px; } +.apply-actions { display: flex; gap: 6px; flex-wrap: wrap; } +#fixStatus { font-size: 12px; color: #555; white-space: pre-wrap; } +``` + +- [ ] **Step 7: Create `validator/public/app.js`** + +```js +import { injectBase } from './preview.js'; + +const BADGE = { + 'Outdated version': 'outdated', 'Not using interact': 'nointeract', + 'Uses extra JS': 'extrajs', 'Uses customEffect': 'custom', 'Clean & current': 'clean', +}; + +const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null }; +const $ = (id) => document.getElementById(id); +const api = (path, opts) => fetch(path, opts).then((r) => r.json()); + +async function loadFiles() { + const { files } = await api('/api/files'); + state.files = files; + renderList(); +} + +async function loadOptions() { + const { options } = await api('/api/options'); + $('fixOptions').innerHTML = options.map((o) => + `` + ).join('
    '); +} + +function renderList() { + $('fileList').innerHTML = state.files.map((f) => { + const d = state.diag[f.path]; + const cat = d ? d.category : ''; + const badge = cat ? `${cat}` : ''; + const draft = state.drafts.has(f.path) ? 'draft' : ''; + const checked = state.selected.has(f.path) ? 'checked' : ''; + return `
  • + + ${f.path}${badge}${draft}
  • `; + }).join(''); +} + +async function scan() { + const { results, summary, total } = await api('/api/scan', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + state.diag = {}; + for (const r of results) state.diag[r.path] = r; + $('summary').textContent = `${total} files · ` + + Object.entries(summary).map(([k, v]) => `${k}: ${v}`).join(' · '); + renderList(); +} + +function baseHrefFor(path) { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); +} + +async function showPreview(path, { draft = false } = {}) { + const url = draft ? `/api/draft?path=${encodeURIComponent(path)}` + : `/api/file?path=${encodeURIComponent(path)}`; + const { source } = await api(url); + $('preview').srcdoc = injectBase(source, baseHrefFor(path)); + $('code').textContent = source; +} + +async function showDiff(path) { + const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); + if (!res.ok) { $('diff').textContent = 'No draft for this file.'; return; } + const { parts } = await res.json(); + $('diff').innerHTML = parts.map((p) => { + const safe = p.value.replace(/${safe}`; + if (p.removed) return `${safe}`; + return `${safe}`; + }).join(''); +} + +function selectTab(tab) { + for (const b of document.querySelectorAll('.tab')) b.classList.toggle('active', b.dataset.tab === tab); + $('preview').hidden = tab !== 'preview'; + $('code').hidden = tab !== 'code'; + $('diff').hidden = tab !== 'diff'; + if (state.current && tab === 'diff') showDiff(state.current); + if (state.current && tab === 'preview') { + showPreview(state.current, { draft: state.drafts.has(state.current) }); + } +} + +async function runFix() { + const paths = [...state.selected]; + if (!paths.length) { $('fixStatus').textContent = 'Select files first.'; return; } + const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); + const customPrompt = $('customPrompt').value; + $('fixStatus').textContent = `Fixing ${paths.length} file(s)…`; + const { results, error } = await api('/api/fix', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths, optionIds, customPrompt }) }); + if (error) { $('fixStatus').textContent = `Error: ${error}`; return; } + for (const r of results) if (r.status !== 'fixFailed') state.drafts.add(r.path); + $('fixStatus').textContent = results.map((r) => + `${r.status === 'fixed' ? '✓' : r.status === 'needsReview' ? '⚠' : '✗'} ${r.path}` + + (r.error ? ` — ${r.error}` : '')).join('\n'); + renderList(); +} + +async function applyOrDiscard(endpoint) { + const paths = [...state.selected].filter((p) => state.drafts.has(p)); + if (!paths.length) { $('fixStatus').textContent = 'No drafts in selection.'; return; } + await api(`/api/${endpoint}`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); + for (const p of paths) state.drafts.delete(p); + $('fixStatus').textContent = `${endpoint === 'apply' ? 'Applied' : 'Discarded'} ${paths.length} draft(s).`; + renderList(); + if (state.current && paths.includes(state.current)) showPreview(state.current); +} + +$('fileList').addEventListener('click', (e) => { + const li = e.target.closest('li'); if (!li) return; + const path = li.dataset.path; + if (e.target.classList.contains('sel')) { + if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); + return; + } + state.current = path; + renderList(); + selectTab('preview'); +}); +$('scanBtn').onclick = scan; +$('selectAllBtn').onclick = () => { + if (state.selected.size === state.files.length) state.selected.clear(); + else state.files.forEach((f) => state.selected.add(f.path)); + renderList(); +}; +$('fixBtn').onclick = runFix; +$('applyBtn').onclick = () => applyOrDiscard('apply'); +$('discardBtn').onclick = () => applyOrDiscard('discard'); +for (const b of document.querySelectorAll('.tab')) b.onclick = () => selectTab(b.dataset.tab); + +loadFiles(); +loadOptions(); +``` + +- [ ] **Step 8: Manual smoke test (UI + a real agent fix)** + +Run: `cd validator && npm start` +Then in a browser open `http://localhost:4500` and verify, in order: +1. The file list loads (grouped paths visible). — Expected: ~130 files listed. +2. Click **Scan / Diagnose**. — Expected: badges appear per file; summary bar shows counts per category (e.g. "Outdated version: N"). +3. Click a file → **Preview** tab renders it in the iframe; **Code** tab shows source. +4. Check one outdated file's checkbox, ensure **Update to latest version** + **Migrate old syntax** are checked, click **Fix selected**. — Expected: status shows `✓` or `⚠`, a "draft" badge appears on that file. +5. Open the **Diff** tab for that file. — Expected: red/green line diff of original vs draft (e.g. the `@wix/interact@X` version line changes to `2.4.0`). +6. With the file still selected, click **Apply selected drafts**. — Expected: status "Applied 1 draft(s)"; `git status` shows the original file modified; the `.drafts/` entry is gone. +7. `git checkout -- ` to restore it after the smoke test. + +- [ ] **Step 9: Add `.drafts/` to gitignore** + +Append `validator/.drafts/` and `validator/node_modules/` to the repo's `.gitignore` (create the file if missing). + +- [ ] **Step 10: Commit** + +```bash +git add validator/public/index.html validator/public/app.js validator/public/styles.css validator/public/preview.js validator/test/preview.test.js .gitignore +git commit -m "feat(validator): validator UI with scan, preview, diff, and apply" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** file list + code view + preview (Task 8) ✔; scan/diagnose + summary with percentages-by-count (Tasks 2, 7, 8) ✔; selection + preset options + custom prompt with hidden fragments (Tasks 4, 8) ✔; Agent SDK via local Claude Code auth (Task 5) ✔; sidecar drafts + diff + preview + apply, git as undo (Tasks 3, 7, 8) ✔; bounded concurrency + post-fix self-check + per-file error handling (Task 6) ✔; path-traversal rejection (Tasks 3, 7) ✔; `explorer.html` untouched, new code under `validator/` ✔; "Remove extra JS" defaults off (Task 4) ✔. +- **Out of scope (per spec):** `@wix/interact-validate` zod integration, auto-commit on apply, remote hosting — intentionally omitted. +- **Type consistency:** `Diagnosis` shape is identical across `detect.js`, `fix.js`, and the server; `Result.status` values (`fixed`/`needsReview`/`fixFailed`) are consistent between `fix.js` and `app.js`; draft functions (`writeDraft`/`readDraft`/`applyDraft`/`discardDraft`/`computeDiff`) match between `drafts.js`, `fix.js`, and `server.js`; `injectBase` signature matches between `preview.js` and `app.js`. From 1dd041f8e6bb231ec66b4d57abd5239c21b983a8 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 29 Jun 2026 17:14:10 +0300 Subject: [PATCH 03/62] feat(validator): scaffold + animation file enumeration Co-Authored-By: Claude Sonnet 4.6 --- validator/lib/constants.js | 13 + validator/lib/files.js | 28 + validator/package-lock.json | 1156 ++++++++++++++++++++++++++++++++++ validator/package.json | 15 + validator/test/files.test.js | 29 + 5 files changed, 1241 insertions(+) create mode 100644 validator/lib/constants.js create mode 100644 validator/lib/files.js create mode 100644 validator/package-lock.json create mode 100644 validator/package.json create mode 100644 validator/test/files.test.js diff --git a/validator/lib/constants.js b/validator/lib/constants.js new file mode 100644 index 0000000..37190d1 --- /dev/null +++ b/validator/lib/constants.js @@ -0,0 +1,13 @@ +export const LATEST_VERSION = '2.4.0'; +export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}`; +export const PRESETS_CDN = 'https://esm.sh/@wix/motion-presets'; +export const DRAFTS_DIR = '.drafts'; + +// Directories never scanned for animation files. +export const IGNORED_DIRS = new Set([ + 'node_modules', '.git', '.drafts', '.backups', + 'analysis', 'explorer-screenshots', 'docs', 'validator', '.cursor', +]); + +// Files at any level that are not animations. +export const IGNORED_FILES = new Set(['explorer.html']); diff --git a/validator/lib/files.js b/validator/lib/files.js new file mode 100644 index 0000000..ffa4139 --- /dev/null +++ b/validator/lib/files.js @@ -0,0 +1,28 @@ +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; +import { IGNORED_DIRS, IGNORED_FILES } from './constants.js'; + +export async function listAnimationFiles(rootDir) { + const out = []; + async function walk(absDir) { + const entries = await readdir(absDir, { withFileTypes: true }); + for (const entry of entries) { + const abs = join(absDir, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name)) continue; + await walk(abs); + } else if (entry.isFile() && entry.name.endsWith('.html')) { + if (IGNORED_FILES.has(entry.name)) continue; + const rel = relative(rootDir, abs).split(sep).join('/'); + const slash = rel.lastIndexOf('/'); + out.push({ + path: rel, + dir: slash === -1 ? '' : rel.slice(0, slash), + file: slash === -1 ? rel : rel.slice(slash + 1), + }); + } + } + } + await walk(rootDir); + return out.sort((a, b) => a.path.localeCompare(b.path)); +} diff --git a/validator/package-lock.json b/validator/package-lock.json new file mode 100644 index 0000000..52eb176 --- /dev/null +++ b/validator/package-lock.json @@ -0,0 +1,1156 @@ +{ + "name": "interact-validator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "interact-validator", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "diff": "^7.0.0", + "express": "^4.21.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.1.77", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz", + "integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/validator/package.json b/validator/package.json new file mode 100644 index 0000000..4c8f04b --- /dev/null +++ b/validator/package.json @@ -0,0 +1,15 @@ +{ + "name": "interact-validator", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "diff": "^7.0.0", + "express": "^4.21.0" + } +} diff --git a/validator/test/files.test.js b/validator/test/files.test.js new file mode 100644 index 0000000..7f36229 --- /dev/null +++ b/validator/test/files.test.js @@ -0,0 +1,29 @@ +// validator/test/files.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { listAnimationFiles } from '../lib/files.js'; + +async function makeRepo() { + const root = await mkdtemp(join(tmpdir(), 'iv-files-')); + await mkdir(join(root, 'Gallery-and-Carousel'), { recursive: true }); + await mkdir(join(root, 'analysis'), { recursive: true }); + await mkdir(join(root, 'node_modules', 'x'), { recursive: true }); + await writeFile(join(root, 'explorer.html'), ''); + await writeFile(join(root, 'Gallery-and-Carousel', 'A.html'), ''); + await writeFile(join(root, 'Gallery-and-Carousel', 'notes.txt'), 'x'); + await writeFile(join(root, 'analysis', 'B.html'), ''); + await writeFile(join(root, 'node_modules', 'x', 'C.html'), ''); + return root; +} + +test('lists html animations and ignores excluded dirs/files', async () => { + const root = await makeRepo(); + const files = await listAnimationFiles(root); + const paths = files.map((f) => f.path).sort(); + assert.deepEqual(paths, ['Gallery-and-Carousel/A.html']); + assert.equal(files[0].dir, 'Gallery-and-Carousel'); + assert.equal(files[0].file, 'A.html'); +}); From b6a8fcbb4a3b6a559a2f9325b28546416188e576 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 29 Jun 2026 17:21:34 +0300 Subject: [PATCH 04/62] feat(validator): static detection engine --- validator/lib/detect.js | 58 ++++++++++++++++++++++++++++ validator/test/detect.test.js | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 validator/lib/detect.js create mode 100644 validator/test/detect.test.js diff --git a/validator/lib/detect.js b/validator/lib/detect.js new file mode 100644 index 0000000..9dd25d6 --- /dev/null +++ b/validator/lib/detect.js @@ -0,0 +1,58 @@ +import { LATEST_VERSION } from './constants.js'; + +const EXTRA_JS_PATTERNS = [ + { re: /addEventListener\(\s*['"`](scroll|wheel|mousemove|pointermove|pointerdown|touchmove)['"`]/g, + label: (m) => `addEventListener(${m[1]})` }, + { re: /\bIntersectionObserver\b/, label: () => 'IntersectionObserver' }, + { re: /\.animate\s*\(/, label: () => 'Element.animate()' }, + { re: /\brequestAnimationFrame\b/, label: () => 'requestAnimationFrame loop' }, + { re: /\bsetInterval\b/, label: () => 'setInterval loop' }, +]; + +function findExtraJs(source) { + const signals = []; + for (const { re, label } of EXTRA_JS_PATTERNS) { + if (re.global) { + let m; + const r = new RegExp(re.source, re.flags); + while ((m = r.exec(source)) !== null) { + const s = label(m); + if (!signals.includes(s)) signals.push(s); + } + } else if (re.test(source)) { + signals.push(label()); + } + } + return signals; +} + +function findOldSyntaxMarkers(source) { + const markers = []; + if (/wix-interact-element/.test(source)) markers.push('wix-interact-element tag (use interact-element)'); + if (/\bmethod\s*:/.test(source)) markers.push('params.method (use stateAction on the effect)'); + if (/\btype\s*:\s*['"`](once|repeat|alternate|state)['"`]/.test(source)) markers.push('params.type play-mode (use triggerType on the effect)'); + if (/\btype\s*:\s*['"`](percentage|px|vh|vw|vmin|vmax|em|rem)['"`]/.test(source)) markers.push('range offset {value,type} (use unit)'); + if (/useCutsomElement/.test(source)) markers.push('useCutsomElement typo (use useCustomElement)'); + return markers; +} + +export function detect(filePath, source) { + const usesInteract = /@wix\/interact/.test(source); + const versionMatch = source.match(/@wix\/interact@(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : null; + const isLatest = version === LATEST_VERSION; + const usesCustomEffect = /customEffect\s*:/.test(source); + const extraJsSignals = findExtraJs(source); + const usesExtraJs = extraJsSignals.length > 0; + const oldSyntaxMarkers = findOldSyntaxMarkers(source); + + let category; + if (!usesInteract) category = 'Not using interact'; + else if (!isLatest || oldSyntaxMarkers.length > 0) category = 'Outdated version'; + else if (usesExtraJs) category = 'Uses extra JS'; + else if (usesCustomEffect) category = 'Uses customEffect'; + else category = 'Clean & current'; + + return { path: filePath, usesInteract, version, isLatest, usesCustomEffect, + usesExtraJs, extraJsSignals, oldSyntaxMarkers, category }; +} diff --git a/validator/test/detect.test.js b/validator/test/detect.test.js new file mode 100644 index 0000000..490a283 --- /dev/null +++ b/validator/test/detect.test.js @@ -0,0 +1,72 @@ +// validator/test/detect.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { detect } from '../lib/detect.js'; + +const clean = ` + +
    x
    `; + +test('clean current file', () => { + const d = detect('X.html', clean); + assert.equal(d.usesInteract, true); + assert.equal(d.version, '2.4.0'); + assert.equal(d.isLatest, true); + assert.equal(d.usesCustomEffect, false); + assert.equal(d.usesExtraJs, false); + assert.deepEqual(d.oldSyntaxMarkers, []); + assert.equal(d.category, 'Clean & current'); +}); + +test('outdated version', () => { + const d = detect('Y.html', `import { Interact } from 'https://esm.sh/@wix/interact@1.79.0';`); + assert.equal(d.usesInteract, true); + assert.equal(d.version, '1.79.0'); + assert.equal(d.isLatest, false); + assert.equal(d.category, 'Outdated version'); +}); + +test('not using interact', () => { + const d = detect('Z.html', ``); + assert.equal(d.usesInteract, false); + assert.equal(d.version, null); + assert.equal(d.category, 'Not using interact'); +}); + +test('old syntax markers flag a latest-version file as outdated', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + params:{ method:'toggle' }, effects:[{ customEffect:()=>{} }] }] }); + `; + const d = detect('W.html', src); + assert.ok(d.oldSyntaxMarkers.some((m) => m.includes('wix-interact-element'))); + assert.ok(d.oldSyntaxMarkers.some((m) => m.includes('method'))); + assert.equal(d.category, 'Outdated version'); +}); + +test('extra js detection', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + window.addEventListener('scroll', () => {}); + new IntersectionObserver(() => {}); + el.animate([], 300);`; + const d = detect('V.html', src); + assert.equal(d.usesExtraJs, true); + assert.ok(d.extraJsSignals.includes('addEventListener(scroll)')); + assert.ok(d.extraJsSignals.includes('IntersectionObserver')); + assert.ok(d.extraJsSignals.includes('Element.animate()')); + assert.equal(d.category, 'Uses extra JS'); +}); + +test('customEffect on a latest, no-extra-js file', () => { + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'pointerMove', + effects:[{ customEffect:(el,p)=>{} }] }] });`; + const d = detect('U.html', src); + assert.equal(d.usesCustomEffect, true); + assert.equal(d.usesExtraJs, false); + assert.equal(d.category, 'Uses customEffect'); +}); From 5e738922340f0622f7d0303459395a447f6e7173 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 29 Jun 2026 17:24:12 +0300 Subject: [PATCH 05/62] refactor(validator): uniform regex cloning in findExtraJs Co-Authored-By: Claude Sonnet 4.6 --- validator/lib/detect.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/validator/lib/detect.js b/validator/lib/detect.js index 9dd25d6..13d6ca9 100644 --- a/validator/lib/detect.js +++ b/validator/lib/detect.js @@ -12,14 +12,14 @@ const EXTRA_JS_PATTERNS = [ function findExtraJs(source) { const signals = []; for (const { re, label } of EXTRA_JS_PATTERNS) { - if (re.global) { + const r = new RegExp(re.source, re.flags); + if (r.global) { let m; - const r = new RegExp(re.source, re.flags); while ((m = r.exec(source)) !== null) { const s = label(m); if (!signals.includes(s)) signals.push(s); } - } else if (re.test(source)) { + } else if (r.test(source)) { signals.push(label()); } } From d38c339ee4b797403b02768544c8e3c4d246b2e3 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 16:55:27 +0300 Subject: [PATCH 06/62] feat(validator): draft store with diff/apply/discard and path safety --- validator/lib/drafts.js | 53 +++++++++++++++++++++++++++++++++ validator/test/drafts.test.js | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 validator/lib/drafts.js create mode 100644 validator/test/drafts.test.js diff --git a/validator/lib/drafts.js b/validator/lib/drafts.js new file mode 100644 index 0000000..4fae934 --- /dev/null +++ b/validator/lib/drafts.js @@ -0,0 +1,53 @@ +import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; +import { resolve, sep, dirname } from 'node:path'; +import { diffLines } from 'diff'; +import { DRAFTS_DIR } from './constants.js'; + +export function resolveSafe(rootDir, relPath) { + const root = resolve(rootDir); + const abs = resolve(root, relPath); + if (abs !== root && !abs.startsWith(root + sep)) { + throw new Error('path escapes root'); + } + return abs; +} + +export function draftAbsPath(rootDir, relPath) { + // Validate relPath is in-root, then place it under DRAFTS_DIR. + resolveSafe(rootDir, relPath); + return resolve(rootDir, DRAFTS_DIR, relPath); +} + +export async function writeDraft(rootDir, relPath, content) { + const abs = draftAbsPath(rootDir, relPath); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, 'utf8'); +} + +export async function readDraft(rootDir, relPath) { + try { + return await readFile(draftAbsPath(rootDir, relPath), 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +export async function readOriginal(rootDir, relPath) { + return readFile(resolveSafe(rootDir, relPath), 'utf8'); +} + +export function computeDiff(original, draft) { + return diffLines(original, draft); +} + +export async function applyDraft(rootDir, relPath) { + const draft = await readDraft(rootDir, relPath); + if (draft === null) throw new Error('no draft'); + await writeFile(resolveSafe(rootDir, relPath), draft, 'utf8'); + await discardDraft(rootDir, relPath); +} + +export async function discardDraft(rootDir, relPath) { + await rm(draftAbsPath(rootDir, relPath), { force: true }); +} diff --git a/validator/test/drafts.test.js b/validator/test/drafts.test.js new file mode 100644 index 0000000..f554030 --- /dev/null +++ b/validator/test/drafts.test.js @@ -0,0 +1,55 @@ +// validator/test/drafts.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { resolveSafe, writeDraft, readDraft, readOriginal, + computeDiff, applyDraft, discardDraft } from '../lib/drafts.js'; + +async function repo() { + const root = await mkdtemp(join(tmpdir(), 'iv-drafts-')); + await mkdir(join(root, 'Gallery-and-Carousel'), { recursive: true }); + await writeFile(join(root, 'Gallery-and-Carousel', 'A.html'), 'ORIGINAL\n'); + return root; +} + +test('resolveSafe rejects traversal', async () => { + const root = await repo(); + assert.throws(() => resolveSafe(root, '../escape.html'), /escapes root/); + assert.doesNotThrow(() => resolveSafe(root, 'Gallery-and-Carousel/A.html')); +}); + +test('write/read draft round trip', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/missing.html'), null); +}); + +test('computeDiff marks added and removed lines', async () => { + const parts = computeDiff('ORIGINAL\n', 'FIXED\n'); + assert.ok(parts.some((p) => p.removed && p.value.includes('ORIGINAL'))); + assert.ok(parts.some((p) => p.added && p.value.includes('FIXED'))); +}); + +test('applyDraft overwrites original and clears draft', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + await applyDraft(root, 'Gallery-and-Carousel/A.html'); + assert.equal(await readOriginal(root, 'Gallery-and-Carousel/A.html'), 'FIXED\n'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), null); +}); + +test('applyDraft throws when no draft', async () => { + const root = await repo(); + await assert.rejects(() => applyDraft(root, 'Gallery-and-Carousel/A.html'), /no draft/); +}); + +test('discardDraft removes draft only', async () => { + const root = await repo(); + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + await discardDraft(root, 'Gallery-and-Carousel/A.html'); + assert.equal(await readDraft(root, 'Gallery-and-Carousel/A.html'), null); + assert.equal(await readOriginal(root, 'Gallery-and-Carousel/A.html'), 'ORIGINAL\n'); +}); From f31a0dc4d82fde469a971c68bc00e4a42cd8fc99 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 17:05:29 +0300 Subject: [PATCH 07/62] feat(validator): fix-option prompt assembly --- validator/lib/prompt.js | 40 +++++++++++++++++++++++++++++++++++ validator/test/prompt.test.js | 35 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 validator/lib/prompt.js create mode 100644 validator/test/prompt.test.js diff --git a/validator/lib/prompt.js b/validator/lib/prompt.js new file mode 100644 index 0000000..4f1d003 --- /dev/null +++ b/validator/lib/prompt.js @@ -0,0 +1,40 @@ +import { INTERACT_CDN, PRESETS_CDN, LATEST_VERSION } from './constants.js'; + +export const FIX_OPTIONS = [ + { id: 'updateVersion', label: 'Update to latest version', default: true, + fragment: `Update all @wix/interact imports to version ${LATEST_VERSION} using "${INTERACT_CDN}" (and "${PRESETS_CDN}" for named presets). Migrate any version-specific syntax that the new version requires.` }, + { id: 'migrateSyntax', label: 'Migrate old syntax', default: true, + fragment: `Migrate outdated syntax to the current API: move play-mode off Interaction.params onto the effect and rename params.type -> triggerType (on TimeEffect) and params.method -> stateAction (on StateEffect); rename range-offset {value,type} -> {value,unit}; rename the custom element tag wix-interact-element -> interact-element; fix the useCutsomElement -> useCustomElement typo.` }, + { id: 'convertCustomEffect', label: 'Convert customEffect → preset/keyframe', default: false, + fragment: `Where a customEffect merely maps to a known namedEffect (from @wix/motion-presets) or a keyframeEffect, replace it with that idiomatic effect. Only keep customEffect when the behavior genuinely requires per-frame DOM manipulation or randomness.` }, + { id: 'removeExtraJs', label: 'Remove extra JavaScript', default: false, + fragment: `Remove hand-written JavaScript (manual addEventListener, IntersectionObserver, direct Element.animate, requestAnimationFrame/setInterval animation loops) and express the same behavior through @wix/interact triggers and effects instead.` }, + { id: 'convertToInteract', label: 'Convert non-interact → interact', default: false, + fragment: `This file does not currently use @wix/interact. Rewrite it so the animation is driven by @wix/interact (import it, wrap targets in , and call Interact.create once), preserving the original visual result.` }, +]; + +const SYSTEM = (specText) => `You are an expert at the @wix/interact animation library. You rewrite standalone HTML animation files so they use @wix/interact correctly on the latest version. + +Follow this canonical reference exactly: +${specText} + +OUTPUT CONTRACT: Return ONLY the complete rewritten HTML file. No markdown code fences, no commentary, no explanation — just the raw HTML from (or the file's first line) to its end. Preserve the original visual design, layout, copy, and asset URLs unless a requested fix requires changing them.`; + +export function buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }) { + const chosen = FIX_OPTIONS.filter((o) => optionIds.includes(o.id)); + const fixList = chosen.length + ? chosen.map((o) => `- ${o.label}: ${o.fragment}`).join('\n') + : '- Apply only the custom instructions below.'; + const custom = customPrompt && customPrompt.trim() + ? `\nCustom instructions (highest priority):\n${customPrompt.trim()}\n` + : ''; + const user = `File: ${diagnosis.path} +Static diagnosis: ${JSON.stringify(diagnosis)} + +Requested fixes: +${fixList} +${custom} +--- ORIGINAL SOURCE --- +${source}`; + return { system: SYSTEM(specText), user }; +} diff --git a/validator/test/prompt.test.js b/validator/test/prompt.test.js new file mode 100644 index 0000000..ca49278 --- /dev/null +++ b/validator/test/prompt.test.js @@ -0,0 +1,35 @@ +// validator/test/prompt.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FIX_OPTIONS, buildPrompt } from '../lib/prompt.js'; + +test('FIX_OPTIONS has expected ids and removeExtraJs defaults off', () => { + const ids = FIX_OPTIONS.map((o) => o.id); + assert.deepEqual(ids, ['updateVersion', 'migrateSyntax', 'convertCustomEffect', 'removeExtraJs', 'convertToInteract']); + assert.equal(FIX_OPTIONS.find((o) => o.id === 'removeExtraJs').default, false); + assert.equal(FIX_OPTIONS.find((o) => o.id === 'updateVersion').default, true); +}); + +test('buildPrompt embeds selected fragments, custom prompt, spec, and source', () => { + const diagnosis = { path: 'A.html', version: '1.79.0', category: 'Outdated version', oldSyntaxMarkers: ['x'] }; + const { system, user } = buildPrompt({ + diagnosis, source: 'SRC', + optionIds: ['updateVersion', 'migrateSyntax'], + customPrompt: 'keep the colors', specText: 'SPEC-RULES', + }); + assert.match(system, /SPEC-RULES/); + assert.match(system, /ONLY the complete rewritten HTML/i); + assert.match(user, /2\.4\.0/); // updateVersion fragment mentions target version + assert.match(user, /triggerType|stateAction/); // migrateSyntax fragment mentions renames + assert.match(user, /keep the colors/); + assert.match(user, /SRC/); + assert.match(user, /Outdated version/); // diagnosis included +}); + +test('buildPrompt ignores unknown option ids and tolerates empty custom prompt', () => { + const { user } = buildPrompt({ + diagnosis: { path: 'A.html', category: 'Clean & current', oldSyntaxMarkers: [] }, + source: 'x', optionIds: ['bogus'], customPrompt: '', specText: 's', + }); + assert.doesNotMatch(user, /undefined/); +}); From 29d3b9730ac188b4072c8f2a8c6acb1c550a6a0e Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 17:17:50 +0300 Subject: [PATCH 08/62] feat(validator): Claude Agent SDK wrapper + html extraction --- validator/lib/agent.js | 32 ++++++++++++++++++++++++++++++++ validator/test/agent.test.js | 14 ++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 validator/lib/agent.js create mode 100644 validator/test/agent.test.js diff --git a/validator/lib/agent.js b/validator/lib/agent.js new file mode 100644 index 0000000..826015d --- /dev/null +++ b/validator/lib/agent.js @@ -0,0 +1,32 @@ +import { query } from '@anthropic-ai/claude-agent-sdk'; + +export function extractHtml(text) { + let t = String(text).trim(); + const fence = t.match(/^```(?:html)?\s*\n([\s\S]*?)\n```$/i); + if (fence) t = fence[1]; + return t.trim(); +} + +export async function runAgent(system, user, { model } = {}) { + const options = { + systemPrompt: system, + allowedTools: [], + maxTurns: 1, + permissionMode: 'default', + }; + if (model) options.model = model; + + let resultText = ''; + let assistantText = ''; + for await (const msg of query({ prompt: user, options })) { + if (msg.type === 'assistant') { + for (const block of msg.message.content) { + if (block.type === 'text') assistantText += block.text; + } + } else if (msg.type === 'result') { + if (msg.subtype === 'success') resultText = msg.result; + else throw new Error(`agent error: ${msg.subtype}`); + } + } + return resultText || assistantText; +} diff --git a/validator/test/agent.test.js b/validator/test/agent.test.js new file mode 100644 index 0000000..b659cfc --- /dev/null +++ b/validator/test/agent.test.js @@ -0,0 +1,14 @@ +// validator/test/agent.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractHtml } from '../lib/agent.js'; + +test('extractHtml strips html code fences', () => { + assert.equal(extractHtml('```html\n
    x
    \n```'), '
    x
    '); +}); +test('extractHtml strips bare fences', () => { + assert.equal(extractHtml('```\n
    x
    \n```'), '
    x
    '); +}); +test('extractHtml passes through plain html', () => { + assert.equal(extractHtml('\n'), '\n'); +}); From 1ac2fd6d7b6c34cdfd85a32eb537d48f4767256e Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 17:27:22 +0300 Subject: [PATCH 09/62] feat(validator): fix orchestrator with bounded concurrency + self-check --- validator/lib/fix.js | 40 +++++++++++++++++++++++ validator/test/fix.test.js | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 validator/lib/fix.js create mode 100644 validator/test/fix.test.js diff --git a/validator/lib/fix.js b/validator/lib/fix.js new file mode 100644 index 0000000..ec642ed --- /dev/null +++ b/validator/lib/fix.js @@ -0,0 +1,40 @@ +import { detect } from './detect.js'; +import { buildPrompt } from './prompt.js'; +import { writeDraft } from './drafts.js'; +import { extractHtml, runAgent as realRunAgent } from './agent.js'; + +export async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + } + const workers = Array.from({ length: Math.min(limit, items.length) }, worker); + await Promise.all(workers); + return results; +} + +export async function fixFile(rootDir, relPath, opts) { + const { source, optionIds, customPrompt, specText, model, runAgent = realRunAgent } = opts; + try { + const diagnosis = detect(relPath, source); + const { system, user } = buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }); + const html = extractHtml(await runAgent(system, user, { model })); + await writeDraft(rootDir, relPath, html); + const recheck = detect(relPath, html); + const clean = recheck.category === 'Clean & current' + || (recheck.isLatest && recheck.oldSyntaxMarkers.length === 0); + return { path: relPath, status: clean ? 'fixed' : 'needsReview', recheck }; + } catch (err) { + return { path: relPath, status: 'fixFailed', error: String(err.message || err) }; + } +} + +export async function runFix(rootDir, files, opts) { + const { concurrency = 4, ...rest } = opts; + return mapLimit(files, concurrency, (f) => + fixFile(rootDir, f.path, { ...rest, source: f.source })); +} diff --git a/validator/test/fix.test.js b/validator/test/fix.test.js new file mode 100644 index 0000000..5165196 --- /dev/null +++ b/validator/test/fix.test.js @@ -0,0 +1,65 @@ +// validator/test/fix.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mapLimit, fixFile, runFix } from '../lib/fix.js'; +import { readDraft } from '../lib/drafts.js'; + +const root = () => mkdtemp(join(tmpdir(), 'iv-fix-')); +const SPEC = 'spec'; + +test('mapLimit preserves order and caps concurrency', async () => { + let active = 0, max = 0; + const fn = async (n) => { + active++; max = Math.max(max, active); + await new Promise((r) => setTimeout(r, 5)); + active--; return n * 2; + }; + const out = await mapLimit([1, 2, 3, 4, 5], 2, fn); + assert.deepEqual(out, [2, 4, 6, 8, 10]); + assert.ok(max <= 2); +}); + +test('fixFile writes a draft and reports fixed when recheck is clean', async () => { + const r = await root(); + const good = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; + const res = await fixFile(r, 'A.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => good, + }); + assert.equal(res.status, 'fixed'); + assert.equal(await readDraft(r, 'A.html'), good); +}); + +test('fixFile reports needsReview when draft still diagnoses as problematic', async () => { + const r = await root(); + const res = await fixFile(r, 'B.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`, + }); + assert.equal(res.status, 'needsReview'); +}); + +test('fixFile reports fixFailed and writes no draft when agent throws', async () => { + const r = await root(); + const res = await fixFile(r, 'C.html', { + source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => { throw new Error('boom'); }, + }); + assert.equal(res.status, 'fixFailed'); + assert.match(res.error, /boom/); + assert.equal(await readDraft(r, 'C.html'), null); +}); + +test('runFix processes a batch', async () => { + const r = await root(); + const results = await runFix(r, + [{ path: 'A.html', source: 'x' }, { path: 'B.html', source: 'y' }], + { optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => 'import "https://esm.sh/@wix/interact@2.4.0";', concurrency: 2 }); + assert.equal(results.length, 2); +}); From 8d3216f63edea69e741d71ca9e5f93127863e33e Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 19:55:45 +0300 Subject: [PATCH 10/62] feat(validator): express server + REST API --- validator/lib/spec.js | 12 ++++ validator/server.js | 106 ++++++++++++++++++++++++++++++++++ validator/test/server.test.js | 66 +++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 validator/lib/spec.js create mode 100644 validator/server.js create mode 100644 validator/test/server.test.js diff --git a/validator/lib/spec.js b/validator/lib/spec.js new file mode 100644 index 0000000..ea31800 --- /dev/null +++ b/validator/lib/spec.js @@ -0,0 +1,12 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function loadSpecText(rootDir) { + try { + return await readFile(join(rootDir, 'full-lean.md'), 'utf8'); + } catch { + return 'Use @wix/interact 2.4.0. Tag: . ' + + 'Effects: namedEffect | keyframeEffect | customEffect. ' + + 'Play-mode: triggerType (TimeEffect) / stateAction (StateEffect).'; + } +} diff --git a/validator/server.js b/validator/server.js new file mode 100644 index 0000000..3086d41 --- /dev/null +++ b/validator/server.js @@ -0,0 +1,106 @@ +import express from 'express'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { listAnimationFiles } from './lib/files.js'; +import { detect } from './lib/detect.js'; +import { readOriginal, readDraft, computeDiff, applyDraft, discardDraft } from './lib/drafts.js'; +import { runFix } from './lib/fix.js'; +import { FIX_OPTIONS } from './lib/prompt.js'; +import { loadSpecText } from './lib/spec.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function createApp(rootDir) { + const root = resolve(rootDir); + const app = express(); + app.use(express.json({ limit: '5mb' })); + app.use(express.static(join(__dirname, 'public'))); + + const bad = (res, msg) => res.status(400).json({ error: msg }); + + app.get('/api/options', (_req, res) => { + res.json({ options: FIX_OPTIONS.map(({ id, label, default: d }) => ({ id, label, default: d })) }); + }); + + app.get('/api/files', async (_req, res) => { + res.json({ files: await listAnimationFiles(root) }); + }); + + app.get('/api/file', async (req, res) => { + try { + res.json({ source: await readOriginal(root, String(req.query.path)) }); + } catch (err) { + bad(res, String(err.message || err)); + } + }); + + app.get('/api/draft', async (req, res) => { + try { + const source = await readDraft(root, String(req.query.path)); + if (source === null) return res.status(404).json({ error: 'no draft' }); + res.json({ source }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/scan', async (req, res) => { + try { + const all = await listAnimationFiles(root); + const wanted = Array.isArray(req.body.paths) && req.body.paths.length + ? all.filter((f) => req.body.paths.includes(f.path)) : all; + const results = []; + for (const f of wanted) { + results.push(detect(f.path, await readOriginal(root, f.path))); + } + const summary = {}; + for (const r of results) summary[r.category] = (summary[r.category] || 0) + 1; + res.json({ results, summary, total: results.length }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/fix', async (req, res) => { + try { + const { paths, optionIds = [], customPrompt = '' } = req.body; + if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); + const specText = await loadSpecText(root); + const files = []; + for (const p of paths) files.push({ path: p, source: await readOriginal(root, p) }); + const results = await runFix(root, files, { optionIds, customPrompt, specText }); + res.json({ results }); + } catch (err) { res.status(500).json({ error: String(err.message || err) }); } + }); + + app.get('/api/diff', async (req, res) => { + try { + const p = String(req.query.path); + const draft = await readDraft(root, p); + if (draft === null) return res.status(404).json({ error: 'no draft' }); + const original = await readOriginal(root, p); + res.json({ parts: computeDiff(original, draft) }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/apply', async (req, res) => { + try { + for (const p of req.body.paths || []) await applyDraft(root, p); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/discard', async (req, res) => { + try { + for (const p of req.body.paths || []) await discardDraft(root, p); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + return app; +} + +// Self-start when run directly (repo root is the parent of validator/). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const root = resolve(__dirname, '..'); + const port = process.env.PORT || 4500; + createApp(root).listen(port, () => { + console.log(`Interact Validator on http://localhost:${port} (root: ${root})`); + }); +} diff --git a/validator/test/server.test.js b/validator/test/server.test.js new file mode 100644 index 0000000..c9272d9 --- /dev/null +++ b/validator/test/server.test.js @@ -0,0 +1,66 @@ +// validator/test/server.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createApp } from '../server.js'; + +async function repo() { + const root = await mkdtemp(join(tmpdir(), 'iv-srv-')); + await mkdir(join(root, 'G'), { recursive: true }); + await writeFile(join(root, 'G', 'A.html'), + `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`); + return root; +} + +async function start(root) { + const app = createApp(root); + const server = app.listen(0); + await new Promise((r) => server.once('listening', r)); + const base = `http://127.0.0.1:${server.address().port}`; + return { base, server }; +} + +test('GET /api/files lists animations', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/files`); + const body = await res.json(); + assert.equal(res.status, 200); + assert.ok(body.files.some((f) => f.path === 'G/A.html')); + server.close(); +}); + +test('POST /api/scan returns per-file diagnosis and a summary', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/scan`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + const body = await res.json(); + assert.equal(body.results[0].category, 'Outdated version'); + assert.equal(body.summary['Outdated version'], 1); + server.close(); +}); + +test('GET /api/file rejects path traversal', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/file?path=${encodeURIComponent('../../etc/passwd')}`); + assert.equal(res.status, 400); + server.close(); +}); + +test('apply flow: seed a draft via discard/apply endpoints', async () => { + const root = await repo(); + const { base, server } = await start(root); + // Write a draft directly through the lib to simulate a completed fix. + const { writeDraft } = await import('../lib/drafts.js'); + await writeDraft(root, 'G/A.html', 'FIXED'); + const diff = await (await fetch(`${base}/api/diff?path=${encodeURIComponent('G/A.html')}`)).json(); + assert.ok(diff.parts.some((p) => p.added && p.value.includes('FIXED'))); + const apply = await fetch(`${base}/api/apply`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths: ['G/A.html'] }) }); + assert.equal(apply.status, 200); + const after = await (await fetch(`${base}/api/file?path=${encodeURIComponent('G/A.html')}`)).json(); + assert.equal(after.source, 'FIXED'); + server.close(); +}); From df66ba94e6994c2a8735accd423ca1d1433f12a5 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 20:27:34 +0300 Subject: [PATCH 11/62] feat(validator): validator UI with scan, preview, diff, and apply Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + validator/public/app.js | 135 +++++++++++++++++++++++++++++++++ validator/public/index.html | 46 +++++++++++ validator/public/preview.js | 9 +++ validator/public/styles.css | 35 +++++++++ validator/test/preview.test.js | 16 ++++ 6 files changed, 243 insertions(+) create mode 100644 .gitignore create mode 100644 validator/public/app.js create mode 100644 validator/public/index.html create mode 100644 validator/public/preview.js create mode 100644 validator/public/styles.css create mode 100644 validator/test/preview.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ef5cc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +validator/.drafts/ +validator/node_modules/ diff --git a/validator/public/app.js b/validator/public/app.js new file mode 100644 index 0000000..dde646f --- /dev/null +++ b/validator/public/app.js @@ -0,0 +1,135 @@ +import { injectBase } from './preview.js'; + +const BADGE = { + 'Outdated version': 'outdated', 'Not using interact': 'nointeract', + 'Uses extra JS': 'extrajs', 'Uses customEffect': 'custom', 'Clean & current': 'clean', +}; + +const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null }; +const $ = (id) => document.getElementById(id); +const api = (path, opts) => fetch(path, opts).then((r) => r.json()); + +async function loadFiles() { + const { files } = await api('/api/files'); + state.files = files; + renderList(); +} + +async function loadOptions() { + const { options } = await api('/api/options'); + $('fixOptions').innerHTML = options.map((o) => + `` + ).join('
    '); +} + +function renderList() { + $('fileList').innerHTML = state.files.map((f) => { + const d = state.diag[f.path]; + const cat = d ? d.category : ''; + const badge = cat ? `${cat}` : ''; + const draft = state.drafts.has(f.path) ? 'draft' : ''; + const checked = state.selected.has(f.path) ? 'checked' : ''; + return `
  • + + ${f.path}${badge}${draft}
  • `; + }).join(''); +} + +async function scan() { + const { results, summary, total } = await api('/api/scan', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + state.diag = {}; + for (const r of results) state.diag[r.path] = r; + $('summary').textContent = `${total} files · ` + + Object.entries(summary).map(([k, v]) => `${k}: ${v}`).join(' · '); + renderList(); +} + +function baseHrefFor(path) { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); +} + +async function showPreview(path, { draft = false } = {}) { + const url = draft ? `/api/draft?path=${encodeURIComponent(path)}` + : `/api/file?path=${encodeURIComponent(path)}`; + const { source } = await api(url); + $('preview').srcdoc = injectBase(source, baseHrefFor(path)); + $('code').textContent = source; +} + +async function showDiff(path) { + const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); + if (!res.ok) { $('diff').textContent = 'No draft for this file.'; return; } + const { parts } = await res.json(); + $('diff').innerHTML = parts.map((p) => { + const safe = p.value.replace(/${safe}`; + if (p.removed) return `${safe}`; + return `${safe}`; + }).join(''); +} + +function selectTab(tab) { + for (const b of document.querySelectorAll('.tab')) b.classList.toggle('active', b.dataset.tab === tab); + $('preview').hidden = tab !== 'preview'; + $('code').hidden = tab !== 'code'; + $('diff').hidden = tab !== 'diff'; + if (state.current && tab === 'diff') showDiff(state.current); + if (state.current && tab === 'preview') { + showPreview(state.current, { draft: state.drafts.has(state.current) }); + } +} + +async function runFix() { + const paths = [...state.selected]; + if (!paths.length) { $('fixStatus').textContent = 'Select files first.'; return; } + const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); + const customPrompt = $('customPrompt').value; + $('fixStatus').textContent = `Fixing ${paths.length} file(s)…`; + const { results, error } = await api('/api/fix', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths, optionIds, customPrompt }) }); + if (error) { $('fixStatus').textContent = `Error: ${error}`; return; } + for (const r of results) if (r.status !== 'fixFailed') state.drafts.add(r.path); + $('fixStatus').textContent = results.map((r) => + `${r.status === 'fixed' ? '✓' : r.status === 'needsReview' ? '⚠' : '✗'} ${r.path}` + + (r.error ? ` — ${r.error}` : '')).join('\n'); + renderList(); +} + +async function applyOrDiscard(endpoint) { + const paths = [...state.selected].filter((p) => state.drafts.has(p)); + if (!paths.length) { $('fixStatus').textContent = 'No drafts in selection.'; return; } + await api(`/api/${endpoint}`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); + for (const p of paths) state.drafts.delete(p); + $('fixStatus').textContent = `${endpoint === 'apply' ? 'Applied' : 'Discarded'} ${paths.length} draft(s).`; + renderList(); + if (state.current && paths.includes(state.current)) showPreview(state.current); +} + +$('fileList').addEventListener('click', (e) => { + const li = e.target.closest('li'); if (!li) return; + const path = li.dataset.path; + if (e.target.classList.contains('sel')) { + if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); + return; + } + state.current = path; + renderList(); + selectTab('preview'); +}); +$('scanBtn').onclick = scan; +$('selectAllBtn').onclick = () => { + if (state.selected.size === state.files.length) state.selected.clear(); + else state.files.forEach((f) => state.selected.add(f.path)); + renderList(); +}; +$('fixBtn').onclick = runFix; +$('applyBtn').onclick = () => applyOrDiscard('apply'); +$('discardBtn').onclick = () => applyOrDiscard('discard'); +for (const b of document.querySelectorAll('.tab')) b.onclick = () => selectTab(b.dataset.tab); + +loadFiles(); +loadOptions(); diff --git a/validator/public/index.html b/validator/public/index.html new file mode 100644 index 0000000..869214e --- /dev/null +++ b/validator/public/index.html @@ -0,0 +1,46 @@ + + + + + + Interact Validator + + + +
    +

    Interact Validator

    +
    + + + +
    +
    +
    +
    +
      +
      +
      +
      + + + +
      + + + +
      + +
      + + + diff --git a/validator/public/preview.js b/validator/public/preview.js new file mode 100644 index 0000000..f478563 --- /dev/null +++ b/validator/public/preview.js @@ -0,0 +1,9 @@ +// Injects a so relative asset URLs in a previewed animation +// resolve against its original directory (same technique explorer.html uses). +export function injectBase(html, baseHref) { + if (/]*>/i.test(html)) { + return html.replace(/]*>/i, (m) => `${m}\n`); + } + return `\n${html}`; +} diff --git a/validator/public/styles.css b/validator/public/styles.css new file mode 100644 index 0000000..fa0611d --- /dev/null +++ b/validator/public/styles.css @@ -0,0 +1,35 @@ +* { box-sizing: border-box; } +body { margin: 0; font: 14px/1.4 system-ui, sans-serif; color: #1a1a1a; } +header { display: flex; justify-content: space-between; align-items: center; + padding: 10px 16px; border-bottom: 1px solid #ddd; } +header h1 { font-size: 16px; margin: 0; } +.actions { display: flex; gap: 8px; align-items: center; } +.summary { color: #555; font-size: 12px; } +button { cursor: pointer; padding: 6px 10px; border: 1px solid #ccc; + background: #f7f7f7; border-radius: 6px; } +main { display: grid; grid-template-columns: 320px 1fr 300px; height: calc(100vh - 53px); } +#listPane { overflow: auto; border-right: 1px solid #eee; } +#fileList { list-style: none; margin: 0; padding: 0; } +#fileList li { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; + display: flex; gap: 8px; align-items: center; } +#fileList li.active { background: #eef4ff; } +.badge { font-size: 11px; padding: 1px 6px; border-radius: 10px; white-space: nowrap; } +.badge.outdated { background: #ffe6cc; } +.badge.nointeract { background: #ffd6d6; } +.badge.extrajs { background: #fff2b3; } +.badge.custom { background: #e0d6ff; } +.badge.clean { background: #cdeccd; } +.badge.draft { background: #cfe9ff; } +#detailPane { display: flex; flex-direction: column; } +.tabs { display: flex; gap: 4px; padding: 6px; border-bottom: 1px solid #eee; } +.tab.active { background: #1a1a1a; color: #fff; } +#preview { flex: 1; border: 0; width: 100%; } +#code, #diff { flex: 1; overflow: auto; margin: 0; padding: 12px; + white-space: pre-wrap; font-family: ui-monospace, monospace; } +#diff ins { background: #d6f5d6; text-decoration: none; display: block; } +#diff del { background: #f8d6d6; text-decoration: none; display: block; } +#fixPane { border-left: 1px solid #eee; padding: 12px; overflow: auto; + display: flex; flex-direction: column; gap: 10px; } +#customPrompt { width: 100%; min-height: 80px; } +.apply-actions { display: flex; gap: 6px; flex-wrap: wrap; } +#fixStatus { font-size: 12px; color: #555; white-space: pre-wrap; } diff --git a/validator/test/preview.test.js b/validator/test/preview.test.js new file mode 100644 index 0000000..a41637b --- /dev/null +++ b/validator/test/preview.test.js @@ -0,0 +1,16 @@ +// validator/test/preview.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { injectBase } from '../public/preview.js'; + +test('injectBase inserts a base tag after ', () => { + const out = injectBase('\nx', '/G/'); + assert.match(out, /\s*\n/); +}); +test('injectBase prepends when no head', () => { + assert.match(injectBase('
      x
      ', '/G/'), /^/); +}); +test('injectBase leaves an existing base alone', () => { + const html = ''; + assert.equal(injectBase(html, '/G/'), html); +}); From e93c5b16f892445ac608ec689c8652bbb26eff5c Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 21:55:24 +0300 Subject: [PATCH 12/62] fix(validator): partial-batch apply results, per-file fix resilience, option-aware self-check, robust html extraction - apply/discard endpoints now process each path independently and always return 200 with {results:[{path,ok,error?}]} - app.js applyOrDiscard only clears state.drafts for ok:true paths, reports failures in fixStatus - /api/fix reads each path defensively, collecting readFailures separately, never aborts whole batch - fixFile clean check now respects optionIds: convertCustomEffect/removeExtraJs requested but still present => needsReview - extractHtml regex changed from anchored to non-anchored, extracts first fenced block even with surrounding prose - Added 4 new tests (server partial batch, fix option-aware needsReview, agent prose-fence, agent no-fence trim) Co-Authored-By: Claude Sonnet 4.6 --- validator/lib/agent.js | 2 +- validator/lib/fix.js | 4 +++- validator/public/app.js | 16 ++++++++++---- validator/server.js | 41 +++++++++++++++++++++++++---------- validator/test/agent.test.js | 6 +++++ validator/test/fix.test.js | 13 +++++++++++ validator/test/server.test.js | 23 ++++++++++++++++++++ 7 files changed, 88 insertions(+), 17 deletions(-) diff --git a/validator/lib/agent.js b/validator/lib/agent.js index 826015d..f731582 100644 --- a/validator/lib/agent.js +++ b/validator/lib/agent.js @@ -2,7 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; export function extractHtml(text) { let t = String(text).trim(); - const fence = t.match(/^```(?:html)?\s*\n([\s\S]*?)\n```$/i); + const fence = t.match(/```(?:html)?\s*\n([\s\S]*?)\n```/i); if (fence) t = fence[1]; return t.trim(); } diff --git a/validator/lib/fix.js b/validator/lib/fix.js index ec642ed..1c07ab0 100644 --- a/validator/lib/fix.js +++ b/validator/lib/fix.js @@ -25,8 +25,10 @@ export async function fixFile(rootDir, relPath, opts) { const html = extractHtml(await runAgent(system, user, { model })); await writeDraft(rootDir, relPath, html); const recheck = detect(relPath, html); - const clean = recheck.category === 'Clean & current' + let clean = recheck.category === 'Clean & current' || (recheck.isLatest && recheck.oldSyntaxMarkers.length === 0); + if (clean && optionIds.includes('convertCustomEffect') && recheck.usesCustomEffect) clean = false; + if (clean && optionIds.includes('removeExtraJs') && recheck.usesExtraJs) clean = false; return { path: relPath, status: clean ? 'fixed' : 'needsReview', recheck }; } catch (err) { return { path: relPath, status: 'fixFailed', error: String(err.message || err) }; diff --git a/validator/public/app.js b/validator/public/app.js index dde646f..2576621 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -101,12 +101,20 @@ async function runFix() { async function applyOrDiscard(endpoint) { const paths = [...state.selected].filter((p) => state.drafts.has(p)); if (!paths.length) { $('fixStatus').textContent = 'No drafts in selection.'; return; } - await api(`/api/${endpoint}`, { method: 'POST', + const data = await api(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); - for (const p of paths) state.drafts.delete(p); - $('fixStatus').textContent = `${endpoint === 'apply' ? 'Applied' : 'Discarded'} ${paths.length} draft(s).`; + const results = data.results || []; + const succeeded = results.filter((r) => r.ok).map((r) => r.path); + const failed = results.filter((r) => !r.ok); + for (const p of succeeded) state.drafts.delete(p); + const verb = endpoint === 'apply' ? 'Applied' : 'Discarded'; + let msg = `${verb} ${succeeded.length} draft(s).`; + if (failed.length) { + msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; + } + $('fixStatus').textContent = msg; renderList(); - if (state.current && paths.includes(state.current)) showPreview(state.current); + if (state.current && succeeded.includes(state.current)) showPreview(state.current); } $('fileList').addEventListener('click', (e) => { diff --git a/validator/server.js b/validator/server.js index 3086d41..a090829 100644 --- a/validator/server.js +++ b/validator/server.js @@ -63,9 +63,16 @@ export function createApp(rootDir) { if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); const specText = await loadSpecText(root); const files = []; - for (const p of paths) files.push({ path: p, source: await readOriginal(root, p) }); - const results = await runFix(root, files, { optionIds, customPrompt, specText }); - res.json({ results }); + const readFailures = []; + for (const p of paths) { + try { + files.push({ path: p, source: await readOriginal(root, p) }); + } catch (err) { + readFailures.push({ path: p, status: 'fixFailed', error: String(err.message || err) }); + } + } + const fixResults = await runFix(root, files, { optionIds, customPrompt, specText }); + res.json({ results: [...readFailures, ...fixResults] }); } catch (err) { res.status(500).json({ error: String(err.message || err) }); } }); @@ -80,17 +87,29 @@ export function createApp(rootDir) { }); app.post('/api/apply', async (req, res) => { - try { - for (const p of req.body.paths || []) await applyDraft(root, p); - res.json({ ok: true }); - } catch (err) { bad(res, String(err.message || err)); } + const results = []; + for (const p of req.body.paths || []) { + try { + await applyDraft(root, p); + results.push({ path: p, ok: true }); + } catch (err) { + results.push({ path: p, ok: false, error: String(err.message || err) }); + } + } + res.json({ results }); }); app.post('/api/discard', async (req, res) => { - try { - for (const p of req.body.paths || []) await discardDraft(root, p); - res.json({ ok: true }); - } catch (err) { bad(res, String(err.message || err)); } + const results = []; + for (const p of req.body.paths || []) { + try { + await discardDraft(root, p); + results.push({ path: p, ok: true }); + } catch (err) { + results.push({ path: p, ok: false, error: String(err.message || err) }); + } + } + res.json({ results }); }); return app; diff --git a/validator/test/agent.test.js b/validator/test/agent.test.js index b659cfc..9ced1f6 100644 --- a/validator/test/agent.test.js +++ b/validator/test/agent.test.js @@ -12,3 +12,9 @@ test('extractHtml strips bare fences', () => { test('extractHtml passes through plain html', () => { assert.equal(extractHtml('\n'), '\n'); }); +test('extractHtml extracts fenced block when prose precedes it', () => { + assert.equal(extractHtml('Here:\n```html\n
      x
      \n```'), '
      x
      '); +}); +test('extractHtml returns trimmed text unchanged when no fence present', () => { + assert.equal(extractHtml(' no fence here '), 'no fence here'); +}); diff --git a/validator/test/fix.test.js b/validator/test/fix.test.js index 5165196..236fe91 100644 --- a/validator/test/fix.test.js +++ b/validator/test/fix.test.js @@ -55,6 +55,19 @@ test('fixFile reports fixFailed and writes no draft when agent throws', async () assert.equal(await readDraft(r, 'C.html'), null); }); +test('fixFile reports needsReview when convertCustomEffect requested but draft still uses customEffect', async () => { + const r = await root(); + // Draft is latest version, no old-syntax markers, but still contains customEffect: + const draftWithCustomEffect = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + effects:[{ customEffect: (el, p) => { el.style.opacity = p; }, duration:300, triggerType:'once' }] }] });`; + const res = await fixFile(r, 'D.html', { + source: 'OLD', optionIds: ['convertCustomEffect'], customPrompt: '', specText: SPEC, + runAgent: async () => draftWithCustomEffect, + }); + assert.equal(res.status, 'needsReview', 'should be needsReview when customEffect conversion was requested but still present'); +}); + test('runFix processes a batch', async () => { const r = await root(); const results = await runFix(r, diff --git a/validator/test/server.test.js b/validator/test/server.test.js index c9272d9..7cee5fd 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -64,3 +64,26 @@ test('apply flow: seed a draft via discard/apply endpoints', async () => { assert.equal(after.source, 'FIXED'); server.close(); }); + +test('apply partial batch: valid path succeeds, missing path fails, always 200', async () => { + const root = await repo(); + const { base, server } = await start(root); + const { writeDraft } = await import('../lib/drafts.js'); + await writeDraft(root, 'G/A.html', 'PATCHED'); + const res = await fetch(`${base}/api/apply`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths: ['G/A.html', 'G/missing.html'] }) }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.results), 'results should be an array'); + const good = body.results.find((r) => r.path === 'G/A.html'); + const bad = body.results.find((r) => r.path === 'G/missing.html'); + assert.ok(good, 'should have result for G/A.html'); + assert.ok(bad, 'should have result for G/missing.html'); + assert.equal(good.ok, true, 'G/A.html should succeed'); + assert.equal(bad.ok, false, 'G/missing.html should fail'); + // Verify the valid original was actually overwritten + const after = await (await fetch(`${base}/api/file?path=${encodeURIComponent('G/A.html')}`)).json(); + assert.equal(after.source, 'PATCHED'); + server.close(); +}); From 3b9526efe52fe019b0172618b4bd1e7c43899209 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 22:25:53 +0300 Subject: [PATCH 13/62] feat(validator): drive fixes via local claude CLI subprocess Replace the @wix/interact Agent SDK call with a spawn of the local `claude -p --output-format json` CLI (reuses `claude login`, no API key), matching the proven interact-xp playground pattern. Parse the result from the message array. Verified end-to-end live (1.79.0 -> 2.4.0 rewrite). Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/agent.js | 78 ++++++--- validator/package-lock.json | 318 ------------------------------------ validator/package.json | 1 - 3 files changed, 56 insertions(+), 341 deletions(-) diff --git a/validator/lib/agent.js b/validator/lib/agent.js index f731582..5ece826 100644 --- a/validator/lib/agent.js +++ b/validator/lib/agent.js @@ -1,32 +1,66 @@ -import { query } from '@anthropic-ai/claude-agent-sdk'; +import { spawn } from 'node:child_process'; +import { writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +// Strips a fenced code block (```html … ``` or bare ``` … ```) if one is +// present anywhere in the text; otherwise returns the trimmed text unchanged. export function extractHtml(text) { - let t = String(text).trim(); + const t = String(text).trim(); const fence = t.match(/```(?:html)?\s*\n([\s\S]*?)\n```/i); - if (fence) t = fence[1]; - return t.trim(); + return (fence ? fence[1] : t).trim(); } +// Collect a child process's stdout/stderr, feeding `stdin` to it. +function spawnCollect(cmd, args, stdin) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.on('data', (c) => { stdout += c; }); + child.stderr.on('data', (c) => { stderr += c; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.on('error', () => {}); // ignore EPIPE if the CLI exits early + child.stdin.write(stdin); + child.stdin.end(); + }); +} + +// One-shot rewrite via the local `claude` CLI (reuses the machine's +// `claude login` — no API key). The system prompt goes to a temp file to +// dodge arg-size limits; the user prompt is piped on stdin. Tools are +// stripped via --exclude-dynamic-system-prompt-sections so it's a pure +// text-in / text-out LLM call. Returns the assistant's final text. export async function runAgent(system, user, { model } = {}) { - const options = { - systemPrompt: system, - allowedTools: [], - maxTurns: 1, - permissionMode: 'default', - }; - if (model) options.model = model; + const dir = await mkdtemp(join(tmpdir(), 'iv-agent-')); + const sysFile = join(dir, 'system.txt'); + await writeFile(sysFile, system, 'utf8'); - let resultText = ''; - let assistantText = ''; - for await (const msg of query({ prompt: user, options })) { - if (msg.type === 'assistant') { - for (const block of msg.message.content) { - if (block.type === 'text') assistantText += block.text; - } - } else if (msg.type === 'result') { - if (msg.subtype === 'success') resultText = msg.result; - else throw new Error(`agent error: ${msg.subtype}`); + const args = ['-p', '--output-format', 'json', + '--system-prompt-file', sysFile, + '--exclude-dynamic-system-prompt-sections']; + if (model) args.push('--model', model); + + try { + const { code, stdout, stderr } = await spawnCollect('claude', args, user); + if (code !== 0) throw new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`could not parse claude output: ${stdout.slice(0, 300)}`); + } + // `--output-format json` yields an array of messages; the final result + // lives in the element with type 'result'. Older CLIs returned that + // object directly, so handle both shapes. + const result = Array.isArray(parsed) + ? parsed.find((m) => m && m.type === 'result') + : parsed; + if (!result || result.is_error || typeof result.result !== 'string') { + throw new Error(`claude error: ${result?.subtype || result?.error || 'no result field'}`); } + return result.result; + } finally { + await rm(dir, { recursive: true, force: true }); } - return resultText || assistantText; } diff --git a/validator/package-lock.json b/validator/package-lock.json index 52eb176..9a5abfb 100644 --- a/validator/package-lock.json +++ b/validator/package-lock.json @@ -8,318 +8,10 @@ "name": "interact-validator", "version": "0.1.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.0", "diff": "^7.0.0", "express": "^4.21.0" } }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.77", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz", - "integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1141,16 +833,6 @@ "engines": { "node": ">= 0.8" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/validator/package.json b/validator/package.json index 4c8f04b..6138d27 100644 --- a/validator/package.json +++ b/validator/package.json @@ -8,7 +8,6 @@ "test": "node --test" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.0", "diff": "^7.0.0", "express": "^4.21.0" } From dee3298aed4173c62a7f06daf1bff22a817b21da Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 22:25:53 +0300 Subject: [PATCH 14/62] feat(validator): Apple-style UI redesign Minimal, tight visual system: SF system font, neutral grays + single blue accent, translucent app bar, segmented tabs, status dots, restyled checkboxes, stat chips, thin scrollbars. Add a file filter box and full HTML escaping in the diff/list rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 115 ++++++++++++------- validator/public/index.html | 50 ++++---- validator/public/styles.css | 223 ++++++++++++++++++++++++++++++------ 3 files changed, 294 insertions(+), 94 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 2576621..3b2fa9d 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -1,13 +1,17 @@ import { injectBase } from './preview.js'; -const BADGE = { - 'Outdated version': 'outdated', 'Not using interact': 'nointeract', - 'Uses extra JS': 'extrajs', 'Uses customEffect': 'custom', 'Clean & current': 'clean', +// category → status-dot modifier class +const SDOT = { + 'Outdated version': 's-outdated', 'Not using interact': 's-nointeract', + 'Uses extra JS': 's-extrajs', 'Uses customEffect': 's-custom', 'Clean & current': 's-clean', }; +// stable order for the summary chips +const CAT_ORDER = ['Outdated version', 'Uses extra JS', 'Uses customEffect', 'Not using interact', 'Clean & current']; -const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null }; +const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '' }; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); +const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); async function loadFiles() { const { files } = await api('/api/files'); @@ -18,31 +22,52 @@ async function loadFiles() { async function loadOptions() { const { options } = await api('/api/options'); $('fixOptions').innerHTML = options.map((o) => - `` - ).join('
      '); + `` + ).join(''); +} + +function visibleFiles() { + if (!state.filter) return state.files; + const q = state.filter.toLowerCase(); + return state.files.filter((f) => f.path.toLowerCase().includes(q)); } function renderList() { - $('fileList').innerHTML = state.files.map((f) => { + const rows = visibleFiles().map((f) => { const d = state.diag[f.path]; - const cat = d ? d.category : ''; - const badge = cat ? `${cat}` : ''; - const draft = state.drafts.has(f.path) ? 'draft' : ''; + const dotClass = d ? (SDOT[d.category] || '') : ''; + const draft = state.drafts.has(f.path) ? 'draft' : ''; const checked = state.selected.has(f.path) ? 'checked' : ''; - return `
    • + const active = state.current === f.path ? ' active' : ''; + const title = d ? `${f.path} — ${d.category}` : f.path; + return `
    • - ${f.path}${badge}${draft}
    • `; + + ${esc(f.path)}${draft}`; }).join(''); + $('fileList').innerHTML = rows; +} + +function renderSummary(summary, total) { + const chips = CAT_ORDER.filter((c) => summary[c]).map((c) => + `${summary[c]}` + ).join(''); + $('summary').innerHTML = `${total} files${chips}`; } async function scan() { - const { results, summary, total } = await api('/api/scan', { - method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); - state.diag = {}; - for (const r of results) state.diag[r.path] = r; - $('summary').textContent = `${total} files · ` + - Object.entries(summary).map(([k, v]) => `${k}: ${v}`).join(' · '); - renderList(); + $('scanBtn').disabled = true; $('scanBtn').textContent = 'Scanning…'; + try { + const { results, summary, total } = await api('/api/scan', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + state.diag = {}; + for (const r of results) state.diag[r.path] = r; + renderSummary(summary, total); + renderList(); + } finally { + $('scanBtn').disabled = false; $('scanBtn').textContent = 'Scan'; + } } function baseHrefFor(path) { @@ -60,10 +85,10 @@ async function showPreview(path, { draft = false } = {}) { async function showDiff(path) { const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); - if (!res.ok) { $('diff').textContent = 'No draft for this file.'; return; } + if (!res.ok) { $('diff').innerHTML = '
      No draft for this file yet.
      '; return; } const { parts } = await res.json(); $('diff').innerHTML = parts.map((p) => { - const safe = p.value.replace(/${safe}`; if (p.removed) return `${safe}`; return `${safe}`; @@ -75,10 +100,9 @@ function selectTab(tab) { $('preview').hidden = tab !== 'preview'; $('code').hidden = tab !== 'code'; $('diff').hidden = tab !== 'diff'; - if (state.current && tab === 'diff') showDiff(state.current); - if (state.current && tab === 'preview') { - showPreview(state.current, { draft: state.drafts.has(state.current) }); - } + if (!state.current) return; + if (tab === 'diff') showDiff(state.current); + if (tab === 'preview') showPreview(state.current, { draft: state.drafts.has(state.current) }); } async function runFix() { @@ -86,16 +110,25 @@ async function runFix() { if (!paths.length) { $('fixStatus').textContent = 'Select files first.'; return; } const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); const customPrompt = $('customPrompt').value; + $('fixBtn').disabled = true; $('fixStatus').textContent = `Fixing ${paths.length} file(s)…`; - const { results, error } = await api('/api/fix', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paths, optionIds, customPrompt }) }); - if (error) { $('fixStatus').textContent = `Error: ${error}`; return; } - for (const r of results) if (r.status !== 'fixFailed') state.drafts.add(r.path); - $('fixStatus').textContent = results.map((r) => - `${r.status === 'fixed' ? '✓' : r.status === 'needsReview' ? '⚠' : '✗'} ${r.path}` + - (r.error ? ` — ${r.error}` : '')).join('\n'); - renderList(); + try { + const { results, error } = await api('/api/fix', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths, optionIds, customPrompt }) }); + if (error) { $('fixStatus').textContent = `Error: ${error}`; return; } + for (const r of results) if (r.status !== 'fixFailed') state.drafts.add(r.path); + $('fixStatus').innerHTML = results.map((r) => { + const mark = r.status === 'fixed' ? '' + : r.status === 'needsReview' ? '' + : ''; + return `${mark} ${esc(r.path)}${r.error ? ` — ${esc(r.error)}` : ''}`; + }).join('\n'); + renderList(); + if (state.current && state.drafts.has(state.current)) selectTab('diff'); + } finally { + $('fixBtn').disabled = false; + } } async function applyOrDiscard(endpoint) { @@ -109,14 +142,13 @@ async function applyOrDiscard(endpoint) { for (const p of succeeded) state.drafts.delete(p); const verb = endpoint === 'apply' ? 'Applied' : 'Discarded'; let msg = `${verb} ${succeeded.length} draft(s).`; - if (failed.length) { - msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; - } + if (failed.length) msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; $('fixStatus').textContent = msg; renderList(); if (state.current && succeeded.includes(state.current)) showPreview(state.current); } +// ── events ────────────────────────────────────────── $('fileList').addEventListener('click', (e) => { const li = e.target.closest('li'); if (!li) return; const path = li.dataset.path; @@ -126,12 +158,15 @@ $('fileList').addEventListener('click', (e) => { } state.current = path; renderList(); - selectTab('preview'); + selectTab(document.querySelector('.tab.active')?.dataset.tab || 'preview'); }); +$('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderList(); }); $('scanBtn').onclick = scan; $('selectAllBtn').onclick = () => { - if (state.selected.size === state.files.length) state.selected.clear(); - else state.files.forEach((f) => state.selected.add(f.path)); + const vis = visibleFiles(); + const allSelected = vis.length && vis.every((f) => state.selected.has(f.path)); + if (allSelected) vis.forEach((f) => state.selected.delete(f.path)); + else vis.forEach((f) => state.selected.add(f.path)); renderList(); }; $('fixBtn').onclick = runFix; diff --git a/validator/public/index.html b/validator/public/index.html index 869214e..1b77296 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -7,37 +7,47 @@ -
      -

      Interact Validator

      -
      - - - +
      +
      Interact Validator
      +
      +
      + +
      -
      +
      + +
      -
      - - - +
      +
      + + + +
      +
      +
      + + +
      - - -
      +
      diff --git a/validator/public/styles.css b/validator/public/styles.css index fa0611d..87411c6 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -1,35 +1,190 @@ +:root { + --bg: #ffffff; + --bg-elevated: #ffffff; + --bg-sidebar: #fbfbfd; + --bg-subtle: #f5f5f7; + --bg-hover: #f0f0f2; + --text: #1d1d1f; + --text-secondary: #6e6e73; + --text-tertiary: #a1a1a6; + --border: #e3e3e6; + --border-strong: #d2d2d7; + --accent: #0071e3; + --accent-hover: #0077ed; + --accent-soft: #ecf4ff; + --radius: 10px; + --radius-sm: 7px; + --radius-pill: 980px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow: 0 4px 16px rgba(0,0,0,0.08); + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; + --mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace; + --green: #34c759; --green-bg: #e8f8ee; --green-fg: #1a7f37; + --amber: #ff9f0a; --amber-bg: #fff4e5; --amber-fg: #95560a; + --yellow: #f5c518; --yellow-bg: #fff8e1; --yellow-fg: #806100; + --purple: #af52de; --purple-bg: #f4ecff; --purple-fg: #6b32a8; + --red: #ff3b30; --red-bg: #ffeceb; --red-fg: #b3261e; + --blue: #0071e3; --blue-bg: #e8f2ff; --blue-fg: #0058b9; +} + * { box-sizing: border-box; } -body { margin: 0; font: 14px/1.4 system-ui, sans-serif; color: #1a1a1a; } -header { display: flex; justify-content: space-between; align-items: center; - padding: 10px 16px; border-bottom: 1px solid #ddd; } -header h1 { font-size: 16px; margin: 0; } -.actions { display: flex; gap: 8px; align-items: center; } -.summary { color: #555; font-size: 12px; } -button { cursor: pointer; padding: 6px 10px; border: 1px solid #ccc; - background: #f7f7f7; border-radius: 6px; } -main { display: grid; grid-template-columns: 320px 1fr 300px; height: calc(100vh - 53px); } -#listPane { overflow: auto; border-right: 1px solid #eee; } -#fileList { list-style: none; margin: 0; padding: 0; } -#fileList li { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; - display: flex; gap: 8px; align-items: center; } -#fileList li.active { background: #eef4ff; } -.badge { font-size: 11px; padding: 1px 6px; border-radius: 10px; white-space: nowrap; } -.badge.outdated { background: #ffe6cc; } -.badge.nointeract { background: #ffd6d6; } -.badge.extrajs { background: #fff2b3; } -.badge.custom { background: #e0d6ff; } -.badge.clean { background: #cdeccd; } -.badge.draft { background: #cfe9ff; } -#detailPane { display: flex; flex-direction: column; } -.tabs { display: flex; gap: 4px; padding: 6px; border-bottom: 1px solid #eee; } -.tab.active { background: #1a1a1a; color: #fff; } -#preview { flex: 1; border: 0; width: 100%; } -#code, #diff { flex: 1; overflow: auto; margin: 0; padding: 12px; - white-space: pre-wrap; font-family: ui-monospace, monospace; } -#diff ins { background: #d6f5d6; text-decoration: none; display: block; } -#diff del { background: #f8d6d6; text-decoration: none; display: block; } -#fixPane { border-left: 1px solid #eee; padding: 12px; overflow: auto; - display: flex; flex-direction: column; gap: 10px; } -#customPrompt { width: 100%; min-height: 80px; } -.apply-actions { display: flex; gap: 6px; flex-wrap: wrap; } -#fixStatus { font-size: 12px; color: #555; white-space: pre-wrap; } +html, body { height: 100%; } +body { + margin: 0; + font-family: var(--font); + font-size: 13px; + line-height: 1.45; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* ── App bar ─────────────────────────────────────── */ +.appbar { + display: flex; align-items: center; justify-content: space-between; + height: 52px; padding: 0 18px; + background: rgba(255,255,255,0.8); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 10; +} +.brand { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } +.brand .logo { + width: 18px; height: 18px; border-radius: 6px; + background: linear-gradient(135deg, #0a84ff, #5e5ce6); + box-shadow: var(--shadow-sm); +} +.toolbar { display: flex; align-items: center; gap: 10px; } + +/* ── Stat chips (scan summary) ──────────────────── */ +.stats { display: flex; align-items: center; gap: 6px; margin-right: 4px; } +.stat { + display: inline-flex; align-items: center; gap: 5px; + font-size: 12px; color: var(--text-secondary); + background: var(--bg-subtle); padding: 3px 9px; border-radius: var(--radius-pill); +} +.stat b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.stat .dot { width: 7px; height: 7px; border-radius: 50%; } + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + font-family: inherit; font-size: 13px; font-weight: 500; + color: var(--text); background: var(--bg-subtle); + border: 0; border-radius: var(--radius-pill); + padding: 7px 15px; cursor: pointer; + transition: background .15s ease, transform .05s ease, opacity .15s; +} +.btn:hover { background: var(--bg-hover); } +.btn:active { transform: scale(0.97); } +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-block { width: 100%; padding: 9px 15px; } +.btn:disabled { opacity: .45; cursor: default; transform: none; } + +/* ── Layout ──────────────────────────────────────── */ +main { + display: grid; + grid-template-columns: 300px minmax(0,1fr) 280px; + height: calc(100vh - 52px); +} + +/* ── File list (left) ────────────────────────────── */ +#listPane { background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } +.list-head { padding: 12px 12px 8px; } +.search { + width: 100%; font-family: inherit; font-size: 13px; color: var(--text); + background: var(--bg); border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); padding: 7px 11px; + transition: border-color .15s, box-shadow .15s; +} +.search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +#fileList { list-style: none; margin: 0; padding: 4px 8px 16px; overflow-y: auto; flex: 1; } +#fileList li { + display: flex; align-items: center; gap: 9px; + padding: 7px 10px; border-radius: var(--radius-sm); cursor: pointer; + transition: background .12s; +} +#fileList li:hover { background: var(--bg-hover); } +#fileList li.active { background: var(--accent-soft); } +#fileList li.active .name { color: var(--accent); font-weight: 500; } +#fileList .name { + flex: 1; min-width: 0; font-size: 12.5px; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; +} +#fileList .status-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--border-strong); } +#fileList .draft-tag { + font-size: 10px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; + color: var(--blue-fg); background: var(--blue-bg); padding: 2px 6px; border-radius: var(--radius-pill); flex: none; +} +/* native checkbox restyle */ +.sel { appearance: none; -webkit-appearance: none; width: 15px; height: 15px; flex: none; + border: 1.5px solid var(--border-strong); border-radius: 5px; background: var(--bg); cursor: pointer; position: relative; transition: background .12s, border-color .12s; } +.sel:hover { border-color: var(--accent); } +.sel:checked { background: var(--accent); border-color: var(--accent); } +.sel:checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; + border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } + +/* status dot colors */ +.s-outdated { background: var(--amber) !important; } +.s-nointeract { background: var(--red) !important; } +.s-extrajs { background: var(--yellow) !important; } +.s-custom { background: var(--purple) !important; } +.s-clean { background: var(--green) !important; } + +/* stat dot colors */ +.dot.s-outdated { background: var(--amber); } .dot.s-nointeract { background: var(--red); } +.dot.s-extrajs { background: var(--yellow); } .dot.s-custom { background: var(--purple); } +.dot.s-clean { background: var(--green); } + +/* ── Detail (center) ─────────────────────────────── */ +#detailPane { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); } +.detail-head { display: flex; align-items: center; justify-content: center; padding: 12px; border-bottom: 1px solid var(--border); } +.segmented { display: inline-flex; background: var(--bg-subtle); border-radius: 9px; padding: 2px; gap: 2px; } +.segmented .tab { + font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--text-secondary); + background: transparent; border: 0; border-radius: 7px; padding: 5px 16px; cursor: pointer; transition: all .15s; +} +.segmented .tab:hover { color: var(--text); } +.segmented .tab.active { background: var(--bg); color: var(--text); box-shadow: var(--shadow-sm); } +.viewport { flex: 1; min-height: 0; position: relative; } +#preview { width: 100%; height: 100%; border: 0; background: #fff; } +#code, #diff { + position: absolute; inset: 0; overflow: auto; margin: 0; padding: 16px 18px; + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; line-height: 1.6; + color: var(--text); background: var(--bg-subtle); +} +.empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: var(--text-tertiary); font-size: 13px; } +#diff ins, #diff del, #diff span { display: block; text-decoration: none; padding: 0 6px; border-radius: 3px; } +#diff ins { background: var(--green-bg); color: var(--green-fg); } +#diff del { background: var(--red-bg); color: var(--red-fg); } +#diff span { color: var(--text-secondary); } + +/* ── Fix panel (right) ───────────────────────────── */ +#fixPane { background: var(--bg-sidebar); border-left: 1px solid var(--border); padding: 16px; overflow-y: auto; + display: flex; flex-direction: column; gap: 14px; } +.panel-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-tertiary); margin: 0; } +#fixOptions { display: flex; flex-direction: column; gap: 2px; } +.opt { display: flex; align-items: flex-start; gap: 9px; padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer; transition: background .12s; } +.opt:hover { background: var(--bg-hover); } +.opt span { font-size: 12.5px; line-height: 1.35; } +#customPrompt { + width: 100%; min-height: 76px; resize: vertical; font-family: inherit; font-size: 12.5px; color: var(--text); + background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 11px; line-height: 1.45; +} +#customPrompt:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +#customPrompt::placeholder { color: var(--text-tertiary); } +.divider { height: 1px; background: var(--border); margin: 2px 0; } +.apply-actions { display: flex; gap: 8px; } +.apply-actions .btn { flex: 1; } +#fixStatus { font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; line-height: 1.55; font-family: var(--mono); } +#fixStatus:empty { display: none; } +.res-ok { color: var(--green-fg); } .res-warn { color: var(--amber-fg); } .res-fail { color: var(--red-fg); } + +/* ── Scrollbars ──────────────────────────────────── */ +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.16); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.28); background-clip: padding-box; } +::-webkit-scrollbar-track { background: transparent; } From e3a432c91ce38ed7a57302da9ce35fadebdd5d86 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 22:29:22 +0300 Subject: [PATCH 15/62] fix(validator): gitignore drafts at repo root The server runs with root = repo root, so drafts land in /.drafts/, not validator/.drafts/. Ignore the root location so generated drafts are never accidentally committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6ef5cc3..e908c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +# Validator sidecar drafts — written under the repo root (server runs with +# root = repo root), so ignore both the root and validator-local locations. +/.drafts/ validator/.drafts/ validator/node_modules/ From e4de144ea7a22541f5f488dedbcf179e0392198c Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 23:12:16 +0300 Subject: [PATCH 16/62] feat(validator): glass UI, live streaming progress, compare & tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign to explorer.html's dark floating-glass aesthetic (translucent blurred panels over a full-bleed preview, Inter, rounded corners). - Stream /api/fix per-file results via SSE; UI shows a live list with spinners → ✓/⚠/✗, a done/total count and an elapsed timer (no more opaque "fixing…"). JSON path preserved for non-streaming callers. - Add a Compare tab: Original vs Draft rendered side by side. - Tooltips on the scan summary chips explaining each category. - Rename Apply/Discard buttons to "Apply selected" / "Discard selected". Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/fix.js | 9 +- validator/public/app.js | 169 +++++++++++++------ validator/public/index.html | 83 ++++----- validator/public/styles.css | 324 ++++++++++++++++++------------------ validator/server.js | 48 ++++-- 5 files changed, 365 insertions(+), 268 deletions(-) diff --git a/validator/lib/fix.js b/validator/lib/fix.js index 1c07ab0..ed87a4d 100644 --- a/validator/lib/fix.js +++ b/validator/lib/fix.js @@ -36,7 +36,10 @@ export async function fixFile(rootDir, relPath, opts) { } export async function runFix(rootDir, files, opts) { - const { concurrency = 4, ...rest } = opts; - return mapLimit(files, concurrency, (f) => - fixFile(rootDir, f.path, { ...rest, source: f.source })); + const { concurrency = 4, onResult, ...rest } = opts; + return mapLimit(files, concurrency, async (f) => { + const result = await fixFile(rootDir, f.path, { ...rest, source: f.source }); + if (onResult) onResult(result); + return result; + }); } diff --git a/validator/public/app.js b/validator/public/app.js index 3b2fa9d..faf9f99 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -1,17 +1,18 @@ import { injectBase } from './preview.js'; -// category → status-dot modifier class -const SDOT = { - 'Outdated version': 's-outdated', 'Not using interact': 's-nointeract', - 'Uses extra JS': 's-extrajs', 'Uses customEffect': 's-custom', 'Clean & current': 's-clean', +const CAT_INFO = { + 'Outdated version': { dot: 's-outdated', tip: 'Imports an old @wix/interact version, or uses outdated syntax (old tag, params.type/method, etc.).' }, + 'Uses extra JS': { dot: 's-extrajs', tip: 'Mixes in hand-written JS — event listeners, IntersectionObserver, .animate — instead of interact triggers.' }, + 'Uses customEffect': { dot: 's-custom', tip: 'Uses a customEffect where a namedEffect or keyframeEffect might do the job.' }, + 'Not using interact':{ dot: 's-nointeract',tip: 'Does not import @wix/interact at all.' }, + 'Clean & current': { dot: 's-clean', tip: 'On the latest version with no issues detected.' }, }; -// stable order for the summary chips const CAT_ORDER = ['Outdated version', 'Uses extra JS', 'Uses customEffect', 'Not using interact', 'Clean & current']; -const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '' }; +const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', tab: 'preview', progress: null }; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); -const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); async function loadFiles() { const { files } = await api('/api/files'); @@ -22,9 +23,8 @@ async function loadFiles() { async function loadOptions() { const { options } = await api('/api/options'); $('fixOptions').innerHTML = options.map((o) => - `` - ).join(''); + ``).join(''); } function visibleFiles() { @@ -34,26 +34,26 @@ function visibleFiles() { } function renderList() { - const rows = visibleFiles().map((f) => { + $('fileList').innerHTML = visibleFiles().map((f) => { const d = state.diag[f.path]; - const dotClass = d ? (SDOT[d.category] || '') : ''; + const dotClass = d ? (CAT_INFO[d.category]?.dot || '') : ''; const draft = state.drafts.has(f.path) ? 'draft' : ''; const checked = state.selected.has(f.path) ? 'checked' : ''; const active = state.current === f.path ? ' active' : ''; const title = d ? `${f.path} — ${d.category}` : f.path; return `
    • - + ${esc(f.path)}${draft}
    • `; }).join(''); - $('fileList').innerHTML = rows; } function renderSummary(summary, total) { - const chips = CAT_ORDER.filter((c) => summary[c]).map((c) => - `${summary[c]}` - ).join(''); - $('summary').innerHTML = `${total} files${chips}`; + const chips = CAT_ORDER.filter((c) => summary[c]).map((c) => { + const i = CAT_INFO[c]; + return `${summary[c]}`; + }).join(''); + $('summary').innerHTML = `${total} files${chips}`; } async function scan() { @@ -74,18 +74,26 @@ function baseHrefFor(path) { const slash = path.lastIndexOf('/'); return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); } +const fetchSource = (kind, path) => api(`/api/${kind}?path=${encodeURIComponent(path)}`).then((r) => r.source); +// the "working" source the Preview/Code tabs show: the draft if one exists, else the original +const currentSource = (path) => fetchSource(state.drafts.has(path) ? 'draft' : 'file', path); +const blankDoc = (label) => `${label}`; -async function showPreview(path, { draft = false } = {}) { - const url = draft ? `/api/draft?path=${encodeURIComponent(path)}` - : `/api/file?path=${encodeURIComponent(path)}`; - const { source } = await api(url); - $('preview').srcdoc = injectBase(source, baseHrefFor(path)); - $('code').textContent = source; +async function showPreview(path) { + $('preview').srcdoc = injectBase(await currentSource(path), baseHrefFor(path)); +} +async function showCode(path) { + $('code').textContent = await currentSource(path); +} +async function showCompare(path) { + const base = baseHrefFor(path); + $('cmpOrig').srcdoc = injectBase(await fetchSource('file', path), base); + if (state.drafts.has(path)) $('cmpDraft').srcdoc = injectBase(await fetchSource('draft', path), base); + else $('cmpDraft').srcdoc = blankDoc('No draft yet — fix this file first'); } - async function showDiff(path) { const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); - if (!res.ok) { $('diff').innerHTML = '
      No draft for this file yet.
      '; return; } + if (!res.ok) { $('diff').innerHTML = '
      No draft for this file yet.
      '; return; } const { parts } = await res.json(); $('diff').innerHTML = parts.map((p) => { const safe = esc(p.value); @@ -96,44 +104,101 @@ async function showDiff(path) { } function selectTab(tab) { + state.tab = tab; for (const b of document.querySelectorAll('.tab')) b.classList.toggle('active', b.dataset.tab === tab); - $('preview').hidden = tab !== 'preview'; - $('code').hidden = tab !== 'code'; - $('diff').hidden = tab !== 'diff'; - if (!state.current) return; - if (tab === 'diff') showDiff(state.current); - if (tab === 'preview') showPreview(state.current, { draft: state.drafts.has(state.current) }); + const has = !!state.current; + $('placeholder').hidden = has; + $('preview').hidden = !(has && tab === 'preview'); + $('compare').hidden = !(has && tab === 'compare'); + $('code').hidden = !(has && tab === 'code'); + $('diff').hidden = !(has && tab === 'diff'); + if (!has) return; + if (tab === 'preview') showPreview(state.current); + else if (tab === 'compare') showCompare(state.current); + else if (tab === 'code') showCode(state.current); + else if (tab === 'diff') showDiff(state.current); +} + +// ── Live fix progress (SSE) ───────────────────────── +let progTimer = null; +function renderProgress() { + const p = state.progress; + if (!p) { $('fixProgress').innerHTML = ''; return; } + const elapsed = Math.round(((p.endedAt || Date.now()) - p.startedAt) / 1000); + const head = p.running + ? `Working…${p.done}/${p.total} · ${elapsed}s` + : `Finished${p.done}/${p.total} · ${elapsed}s`; + const items = [...p.items.entries()].map(([path, st]) => { + const mk = st.status === 'pending' ? '' + : st.status === 'fixed' ? '' + : st.status === 'needsReview' ? '' + : ''; + const t = `${path}${st.error ? ' — ' + st.error : ''}`; + return `
      ${mk}${esc(path)}
      `; + }).join(''); + $('fixProgress').innerHTML = `
      ${head}
      ${items}
      `; +} + +function applyResult(r) { + if (!state.progress) return; + state.progress.items.set(r.path, { status: r.status, error: r.error }); + state.progress.done++; + if (r.status !== 'fixFailed') state.drafts.add(r.path); + renderProgress(); + renderList(); +} + +function handleFrame(frame) { + const ev = /event:\s*(.+)/.exec(frame); + const dt = /data:\s*([\s\S]+)/.exec(frame); + if (!ev || !dt) return; + if (ev[1].trim() !== 'result') return; + try { applyResult(JSON.parse(dt[1])); } catch { /* ignore malformed frame */ } } async function runFix() { const paths = [...state.selected]; - if (!paths.length) { $('fixStatus').textContent = 'Select files first.'; return; } + if (!paths.length) { $('applyStatus').textContent = 'Select files first.'; return; } const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); const customPrompt = $('customPrompt').value; - $('fixBtn').disabled = true; - $('fixStatus').textContent = `Fixing ${paths.length} file(s)…`; + state.progress = { running: true, total: paths.length, done: 0, startedAt: Date.now(), endedAt: null, + items: new Map(paths.map((p) => [p, { status: 'pending' }])) }; + $('fixBtn').disabled = true; $('applyStatus').textContent = ''; + renderProgress(); + progTimer = setInterval(renderProgress, 500); try { - const { results, error } = await api('/api/fix', { - method: 'POST', headers: { 'content-type': 'application/json' }, + const res = await fetch('/api/fix', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ paths, optionIds, customPrompt }) }); - if (error) { $('fixStatus').textContent = `Error: ${error}`; return; } - for (const r of results) if (r.status !== 'fixFailed') state.drafts.add(r.path); - $('fixStatus').innerHTML = results.map((r) => { - const mark = r.status === 'fixed' ? '' - : r.status === 'needsReview' ? '' - : ''; - return `${mark} ${esc(r.path)}${r.error ? ` — ${esc(r.error)}` : ''}`; - }).join('\n'); - renderList(); - if (state.current && state.drafts.has(state.current)) selectTab('diff'); + if (res.body && res.headers.get('content-type')?.includes('text/event-stream')) { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { handleFrame(buf.slice(0, i)); buf = buf.slice(i + 2); } + } + } else { + const data = await res.json(); + (data.results || []).forEach(applyResult); + } } finally { + state.progress.running = false; + state.progress.endedAt = Date.now(); + clearInterval(progTimer); + renderProgress(); $('fixBtn').disabled = false; + renderList(); + if (state.current && state.drafts.has(state.current)) selectTab('compare'); } } async function applyOrDiscard(endpoint) { const paths = [...state.selected].filter((p) => state.drafts.has(p)); - if (!paths.length) { $('fixStatus').textContent = 'No drafts in selection.'; return; } + if (!paths.length) { $('applyStatus').textContent = 'No drafts in selection.'; return; } const data = await api(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); const results = data.results || []; @@ -143,22 +208,22 @@ async function applyOrDiscard(endpoint) { const verb = endpoint === 'apply' ? 'Applied' : 'Discarded'; let msg = `${verb} ${succeeded.length} draft(s).`; if (failed.length) msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; - $('fixStatus').textContent = msg; + $('applyStatus').textContent = msg; renderList(); - if (state.current && succeeded.includes(state.current)) showPreview(state.current); + if (state.current && succeeded.includes(state.current)) selectTab(state.tab); } // ── events ────────────────────────────────────────── $('fileList').addEventListener('click', (e) => { const li = e.target.closest('li'); if (!li) return; const path = li.dataset.path; - if (e.target.classList.contains('sel')) { + if (e.target.classList.contains('cb')) { if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); return; } state.current = path; renderList(); - selectTab(document.querySelector('.tab.active')?.dataset.tab || 'preview'); + selectTab(state.tab); }); $('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderList(); }); $('scanBtn').onclick = scan; diff --git a/validator/public/index.html b/validator/public/index.html index 1b77296..3284e9d 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -7,50 +7,55 @@ -
      + +
      + + + + +

      Select a file to preview

      +
      + + +
      + + + + +
      + + +
      -
      - +
        + -
        -
        -
        - - - -
        -
        -
        - - - -
        -
        + + - -
        diff --git a/validator/public/styles.css b/validator/public/styles.css index 87411c6..59ee47a 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -1,190 +1,190 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + :root { - --bg: #ffffff; - --bg-elevated: #ffffff; - --bg-sidebar: #fbfbfd; - --bg-subtle: #f5f5f7; - --bg-hover: #f0f0f2; - --text: #1d1d1f; - --text-secondary: #6e6e73; - --text-tertiary: #a1a1a6; - --border: #e3e3e6; - --border-strong: #d2d2d7; - --accent: #0071e3; - --accent-hover: #0077ed; - --accent-soft: #ecf4ff; - --radius: 10px; - --radius-sm: 7px; - --radius-pill: 980px; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); - --shadow: 0 4px 16px rgba(0,0,0,0.08); - --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; - --mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace; - --green: #34c759; --green-bg: #e8f8ee; --green-fg: #1a7f37; - --amber: #ff9f0a; --amber-bg: #fff4e5; --amber-fg: #95560a; - --yellow: #f5c518; --yellow-bg: #fff8e1; --yellow-fg: #806100; - --purple: #af52de; --purple-bg: #f4ecff; --purple-fg: #6b32a8; - --red: #ff3b30; --red-bg: #ffeceb; --red-fg: #b3261e; - --blue: #0071e3; --blue-bg: #e8f2ff; --blue-fg: #0058b9; + --glass-bg: rgba(30, 30, 30, 0.78); + --glass-blur: blur(40px) saturate(1.6); + --hair: rgba(255,255,255,0.08); + --text: #f5f5f7; + --text-2: rgba(255,255,255,0.62); + --text-3: rgba(255,255,255,0.4); + --fill-1: rgba(255,255,255,0.05); + --fill-2: rgba(255,255,255,0.09); + --fill-3: rgba(255,255,255,0.14); + --accent: #3b82f6; + --accent-soft: rgba(59,130,246,0.28); + --radius: 16px; + --radius-sm: 10px; + --radius-xs: 8px; + --shadow: + 0 0 0 0.5px rgba(255,255,255,0.06), + 0 8px 40px rgba(0,0,0,0.55), + 0 2px 12px rgba(0,0,0,0.3); + --mono: "SF Mono", ui-monospace, Menlo, monospace; + --c-outdated: #fb923c; --c-extrajs: #fbbf24; --c-custom: #a855f7; + --c-nointeract: #f87171; --c-clean: #34d399; --c-draft: #60a5fa; } -* { box-sizing: border-box; } -html, body { height: 100%; } +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } body { - margin: 0; - font-family: var(--font); - font-size: 13px; - line-height: 1.45; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: + radial-gradient(1200px 800px at 18% -10%, rgba(59,130,246,0.10), transparent 60%), + radial-gradient(1000px 700px at 110% 120%, rgba(168,85,247,0.10), transparent 55%), + #161617; color: var(--text); - background: var(--bg); + font-size: 13px; -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; } -/* ── App bar ─────────────────────────────────────── */ -.appbar { - display: flex; align-items: center; justify-content: space-between; - height: 52px; padding: 0 18px; - background: rgba(255,255,255,0.8); - backdrop-filter: saturate(180%) blur(20px); - -webkit-backdrop-filter: saturate(180%) blur(20px); - border-bottom: 1px solid var(--border); - position: sticky; top: 0; z-index: 10; +.glass { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--hair); + border-radius: var(--radius); + box-shadow: var(--shadow); } -.brand { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } -.brand .logo { - width: 18px; height: 18px; border-radius: 6px; - background: linear-gradient(135deg, #0a84ff, #5e5ce6); - box-shadow: var(--shadow-sm); + +/* ── Full-bleed viewport (behind panels) ─────────── */ +#viewport { position: fixed; inset: 0; z-index: 0; background: #0e0e0f; } +#viewport > * { position: absolute; inset: 0; } +#preview, #cmpOrig, #cmpDraft { width: 100%; height: 100%; border: 0; background: #fff; } +#code, #diff { + overflow: auto; padding: 84px 28px 28px; margin: 0; color: var(--text); + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; line-height: 1.65; } -.toolbar { display: flex; align-items: center; gap: 10px; } - -/* ── Stat chips (scan summary) ──────────────────── */ -.stats { display: flex; align-items: center; gap: 6px; margin-right: 4px; } -.stat { - display: inline-flex; align-items: center; gap: 5px; - font-size: 12px; color: var(--text-secondary); - background: var(--bg-subtle); padding: 3px 9px; border-radius: var(--radius-pill); +#diff ins, #diff del, #diff span { display: block; text-decoration: none; padding: 0 8px; border-radius: 3px; } +#diff ins { background: rgba(52,211,153,0.16); color: #6ee7b7; } +#diff del { background: rgba(248,113,113,0.16); color: #fca5a5; } +#diff span { color: var(--text-2); } +#placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--text-3); } +#placeholder .ic { font-size: 52px; opacity: .35; } + +/* Compare: two rendered frames side by side */ +#compare { display: flex; } +#compare .cmp-col { position: relative; flex: 1; min-width: 0; border-right: 1px solid rgba(255,255,255,0.06); } +#compare .cmp-col:last-child { border-right: 0; } +#compare iframe { width: 100%; height: 100%; } +.cmp-label { + position: absolute; top: 16px; left: 50%; transform: translateX(-50%); z-index: 5; + font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; + color: var(--text); background: var(--glass-bg); backdrop-filter: var(--glass-blur); + border: 1px solid var(--hair); padding: 4px 12px; border-radius: 980px; box-shadow: var(--shadow); } -.stat b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } -.stat .dot { width: 7px; height: 7px; border-radius: 50%; } + +/* ── Floating segmented tabs (top center) ────────── */ +#tabs { + position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 60; + display: inline-flex; gap: 2px; padding: 4px; +} +.tab { + font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--text-2); + background: transparent; border: 0; border-radius: 11px; padding: 6px 16px; cursor: pointer; transition: all .16s; +} +.tab:hover { color: var(--text); } +.tab.active { background: var(--fill-3); color: var(--text); } + +/* ── Panels (left & right, full height) ──────────── */ +#listPane, #fixPane { position: fixed; top: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; } +#listPane { left: 16px; width: 290px; } +#fixPane { right: 16px; width: 300px; padding: 16px; gap: 13px; overflow-y: auto; } + +.brand { display: flex; align-items: center; gap: 9px; padding: 15px 16px 12px; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; } +.brand .logo { width: 18px; height: 18px; border-radius: 6px; background: linear-gradient(135deg, #0a84ff, #5e5ce6); box-shadow: 0 2px 8px rgba(0,0,0,.4); } + +.list-actions { display: flex; gap: 7px; padding: 0 16px 11px; } +#summary { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 12px; } +#summary:empty { display: none; } +.list-head { padding: 0 16px 12px; } /* ── Buttons ─────────────────────────────────────── */ .btn { - font-family: inherit; font-size: 13px; font-weight: 500; - color: var(--text); background: var(--bg-subtle); - border: 0; border-radius: var(--radius-pill); - padding: 7px 15px; cursor: pointer; - transition: background .15s ease, transform .05s ease, opacity .15s; + font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--text); + background: var(--fill-2); border: 0; border-radius: var(--radius-xs); + padding: 8px 14px; cursor: pointer; transition: background .15s, transform .05s, opacity .15s; } -.btn:hover { background: var(--bg-hover); } +.btn:hover { background: var(--fill-3); } .btn:active { transform: scale(0.97); } -.btn-primary { background: var(--accent); color: #fff; } -.btn-primary:hover { background: var(--accent-hover); } -.btn-block { width: 100%; padding: 9px 15px; } -.btn:disabled { opacity: .45; cursor: default; transform: none; } - -/* ── Layout ──────────────────────────────────────── */ -main { - display: grid; - grid-template-columns: 300px minmax(0,1fr) 280px; - height: calc(100vh - 52px); +.btn-primary { background: var(--accent); } +.btn-primary:hover { background: #4f8ff7; } +.btn-block { width: 100%; padding: 10px 14px; } +.btn:disabled { opacity: .5; cursor: default; transform: none; } +.list-actions .btn { flex: 1; } + +/* ── Stat chips + tooltips ───────────────────────── */ +.stat { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); + background: var(--fill-1); padding: 3px 9px; border-radius: 980px; } +.stat b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.stat .dot { width: 8px; height: 8px; border-radius: 50%; } + +.tip { position: relative; } +.tip::after { + content: attr(data-tip); position: absolute; top: calc(100% + 8px); left: 0; z-index: 200; + width: max-content; max-width: 230px; padding: 8px 11px; border-radius: var(--radius-xs); + font-size: 11.5px; font-weight: 400; line-height: 1.4; color: var(--text); text-align: left; + background: rgba(20,20,20,0.92); backdrop-filter: var(--glass-blur); border: 1px solid var(--hair); + box-shadow: var(--shadow); opacity: 0; transform: translateY(-3px); pointer-events: none; transition: opacity .15s, transform .15s; } +.tip:hover::after { opacity: 1; transform: translateY(0); } -/* ── File list (left) ────────────────────────────── */ -#listPane { background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } -.list-head { padding: 12px 12px 8px; } +.dot.s-outdated, .status-dot.s-outdated { background: var(--c-outdated); } +.dot.s-extrajs, .status-dot.s-extrajs { background: var(--c-extrajs); } +.dot.s-custom, .status-dot.s-custom { background: var(--c-custom); } +.dot.s-nointeract, .status-dot.s-nointeract { background: var(--c-nointeract); } +.dot.s-clean, .status-dot.s-clean { background: var(--c-clean); } + +/* ── File list ───────────────────────────────────── */ .search { - width: 100%; font-family: inherit; font-size: 13px; color: var(--text); - background: var(--bg); border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); padding: 7px 11px; - transition: border-color .15s, box-shadow .15s; -} -.search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -#fileList { list-style: none; margin: 0; padding: 4px 8px 16px; overflow-y: auto; flex: 1; } -#fileList li { - display: flex; align-items: center; gap: 9px; - padding: 7px 10px; border-radius: var(--radius-sm); cursor: pointer; - transition: background .12s; + width: 100%; font-family: inherit; font-size: 12.5px; color: var(--text); + background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); padding: 8px 11px; transition: border-color .15s, background .15s; } -#fileList li:hover { background: var(--bg-hover); } +.search::placeholder { color: var(--text-3); } +.search:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-2); } +#fileList { list-style: none; overflow-y: auto; flex: 1; padding: 0 8px 12px; } +#fileList li { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-xs); cursor: pointer; transition: background .12s; } +#fileList li:hover { background: var(--fill-1); } #fileList li.active { background: var(--accent-soft); } -#fileList li.active .name { color: var(--accent); font-weight: 500; } -#fileList .name { - flex: 1; min-width: 0; font-size: 12.5px; color: var(--text); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; -} -#fileList .status-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--border-strong); } -#fileList .draft-tag { - font-size: 10px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; - color: var(--blue-fg); background: var(--blue-bg); padding: 2px 6px; border-radius: var(--radius-pill); flex: none; -} -/* native checkbox restyle */ -.sel { appearance: none; -webkit-appearance: none; width: 15px; height: 15px; flex: none; - border: 1.5px solid var(--border-strong); border-radius: 5px; background: var(--bg); cursor: pointer; position: relative; transition: background .12s, border-color .12s; } -.sel:hover { border-color: var(--accent); } -.sel:checked { background: var(--accent); border-color: var(--accent); } -.sel:checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; - border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } - -/* status dot colors */ -.s-outdated { background: var(--amber) !important; } -.s-nointeract { background: var(--red) !important; } -.s-extrajs { background: var(--yellow) !important; } -.s-custom { background: var(--purple) !important; } -.s-clean { background: var(--green) !important; } - -/* stat dot colors */ -.dot.s-outdated { background: var(--amber); } .dot.s-nointeract { background: var(--red); } -.dot.s-extrajs { background: var(--yellow); } .dot.s-custom { background: var(--purple); } -.dot.s-clean { background: var(--green); } - -/* ── Detail (center) ─────────────────────────────── */ -#detailPane { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); } -.detail-head { display: flex; align-items: center; justify-content: center; padding: 12px; border-bottom: 1px solid var(--border); } -.segmented { display: inline-flex; background: var(--bg-subtle); border-radius: 9px; padding: 2px; gap: 2px; } -.segmented .tab { - font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--text-secondary); - background: transparent; border: 0; border-radius: 7px; padding: 5px 16px; cursor: pointer; transition: all .15s; -} -.segmented .tab:hover { color: var(--text); } -.segmented .tab.active { background: var(--bg); color: var(--text); box-shadow: var(--shadow-sm); } -.viewport { flex: 1; min-height: 0; position: relative; } -#preview { width: 100%; height: 100%; border: 0; background: #fff; } -#code, #diff { - position: absolute; inset: 0; overflow: auto; margin: 0; padding: 16px 18px; - white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; line-height: 1.6; - color: var(--text); background: var(--bg-subtle); -} -.empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; - color: var(--text-tertiary); font-size: 13px; } -#diff ins, #diff del, #diff span { display: block; text-decoration: none; padding: 0 6px; border-radius: 3px; } -#diff ins { background: var(--green-bg); color: var(--green-fg); } -#diff del { background: var(--red-bg); color: var(--red-fg); } -#diff span { color: var(--text-secondary); } - -/* ── Fix panel (right) ───────────────────────────── */ -#fixPane { background: var(--bg-sidebar); border-left: 1px solid var(--border); padding: 16px; overflow-y: auto; - display: flex; flex-direction: column; gap: 14px; } -.panel-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-tertiary); margin: 0; } -#fixOptions { display: flex; flex-direction: column; gap: 2px; } -.opt { display: flex; align-items: flex-start; gap: 9px; padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer; transition: background .12s; } -.opt:hover { background: var(--bg-hover); } +#fileList .name { flex: 1; min-width: 0; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } +#fileList li.active .name { color: #fff; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--fill-3); } +.draft-tag { font-size: 9.5px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: #cfe3ff; background: rgba(96,165,250,0.22); padding: 2px 6px; border-radius: 980px; flex: none; } + +/* checkbox restyle */ +.cb { appearance: none; -webkit-appearance: none; width: 15px; height: 15px; flex: none; border: 1.5px solid var(--fill-3); border-radius: 5px; background: transparent; cursor: pointer; position: relative; transition: background .12s, border-color .12s; } +.cb:hover { border-color: var(--accent); } +.cb:checked { background: var(--accent); border-color: var(--accent); } +.cb:checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } + +/* ── Fix panel ───────────────────────────────────── */ +.panel-title { font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--text-3); } +#fixOptions { display: flex; flex-direction: column; gap: 1px; } +.opt { display: flex; align-items: flex-start; gap: 9px; padding: 8px 9px; border-radius: var(--radius-xs); cursor: pointer; transition: background .12s; } +.opt:hover { background: var(--fill-1); } .opt span { font-size: 12.5px; line-height: 1.35; } -#customPrompt { - width: 100%; min-height: 76px; resize: vertical; font-family: inherit; font-size: 12.5px; color: var(--text); - background: var(--bg); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 11px; line-height: 1.45; -} -#customPrompt:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -#customPrompt::placeholder { color: var(--text-tertiary); } -.divider { height: 1px; background: var(--border); margin: 2px 0; } +#customPrompt { width: 100%; min-height: 70px; resize: vertical; font-family: inherit; font-size: 12.5px; color: var(--text); background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); padding: 9px 11px; line-height: 1.45; } +#customPrompt::placeholder { color: var(--text-3); } +#customPrompt:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-2); } +.divider { height: 1px; background: var(--hair); } .apply-actions { display: flex; gap: 8px; } .apply-actions .btn { flex: 1; } -#fixStatus { font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; line-height: 1.55; font-family: var(--mono); } -#fixStatus:empty { display: none; } -.res-ok { color: var(--green-fg); } .res-warn { color: var(--amber-fg); } .res-fail { color: var(--red-fg); } +#applyStatus { font-size: 11.5px; color: var(--text-2); white-space: pre-wrap; line-height: 1.5; font-family: var(--mono); } +#applyStatus:empty { display: none; } + +/* ── Live progress ───────────────────────────────── */ +#fixProgress:empty { display: none; } +.prog-head { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text); margin-bottom: 8px; } +.prog-head .count { color: var(--text-2); font-variant-numeric: tabular-nums; } +.prog-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; } +.prog-item { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); } +.prog-item .nm { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } +.mk { width: 14px; text-align: center; flex: none; } +.mk-ok { color: var(--c-clean); } .mk-warn { color: var(--c-extrajs); } .mk-fail { color: var(--c-nointeract); } +.spinner { width: 12px; height: 12px; border: 2px solid var(--fill-3); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex: none; } +@keyframes spin { to { transform: rotate(360deg); } } /* ── Scrollbars ──────────────────────────────────── */ ::-webkit-scrollbar { width: 9px; height: 9px; } -::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.16); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } -::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.28); background-clip: padding-box; } +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.16); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.26); background-clip: padding-box; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/validator/server.js b/validator/server.js index a090829..54cb948 100644 --- a/validator/server.js +++ b/validator/server.js @@ -58,19 +58,43 @@ export function createApp(rootDir) { }); app.post('/api/fix', async (req, res) => { - try { - const { paths, optionIds = [], customPrompt = '' } = req.body; - if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); - const specText = await loadSpecText(root); - const files = []; - const readFailures = []; - for (const p of paths) { - try { - files.push({ path: p, source: await readOriginal(root, p) }); - } catch (err) { - readFailures.push({ path: p, status: 'fixFailed', error: String(err.message || err) }); - } + const { paths, optionIds = [], customPrompt = '' } = req.body; + if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); + const specText = await loadSpecText(root); + + // Read sources defensively — a bad path becomes a fixFailed result. + const files = []; + const readFailures = []; + for (const p of paths) { + try { + files.push({ path: p, source: await readOriginal(root, p) }); + } catch (err) { + readFailures.push({ path: p, status: 'fixFailed', error: String(err.message || err) }); + } + } + + // Streaming mode: emit a result per file as it finishes (Server-Sent + // Events) so the UI can show live progress. Opt-in via Accept header. + if ((req.headers.accept || '').includes('text/event-stream')) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + send('start', { total: paths.length, paths }); + for (const rf of readFailures) send('result', rf); + try { + await runFix(root, files, { optionIds, customPrompt, specText, onResult: (r) => send('result', r) }); + send('done', { ok: true }); + } catch (err) { + send('error', { error: String(err.message || err) }); } + return res.end(); + } + + // Non-streaming mode (default): one JSON response with all results. + try { const fixResults = await runFix(root, files, { optionIds, customPrompt, specText }); res.json({ results: [...readFailures, ...fixResults] }); } catch (err) { res.status(500).json({ error: String(err.message || err) }); } From 9b75c13e263c20d89bb2b29ebb8b1aee6408feb0 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 23:21:39 +0300 Subject: [PATCH 17/62] feat(validator): split view into mode + version tab groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single tab row / side-by-side compare with two independent toggle groups: mode (Preview | Code | Diff) and version (Current | Draft). Toggle either to navigate — e.g. pick Code then flip Current<->Draft to compare source, or Preview then flip to compare renders. The version group hides for Diff (which inherently compares both). Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 71 ++++++++++++++++++++----------------- validator/public/index.html | 21 +++++------ validator/public/styles.css | 21 +++-------- 3 files changed, 54 insertions(+), 59 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index faf9f99..e319746 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -9,7 +9,7 @@ const CAT_INFO = { }; const CAT_ORDER = ['Outdated version', 'Uses extra JS', 'Uses customEffect', 'Not using interact', 'Clean & current']; -const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', tab: 'preview', progress: null }; +const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', mode: 'preview', version: 'current', progress: null }; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); @@ -75,25 +75,18 @@ function baseHrefFor(path) { return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); } const fetchSource = (kind, path) => api(`/api/${kind}?path=${encodeURIComponent(path)}`).then((r) => r.source); -// the "working" source the Preview/Code tabs show: the draft if one exists, else the original -const currentSource = (path) => fetchSource(state.drafts.has(path) ? 'draft' : 'file', path); const blankDoc = (label) => `${label}`; -async function showPreview(path) { - $('preview').srcdoc = injectBase(await currentSource(path), baseHrefFor(path)); +// Resolve which file source to show for the chosen version. 'draft' with no +// draft on disk yields null (callers render a placeholder). +async function sourceFor(path, version) { + if (version === 'draft') return state.drafts.has(path) ? fetchSource('draft', path) : null; + return fetchSource('file', path); } -async function showCode(path) { - $('code').textContent = await currentSource(path); -} -async function showCompare(path) { - const base = baseHrefFor(path); - $('cmpOrig').srcdoc = injectBase(await fetchSource('file', path), base); - if (state.drafts.has(path)) $('cmpDraft').srcdoc = injectBase(await fetchSource('draft', path), base); - else $('cmpDraft').srcdoc = blankDoc('No draft yet — fix this file first'); -} -async function showDiff(path) { + +async function renderDiff(path) { const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); - if (!res.ok) { $('diff').innerHTML = '
        No draft for this file yet.
        '; return; } + if (!res.ok) { $('diff').innerHTML = '
        No draft for this file yet — fix it first.
        '; return; } const { parts } = await res.json(); $('diff').innerHTML = parts.map((p) => { const safe = esc(p.value); @@ -103,20 +96,29 @@ async function showDiff(path) { }).join(''); } -function selectTab(tab) { - state.tab = tab; - for (const b of document.querySelectorAll('.tab')) b.classList.toggle('active', b.dataset.tab === tab); - const has = !!state.current; +// Reflect state.mode + state.version into the viewport. +async function render() { + const { mode, version, current } = state; + for (const b of document.querySelectorAll('#modeTabs .tab')) b.classList.toggle('active', b.dataset.mode === mode); + for (const b of document.querySelectorAll('#verTabs .tab')) b.classList.toggle('active', b.dataset.ver === version); + $('topbar').classList.toggle('diff', mode === 'diff'); // hides version group for Diff + + const has = !!current; $('placeholder').hidden = has; - $('preview').hidden = !(has && tab === 'preview'); - $('compare').hidden = !(has && tab === 'compare'); - $('code').hidden = !(has && tab === 'code'); - $('diff').hidden = !(has && tab === 'diff'); + $('preview').hidden = !(has && mode === 'preview'); + $('code').hidden = !(has && mode === 'code'); + $('diff').hidden = !(has && mode === 'diff'); if (!has) return; - if (tab === 'preview') showPreview(state.current); - else if (tab === 'compare') showCompare(state.current); - else if (tab === 'code') showCode(state.current); - else if (tab === 'diff') showDiff(state.current); + + if (mode === 'diff') { renderDiff(current); return; } + + const src = await sourceFor(current, version); + if (mode === 'preview') { + $('preview').srcdoc = src === null ? blankDoc('No draft yet — fix this file first') + : injectBase(src, baseHrefFor(current)); + } else { // code + $('code').textContent = src === null ? 'No draft yet — fix this file first.' : src; + } } // ── Live fix progress (SSE) ───────────────────────── @@ -192,7 +194,9 @@ async function runFix() { renderProgress(); $('fixBtn').disabled = false; renderList(); - if (state.current && state.drafts.has(state.current)) selectTab('compare'); + // surface the freshly-written draft for the open file + if (state.current && state.drafts.has(state.current)) state.version = 'draft'; + render(); } } @@ -210,7 +214,9 @@ async function applyOrDiscard(endpoint) { if (failed.length) msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; $('applyStatus').textContent = msg; renderList(); - if (state.current && succeeded.includes(state.current)) selectTab(state.tab); + // the draft is gone for applied/discarded files — fall back to Current + if (state.current && succeeded.includes(state.current)) state.version = 'current'; + render(); } // ── events ────────────────────────────────────────── @@ -223,7 +229,7 @@ $('fileList').addEventListener('click', (e) => { } state.current = path; renderList(); - selectTab(state.tab); + render(); }); $('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderList(); }); $('scanBtn').onclick = scan; @@ -237,7 +243,8 @@ $('selectAllBtn').onclick = () => { $('fixBtn').onclick = runFix; $('applyBtn').onclick = () => applyOrDiscard('apply'); $('discardBtn').onclick = () => applyOrDiscard('discard'); -for (const b of document.querySelectorAll('.tab')) b.onclick = () => selectTab(b.dataset.tab); +for (const b of document.querySelectorAll('#modeTabs .tab')) b.onclick = () => { state.mode = b.dataset.mode; render(); }; +for (const b of document.querySelectorAll('#verTabs .tab')) b.onclick = () => { state.version = b.dataset.ver; render(); }; loadFiles(); loadOptions(); diff --git a/validator/public/index.html b/validator/public/index.html index 3284e9d..8aca125 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -10,21 +10,22 @@
        -

        Select a file to preview

        - -
        - - - - + +
        +
        + + + +
        +
        + + +
        diff --git a/validator/public/styles.css b/validator/public/styles.css index 59ee47a..d426263 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -61,23 +61,10 @@ body { #placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--text-3); } #placeholder .ic { font-size: 52px; opacity: .35; } -/* Compare: two rendered frames side by side */ -#compare { display: flex; } -#compare .cmp-col { position: relative; flex: 1; min-width: 0; border-right: 1px solid rgba(255,255,255,0.06); } -#compare .cmp-col:last-child { border-right: 0; } -#compare iframe { width: 100%; height: 100%; } -.cmp-label { - position: absolute; top: 16px; left: 50%; transform: translateX(-50%); z-index: 5; - font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; - color: var(--text); background: var(--glass-bg); backdrop-filter: var(--glass-blur); - border: 1px solid var(--hair); padding: 4px 12px; border-radius: 980px; box-shadow: var(--shadow); -} - -/* ── Floating segmented tabs (top center) ────────── */ -#tabs { - position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 60; - display: inline-flex; gap: 2px; padding: 4px; -} +/* ── Floating tab groups (top center) ────────────── */ +#topbar { position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 60; display: flex; gap: 10px; } +#topbar.diff #verTabs { display: none; } +.seg { display: inline-flex; gap: 2px; padding: 4px; } .tab { font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--text-2); background: transparent; border: 0; border-radius: 11px; padding: 6px 16px; cursor: pointer; transition: all .16s; From 4df9a23893e02906e8cc9b3d4ad1184619dbd65a Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 23:25:42 +0300 Subject: [PATCH 18/62] fix(validator): placeholder never hid; code hidden behind panel - A #placeholder { display:flex } rule overrode the [hidden] attribute, so the "Select a file" overlay stayed on top of the viewport and swallowed pointer events from the animation. Add [hidden]{display:none!important}. - Code/Diff were full-bleed (inset:0), starting under the left panel. Inset them into the central column as a floating glass panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/styles.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/validator/public/styles.css b/validator/public/styles.css index d426263..04bc963 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -25,6 +25,7 @@ } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +[hidden] { display: none !important; } /* beat element display rules */ html, body { height: 100%; overflow: hidden; } body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; @@ -50,9 +51,14 @@ body { #viewport { position: fixed; inset: 0; z-index: 0; background: #0e0e0f; } #viewport > * { position: absolute; inset: 0; } #preview, #cmpOrig, #cmpDraft { width: 100%; height: 100%; border: 0; background: #fff; } +/* Code/Diff read as a floating panel in the central column (clear of the + left/right panels and the top tab groups). */ #code, #diff { - overflow: auto; padding: 84px 28px 28px; margin: 0; color: var(--text); + inset: 68px 332px 16px 322px; + overflow: auto; padding: 18px 22px; margin: 0; color: var(--text); white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12px; line-height: 1.65; + background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--hair); border-radius: var(--radius); box-shadow: var(--shadow); } #diff ins, #diff del, #diff span { display: block; text-decoration: none; padding: 0 8px; border-radius: 3px; } #diff ins { background: rgba(52,211,153,0.16); color: #6ee7b7; } From a51640b8ae9f03b8ba7f4dd856dfcf8679d001a7 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 23:42:54 +0300 Subject: [PATCH 19/62] feat(validator): force pinned @wix/interact@2.4.0 in fix prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updateVersion fragment and a new system-prompt VERSION RULE now require every @wix/interact import to be pinned to the exact latest version — an unpinned esm.sh import was slipping through and tripping the version check. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/prompt.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/validator/lib/prompt.js b/validator/lib/prompt.js index 4f1d003..5f7d436 100644 --- a/validator/lib/prompt.js +++ b/validator/lib/prompt.js @@ -2,7 +2,7 @@ import { INTERACT_CDN, PRESETS_CDN, LATEST_VERSION } from './constants.js'; export const FIX_OPTIONS = [ { id: 'updateVersion', label: 'Update to latest version', default: true, - fragment: `Update all @wix/interact imports to version ${LATEST_VERSION} using "${INTERACT_CDN}" (and "${PRESETS_CDN}" for named presets). Migrate any version-specific syntax that the new version requires.` }, + fragment: `Pin EVERY @wix/interact import to the exact version ${LATEST_VERSION}. Any unversioned import (e.g. "https://esm.sh/@wix/interact") or older-version import MUST become exactly "${INTERACT_CDN}" — never leave an import unpinned. Named presets must import from "${PRESETS_CDN}". The final file must literally contain the string "@wix/interact@${LATEST_VERSION}". Migrate any version-specific syntax the new version requires.` }, { id: 'migrateSyntax', label: 'Migrate old syntax', default: true, fragment: `Migrate outdated syntax to the current API: move play-mode off Interaction.params onto the effect and rename params.type -> triggerType (on TimeEffect) and params.method -> stateAction (on StateEffect); rename range-offset {value,type} -> {value,unit}; rename the custom element tag wix-interact-element -> interact-element; fix the useCutsomElement -> useCustomElement typo.` }, { id: 'convertCustomEffect', label: 'Convert customEffect → preset/keyframe', default: false, @@ -10,7 +10,7 @@ export const FIX_OPTIONS = [ { id: 'removeExtraJs', label: 'Remove extra JavaScript', default: false, fragment: `Remove hand-written JavaScript (manual addEventListener, IntersectionObserver, direct Element.animate, requestAnimationFrame/setInterval animation loops) and express the same behavior through @wix/interact triggers and effects instead.` }, { id: 'convertToInteract', label: 'Convert non-interact → interact', default: false, - fragment: `This file does not currently use @wix/interact. Rewrite it so the animation is driven by @wix/interact (import it, wrap targets in , and call Interact.create once), preserving the original visual result.` }, + fragment: `This file does not currently use @wix/interact. Rewrite it so the animation is driven by @wix/interact: import it from exactly "${INTERACT_CDN}" (pinned), wrap targets in , and call Interact.create once, preserving the original visual result.` }, ]; const SYSTEM = (specText) => `You are an expert at the @wix/interact animation library. You rewrite standalone HTML animation files so they use @wix/interact correctly on the latest version. @@ -18,6 +18,8 @@ const SYSTEM = (specText) => `You are an expert at the @wix/interact animation l Follow this canonical reference exactly: ${specText} +VERSION RULE: Whenever the file imports @wix/interact, pin it to exactly "${INTERACT_CDN}" (and "${PRESETS_CDN}" for presets). Never emit an unpinned @wix/interact import. + OUTPUT CONTRACT: Return ONLY the complete rewritten HTML file. No markdown code fences, no commentary, no explanation — just the raw HTML from (or the file's first line) to its end. Preserve the original visual design, layout, copy, and asset URLs unless a requested fix requires changing them.`; export function buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }) { From 4043d3306942ea4a8bd4e453528b89411ea3f10f Mon Sep 17 00:00:00 2001 From: hassankettany Date: Tue, 30 Jun 2026 23:42:54 +0300 Subject: [PATCH 20/62] =?UTF-8?q?feat(validator):=20per-file=20indicators?= =?UTF-8?q?=20=E2=80=94=20version=20dot=20+=20customEffect=20+=20JS=20flag?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single category dot with independent indicators: a version dot (green=latest, yellow=old version/syntax, red=no interact) plus additive flags — a purple dot for customEffect usage and a blue "JS" badge for JavaScript animation not tied to a customEffect. A file can show several (e.g. yellow + purple, or yellow + JS). Summary chips and tooltips updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 62 +++++++++++++++++++++++++------------ validator/public/styles.css | 18 ++++++++--- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index e319746..223265f 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -1,13 +1,25 @@ import { injectBase } from './preview.js'; -const CAT_INFO = { - 'Outdated version': { dot: 's-outdated', tip: 'Imports an old @wix/interact version, or uses outdated syntax (old tag, params.type/method, etc.).' }, - 'Uses extra JS': { dot: 's-extrajs', tip: 'Mixes in hand-written JS — event listeners, IntersectionObserver, .animate — instead of interact triggers.' }, - 'Uses customEffect': { dot: 's-custom', tip: 'Uses a customEffect where a namedEffect or keyframeEffect might do the job.' }, - 'Not using interact':{ dot: 's-nointeract',tip: 'Does not import @wix/interact at all.' }, - 'Clean & current': { dot: 's-clean', tip: 'On the latest version with no issues detected.' }, +// Version state — mutually exclusive (green / yellow / red). +const VER = { + clean: { cls: 'green', label: 'Latest version', tip: 'Uses @wix/interact pinned to the latest version, with no outdated syntax.' }, + outdated: { cls: 'yellow', label: 'Old version / syntax', tip: 'Uses @wix/interact but on an old or unpinned version, or with outdated syntax.' }, + none: { cls: 'red', label: 'No interact', tip: 'Does not use @wix/interact at all.' }, }; -const CAT_ORDER = ['Outdated version', 'Uses extra JS', 'Uses customEffect', 'Not using interact', 'Clean & current']; +function versionState(d) { + if (!d.usesInteract) return 'none'; + if (d.isLatest && (d.oldSyntaxMarkers?.length || 0) === 0) return 'clean'; + return 'outdated'; +} +// Per-file indicators: version dot + additive flags (purple customEffect, blue JS). +function indicatorsHTML(d) { + if (!d) return ''; + const v = VER[versionState(d)]; + let h = ``; + if (d.usesCustomEffect) h += ``; + if (d.usesExtraJs) h += `JS`; + return h; +} const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', mode: 'preview', version: 'current', progress: null }; const $ = (id) => document.getElementById(id); @@ -36,34 +48,46 @@ function visibleFiles() { function renderList() { $('fileList').innerHTML = visibleFiles().map((f) => { const d = state.diag[f.path]; - const dotClass = d ? (CAT_INFO[d.category]?.dot || '') : ''; const draft = state.drafts.has(f.path) ? 'draft' : ''; const checked = state.selected.has(f.path) ? 'checked' : ''; const active = state.current === f.path ? ' active' : ''; - const title = d ? `${f.path} — ${d.category}` : f.path; + const title = d ? `${f.path} — ${VER[versionState(d)].label}` : f.path; return `
      • - - ${esc(f.path)}${draft}
      • `; + ${esc(f.path)} + ${indicatorsHTML(d)}${draft}`; }).join(''); } -function renderSummary(summary, total) { - const chips = CAT_ORDER.filter((c) => summary[c]).map((c) => { - const i = CAT_INFO[c]; - return `${summary[c]}`; - }).join(''); - $('summary').innerHTML = `${total} files${chips}`; +function renderSummary() { + const ds = Object.values(state.diag); + if (!ds.length) { $('summary').innerHTML = ''; return; } + let green = 0, yellow = 0, red = 0, purple = 0, js = 0; + for (const d of ds) { + const v = versionState(d); + if (v === 'clean') green++; else if (v === 'outdated') yellow++; else red++; + if (d.usesCustomEffect) purple++; + if (d.usesExtraJs) js++; + } + const chip = (cls, n, label, tip) => + `${n}`; + $('summary').innerHTML = + `${ds.length} files` + + chip('green', green, VER.clean.label, VER.clean.tip) + + chip('yellow', yellow, VER.outdated.label, VER.outdated.tip) + + chip('red', red, VER.none.label, VER.none.tip) + + chip('purple', purple, 'customEffect', 'Files that use a customEffect.') + + `JS${js}`; } async function scan() { $('scanBtn').disabled = true; $('scanBtn').textContent = 'Scanning…'; try { - const { results, summary, total } = await api('/api/scan', { + const { results } = await api('/api/scan', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); state.diag = {}; for (const r of results) state.diag[r.path] = r; - renderSummary(summary, total); + renderSummary(); renderList(); } finally { $('scanBtn').disabled = false; $('scanBtn').textContent = 'Scan'; diff --git a/validator/public/styles.css b/validator/public/styles.css index 04bc963..16fe854 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -121,11 +121,19 @@ body { } .tip:hover::after { opacity: 1; transform: translateY(0); } -.dot.s-outdated, .status-dot.s-outdated { background: var(--c-outdated); } -.dot.s-extrajs, .status-dot.s-extrajs { background: var(--c-extrajs); } -.dot.s-custom, .status-dot.s-custom { background: var(--c-custom); } -.dot.s-nointeract, .status-dot.s-nointeract { background: var(--c-nointeract); } -.dot.s-clean, .status-dot.s-clean { background: var(--c-clean); } +/* Indicator dots (version + flags) */ +.ind { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; } +.ind.green { background: var(--c-clean); } +.ind.yellow { background: var(--c-extrajs); } +.ind.red { background: var(--c-nointeract); } +.ind.purple { background: var(--c-custom); } +.js-badge { + display: inline-flex; align-items: center; justify-content: center; flex: none; + font-size: 8.5px; font-weight: 700; letter-spacing: .02em; line-height: 1; color: #cfe3ff; + background: rgba(96,165,250,0.22); border: 1px solid rgba(96,165,250,0.45); + padding: 2px 3px; border-radius: 4px; +} +.inds { display: inline-flex; align-items: center; gap: 5px; flex: none; } /* ── File list ───────────────────────────────────── */ .search { From c2727c6c79dd1d8670d4f19c4de42c2fa9234716 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 15:20:31 +0300 Subject: [PATCH 21/62] feat(validator): hybrid codemod + agent fixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deterministic codemod pass (codemod.js) for the mechanical fixes — pin @wix/interact@2.4.0, rename wix-interact-element→interact-element, fix the useCutsomElement typo, and rename range-offset type→unit (unit-guarded). fixFile now runs codemods first and only calls the agent when there's real semantic work: a semantic option (convert customEffect / remove JS / convert to interact), a custom prompt, or leftover structural markers (params.type/ method play-mode) after the codemod. Version-only / trivial-rename fixes are now instant, free, and deterministic — no LLM. Each result reports whether it was done via 'script' or 'agent', shown per file in the live progress list. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/codemod.js | 43 ++++++++++++++++ validator/lib/fix.js | 36 +++++++++++-- validator/public/app.js | 5 +- validator/public/styles.css | 1 + validator/test/codemod.test.js | 52 +++++++++++++++++++ validator/test/fix.test.js | 93 ++++++++++++++++++++++++++++------ 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 validator/lib/codemod.js create mode 100644 validator/test/codemod.test.js diff --git a/validator/lib/codemod.js b/validator/lib/codemod.js new file mode 100644 index 0000000..cfd44a1 --- /dev/null +++ b/validator/lib/codemod.js @@ -0,0 +1,43 @@ +import { LATEST_VERSION } from './constants.js'; + +// Constrained set of range-offset units — used so the type→unit rename never +// touches an unrelated `type:` key (e.g. namedEffect: { type: 'FadeIn' }). +const UNITS = 'percentage|px|em|rem|vh|vw|vmin|vmax'; + +// Pin every @wix/interact import to the latest version. +function pinInteract(src) { + return src + // already-versioned imports → bump + .replace(/@wix\/interact@\d+\.\d+\.\d+/g, `@wix/interact@${LATEST_VERSION}`) + // unpinned imports (not followed by @version, a word char, or a hyphen) → pin. + // A trailing "/web" style subpath is preserved (lookahead allows "/"). + .replace(/@wix\/interact(?!@)(?![\w-])/g, `@wix/interact@${LATEST_VERSION}`); +} + +const renameTag = (src) => src.replace(/wix-interact-element/g, 'interact-element'); +const fixTypo = (src) => src.replace(/useCutsomElement/g, 'useCustomElement'); +const renameOffsetUnit = (src) => + src.replace(new RegExp(`\\btype(\\s*):(\\s*)(['"\`])(${UNITS})\\3`, 'g'), 'unit$1:$2$3$4$3'); + +// Deterministic, text-only transforms gated by the selected fix options. +// Returns the rewritten source and a list of the transforms that changed it. +// Does NOT handle structural changes (params.type/method play-mode relocation, +// customEffect conversion, non-interact conversion) — those stay agent-only. +export function applyCodemods(source, optionIds = []) { + const steps = []; + if (optionIds.includes('updateVersion')) { + steps.push([`Pinned @wix/interact to ${LATEST_VERSION}`, pinInteract]); + } + if (optionIds.includes('migrateSyntax')) { + steps.push(['Renamed wix-interact-element → interact-element', renameTag]); + steps.push(['Fixed useCutsomElement typo', fixTypo]); + steps.push(['Renamed range-offset type → unit', renameOffsetUnit]); + } + let output = source; + const applied = []; + for (const [label, fn] of steps) { + const next = fn(output); + if (next !== output) { applied.push(label); output = next; } + } + return { output, applied }; +} diff --git a/validator/lib/fix.js b/validator/lib/fix.js index ed87a4d..4bc026f 100644 --- a/validator/lib/fix.js +++ b/validator/lib/fix.js @@ -2,6 +2,10 @@ import { detect } from './detect.js'; import { buildPrompt } from './prompt.js'; import { writeDraft } from './drafts.js'; import { extractHtml, runAgent as realRunAgent } from './agent.js'; +import { applyCodemods } from './codemod.js'; + +// Options whose work is inherently semantic — always require the agent. +const SEMANTIC_OPTIONS = ['convertCustomEffect', 'removeExtraJs', 'convertToInteract']; export async function mapLimit(items, limit, fn) { const results = new Array(items.length); @@ -18,18 +22,40 @@ export async function mapLimit(items, limit, fn) { } export async function fixFile(rootDir, relPath, opts) { - const { source, optionIds, customPrompt, specText, model, runAgent = realRunAgent } = opts; + const { source, optionIds = [], customPrompt, specText, model, runAgent = realRunAgent } = opts; try { - const diagnosis = detect(relPath, source); - const { system, user } = buildPrompt({ diagnosis, source, optionIds, customPrompt, specText }); - const html = extractHtml(await runAgent(system, user, { model })); + // 1. Deterministic pass: pin version, rename tag/typo/offset-unit. + const { output: codemodOut, applied } = applyCodemods(source, optionIds); + + // 2. Decide whether the agent is still needed. It is, if a semantic option + // is selected, a custom prompt was given, or "migrate old syntax" was + // requested and structural markers (params.type/method play-mode) remain + // after the deterministic pass. + const hasCustom = !!(customPrompt && customPrompt.trim()); + const migrateResidual = optionIds.includes('migrateSyntax') + && detect(relPath, codemodOut).oldSyntaxMarkers.length > 0; + const needsAgent = hasCustom + || optionIds.some((o) => SEMANTIC_OPTIONS.includes(o)) + || migrateResidual; + + let html, via; + if (needsAgent) { + const diagnosis = detect(relPath, codemodOut); + const { system, user } = buildPrompt({ diagnosis, source: codemodOut, optionIds, customPrompt, specText }); + html = extractHtml(await runAgent(system, user, { model })); + via = 'agent'; + } else { + html = codemodOut; + via = 'script'; + } + await writeDraft(rootDir, relPath, html); const recheck = detect(relPath, html); let clean = recheck.category === 'Clean & current' || (recheck.isLatest && recheck.oldSyntaxMarkers.length === 0); if (clean && optionIds.includes('convertCustomEffect') && recheck.usesCustomEffect) clean = false; if (clean && optionIds.includes('removeExtraJs') && recheck.usesExtraJs) clean = false; - return { path: relPath, status: clean ? 'fixed' : 'needsReview', recheck }; + return { path: relPath, status: clean ? 'fixed' : 'needsReview', via, applied, recheck }; } catch (err) { return { path: relPath, status: 'fixFailed', error: String(err.message || err) }; } diff --git a/validator/public/app.js b/validator/public/app.js index 223265f..f0ed00a 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -160,14 +160,15 @@ function renderProgress() { : st.status === 'needsReview' ? '' : ''; const t = `${path}${st.error ? ' — ' + st.error : ''}`; - return `
        ${mk}${esc(path)}
        `; + const via = st.via ? `${esc(st.via)}` : ''; + return `
        ${mk}${esc(path)}${via}
        `; }).join(''); $('fixProgress').innerHTML = `
        ${head}
        ${items}
        `; } function applyResult(r) { if (!state.progress) return; - state.progress.items.set(r.path, { status: r.status, error: r.error }); + state.progress.items.set(r.path, { status: r.status, error: r.error, via: r.via }); state.progress.done++; if (r.status !== 'fixFailed') state.drafts.add(r.path); renderProgress(); diff --git a/validator/public/styles.css b/validator/public/styles.css index 16fe854..85f3fb0 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -179,6 +179,7 @@ body { .prog-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; } .prog-item { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); } .prog-item .nm { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } +.via { font-size: 9px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); flex: none; } .mk { width: 14px; text-align: center; flex: none; } .mk-ok { color: var(--c-clean); } .mk-warn { color: var(--c-extrajs); } .mk-fail { color: var(--c-nointeract); } .spinner { width: 12px; height: 12px; border: 2px solid var(--fill-3); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex: none; } diff --git a/validator/test/codemod.test.js b/validator/test/codemod.test.js new file mode 100644 index 0000000..961617f --- /dev/null +++ b/validator/test/codemod.test.js @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyCodemods } from '../lib/codemod.js'; + +test('updateVersion pins an old explicit version', () => { + const { output, applied } = applyCodemods("from 'https://esm.sh/@wix/interact@1.79.0'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.4\.0/); + assert.doesNotMatch(output, /@1\.79\.0/); + assert.equal(applied.length, 1); +}); + +test('updateVersion pins an unpinned import', () => { + const { output } = applyCodemods("from 'https://esm.sh/@wix/interact'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.4\.0'/); +}); + +test('updateVersion preserves a /web subpath', () => { + const { output } = applyCodemods("from 'https://esm.sh/@wix/interact/web'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.4\.0\/web/); +}); + +test('updateVersion leaves an already-latest import unchanged (no-op)', () => { + const { output, applied } = applyCodemods("from 'https://esm.sh/@wix/interact@2.4.0'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.4\.0/); + assert.equal(applied.length, 0); +}); + +test('updateVersion does not touch @wix/motion-presets', () => { + const { output } = applyCodemods("from 'https://esm.sh/@wix/motion-presets'", ['updateVersion']); + assert.equal(output, "from 'https://esm.sh/@wix/motion-presets'"); +}); + +test('migrateSyntax renames the tag and fixes the typo', () => { + const { output, applied } = applyCodemods(' useCutsomElement', ['migrateSyntax']); + assert.doesNotMatch(output, /wix-interact-element/); + assert.match(output, /<\/interact-element>/); + assert.match(output, /useCustomElement/); + assert.equal(applied.length, 2); +}); + +test('migrateSyntax renames range-offset type→unit but not a namedEffect type', () => { + const { output } = applyCodemods("offset: { value: 0, type: 'percentage' }, namedEffect: { type: 'FadeIn' }", ['migrateSyntax']); + assert.match(output, /value: 0, unit: 'percentage'/); + assert.match(output, /namedEffect: \{ type: 'FadeIn' \}/); // untouched +}); + +test('no options selected is a no-op', () => { + const src = "from 'https://esm.sh/@wix/interact@1.79.0'"; + const { output, applied } = applyCodemods(src, []); + assert.equal(output, src); + assert.equal(applied.length, 0); +}); diff --git a/validator/test/fix.test.js b/validator/test/fix.test.js index 236fe91..1feb3c5 100644 --- a/validator/test/fix.test.js +++ b/validator/test/fix.test.js @@ -9,6 +9,10 @@ import { readDraft } from '../lib/drafts.js'; const root = () => mkdtemp(join(tmpdir(), 'iv-fix-')); const SPEC = 'spec'; +// a clean, latest-version, no-customEffect snippet +const CLEAN = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; test('mapLimit preserves order and caps concurrency', async () => { let active = 0, max = 0; @@ -22,50 +26,107 @@ test('mapLimit preserves order and caps concurrency', async () => { assert.ok(max <= 2); }); -test('fixFile writes a draft and reports fixed when recheck is clean', async () => { +test('updateVersion is done by codemod (no agent) and pins the version', async () => { const r = await root(); - const good = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const src = `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0'; Interact.create({ interactions:[{ key:'a', trigger:'hover', effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; + let agentCalled = false; const res = await fixFile(r, 'A.html', { - source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, - runAgent: async () => good, + source: src, optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + runAgent: async () => { agentCalled = true; return 'UNUSED'; }, }); + assert.equal(agentCalled, false, 'agent must not be called for a pure version bump'); + assert.equal(res.via, 'script'); assert.equal(res.status, 'fixed'); - assert.equal(await readDraft(r, 'A.html'), good); + const draft = await readDraft(r, 'A.html'); + assert.match(draft, /@wix\/interact@2\.4\.0/); + assert.doesNotMatch(draft, /@1\.79\.0/); }); -test('fixFile reports needsReview when draft still diagnoses as problematic', async () => { +test('migrateSyntax with only a tag rename is done by codemod (no agent)', async () => { const r = await root(); + const src = `
        x
        + import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', + effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; + let agentCalled = false; const res = await fixFile(r, 'B.html', { - source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, - runAgent: async () => `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`, + source: src, optionIds: ['migrateSyntax'], customPrompt: '', specText: SPEC, + runAgent: async () => { agentCalled = true; return 'UNUSED'; }, }); - assert.equal(res.status, 'needsReview'); + assert.equal(agentCalled, false); + assert.equal(res.via, 'script'); + assert.doesNotMatch(await readDraft(r, 'B.html'), /wix-interact-element/); }); -test('fixFile reports fixFailed and writes no draft when agent throws', async () => { +test('migrateSyntax with play-mode still needs the agent', async () => { const r = await root(); + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', params:{ method:'toggle' }, + effects:[{ customEffect:()=>{} }] }] });`; + let agentCalled = false; const res = await fixFile(r, 'C.html', { - source: 'OLD', optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, + source: src, optionIds: ['migrateSyntax'], customPrompt: '', specText: SPEC, + runAgent: async () => { agentCalled = true; return CLEAN; }, + }); + assert.equal(agentCalled, true, 'params.method play-mode is a structural change → agent'); + assert.equal(res.via, 'agent'); + assert.equal(res.status, 'fixed'); +}); + +test('a semantic option (convertToInteract) calls the agent', async () => { + const r = await root(); + let agentCalled = false; + const res = await fixFile(r, 'D.html', { + source: '
        plain html, no interact
        ', optionIds: ['convertToInteract'], customPrompt: '', specText: SPEC, + runAgent: async () => { agentCalled = true; return CLEAN; }, + }); + assert.equal(agentCalled, true); + assert.equal(res.via, 'agent'); + assert.equal(res.status, 'fixed'); +}); + +test('a non-empty custom prompt forces the agent even with only mechanical options', async () => { + const r = await root(); + let agentCalled = false; + await fixFile(r, 'E.html', { + source: CLEAN, optionIds: ['updateVersion'], customPrompt: 'make the cards bigger', specText: SPEC, + runAgent: async () => { agentCalled = true; return CLEAN; }, + }); + assert.equal(agentCalled, true); +}); + +test('fixFile reports fixFailed and writes no draft when the agent throws', async () => { + const r = await root(); + const res = await fixFile(r, 'F.html', { + source: 'x', optionIds: ['convertToInteract'], customPrompt: '', specText: SPEC, runAgent: async () => { throw new Error('boom'); }, }); assert.equal(res.status, 'fixFailed'); assert.match(res.error, /boom/); - assert.equal(await readDraft(r, 'C.html'), null); + assert.equal(await readDraft(r, 'F.html'), null); +}); + +test('fixFile reports needsReview when the agent draft is still outdated', async () => { + const r = await root(); + const res = await fixFile(r, 'G.html', { + source: 'x', optionIds: ['convertToInteract'], customPrompt: '', specText: SPEC, + runAgent: async () => `import {Interact} from 'https://esm.sh/@wix/interact@1.79.0';`, + }); + assert.equal(res.status, 'needsReview'); }); test('fixFile reports needsReview when convertCustomEffect requested but draft still uses customEffect', async () => { const r = await root(); - // Draft is latest version, no old-syntax markers, but still contains customEffect: const draftWithCustomEffect = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; Interact.create({ interactions:[{ key:'a', trigger:'hover', effects:[{ customEffect: (el, p) => { el.style.opacity = p; }, duration:300, triggerType:'once' }] }] });`; - const res = await fixFile(r, 'D.html', { + const res = await fixFile(r, 'H.html', { source: 'OLD', optionIds: ['convertCustomEffect'], customPrompt: '', specText: SPEC, runAgent: async () => draftWithCustomEffect, }); - assert.equal(res.status, 'needsReview', 'should be needsReview when customEffect conversion was requested but still present'); + assert.equal(res.status, 'needsReview'); }); test('runFix processes a batch', async () => { @@ -73,6 +134,6 @@ test('runFix processes a batch', async () => { const results = await runFix(r, [{ path: 'A.html', source: 'x' }, { path: 'B.html', source: 'y' }], { optionIds: ['updateVersion'], customPrompt: '', specText: SPEC, - runAgent: async () => 'import "https://esm.sh/@wix/interact@2.4.0";', concurrency: 2 }); + runAgent: async () => 'UNUSED', concurrency: 2 }); assert.equal(results.length, 2); }); From 9f021c2232981cf019ee120315e42f38213cf892 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 16:00:25 +0300 Subject: [PATCH 22/62] fix(validator): migrate data-wix-path when renaming the interact tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old binds via data-wix-path; the public binds via data-interact-key. The tag-rename codemod left data-wix-path in place, so migrated elements had no key and bound to nothing — the animation silently died (CardSpread). The codemod now renames data-wix-path → data-interact-key alongside the tag, and detect.js flags a lingering data-wix-path as an old-syntax marker so the self-check catches it. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/codemod.js | 5 +++++ validator/lib/detect.js | 1 + validator/test/codemod.test.js | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/validator/lib/codemod.js b/validator/lib/codemod.js index cfd44a1..4a4ff9a 100644 --- a/validator/lib/codemod.js +++ b/validator/lib/codemod.js @@ -15,6 +15,10 @@ function pinInteract(src) { } const renameTag = (src) => src.replace(/wix-interact-element/g, 'interact-element'); +// The old bound via data-wix-path; the public +// binds via data-interact-key (same value → the interaction +// key). Renaming the tag without this leaves elements unbound (nothing animates). +const renameKeyAttr = (src) => src.replace(/data-wix-path/g, 'data-interact-key'); const fixTypo = (src) => src.replace(/useCutsomElement/g, 'useCustomElement'); const renameOffsetUnit = (src) => src.replace(new RegExp(`\\btype(\\s*):(\\s*)(['"\`])(${UNITS})\\3`, 'g'), 'unit$1:$2$3$4$3'); @@ -30,6 +34,7 @@ export function applyCodemods(source, optionIds = []) { } if (optionIds.includes('migrateSyntax')) { steps.push(['Renamed wix-interact-element → interact-element', renameTag]); + steps.push(['Renamed data-wix-path → data-interact-key', renameKeyAttr]); steps.push(['Fixed useCutsomElement typo', fixTypo]); steps.push(['Renamed range-offset type → unit', renameOffsetUnit]); } diff --git a/validator/lib/detect.js b/validator/lib/detect.js index 13d6ca9..37f8c29 100644 --- a/validator/lib/detect.js +++ b/validator/lib/detect.js @@ -29,6 +29,7 @@ function findExtraJs(source) { function findOldSyntaxMarkers(source) { const markers = []; if (/wix-interact-element/.test(source)) markers.push('wix-interact-element tag (use interact-element)'); + if (/data-wix-path/.test(source)) markers.push('data-wix-path attribute (use data-interact-key)'); if (/\bmethod\s*:/.test(source)) markers.push('params.method (use stateAction on the effect)'); if (/\btype\s*:\s*['"`](once|repeat|alternate|state)['"`]/.test(source)) markers.push('params.type play-mode (use triggerType on the effect)'); if (/\btype\s*:\s*['"`](percentage|px|vh|vw|vmin|vmax|em|rem)['"`]/.test(source)) markers.push('range offset {value,type} (use unit)'); diff --git a/validator/test/codemod.test.js b/validator/test/codemod.test.js index 961617f..8566c27 100644 --- a/validator/test/codemod.test.js +++ b/validator/test/codemod.test.js @@ -38,6 +38,13 @@ test('migrateSyntax renames the tag and fixes the typo', () => { assert.equal(applied.length, 2); }); +test('migrateSyntax migrates data-wix-path → data-interact-key alongside the tag', () => { + const { output } = applyCodemods('
        ', ['migrateSyntax']); + assert.match(output, //); + assert.doesNotMatch(output, /wix-interact-element/); + assert.doesNotMatch(output, /data-wix-path/); +}); + test('migrateSyntax renames range-offset type→unit but not a namedEffect type', () => { const { output } = applyCodemods("offset: { value: 0, type: 'percentage' }, namedEffect: { type: 'FadeIn' }", ['migrateSyntax']); assert.match(output, /value: 0, unit: 'percentage'/); From cb2f9b975f45141713dcd81022259d15402977dc Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 22:20:15 +0300 Subject: [PATCH 23/62] feat(validator): pin imports to @wix/interact@2.5.1/web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump target to 2.5.1 and require the /web subpath (exports the custom element). The pin codemod now normalizes any @wix/interact import — versioned, unpinned, or with another subpath — to exactly @wix/interact@2.5.1/web, and uses a lookbehind so prose mentions in comments are left alone. Tests updated to the new target. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/codemod.js | 16 +++++++++------- validator/lib/constants.js | 5 +++-- validator/test/codemod.test.js | 30 +++++++++++++++++++++--------- validator/test/detect.test.js | 10 +++++----- validator/test/fix.test.js | 10 +++++----- validator/test/prompt.test.js | 2 +- 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/validator/lib/codemod.js b/validator/lib/codemod.js index 4a4ff9a..dd9a7b7 100644 --- a/validator/lib/codemod.js +++ b/validator/lib/codemod.js @@ -4,14 +4,16 @@ import { LATEST_VERSION } from './constants.js'; // touches an unrelated `type:` key (e.g. namedEffect: { type: 'FadeIn' }). const UNITS = 'percentage|px|em|rem|vh|vw|vmin|vmax'; -// Pin every @wix/interact import to the latest version. +// Pin every @wix/interact import to the latest version AND the /web subpath. +// Matches the specifier only when it follows a quote or slash (import URL / +// bare specifier), so prose mentions of "@wix/interact" in comments are left +// alone. Any existing @version and/or /subpath is normalized to @LATEST/web. +const PIN_TARGET = `@wix/interact@${LATEST_VERSION}/web`; function pinInteract(src) { - return src - // already-versioned imports → bump - .replace(/@wix\/interact@\d+\.\d+\.\d+/g, `@wix/interact@${LATEST_VERSION}`) - // unpinned imports (not followed by @version, a word char, or a hyphen) → pin. - // A trailing "/web" style subpath is preserved (lookahead allows "/"). - .replace(/@wix\/interact(?!@)(?![\w-])/g, `@wix/interact@${LATEST_VERSION}`); + return src.replace( + /(?<=['"`/])@wix\/interact(?:@\d+\.\d+\.\d+)?(?:\/[\w.-]+)?/g, + PIN_TARGET, + ); } const renameTag = (src) => src.replace(/wix-interact-element/g, 'interact-element'); diff --git a/validator/lib/constants.js b/validator/lib/constants.js index 37190d1..5f50663 100644 --- a/validator/lib/constants.js +++ b/validator/lib/constants.js @@ -1,5 +1,6 @@ -export const LATEST_VERSION = '2.4.0'; -export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}`; +export const LATEST_VERSION = '2.5.1'; +// The /web subpath is required — it exports the custom element. +export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}/web`; export const PRESETS_CDN = 'https://esm.sh/@wix/motion-presets'; export const DRAFTS_DIR = '.drafts'; diff --git a/validator/test/codemod.test.js b/validator/test/codemod.test.js index 8566c27..26cf311 100644 --- a/validator/test/codemod.test.js +++ b/validator/test/codemod.test.js @@ -2,29 +2,41 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { applyCodemods } from '../lib/codemod.js'; -test('updateVersion pins an old explicit version', () => { +test('updateVersion pins an old explicit version to @latest/web', () => { const { output, applied } = applyCodemods("from 'https://esm.sh/@wix/interact@1.79.0'", ['updateVersion']); - assert.match(output, /@wix\/interact@2\.4\.0/); + assert.match(output, /@wix\/interact@2\.5\.1\/web/); assert.doesNotMatch(output, /@1\.79\.0/); assert.equal(applied.length, 1); }); -test('updateVersion pins an unpinned import', () => { +test('updateVersion pins an unpinned import and adds /web', () => { const { output } = applyCodemods("from 'https://esm.sh/@wix/interact'", ['updateVersion']); - assert.match(output, /@wix\/interact@2\.4\.0'/); + assert.match(output, /@wix\/interact@2\.5\.1\/web'/); }); -test('updateVersion preserves a /web subpath', () => { +test('updateVersion normalizes a versionless /web subpath', () => { const { output } = applyCodemods("from 'https://esm.sh/@wix/interact/web'", ['updateVersion']); - assert.match(output, /@wix\/interact@2\.4\.0\/web/); + assert.match(output, /@wix\/interact@2\.5\.1\/web/); }); -test('updateVersion leaves an already-latest import unchanged (no-op)', () => { - const { output, applied } = applyCodemods("from 'https://esm.sh/@wix/interact@2.4.0'", ['updateVersion']); - assert.match(output, /@wix\/interact@2\.4\.0/); +test('updateVersion normalizes a versioned import that lacks /web', () => { + const { output } = applyCodemods("from 'https://esm.sh/@wix/interact@2.4.0'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.5\.1\/web/); + assert.doesNotMatch(output, /@2\.4\.0/); +}); + +test('updateVersion leaves an already-correct import unchanged (no-op)', () => { + const { output, applied } = applyCodemods("from 'https://esm.sh/@wix/interact@2.5.1/web'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.5\.1\/web/); assert.equal(applied.length, 0); }); +test('updateVersion does not touch @wix/interact mentioned in prose/comments', () => { + const src = "// driven by @wix/interact's pointerMove trigger"; + const { output } = applyCodemods(src, ['updateVersion']); + assert.equal(output, src); +}); + test('updateVersion does not touch @wix/motion-presets', () => { const { output } = applyCodemods("from 'https://esm.sh/@wix/motion-presets'", ['updateVersion']); assert.equal(output, "from 'https://esm.sh/@wix/motion-presets'"); diff --git a/validator/test/detect.test.js b/validator/test/detect.test.js index 490a283..6ebce08 100644 --- a/validator/test/detect.test.js +++ b/validator/test/detect.test.js @@ -5,7 +5,7 @@ import { detect } from '../lib/detect.js'; const clean = ` @@ -14,7 +14,7 @@ const clean = ` test('clean current file', () => { const d = detect('X.html', clean); assert.equal(d.usesInteract, true); - assert.equal(d.version, '2.4.0'); + assert.equal(d.version, '2.5.1'); assert.equal(d.isLatest, true); assert.equal(d.usesCustomEffect, false); assert.equal(d.usesExtraJs, false); @@ -38,7 +38,7 @@ test('not using interact', () => { }); test('old syntax markers flag a latest-version file as outdated', () => { - const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'hover', params:{ method:'toggle' }, effects:[{ customEffect:()=>{} }] }] }); `; @@ -49,7 +49,7 @@ test('old syntax markers flag a latest-version file as outdated', () => { }); test('extra js detection', () => { - const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; window.addEventListener('scroll', () => {}); new IntersectionObserver(() => {}); el.animate([], 300);`; @@ -62,7 +62,7 @@ test('extra js detection', () => { }); test('customEffect on a latest, no-extra-js file', () => { - const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'pointerMove', effects:[{ customEffect:(el,p)=>{} }] }] });`; const d = detect('U.html', src); diff --git a/validator/test/fix.test.js b/validator/test/fix.test.js index 1feb3c5..48dc6e9 100644 --- a/validator/test/fix.test.js +++ b/validator/test/fix.test.js @@ -10,7 +10,7 @@ import { readDraft } from '../lib/drafts.js'; const root = () => mkdtemp(join(tmpdir(), 'iv-fix-')); const SPEC = 'spec'; // a clean, latest-version, no-customEffect snippet -const CLEAN = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; +const CLEAN = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'hover', effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; @@ -40,14 +40,14 @@ test('updateVersion is done by codemod (no agent) and pins the version', async ( assert.equal(res.via, 'script'); assert.equal(res.status, 'fixed'); const draft = await readDraft(r, 'A.html'); - assert.match(draft, /@wix\/interact@2\.4\.0/); + assert.match(draft, /@wix\/interact@2\.5\.1\/web/); assert.doesNotMatch(draft, /@1\.79\.0/); }); test('migrateSyntax with only a tag rename is done by codemod (no agent)', async () => { const r = await root(); const src = `
        x
        - import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'hover', effects:[{ namedEffect:{type:'FadeIn'}, duration:300, triggerType:'once' }] }] });`; let agentCalled = false; @@ -62,7 +62,7 @@ test('migrateSyntax with only a tag rename is done by codemod (no agent)', async test('migrateSyntax with play-mode still needs the agent', async () => { const r = await root(); - const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const src = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'hover', params:{ method:'toggle' }, effects:[{ customEffect:()=>{} }] }] });`; let agentCalled = false; @@ -119,7 +119,7 @@ test('fixFile reports needsReview when the agent draft is still outdated', async test('fixFile reports needsReview when convertCustomEffect requested but draft still uses customEffect', async () => { const r = await root(); - const draftWithCustomEffect = `import {Interact} from 'https://esm.sh/@wix/interact@2.4.0'; + const draftWithCustomEffect = `import {Interact} from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions:[{ key:'a', trigger:'hover', effects:[{ customEffect: (el, p) => { el.style.opacity = p; }, duration:300, triggerType:'once' }] }] });`; const res = await fixFile(r, 'H.html', { diff --git a/validator/test/prompt.test.js b/validator/test/prompt.test.js index ca49278..bb569f4 100644 --- a/validator/test/prompt.test.js +++ b/validator/test/prompt.test.js @@ -19,7 +19,7 @@ test('buildPrompt embeds selected fragments, custom prompt, spec, and source', ( }); assert.match(system, /SPEC-RULES/); assert.match(system, /ONLY the complete rewritten HTML/i); - assert.match(user, /2\.4\.0/); // updateVersion fragment mentions target version + assert.match(user, /2\.5\.1/); // updateVersion fragment mentions target version assert.match(user, /triggerType|stateAction/); // migrateSyntax fragment mentions renames assert.match(user, /keep the colors/); assert.match(user, /SRC/); From 85e25d7afb4bb4b4e35a52f926915a0853b9411f Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 22:38:39 +0300 Subject: [PATCH 24/62] feat(validator): stream agent reasoning over SSE runAgent now drives `claude --output-format stream-json --include-partial- messages`, forwarding text/thinking deltas via an onDelta callback. fixFile threads it as onLog(path,text,kind); the /api/fix SSE stream emits per-token `log` events so the UI can show the model working live. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/agent.js | 93 ++++++++++++++++++++---------------------- validator/lib/fix.js | 5 ++- validator/server.js | 4 +- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/validator/lib/agent.js b/validator/lib/agent.js index 5ece826..3356a85 100644 --- a/validator/lib/agent.js +++ b/validator/lib/agent.js @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { writeFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createInterface } from 'node:readline'; // Strips a fenced code block (```html … ``` or bare ``` … ```) if one is // present anywhere in the text; otherwise returns the trimmed text unchanged. @@ -11,56 +12,52 @@ export function extractHtml(text) { return (fence ? fence[1] : t).trim(); } -// Collect a child process's stdout/stderr, feeding `stdin` to it. -function spawnCollect(cmd, args, stdin) { +// One-shot rewrite via the local `claude` CLI (reuses the machine's +// `claude login` — no API key). Uses stream-json so we can surface the +// model's reasoning/output live via the onDelta callback. The system prompt +// goes to a temp file (arg-size limits); the user prompt is piped on stdin. +// Tools are stripped via --exclude-dynamic-system-prompt-sections so it is a +// pure text-in / text-out call. Returns the assistant's final text. +// onDelta(text, kind) kind ∈ { 'text', 'thinking' } — called per token chunk. +export function runAgent(system, user, { model, onDelta } = {}) { return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); - let stdout = '', stderr = ''; - child.stdout.on('data', (c) => { stdout += c; }); - child.stderr.on('data', (c) => { stderr += c; }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.on('error', () => {}); // ignore EPIPE if the CLI exits early - child.stdin.write(stdin); - child.stdin.end(); - }); -} + (async () => { + const dir = await mkdtemp(join(tmpdir(), 'iv-agent-')); + const sysFile = join(dir, 'system.txt'); + await writeFile(sysFile, system, 'utf8'); -// One-shot rewrite via the local `claude` CLI (reuses the machine's -// `claude login` — no API key). The system prompt goes to a temp file to -// dodge arg-size limits; the user prompt is piped on stdin. Tools are -// stripped via --exclude-dynamic-system-prompt-sections so it's a pure -// text-in / text-out LLM call. Returns the assistant's final text. -export async function runAgent(system, user, { model } = {}) { - const dir = await mkdtemp(join(tmpdir(), 'iv-agent-')); - const sysFile = join(dir, 'system.txt'); - await writeFile(sysFile, system, 'utf8'); + const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', + '--verbose', '--system-prompt-file', sysFile, '--exclude-dynamic-system-prompt-sections']; + if (model) args.push('--model', model); + + const child = spawn('claude', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let stderr = '', resultText = null, resultErr = null; + const rl = createInterface({ input: child.stdout }); - const args = ['-p', '--output-format', 'json', - '--system-prompt-file', sysFile, - '--exclude-dynamic-system-prompt-sections']; - if (model) args.push('--model', model); + rl.on('line', (line) => { + if (!line.trim()) return; + let m; try { m = JSON.parse(line); } catch { return; } + if (m.type === 'stream_event' && m.event?.type === 'content_block_delta') { + const d = m.event.delta; + if (d?.type === 'text_delta' && d.text) onDelta?.(d.text, 'text'); + else if (d?.type === 'thinking_delta' && d.thinking) onDelta?.(d.thinking, 'thinking'); + } else if (m.type === 'result') { + if (!m.is_error && typeof m.result === 'string') resultText = m.result; + else resultErr = m.subtype || m.error || 'agent error'; + } + }); + child.stderr.on('data', (c) => { stderr += c; }); + child.stdin.on('error', () => {}); // ignore EPIPE if the CLI exits early + child.stdin.write(user); child.stdin.end(); - try { - const { code, stdout, stderr } = await spawnCollect('claude', args, user); - if (code !== 0) throw new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`); - let parsed; - try { - parsed = JSON.parse(stdout); - } catch { - throw new Error(`could not parse claude output: ${stdout.slice(0, 300)}`); - } - // `--output-format json` yields an array of messages; the final result - // lives in the element with type 'result'. Older CLIs returned that - // object directly, so handle both shapes. - const result = Array.isArray(parsed) - ? parsed.find((m) => m && m.type === 'result') - : parsed; - if (!result || result.is_error || typeof result.result !== 'string') { - throw new Error(`claude error: ${result?.subtype || result?.error || 'no result field'}`); - } - return result.result; - } finally { - await rm(dir, { recursive: true, force: true }); - } + child.on('error', async (err) => { await rm(dir, { recursive: true, force: true }); reject(err); }); + child.on('close', async (code) => { + await rm(dir, { recursive: true, force: true }); + if (code !== 0) return reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`)); + if (resultErr) return reject(new Error(`claude error: ${resultErr}`)); + if (resultText === null) return reject(new Error('claude produced no result')); + resolve(resultText); + }); + })().catch(reject); + }); } diff --git a/validator/lib/fix.js b/validator/lib/fix.js index 4bc026f..1f2108d 100644 --- a/validator/lib/fix.js +++ b/validator/lib/fix.js @@ -22,7 +22,7 @@ export async function mapLimit(items, limit, fn) { } export async function fixFile(rootDir, relPath, opts) { - const { source, optionIds = [], customPrompt, specText, model, runAgent = realRunAgent } = opts; + const { source, optionIds = [], customPrompt, specText, model, onLog, runAgent = realRunAgent } = opts; try { // 1. Deterministic pass: pin version, rename tag/typo/offset-unit. const { output: codemodOut, applied } = applyCodemods(source, optionIds); @@ -42,7 +42,8 @@ export async function fixFile(rootDir, relPath, opts) { if (needsAgent) { const diagnosis = detect(relPath, codemodOut); const { system, user } = buildPrompt({ diagnosis, source: codemodOut, optionIds, customPrompt, specText }); - html = extractHtml(await runAgent(system, user, { model })); + const onDelta = onLog ? (text, kind) => onLog(relPath, text, kind) : undefined; + html = extractHtml(await runAgent(system, user, { model, onDelta })); via = 'agent'; } else { html = codemodOut; diff --git a/validator/server.js b/validator/server.js index 54cb948..d068061 100644 --- a/validator/server.js +++ b/validator/server.js @@ -85,7 +85,9 @@ export function createApp(rootDir) { send('start', { total: paths.length, paths }); for (const rf of readFailures) send('result', rf); try { - await runFix(root, files, { optionIds, customPrompt, specText, onResult: (r) => send('result', r) }); + await runFix(root, files, { optionIds, customPrompt, specText, + onResult: (r) => send('result', r), + onLog: (path, text, kind) => send('log', { path, text, kind }) }); send('done', { ok: true }); } catch (err) { send('error', { error: String(err.message || err) }); From 26ffe389e26722338b451238c712d3b826f01e73 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 22:38:39 +0300 Subject: [PATCH 25/62] feat(validator): file tree, collapsible panels, agent-activity modal - File list is now a collapsible folder tree (folder names + nested files) instead of full slash-paths; folders toggle open/closed, filter auto-expands. - Edge toggles collapse the left/right glass panels off-screen for an unobstructed view of the animation. - "Agent activity" button opens a modal that live-prints the model's streamed reasoning/output per file (CLI-style), with a file picker; mechanical codemod fixes show a note explaining they produced no LLM output. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 158 +++++++++++++++++++++++++++--------- validator/public/index.html | 21 ++++- validator/public/styles.css | 45 ++++++++-- 3 files changed, 179 insertions(+), 45 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index f0ed00a..176f530 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -11,7 +11,6 @@ function versionState(d) { if (d.isLatest && (d.oldSyntaxMarkers?.length || 0) === 0) return 'clean'; return 'outdated'; } -// Per-file indicators: version dot + additive flags (purple customEffect, blue JS). function indicatorsHTML(d) { if (!d) return ''; const v = VER[versionState(d)]; @@ -21,7 +20,13 @@ function indicatorsHTML(d) { return h; } -const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', mode: 'preview', version: 'current', progress: null }; +const state = { + files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, + filter: '', mode: 'preview', version: 'current', progress: null, + expanded: new Set(), // expanded folder paths + logs: new Map(), // path -> streamed agent output + activity: { open: false, file: null, follow: true }, +}; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); @@ -29,7 +34,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '&l async function loadFiles() { const { files } = await api('/api/files'); state.files = files; - renderList(); + renderTree(); } async function loadOptions() { @@ -45,18 +50,52 @@ function visibleFiles() { return state.files.filter((f) => f.path.toLowerCase().includes(q)); } -function renderList() { - $('fileList').innerHTML = visibleFiles().map((f) => { - const d = state.diag[f.path]; - const draft = state.drafts.has(f.path) ? 'draft' : ''; - const checked = state.selected.has(f.path) ? 'checked' : ''; - const active = state.current === f.path ? ' active' : ''; - const title = d ? `${f.path} — ${VER[versionState(d)].label}` : f.path; - return `
      • - - ${esc(f.path)} - ${indicatorsHTML(d)}${draft}
      • `; - }).join(''); +// ── File tree ─────────────────────────────────────── +function buildTree(files) { + const root = { dirs: new Map(), files: [] }; + for (const f of files) { + const parts = f.path.split('/'); + let node = root, prefix = ''; + for (let i = 0; i < parts.length - 1; i++) { + prefix = prefix ? `${prefix}/${parts[i]}` : parts[i]; + if (!node.dirs.has(parts[i])) node.dirs.set(parts[i], { name: parts[i], path: prefix, dirs: new Map(), files: [] }); + node = node.dirs.get(parts[i]); + } + node.files.push(f); + } + return root; +} + +function fileRow(f) { + const d = state.diag[f.path]; + const draft = state.drafts.has(f.path) ? 'draft' : ''; + const checked = state.selected.has(f.path) ? 'checked' : ''; + const active = state.current === f.path ? ' active' : ''; + const title = d ? `${f.path} — ${VER[versionState(d)].label}` : f.path; + return `
        + + ${esc(f.file)} + ${indicatorsHTML(d)}${draft}
        `; +} + +function renderNodes(node, depth, forceOpen) { + const pad = (n) => `style="padding-left:${8 + n * 14}px"`; + let html = ''; + for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) { + const open = forceOpen || state.expanded.has(dir.path); + html += `
        + ${open ? '▾' : '▸'}${esc(dir.name)}
        `; + if (open) html += `
        ${renderNodes(dir, depth + 1, forceOpen)}
        `; + } + for (const f of node.files.sort((a, b) => a.file.localeCompare(b.file))) { + html += `
        ${fileRow(f)}
        `; + } + return html; +} + +function renderTree() { + const forceOpen = !!state.filter; // when filtering, reveal all matches + $('fileTree').innerHTML = renderNodes(buildTree(visibleFiles()), 0, forceOpen); } function renderSummary() { @@ -88,12 +127,13 @@ async function scan() { state.diag = {}; for (const r of results) state.diag[r.path] = r; renderSummary(); - renderList(); + renderTree(); } finally { $('scanBtn').disabled = false; $('scanBtn').textContent = 'Scan'; } } +// ── Viewport (preview / code / diff) ──────────────── function baseHrefFor(path) { const slash = path.lastIndexOf('/'); return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); @@ -101,8 +141,6 @@ function baseHrefFor(path) { const fetchSource = (kind, path) => api(`/api/${kind}?path=${encodeURIComponent(path)}`).then((r) => r.source); const blankDoc = (label) => `${label}`; -// Resolve which file source to show for the chosen version. 'draft' with no -// draft on disk yields null (callers render a placeholder). async function sourceFor(path, version) { if (version === 'draft') return state.drafts.has(path) ? fetchSource('draft', path) : null; return fetchSource('file', path); @@ -120,12 +158,11 @@ async function renderDiff(path) { }).join(''); } -// Reflect state.mode + state.version into the viewport. async function render() { const { mode, version, current } = state; for (const b of document.querySelectorAll('#modeTabs .tab')) b.classList.toggle('active', b.dataset.mode === mode); for (const b of document.querySelectorAll('#verTabs .tab')) b.classList.toggle('active', b.dataset.ver === version); - $('topbar').classList.toggle('diff', mode === 'diff'); // hides version group for Diff + $('topbar').classList.toggle('diff', mode === 'diff'); const has = !!current; $('placeholder').hidden = has; @@ -135,12 +172,10 @@ async function render() { if (!has) return; if (mode === 'diff') { renderDiff(current); return; } - const src = await sourceFor(current, version); if (mode === 'preview') { - $('preview').srcdoc = src === null ? blankDoc('No draft yet — fix this file first') - : injectBase(src, baseHrefFor(current)); - } else { // code + $('preview').srcdoc = src === null ? blankDoc('No draft yet — fix this file first') : injectBase(src, baseHrefFor(current)); + } else { $('code').textContent = src === null ? 'No draft yet — fix this file first.' : src; } } @@ -161,7 +196,8 @@ function renderProgress() { : ''; const t = `${path}${st.error ? ' — ' + st.error : ''}`; const via = st.via ? `${esc(st.via)}` : ''; - return `
        ${mk}${esc(path)}${via}
        `; + const short = path.split('/').pop(); + return `
        ${mk}${esc(short)}${via}
        `; }).join(''); $('fixProgress').innerHTML = `
        ${head}
        ${items}
        `; } @@ -172,15 +208,23 @@ function applyResult(r) { state.progress.done++; if (r.status !== 'fixFailed') state.drafts.add(r.path); renderProgress(); - renderList(); + renderTree(); +} + +function appendLog(path, text) { + state.logs.set(path, (state.logs.get(path) || '') + text); + if (state.activity.follow) state.activity.file = path; + if (state.activity.open) renderActivity(); } function handleFrame(frame) { const ev = /event:\s*(.+)/.exec(frame); const dt = /data:\s*([\s\S]+)/.exec(frame); if (!ev || !dt) return; - if (ev[1].trim() !== 'result') return; - try { applyResult(JSON.parse(dt[1])); } catch { /* ignore malformed frame */ } + let data; try { data = JSON.parse(dt[1]); } catch { return; } + const type = ev[1].trim(); + if (type === 'result') applyResult(data); + else if (type === 'log') appendLog(data.path, data.text); } async function runFix() { @@ -190,6 +234,9 @@ async function runFix() { const customPrompt = $('customPrompt').value; state.progress = { running: true, total: paths.length, done: 0, startedAt: Date.now(), endedAt: null, items: new Map(paths.map((p) => [p, { status: 'pending' }])) }; + state.logs = new Map(); + state.activity.follow = true; + if (state.activity.open) renderActivity(); $('fixBtn').disabled = true; $('applyStatus').textContent = ''; renderProgress(); progTimer = setInterval(renderProgress, 500); @@ -218,8 +265,7 @@ async function runFix() { clearInterval(progTimer); renderProgress(); $('fixBtn').disabled = false; - renderList(); - // surface the freshly-written draft for the open file + renderTree(); if (state.current && state.drafts.has(state.current)) state.version = 'draft'; render(); } @@ -238,32 +284,58 @@ async function applyOrDiscard(endpoint) { let msg = `${verb} ${succeeded.length} draft(s).`; if (failed.length) msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; $('applyStatus').textContent = msg; - renderList(); - // the draft is gone for applied/discarded files — fall back to Current + renderTree(); if (state.current && succeeded.includes(state.current)) state.version = 'current'; render(); } +// ── Agent activity modal ──────────────────────────── +function renderActivity() { + const paths = [...state.logs.keys()]; + if (state.activity.file && !state.logs.has(state.activity.file)) state.activity.file = null; + if (!state.activity.file && paths.length) state.activity.file = paths[paths.length - 1]; + $('activityFile').innerHTML = paths.length + ? paths.map((p) => ``).join('') + : ''; + const body = $('activityBody'); + if (!paths.length) { + body.textContent = 'No agent output yet. Run a fix that needs the model — mechanical fixes (version pin, tag rename) are done by the deterministic codemod and produce no reasoning.'; + } else { + body.textContent = state.logs.get(state.activity.file) || '(waiting for output…)'; + body.scrollTop = body.scrollHeight; + } +} +function openActivity() { state.activity.open = true; $('activityModal').hidden = false; renderActivity(); } +function closeActivity() { state.activity.open = false; $('activityModal').hidden = true; } + // ── events ────────────────────────────────────────── -$('fileList').addEventListener('click', (e) => { - const li = e.target.closest('li'); if (!li) return; - const path = li.dataset.path; +$('fileTree').addEventListener('click', (e) => { + const folder = e.target.closest('.folder-row'); + if (folder) { + const p = folder.dataset.folder; + if (state.expanded.has(p)) state.expanded.delete(p); else state.expanded.add(p); + renderTree(); + return; + } + const row = e.target.closest('.file-row'); + if (!row) return; + const path = row.dataset.path; if (e.target.classList.contains('cb')) { if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); return; } state.current = path; - renderList(); + renderTree(); render(); }); -$('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderList(); }); +$('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderTree(); }); $('scanBtn').onclick = scan; $('selectAllBtn').onclick = () => { const vis = visibleFiles(); const allSelected = vis.length && vis.every((f) => state.selected.has(f.path)); if (allSelected) vis.forEach((f) => state.selected.delete(f.path)); else vis.forEach((f) => state.selected.add(f.path)); - renderList(); + renderTree(); }; $('fixBtn').onclick = runFix; $('applyBtn').onclick = () => applyOrDiscard('apply'); @@ -271,5 +343,15 @@ $('discardBtn').onclick = () => applyOrDiscard('discard'); for (const b of document.querySelectorAll('#modeTabs .tab')) b.onclick = () => { state.mode = b.dataset.mode; render(); }; for (const b of document.querySelectorAll('#verTabs .tab')) b.onclick = () => { state.version = b.dataset.ver; render(); }; +// panel collapse +$('toggleLeft').onclick = () => $('listPane').classList.toggle('collapsed'); +$('toggleRight').onclick = () => $('fixPane').classList.toggle('collapsed'); + +// activity modal +$('activityBtn').onclick = openActivity; +$('activityClose').onclick = closeActivity; +$('activityModal').addEventListener('click', (e) => { if (e.target.id === 'activityModal') closeActivity(); }); +$('activityFile').onchange = (e) => { state.activity.file = e.target.value; state.activity.follow = false; renderActivity(); }; + loadFiles(); loadOptions(); diff --git a/validator/public/index.html b/validator/public/index.html index 8aca125..b4861a5 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -28,6 +28,10 @@
        + + + + @@ -47,6 +51,7 @@

        Fix options

        +
        @@ -57,6 +62,20 @@

        Fix options

        + + + diff --git a/validator/public/styles.css b/validator/public/styles.css index 85f3fb0..63aba51 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -142,12 +142,17 @@ body { } .search::placeholder { color: var(--text-3); } .search:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-2); } -#fileList { list-style: none; overflow-y: auto; flex: 1; padding: 0 8px 12px; } -#fileList li { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-xs); cursor: pointer; transition: background .12s; } -#fileList li:hover { background: var(--fill-1); } -#fileList li.active { background: var(--accent-soft); } -#fileList .name { flex: 1; min-width: 0; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } -#fileList li.active .name { color: #fff; } +/* ── File tree ───────────────────────────────────── */ +#fileTree { overflow-y: auto; flex: 1; padding: 0 8px 12px; } +.folder-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-xs); cursor: pointer; color: var(--text-2); transition: background .12s; user-select: none; } +.folder-row:hover { background: var(--fill-1); color: var(--text); } +.folder-row .chev { width: 12px; font-size: 10px; flex: none; opacity: .8; } +.folder-row .fname { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-row { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: var(--radius-xs); cursor: pointer; transition: background .12s; } +.file-row:hover { background: var(--fill-1); } +.file-row.active { background: var(--accent-soft); } +.file-row .fname { flex: 1; min-width: 0; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-row.active .fname { color: #fff; } .status-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--fill-3); } .draft-tag { font-size: 9.5px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: #cfe3ff; background: rgba(96,165,250,0.22); padding: 2px 6px; border-radius: 980px; flex: none; } @@ -185,6 +190,34 @@ body { .spinner { width: 12px; height: 12px; border: 2px solid var(--fill-3); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; flex: none; } @keyframes spin { to { transform: rotate(360deg); } } +.btn-ghost { background: transparent; border: 1px solid var(--hair); color: var(--text-2); } +.btn-ghost:hover { background: var(--fill-1); color: var(--text); } + +/* ── Panel collapse ──────────────────────────────── */ +#listPane, #fixPane { transition: transform .28s cubic-bezier(0.4,0,0.2,1), opacity .2s; } +#listPane.collapsed { transform: translateX(calc(-100% - 24px)); opacity: 0; pointer-events: none; } +#fixPane.collapsed { transform: translateX(calc(100% + 24px)); opacity: 0; pointer-events: none; } +.edge-toggle { + position: fixed; top: 50%; transform: translateY(-50%); z-index: 70; + width: 26px; height: 52px; display: flex; align-items: center; justify-content: center; + font-size: 16px; color: var(--text-2); cursor: pointer; border-radius: 12px; padding: 0; + transition: color .15s, background .15s; +} +.edge-toggle:hover { color: var(--text); background: var(--fill-2); } +.edge-toggle.left { left: 8px; } +.edge-toggle.right { right: 8px; } + +/* ── Agent activity modal ────────────────────────── */ +.modal-backdrop { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.45); backdrop-filter: blur(3px); } +.modal { width: min(760px, 88vw); height: min(70vh, 640px); display: flex; flex-direction: column; overflow: hidden; padding: 0; } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px; border-bottom: 1px solid var(--hair); font-weight: 600; font-size: 13px; } +.modal-head-actions { display: flex; align-items: center; gap: 10px; } +.mini-select { font-family: var(--mono); font-size: 11.5px; color: var(--text); background: var(--fill-1); border: 1px solid var(--hair); border-radius: 7px; padding: 4px 8px; max-width: 320px; } +.icon-btn { background: transparent; border: 0; color: var(--text-2); font-size: 15px; cursor: pointer; padding: 2px 6px; border-radius: 6px; } +.icon-btn:hover { background: var(--fill-2); color: var(--text); } +.modal-body { flex: 1; overflow: auto; margin: 0; padding: 16px 18px; font-family: var(--mono); font-size: 12px; line-height: 1.6; color: var(--text-2); white-space: pre-wrap; word-break: break-word; } + /* ── Scrollbars ──────────────────────────────────── */ ::-webkit-scrollbar { width: 9px; height: 9px; } ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.16); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } From 278a9af6d2171a16a6274296505692d422221131 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 22:47:57 +0300 Subject: [PATCH 26/62] feat(validator): folder icons + honest activity-modal waiting state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a folder icon to each folder row in the file tree. - The agent-activity modal now distinguishes "waiting for the model to stream" (during an in-flight run) from "no agent output" (idle), and hints to restart a stale server if nothing streams — instead of always showing the misleading mechanical-fix note. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 9 +++++++-- validator/public/styles.css | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 176f530..ace0385 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -78,13 +78,15 @@ function fileRow(f) { ${indicatorsHTML(d)}${draft}`; } +const FOLDER_ICON = ''; + function renderNodes(node, depth, forceOpen) { const pad = (n) => `style="padding-left:${8 + n * 14}px"`; let html = ''; for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) { const open = forceOpen || state.expanded.has(dir.path); html += `
        - ${open ? '▾' : '▸'}${esc(dir.name)}
        `; + ${open ? '▾' : '▸'}${FOLDER_ICON}${esc(dir.name)}`; if (open) html += `
        ${renderNodes(dir, depth + 1, forceOpen)}
        `; } for (const f of node.files.sort((a, b) => a.file.localeCompare(b.file))) { @@ -299,7 +301,10 @@ function renderActivity() { : ''; const body = $('activityBody'); if (!paths.length) { - body.textContent = 'No agent output yet. Run a fix that needs the model — mechanical fixes (version pin, tag rename) are done by the deterministic codemod and produce no reasoning.'; + const running = state.progress && state.progress.running; + body.textContent = running + ? 'Waiting for the model to start streaming…\n\nThe claude CLI takes a few seconds to spin up before the first token. If nothing appears after that, your validator server may predate this feature — restart it (Ctrl-C, then `npm start`).' + : 'No agent output yet. Run a fix that needs the model.\n\nMechanical fixes (version pin, tag rename) are done by the deterministic codemod and produce no reasoning — only semantic fixes (convert to interact, convert customEffect, remove JS) or a custom prompt call the model.'; } else { body.textContent = state.logs.get(state.activity.file) || '(waiting for output…)'; body.scrollTop = body.scrollHeight; diff --git a/validator/public/styles.css b/validator/public/styles.css index 63aba51..6033325 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -147,6 +147,7 @@ body { .folder-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-xs); cursor: pointer; color: var(--text-2); transition: background .12s; user-select: none; } .folder-row:hover { background: var(--fill-1); color: var(--text); } .folder-row .chev { width: 12px; font-size: 10px; flex: none; opacity: .8; } +.folder-row .ficon { flex: none; color: #6f86ff; opacity: .9; margin-right: 1px; } .folder-row .fname { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-row { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: var(--radius-xs); cursor: pointer; transition: background .12s; } .file-row:hover { background: var(--fill-1); } From 5520bb2ffa45c021aa8fc53e084a5f38cad2ba75 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 1 Jul 2026 23:01:56 +0300 Subject: [PATCH 27/62] fix(validator): strip prose the model wraps around the HTML document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractHtml now clamps output to the / … boundaries, dropping any preamble ("per the output contract, here it is:") or trailing remarks the model sometimes adds — which were leaking into the draft and rendering in the preview. Fragments without those markers are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/agent.js | 17 +++++++++++++---- validator/test/agent.test.js | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/validator/lib/agent.js b/validator/lib/agent.js index 3356a85..1ba53d4 100644 --- a/validator/lib/agent.js +++ b/validator/lib/agent.js @@ -4,12 +4,21 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; -// Strips a fenced code block (```html … ``` or bare ``` … ```) if one is -// present anywhere in the text; otherwise returns the trimmed text unchanged. +// Extract just the HTML document from a model response. Handles two kinds of +// stray wrapping: (1) a ```html … ``` fence, and (2) prose the model prepends +// or appends (e.g. "per the output contract, here it is:"). If the text +// contains an HTML document, everything before / and +// after the final is dropped. A fragment without those markers is +// returned trimmed (after fence removal) unchanged. export function extractHtml(text) { - const t = String(text).trim(); + let t = String(text).trim(); const fence = t.match(/```(?:html)?\s*\n([\s\S]*?)\n```/i); - return (fence ? fence[1] : t).trim(); + if (fence) t = fence[1].trim(); + const start = t.search(/|]/i); + if (start > 0) t = t.slice(start); + const end = t.toLowerCase().lastIndexOf(''); + if (end !== -1) t = t.slice(0, end + ''.length); + return t.trim(); } // One-shot rewrite via the local `claude` CLI (reuses the machine's diff --git a/validator/test/agent.test.js b/validator/test/agent.test.js index 9ced1f6..e1624f5 100644 --- a/validator/test/agent.test.js +++ b/validator/test/agent.test.js @@ -16,5 +16,21 @@ test('extractHtml extracts fenced block when prose precedes it', () => { assert.equal(extractHtml('Here:\n```html\n
        x
        \n```'), '
        x
        '); }); test('extractHtml returns trimmed text unchanged when no fence present', () => { - assert.equal(extractHtml(' no fence here '), 'no fence here'); + assert.equal(extractHtml('no fence here'), 'no fence here'); +}); +test('extractHtml drops prose the model prepends before the document', () => { + const out = extractHtml("Per the output contract, here it is:\n\n\nx"); + assert.equal(out, '\nx'); +}); +test('extractHtml drops trailing prose after ', () => { + const out = extractHtml('\n\n\nLet me know if you want changes!'); + assert.equal(out, '\n'); +}); +test('extractHtml handles prose + fence + prose together', () => { + const out = extractHtml("Sure:\n```html\nnote\n\n\nthanks\n```"); + assert.equal(out, '\n'); +}); +test('extractHtml clamps to when there is no doctype', () => { + const out = extractHtml('Here you go:\ny — done'); + assert.equal(out, 'y'); }); From 82f606ccced6ebee90bd6982b6c7c58d6f0cdb7e Mon Sep 17 00:00:00 2001 From: hassankettany Date: Thu, 2 Jul 2026 13:33:00 +0300 Subject: [PATCH 28/62] =?UTF-8?q?feat(validator):=20convert-to-prompt=20ba?= =?UTF-8?q?ckend=20(skill=20=E2=86=92=20guideline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a /api/convert flow that runs the convert-interact-demo-example skill headlessly (SKILL.md + bundled exemplar as the system prompt) over selected files and writes each structured guideline to "Ani-Mate Prompts/.md" (mirroring the source tree). Streams per-token log + per-file result over SSE like /api/fix. New endpoints: GET /api/prompts (list) and GET /api/prompt (read). Path-safe, tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/lib/constants.js | 3 ++ validator/lib/convert.js | 49 +++++++++++++++++++++++++ validator/lib/prompts.js | 62 ++++++++++++++++++++++++++++++++ validator/lib/skill.js | 14 ++++++++ validator/server.js | 48 +++++++++++++++++++++++++ validator/test/convert.test.js | 66 ++++++++++++++++++++++++++++++++++ validator/test/server.test.js | 14 ++++++++ 7 files changed, 256 insertions(+) create mode 100644 validator/lib/convert.js create mode 100644 validator/lib/prompts.js create mode 100644 validator/lib/skill.js create mode 100644 validator/test/convert.test.js diff --git a/validator/lib/constants.js b/validator/lib/constants.js index 5f50663..35914f0 100644 --- a/validator/lib/constants.js +++ b/validator/lib/constants.js @@ -3,11 +3,14 @@ export const LATEST_VERSION = '2.5.1'; export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}/web`; export const PRESETS_CDN = 'https://esm.sh/@wix/motion-presets'; export const DRAFTS_DIR = '.drafts'; +// Generated prose guidelines ("convert to prompt" output) live here. +export const PROMPTS_DIR = 'Ani-Mate Prompts'; // Directories never scanned for animation files. export const IGNORED_DIRS = new Set([ 'node_modules', '.git', '.drafts', '.backups', 'analysis', 'explorer-screenshots', 'docs', 'validator', '.cursor', + PROMPTS_DIR, ]); // Files at any level that are not animations. diff --git a/validator/lib/convert.js b/validator/lib/convert.js new file mode 100644 index 0000000..1f5cc44 --- /dev/null +++ b/validator/lib/convert.js @@ -0,0 +1,49 @@ +import { mapLimit } from './fix.js'; +import { runAgent as realRunAgent } from './agent.js'; +import { writePrompt } from './prompts.js'; + +// Assemble the system+user prompt that runs the convert-interact-demo-example +// skill headlessly: the skill instructions and exemplar go in the system +// prompt; the demo source is the user message. +export function buildConvertPrompt({ skill, exemplar, relPath, source }) { + const system = `You are executing the "convert-interact-demo-example" skill. Follow its instructions exactly to turn a @wix/interact demo into a structured prose guideline. + +=== SKILL INSTRUCTIONS === +${skill} + +=== REFERENCE EXEMPLAR (match this structure exactly) === +${exemplar} + +OUTPUT CONTRACT: Return ONLY the finished guideline as raw markdown. Do NOT wrap the whole document in a code fence, and do NOT add any preamble or closing remarks. Begin with the "# " H1 line.`; + const user = `Convert this @wix/interact demo into the guideline. Source file: ${relPath}\n\n${source}`; + return { system, user }; +} + +// The model occasionally wraps the whole doc in a ```markdown fence — strip it. +function stripMarkdownFence(text) { + const t = String(text).trim(); + const m = t.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + return (m ? m[1] : t).trim(); +} + +export async function convertFile(rootDir, relPath, opts) { + const { source, skill, exemplar, model, onLog, runAgent = realRunAgent } = opts; + try { + const { system, user } = buildConvertPrompt({ skill, exemplar, relPath, source }); + const onDelta = onLog ? (text) => onLog(relPath, text) : undefined; + const md = stripMarkdownFence(await runAgent(system, user, { model, onDelta })); + const outPath = await writePrompt(rootDir, relPath, md); + return { path: relPath, status: 'converted', via: 'agent', outPath }; + } catch (err) { + return { path: relPath, status: 'failed', error: String(err.message || err) }; + } +} + +export async function runConvert(rootDir, files, opts) { + const { concurrency = 4, onResult, ...rest } = opts; + return mapLimit(files, concurrency, async (f) => { + const result = await convertFile(rootDir, f.path, { ...rest, source: f.source }); + if (onResult) onResult(result); + return result; + }); +} diff --git a/validator/lib/prompts.js b/validator/lib/prompts.js new file mode 100644 index 0000000..9f4699e --- /dev/null +++ b/validator/lib/prompts.js @@ -0,0 +1,62 @@ +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { resolve, dirname, join, relative, sep } from 'node:path'; +import { PROMPTS_DIR } from './constants.js'; + +// Map a source example path to its prompt path, RELATIVE TO the prompts dir. +// "Gallery-and-Carousel/CardSpread.html" -> "Gallery-and-Carousel/CardSpread.md" +export function promptRelPath(sourceRel) { + return sourceRel.replace(/\.html?$/i, '.md'); +} + +// Resolve a prompts-dir-relative path to an absolute path, refusing anything +// that escapes the prompts directory. +function promptAbs(rootDir, rel) { + const baseDir = resolve(rootDir, PROMPTS_DIR); + const abs = resolve(baseDir, rel); + if (abs !== baseDir && !abs.startsWith(baseDir + sep)) throw new Error('path escapes prompts dir'); + return abs; +} + +export async function writePrompt(rootDir, sourceRel, content) { + const rel = promptRelPath(sourceRel); + const abs = promptAbs(rootDir, rel); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, 'utf8'); + return rel; +} + +export async function readPrompt(rootDir, rel) { + try { + return await readFile(promptAbs(rootDir, rel), 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +} + +// List every .md guideline under the prompts dir, as { path, dir, file } +// with paths relative to the prompts dir (mirrors the examples tree shape). +export async function listPrompts(rootDir) { + const base = resolve(rootDir, PROMPTS_DIR); + const out = []; + async function walk(absDir) { + let entries; + try { entries = await readdir(absDir, { withFileTypes: true }); } + catch { return; } // dir may not exist yet + for (const entry of entries) { + const abs = join(absDir, entry.name); + if (entry.isDirectory()) await walk(abs); + else if (entry.isFile() && entry.name.endsWith('.md')) { + const rel = relative(base, abs).split(sep).join('/'); + const slash = rel.lastIndexOf('/'); + out.push({ + path: rel, + dir: slash === -1 ? '' : rel.slice(0, slash), + file: slash === -1 ? rel : rel.slice(slash + 1), + }); + } + } + } + await walk(base); + return out.sort((a, b) => a.path.localeCompare(b.path)); +} diff --git a/validator/lib/skill.js b/validator/lib/skill.js new file mode 100644 index 0000000..45faeef --- /dev/null +++ b/validator/lib/skill.js @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const SKILL_DIR = join(homedir(), '.claude', 'skills', 'convert-interact-demo-example'); + +// Load the convert-interact-demo-example skill's instructions + bundled +// exemplar so a headless `claude -p` run can follow it without relying on +// dynamic skill auto-loading (which we strip for a clean text-in/text-out call). +export async function loadConvertSkill() { + const skill = await readFile(join(SKILL_DIR, 'SKILL.md'), 'utf8'); + const exemplar = await readFile(join(SKILL_DIR, 'reference', 'example.guideline.md'), 'utf8').catch(() => ''); + return { skill, exemplar }; +} diff --git a/validator/server.js b/validator/server.js index d068061..f8d5a99 100644 --- a/validator/server.js +++ b/validator/server.js @@ -5,6 +5,9 @@ import { listAnimationFiles } from './lib/files.js'; import { detect } from './lib/detect.js'; import { readOriginal, readDraft, computeDiff, applyDraft, discardDraft } from './lib/drafts.js'; import { runFix } from './lib/fix.js'; +import { runConvert } from './lib/convert.js'; +import { listPrompts, readPrompt } from './lib/prompts.js'; +import { loadConvertSkill } from './lib/skill.js'; import { FIX_OPTIONS } from './lib/prompt.js'; import { loadSpecText } from './lib/spec.js'; @@ -138,6 +141,51 @@ export function createApp(rootDir) { res.json({ results }); }); + // ── Prompts (convert-to-prompt output) ───────────── + app.get('/api/prompts', async (_req, res) => { + res.json({ files: await listPrompts(root) }); + }); + + app.get('/api/prompt', async (req, res) => { + try { + const source = await readPrompt(root, String(req.query.path)); + if (source === null) return res.status(404).json({ error: 'no prompt' }); + res.json({ source }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/convert', async (req, res) => { + const { paths } = req.body; + if (!Array.isArray(paths) || !paths.length) return bad(res, 'paths required'); + const { skill, exemplar } = await loadConvertSkill(); + + const files = []; + const readFailures = []; + for (const p of paths) { + try { files.push({ path: p, source: await readOriginal(root, p) }); } + catch (err) { readFailures.push({ path: p, status: 'failed', error: String(err.message || err) }); } + } + + if ((req.headers.accept || '').includes('text/event-stream')) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + send('start', { total: paths.length, paths }); + for (const rf of readFailures) send('result', rf); + try { + await runConvert(root, files, { skill, exemplar, + onResult: (r) => send('result', r), + onLog: (path, text) => send('log', { path, text }) }); + send('done', { ok: true }); + } catch (err) { send('error', { error: String(err.message || err) }); } + return res.end(); + } + + try { + const results = await runConvert(root, files, { skill, exemplar }); + res.json({ results: [...readFailures, ...results] }); + } catch (err) { res.status(500).json({ error: String(err.message || err) }); } + }); + return app; } diff --git a/validator/test/convert.test.js b/validator/test/convert.test.js new file mode 100644 index 0000000..1ef1e7f --- /dev/null +++ b/validator/test/convert.test.js @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildConvertPrompt, convertFile, runConvert } from '../lib/convert.js'; +import { promptRelPath, readPrompt, listPrompts } from '../lib/prompts.js'; + +const root = () => mkdtemp(join(tmpdir(), 'iv-conv-')); + +test('promptRelPath maps .html source to .md under the prompts dir', () => { + assert.equal(promptRelPath('Gallery-and-Carousel/CardSpread.html'), 'Gallery-and-Carousel/CardSpread.md'); + assert.equal(promptRelPath('label.htm'), 'label.md'); +}); + +test('buildConvertPrompt embeds the skill, exemplar, and source', () => { + const { system, user } = buildConvertPrompt({ + skill: 'SKILL-BODY', exemplar: 'EXEMPLAR-BODY', relPath: 'a/b.html', source: 'SRC' }); + assert.match(system, /SKILL-BODY/); + assert.match(system, /EXEMPLAR-BODY/); + assert.match(system, /ONLY the finished guideline/i); + assert.match(user, /a\/b\.html/); + assert.match(user, /SRC/); +}); + +test('convertFile writes the guideline to the mirrored prompt path', async () => { + const r = await root(); + const res = await convertFile(r, 'Gallery-and-Carousel/CardSpread.html', { + source: '', skill: 'S', exemplar: 'E', + runAgent: async () => '# Card Spread\n\nA guideline.', + }); + assert.equal(res.status, 'converted'); + assert.equal(res.via, 'agent'); + assert.equal(res.outPath, 'Gallery-and-Carousel/CardSpread.md'); + assert.equal(await readPrompt(r, 'Gallery-and-Carousel/CardSpread.md'), '# Card Spread\n\nA guideline.'); +}); + +test('convertFile strips a whole-document markdown fence', async () => { + const r = await root(); + await convertFile(r, 'x.html', { source: 'x', skill: 'S', exemplar: 'E', + runAgent: async () => '```markdown\n# Title\ntext\n```' }); + assert.equal(await readPrompt(r, 'x.md'), '# Title\ntext'); +}); + +test('convertFile reports failed and writes nothing when the agent throws', async () => { + const r = await root(); + const res = await convertFile(r, 'y.html', { source: 'x', skill: 'S', exemplar: 'E', + runAgent: async () => { throw new Error('boom'); } }); + assert.equal(res.status, 'failed'); + assert.match(res.error, /boom/); + assert.equal(await readPrompt(r, 'y.md'), null); +}); + +test('runConvert processes a batch and listPrompts finds the results', async () => { + const r = await root(); + await runConvert(r, [{ path: 'a/one.html', source: 's' }, { path: 'two.html', source: 's' }], + { skill: 'S', exemplar: 'E', runAgent: async () => '# G', concurrency: 2 }); + const prompts = await listPrompts(r); + assert.deepEqual(prompts.map((p) => p.path).sort(), ['a/one.md', 'two.md']); + assert.equal(prompts.find((p) => p.path === 'a/one.md').dir, 'a'); +}); + +test('readPrompt refuses path traversal out of the prompts dir', async () => { + const r = await root(); + await assert.rejects(() => readPrompt(r, '../../etc/passwd'), /escapes prompts dir/); +}); diff --git a/validator/test/server.test.js b/validator/test/server.test.js index 7cee5fd..b3ee9a7 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -87,3 +87,17 @@ test('apply partial batch: valid path succeeds, missing path fails, always 200', assert.equal(after.source, 'PATCHED'); server.close(); }); + +test('GET /api/prompts lists generated guidelines and /api/prompt reads one', async () => { + const root = await repo(); + const { writePrompt } = await import('../lib/prompts.js'); + await writePrompt(root, 'G/A.html', '# A Guideline\n\ntext'); + const { base, server } = await start(root); + const list = await (await fetch(`${base}/api/prompts`)).json(); + assert.ok(list.files.some((f) => f.path === 'G/A.md'), 'prompt should be listed'); + const one = await (await fetch(`${base}/api/prompt?path=${encodeURIComponent('G/A.md')}`)).json(); + assert.match(one.source, /# A Guideline/); + const missing = await fetch(`${base}/api/prompt?path=${encodeURIComponent('G/nope.md')}`); + assert.equal(missing.status, 404); + server.close(); +}); From 7a7a6dcf13763e643234306af66c7ac837c97990 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Thu, 2 Jul 2026 13:33:00 +0300 Subject: [PATCH 29/62] feat(validator): Examples/Prompts tabs + Convert-to-prompt UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Examples ↔ Prompts view tabs under the search box. Examples shows the animation tree (as before); Prompts shows the generated guideline tree from "Ani-Mate Prompts". - "Convert to prompt" button runs the selected examples through the skill with the same live streaming progress + agent-activity modal. - Selecting a prompt renders it as formatted markdown (mode tabs become Rendered / Raw); includes a small dependency-free markdown renderer. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 289 ++++++++++++++++++++++-------------- validator/public/index.html | 6 + validator/public/md.js | 62 ++++++++ validator/public/styles.css | 31 ++++ validator/test/md.test.js | 33 ++++ 5 files changed, 312 insertions(+), 109 deletions(-) create mode 100644 validator/public/md.js create mode 100644 validator/test/md.test.js diff --git a/validator/public/app.js b/validator/public/app.js index ace0385..e407c7b 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -1,4 +1,5 @@ import { injectBase } from './preview.js'; +import { mdToHtml } from './md.js'; // Version state — mutually exclusive (green / yellow / red). const VER = { @@ -23,20 +24,15 @@ function indicatorsHTML(d) { const state = { files: [], diag: {}, drafts: new Set(), selected: new Set(), current: null, filter: '', mode: 'preview', version: 'current', progress: null, - expanded: new Set(), // expanded folder paths - logs: new Map(), // path -> streamed agent output - activity: { open: false, file: null, follow: true }, + expanded: new Set(), logs: new Map(), activity: { open: false, file: null, follow: true }, + view: 'examples', prompts: [], promptExpanded: new Set(), currentPrompt: null, promptMode: 'rendered', }; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); -async function loadFiles() { - const { files } = await api('/api/files'); - state.files = files; - renderTree(); -} - +async function loadFiles() { state.files = (await api('/api/files')).files; renderTree(); } +async function loadPrompts() { state.prompts = (await api('/api/prompts')).files; if (state.view === 'prompts') renderTree(); } async function loadOptions() { const { options } = await api('/api/options'); $('fixOptions').innerHTML = options.map((o) => @@ -44,13 +40,11 @@ async function loadOptions() { ${esc(o.label)}`).join(''); } -function visibleFiles() { - if (!state.filter) return state.files; - const q = state.filter.toLowerCase(); - return state.files.filter((f) => f.path.toLowerCase().includes(q)); -} +const matchesFilter = (list) => state.filter ? list.filter((f) => f.path.toLowerCase().includes(state.filter.toLowerCase())) : list; +const visibleFiles = () => matchesFilter(state.files); +const visiblePrompts = () => matchesFilter(state.prompts); -// ── File tree ─────────────────────────────────────── +// ── File tree (shared by both views) ──────────────── function buildTree(files) { const root = { dirs: new Map(), files: [] }; for (const f of files) { @@ -65,6 +59,7 @@ function buildTree(files) { } return root; } +const FOLDER_ICON = ''; function fileRow(f) { const d = state.diag[f.path]; @@ -77,27 +72,35 @@ function fileRow(f) { ${esc(f.file)} ${indicatorsHTML(d)}${draft}`; } +function promptRow(f) { + const active = state.currentPrompt === f.path ? ' active' : ''; + return `
        + ${esc(f.file)}md
        `; +} -const FOLDER_ICON = ''; - -function renderNodes(node, depth, forceOpen) { +function renderNodes(node, depth, forceOpen, expanded, rowFn) { const pad = (n) => `style="padding-left:${8 + n * 14}px"`; let html = ''; for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) { - const open = forceOpen || state.expanded.has(dir.path); + const open = forceOpen || expanded.has(dir.path); html += `
        ${open ? '▾' : '▸'}${FOLDER_ICON}${esc(dir.name)}
        `; - if (open) html += `
        ${renderNodes(dir, depth + 1, forceOpen)}
        `; + if (open) html += `
        ${renderNodes(dir, depth + 1, forceOpen, expanded, rowFn)}
        `; } for (const f of node.files.sort((a, b) => a.file.localeCompare(b.file))) { - html += `
        ${fileRow(f)}
        `; + html += `
        ${rowFn(f)}
        `; } return html; } function renderTree() { - const forceOpen = !!state.filter; // when filtering, reveal all matches - $('fileTree').innerHTML = renderNodes(buildTree(visibleFiles()), 0, forceOpen); + const isEx = state.view === 'examples'; + const files = isEx ? visibleFiles() : visiblePrompts(); + const expanded = isEx ? state.expanded : state.promptExpanded; + const rowFn = isEx ? fileRow : promptRow; + const empty = !isEx && !files.length + ? '
        No prompts yet. Select example(s) and click Convert to prompt.
        ' : ''; + $('fileTree').innerHTML = empty || renderNodes(buildTree(files), 0, !!state.filter, expanded, rowFn); } function renderSummary() { @@ -124,8 +127,7 @@ function renderSummary() { async function scan() { $('scanBtn').disabled = true; $('scanBtn').textContent = 'Scanning…'; try { - const { results } = await api('/api/scan', { - method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); + const { results } = await api('/api/scan', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); state.diag = {}; for (const r of results) state.diag[r.path] = r; renderSummary(); @@ -135,19 +137,16 @@ async function scan() { } } -// ── Viewport (preview / code / diff) ──────────────── -function baseHrefFor(path) { - const slash = path.lastIndexOf('/'); - return slash === -1 ? '/' : '/' + path.slice(0, slash + 1); -} +// ── Viewport ──────────────────────────────────────── +function baseHrefFor(path) { const s = path.lastIndexOf('/'); return s === -1 ? '/' : '/' + path.slice(0, s + 1); } const fetchSource = (kind, path) => api(`/api/${kind}?path=${encodeURIComponent(path)}`).then((r) => r.source); +const fetchPrompt = (path) => api(`/api/prompt?path=${encodeURIComponent(path)}`).then((r) => r.source); const blankDoc = (label) => `${label}`; async function sourceFor(path, version) { if (version === 'draft') return state.drafts.has(path) ? fetchSource('draft', path) : null; return fetchSource('file', path); } - async function renderDiff(path) { const res = await fetch(`/api/diff?path=${encodeURIComponent(path)}`); if (!res.ok) { $('diff').innerHTML = '
        No draft for this file yet — fix it first.
        '; return; } @@ -160,29 +159,57 @@ async function renderDiff(path) { }).join(''); } +function renderTopbar() { + const mt = $('modeTabs'), vt = $('verTabs'); + if (state.view === 'examples') { + mt.innerHTML = ['preview', 'code', 'diff'].map((m) => + ``).join(''); + vt.style.display = ''; + for (const b of vt.querySelectorAll('.tab')) b.classList.toggle('active', b.dataset.ver === state.version); + $('topbar').classList.toggle('diff', state.mode === 'diff'); + } else { + mt.innerHTML = [['rendered', 'Rendered'], ['raw', 'Raw']].map(([m, lbl]) => + ``).join(''); + vt.style.display = 'none'; + $('topbar').classList.remove('diff'); + } +} + async function render() { - const { mode, version, current } = state; - for (const b of document.querySelectorAll('#modeTabs .tab')) b.classList.toggle('active', b.dataset.mode === mode); - for (const b of document.querySelectorAll('#verTabs .tab')) b.classList.toggle('active', b.dataset.ver === version); - $('topbar').classList.toggle('diff', mode === 'diff'); + renderTopbar(); + $('placeholder').querySelector('p').textContent = state.view === 'prompts' ? 'Select a prompt to view' : 'Select a file to preview'; + if (state.view === 'prompts') return renderPromptView(); + return renderExampleView(); +} +async function renderExampleView() { + const { mode, version, current } = state; + $('markdown').hidden = true; const has = !!current; $('placeholder').hidden = has; $('preview').hidden = !(has && mode === 'preview'); $('code').hidden = !(has && mode === 'code'); $('diff').hidden = !(has && mode === 'diff'); if (!has) return; - if (mode === 'diff') { renderDiff(current); return; } const src = await sourceFor(current, version); - if (mode === 'preview') { - $('preview').srcdoc = src === null ? blankDoc('No draft yet — fix this file first') : injectBase(src, baseHrefFor(current)); - } else { - $('code').textContent = src === null ? 'No draft yet — fix this file first.' : src; - } + if (mode === 'preview') $('preview').srcdoc = src === null ? blankDoc('No draft yet — fix this file first') : injectBase(src, baseHrefFor(current)); + else $('code').textContent = src === null ? 'No draft yet — fix this file first.' : src; +} + +async function renderPromptView() { + $('preview').hidden = true; $('diff').hidden = true; + const has = !!state.currentPrompt; + $('placeholder').hidden = has; + $('markdown').hidden = !(has && state.promptMode === 'rendered'); + $('code').hidden = !(has && state.promptMode === 'raw'); + if (!has) return; + const src = await fetchPrompt(state.currentPrompt); + if (state.promptMode === 'rendered') $('markdown').innerHTML = mdToHtml(src); + else $('code').textContent = src; } -// ── Live fix progress (SSE) ───────────────────────── +// ── Live progress (SSE) ───────────────────────────── let progTimer = null; function renderProgress() { const p = state.progress; @@ -198,92 +225,121 @@ function renderProgress() { : ''; const t = `${path}${st.error ? ' — ' + st.error : ''}`; const via = st.via ? `${esc(st.via)}` : ''; - const short = path.split('/').pop(); - return `
        ${mk}${esc(short)}${via}
        `; + return `
        ${mk}${esc(path.split('/').pop())}${via}
        `; }).join(''); $('fixProgress').innerHTML = `
        ${head}
        ${items}
        `; } -function applyResult(r) { - if (!state.progress) return; - state.progress.items.set(r.path, { status: r.status, error: r.error, via: r.via }); - state.progress.done++; - if (r.status !== 'fixFailed') state.drafts.add(r.path); - renderProgress(); - renderTree(); -} - function appendLog(path, text) { state.logs.set(path, (state.logs.get(path) || '') + text); if (state.activity.follow) state.activity.file = path; if (state.activity.open) renderActivity(); } -function handleFrame(frame) { - const ev = /event:\s*(.+)/.exec(frame); - const dt = /data:\s*([\s\S]+)/.exec(frame); - if (!ev || !dt) return; - let data; try { data = JSON.parse(dt[1]); } catch { return; } - const type = ev[1].trim(); - if (type === 'result') applyResult(data); - else if (type === 'log') appendLog(data.path, data.text); +async function streamSSE(res, onEvent) { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { + const frame = buf.slice(0, i); buf = buf.slice(i + 2); + const ev = /event:\s*(.+)/.exec(frame); + const dt = /data:\s*([\s\S]+)/.exec(frame); + if (!ev || !dt) continue; + let d; try { d = JSON.parse(dt[1]); } catch { continue; } + onEvent(ev[1].trim(), d); + } + } } -async function runFix() { - const paths = [...state.selected]; - if (!paths.length) { $('applyStatus').textContent = 'Select files first.'; return; } - const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); - const customPrompt = $('customPrompt').value; +function startRun(paths) { state.progress = { running: true, total: paths.length, done: 0, startedAt: Date.now(), endedAt: null, items: new Map(paths.map((p) => [p, { status: 'pending' }])) }; state.logs = new Map(); state.activity.follow = true; if (state.activity.open) renderActivity(); - $('fixBtn').disabled = true; $('applyStatus').textContent = ''; renderProgress(); progTimer = setInterval(renderProgress, 500); +} +function endRun() { + state.progress.running = false; + state.progress.endedAt = Date.now(); + clearInterval(progTimer); + renderProgress(); +} + +function applyResult(r) { + if (!state.progress) return; + state.progress.items.set(r.path, { status: r.status, error: r.error, via: r.via }); + state.progress.done++; + if (r.status !== 'fixFailed') state.drafts.add(r.path); + renderProgress(); + renderTree(); +} +function applyConvertResult(r) { + if (!state.progress) return; + const status = r.status === 'converted' ? 'fixed' : r.status === 'failed' ? 'fixFailed' : r.status; + state.progress.items.set(r.path, { status, error: r.error, via: r.via }); + state.progress.done++; + renderProgress(); +} + +async function runFix() { + const paths = [...state.selected]; + if (!paths.length) { $('applyStatus').textContent = 'Select files first.'; return; } + const optionIds = [...document.querySelectorAll('input[name=opt]:checked')].map((c) => c.value); + const customPrompt = $('customPrompt').value; + startRun(paths); + $('fixBtn').disabled = true; $('convertBtn').disabled = true; $('applyStatus').textContent = ''; try { const res = await fetch('/api/fix', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ paths, optionIds, customPrompt }) }); if (res.body && res.headers.get('content-type')?.includes('text/event-stream')) { - const reader = res.body.getReader(); - const dec = new TextDecoder(); - let buf = ''; - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - buf += dec.decode(value, { stream: true }); - let i; - while ((i = buf.indexOf('\n\n')) >= 0) { handleFrame(buf.slice(0, i)); buf = buf.slice(i + 2); } - } - } else { - const data = await res.json(); - (data.results || []).forEach(applyResult); - } + await streamSSE(res, (type, d) => { if (type === 'result') applyResult(d); else if (type === 'log') appendLog(d.path, d.text); }); + } else { (await res.json()).results?.forEach(applyResult); } } finally { - state.progress.running = false; - state.progress.endedAt = Date.now(); - clearInterval(progTimer); - renderProgress(); - $('fixBtn').disabled = false; + endRun(); + $('fixBtn').disabled = false; $('convertBtn').disabled = false; renderTree(); - if (state.current && state.drafts.has(state.current)) state.version = 'draft'; + if (state.view === 'examples' && state.current && state.drafts.has(state.current)) state.version = 'draft'; render(); } } +async function runConvert() { + const paths = [...state.selected]; + if (!paths.length) { $('applyStatus').textContent = 'Select example files first.'; return; } + startRun(paths); + $('fixBtn').disabled = true; $('convertBtn').disabled = true; $('applyStatus').textContent = ''; + try { + const res = await fetch('/api/convert', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ paths }) }); + if (res.body && res.headers.get('content-type')?.includes('text/event-stream')) { + await streamSSE(res, (type, d) => { if (type === 'result') applyConvertResult(d); else if (type === 'log') appendLog(d.path, d.text); }); + } else { (await res.json()).results?.forEach(applyConvertResult); } + } finally { + endRun(); + $('fixBtn').disabled = false; $('convertBtn').disabled = false; + await loadPrompts(); + $('applyStatus').textContent += ' Prompts updated — see the Prompts tab.'; + } +} + async function applyOrDiscard(endpoint) { const paths = [...state.selected].filter((p) => state.drafts.has(p)); if (!paths.length) { $('applyStatus').textContent = 'No drafts in selection.'; return; } - const data = await api(`/api/${endpoint}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); + const data = await api(`/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ paths }) }); const results = data.results || []; const succeeded = results.filter((r) => r.ok).map((r) => r.path); const failed = results.filter((r) => !r.ok); for (const p of succeeded) state.drafts.delete(p); - const verb = endpoint === 'apply' ? 'Applied' : 'Discarded'; - let msg = `${verb} ${succeeded.length} draft(s).`; + let msg = `${endpoint === 'apply' ? 'Applied' : 'Discarded'} ${succeeded.length} draft(s).`; if (failed.length) msg += ` Failed ${failed.length}: ${failed.map((r) => r.path).join(', ')}`; $('applyStatus').textContent = msg; renderTree(); @@ -304,7 +360,7 @@ function renderActivity() { const running = state.progress && state.progress.running; body.textContent = running ? 'Waiting for the model to start streaming…\n\nThe claude CLI takes a few seconds to spin up before the first token. If nothing appears after that, your validator server may predate this feature — restart it (Ctrl-C, then `npm start`).' - : 'No agent output yet. Run a fix that needs the model.\n\nMechanical fixes (version pin, tag rename) are done by the deterministic codemod and produce no reasoning — only semantic fixes (convert to interact, convert customEffect, remove JS) or a custom prompt call the model.'; + : 'No agent output yet. Run a fix or a convert that needs the model.\n\nMechanical fixes (version pin, tag rename) are done by the deterministic codemod and produce no reasoning.'; } else { body.textContent = state.logs.get(state.activity.file) || '(waiting for output…)'; body.scrollTop = body.scrollHeight; @@ -317,42 +373,55 @@ function closeActivity() { state.activity.open = false; $('activityModal').hidde $('fileTree').addEventListener('click', (e) => { const folder = e.target.closest('.folder-row'); if (folder) { + const set = state.view === 'examples' ? state.expanded : state.promptExpanded; const p = folder.dataset.folder; - if (state.expanded.has(p)) state.expanded.delete(p); else state.expanded.add(p); + if (set.has(p)) set.delete(p); else set.add(p); renderTree(); return; } const row = e.target.closest('.file-row'); if (!row) return; - const path = row.dataset.path; - if (e.target.classList.contains('cb')) { - if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); - return; + if (state.view === 'examples') { + const path = row.dataset.path; + if (e.target.classList.contains('cb')) { + if (state.selected.has(path)) state.selected.delete(path); else state.selected.add(path); + return; + } + state.current = path; renderTree(); render(); + } else { + state.currentPrompt = row.dataset.ppath; renderTree(); render(); } - state.current = path; - renderTree(); - render(); }); $('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderTree(); }); $('scanBtn').onclick = scan; $('selectAllBtn').onclick = () => { const vis = visibleFiles(); - const allSelected = vis.length && vis.every((f) => state.selected.has(f.path)); - if (allSelected) vis.forEach((f) => state.selected.delete(f.path)); - else vis.forEach((f) => state.selected.add(f.path)); + const all = vis.length && vis.every((f) => state.selected.has(f.path)); + if (all) vis.forEach((f) => state.selected.delete(f.path)); else vis.forEach((f) => state.selected.add(f.path)); renderTree(); }; $('fixBtn').onclick = runFix; +$('convertBtn').onclick = runConvert; $('applyBtn').onclick = () => applyOrDiscard('apply'); $('discardBtn').onclick = () => applyOrDiscard('discard'); -for (const b of document.querySelectorAll('#modeTabs .tab')) b.onclick = () => { state.mode = b.dataset.mode; render(); }; -for (const b of document.querySelectorAll('#verTabs .tab')) b.onclick = () => { state.version = b.dataset.ver; render(); }; - -// panel collapse +$('modeTabs').addEventListener('click', (e) => { + const b = e.target.closest('.tab'); if (!b) return; + if (state.view === 'examples') state.mode = b.dataset.mode; else state.promptMode = b.dataset.mode; + render(); +}); +$('verTabs').addEventListener('click', (e) => { + const b = e.target.closest('.tab'); if (!b) return; + state.version = b.dataset.ver; render(); +}); +for (const b of document.querySelectorAll('#viewTabs .vt')) b.onclick = () => { + state.view = b.dataset.view; + for (const x of document.querySelectorAll('#viewTabs .vt')) x.classList.toggle('active', x === b); + if (state.view === 'prompts') loadPrompts(); + renderTree(); + render(); +}; $('toggleLeft').onclick = () => $('listPane').classList.toggle('collapsed'); $('toggleRight').onclick = () => $('fixPane').classList.toggle('collapsed'); - -// activity modal $('activityBtn').onclick = openActivity; $('activityClose').onclick = closeActivity; $('activityModal').addEventListener('click', (e) => { if (e.target.id === 'activityModal') closeActivity(); }); @@ -360,3 +429,5 @@ $('activityFile').onchange = (e) => { state.activity.file = e.target.value; stat loadFiles(); loadOptions(); +loadPrompts(); +renderTopbar(); diff --git a/validator/public/index.html b/validator/public/index.html index b4861a5..ad173f4 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -12,6 +12,7 @@ +

        Select a file to preview

        @@ -43,6 +44,10 @@
        +
        + + +
        @@ -53,6 +58,7 @@

        Fix options

        +
        diff --git a/validator/public/md.js b/validator/public/md.js new file mode 100644 index 0000000..c79f269 --- /dev/null +++ b/validator/public/md.js @@ -0,0 +1,62 @@ +// Minimal, dependency-free Markdown → HTML renderer. Supports the constructs +// the convert-interact guideline uses: headings, fenced code, GFM pipe tables, +// unordered/ordered lists, hr, paragraphs, and inline code/bold/italic/links. +const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +function inline(s) { + return esc(s) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +const isTableSep = (l) => /^\s*\|?[\s:-]*-{2,}[\s:|-]*\|?\s*$/.test(l) && l.includes('-'); + +export function mdToHtml(md) { + const lines = String(md).replace(/\r\n/g, '\n').split('\n'); + let html = '', i = 0; + while (i < lines.length) { + const l = lines[i]; + if (/^\s*```/.test(l)) { + i++; let code = ''; + while (i < lines.length && !/^\s*```/.test(lines[i])) { code += lines[i] + '\n'; i++; } + i++; + html += `
        ${esc(code.replace(/\n$/, ''))}
        `; + continue; + } + const h = l.match(/^(#{1,6})\s+(.*)$/); + if (h) { const n = h[1].length; html += `${inline(h[2])}`; i++; continue; } + if (/^\s*([-*_])\1{2,}\s*$/.test(l)) { html += '
        '; i++; continue; } + if (l.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1])) { + const parseRow = (r) => r.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); + const headers = parseRow(l); i += 2; + const rows = []; + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(parseRow(lines[i])); i++; } + html += '' + headers.map((c) => ``).join('') + '' + + rows.map((r) => '' + r.map((c) => ``).join('') + '').join('') + '
        ${inline(c)}
        ${inline(c)}
        '; + continue; + } + if (/^\s*[-*]\s+/.test(l)) { + const items = []; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s+/, '')); i++; } + html += '
          ' + items.map((it) => `
        • ${inline(it)}
        • `).join('') + '
        '; + continue; + } + if (/^\s*\d+\.\s+/.test(l)) { + const items = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s+/, '')); i++; } + html += '
          ' + items.map((it) => `
        1. ${inline(it)}
        2. `).join('') + '
        '; + continue; + } + if (!l.trim()) { i++; continue; } + const para = []; + while (i < lines.length && lines[i].trim() + && !/^\s*(#{1,6}\s|```|[-*]\s|\d+\.\s)/.test(lines[i]) + && !(lines[i].includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]))) { + para.push(lines[i]); i++; + } + html += `

        ${inline(para.join(' '))}

        `; + } + return html; +} diff --git a/validator/public/styles.css b/validator/public/styles.css index 6033325..05bf6e8 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -67,6 +67,28 @@ body { #placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--text-3); } #placeholder .ic { font-size: 52px; opacity: .35; } +/* Rendered markdown (prompt guideline view) */ +#markdown { inset: 68px 332px 16px 322px; overflow: auto; padding: 26px 30px; + background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--hair); border-radius: var(--radius); box-shadow: var(--shadow); + color: var(--text); font-size: 13.5px; line-height: 1.6; } +#markdown h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: -0.02em; } +#markdown h2 { font-size: 16px; margin: 24px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--hair); } +#markdown h3 { font-size: 13.5px; margin: 18px 0 6px; } +#markdown p { margin: 8px 0; color: var(--text-2); } +#markdown ul, #markdown ol { margin: 8px 0; padding-left: 22px; color: var(--text-2); } +#markdown li { margin: 3px 0; } +#markdown code { font-family: var(--mono); font-size: 12px; background: var(--fill-2); padding: 1px 5px; border-radius: 5px; color: #e6c07b; } +#markdown pre.md-code { background: rgba(0,0,0,0.35); border: 1px solid var(--hair); border-radius: var(--radius-xs); padding: 12px 14px; overflow: auto; margin: 10px 0; } +#markdown pre.md-code code { background: none; padding: 0; color: #d6deeb; } +#markdown table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 12.5px; } +#markdown th, #markdown td { border: 1px solid var(--hair); padding: 7px 10px; text-align: left; vertical-align: top; } +#markdown th { background: var(--fill-1); font-weight: 600; } +#markdown td { color: var(--text-2); } +#markdown hr { border: 0; border-top: 1px solid var(--hair); margin: 18px 0; } +#markdown a { color: #6f9bff; } +#markdown strong { color: var(--text); } + /* ── Floating tab groups (top center) ────────────── */ #topbar { position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 60; display: flex; gap: 10px; } #topbar.diff #verTabs { display: none; } @@ -142,8 +164,17 @@ body { } .search::placeholder { color: var(--text-3); } .search:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-2); } +/* ── Examples / Prompts view tabs ────────────────── */ +.view-tabs { display: flex; gap: 4px; padding: 0 16px 10px; } +.vt { flex: 1; font-family: inherit; font-size: 12px; font-weight: 500; color: var(--text-2); + background: var(--fill-1); border: 0; border-radius: var(--radius-xs); padding: 6px 0; cursor: pointer; transition: all .15s; } +.vt:hover { background: var(--fill-2); color: var(--text); } +.vt.active { background: var(--fill-3); color: var(--text); } + /* ── File tree ───────────────────────────────────── */ #fileTree { overflow-y: auto; flex: 1; padding: 0 8px 12px; } +.md-badge { font-size: 9.5px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; flex: none; + color: #c9b8ff; background: rgba(168,85,247,0.2); padding: 2px 6px; border-radius: 980px; } .folder-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-xs); cursor: pointer; color: var(--text-2); transition: background .12s; user-select: none; } .folder-row:hover { background: var(--fill-1); color: var(--text); } .folder-row .chev { width: 12px; font-size: 10px; flex: none; opacity: .8; } diff --git a/validator/test/md.test.js b/validator/test/md.test.js new file mode 100644 index 0000000..a7cd949 --- /dev/null +++ b/validator/test/md.test.js @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mdToHtml } from '../public/md.js'; + +test('renders headings and inline styles', () => { + const h = mdToHtml('# Title\n\nsome **bold** and `code` here'); + assert.match(h, /

        Title<\/h1>/); + assert.match(h, /bold<\/strong>/); + assert.match(h, /code<\/code>/); +}); + +test('renders a fenced code block with escaping', () => { + const h = mdToHtml('```ts\nconst x = a < b;\n```'); + assert.match(h, /
        const x = a < b;<\/code><\/pre>/);
        +});
        +
        +test('renders a GFM pipe table', () => {
        +  const h = mdToHtml('| Role | Guidance |\n| --- | --- |\n| card | move it |');
        +  assert.match(h, //);
        +  assert.match(h, /
        Role<\/th>/); + assert.match(h, /card<\/td>/); + assert.match(h, /move it<\/td>/); +}); + +test('renders unordered and ordered lists', () => { + assert.match(mdToHtml('- a\n- b'), /
        • a<\/li>
        • b<\/li><\/ul>/); + assert.match(mdToHtml('1. first\n2. second'), /
          1. first<\/li>
          2. second<\/li><\/ol>/); +}); + +test('wraps loose text in paragraphs and escapes html', () => { + const h = mdToHtml('a + +``` + +Run: `cd validator && PORT=4790 node server.js &` then `sleep 1 && curl -s localhost:4790/vendor/_smoke.html | grep -c createExperience` (serves via existing static). Expected: `1`. (A deeper pixel check happens in the Task 6 manual smoke.) Then `rm validator/vendor/_smoke.html` and kill the server. + +- [ ] **Step 6: Commit** + +```bash +git add validator/package.json validator/package-lock.json validator/scripts/build-vendor.mjs validator/vendor/render-runtime.js validator/vendor/experience.schema.json +git commit -m "feat(validator): vendor build — bundle interact-experience renderer + schema" +``` + +--- + +### Task 2: Constants + playground client (`playground.js`) + +**Files:** +- Modify: `validator/lib/constants.js` +- Create: `validator/lib/playground.js` +- Test: `validator/test/playground.test.js` + +**Interfaces:** +- Consumes: `buildGenerate` (dynamic import from `/packages/interact-experience-prompt/dist/es/index.js`); vendored `validator/vendor/experience.schema.json`. +- Produces: + - `PLAYGROUND_REPO`, `PLAYGROUND_URL`, `SECTION_INSTRUCTION` (constants.js). + - `assemblePayload({ buildGenerate, schema, html, css, guideline }) -> { user_input, system_rules }` (pure). + - `listSections(sectionsDir?) -> Promise>`. + - `buildPayload({ html, css, guideline }) -> Promise<{ user_input, system_rules }>`. + - `generate({ html, css, guideline }, { playgroundUrl?, fetchImpl? }) -> Promise<{ config, sessionId }>`. + - `pingStatus({ playgroundUrl?, fetchImpl? }) -> Promise`. + +- [ ] **Step 1: Add constants** + +In `validator/lib/constants.js` append: + +```js +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const PLAYGROUND_REPO = process.env.PLAYGROUND_REPO || join(homedir(), 'Documents/Dev/Wix/interact-xp'); +export const PLAYGROUND_URL = process.env.PLAYGROUND_URL || 'http://localhost:5173'; +export const SECTION_INSTRUCTION = + 'Apply the animation pattern described in the example to this section. Follow its Selector Contract and Interact Template, adapting the roles to this section’s DOM. Return only the experience config.'; +``` + +- [ ] **Step 2: Write the failing tests** + +```js +// validator/test/playground.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { assemblePayload, listSections, generate, pingStatus } from '../lib/playground.js'; + +test('assemblePayload routes guideline→userPromptExample, instruction→userPrompt, embeds schema', () => { + const calls = []; + const buildGenerate = (args) => { calls.push(args); return { system: 'SYS', user: 'USR' }; }; + const out = assemblePayload({ buildGenerate, schema: { s: 1 }, html: '', css: 'c', guideline: 'GUIDE' }); + assert.deepEqual(out, { user_input: 'USR', system_rules: 'SYS' }); + assert.equal(calls[0].userPromptExample, 'GUIDE'); + assert.equal(calls[0].html, ''); + assert.equal(calls[0].css, 'c'); + assert.deepEqual(calls[0].schema, { s: 1 }); + assert.match(calls[0].userPrompt, /Apply the animation pattern/); +}); + +test('listSections reads section html/css (sanitized preferred)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'iv-sec-')); + await mkdir(join(dir, 'cards'), { recursive: true }); + await writeFile(join(dir, 'cards', 'section.html'), ''); + await writeFile(join(dir, 'cards', 'section.sanitized.html'), ''); + await writeFile(join(dir, 'cards', 'section.css'), '.c{}'); + await mkdir(join(dir, 'hero'), { recursive: true }); + await writeFile(join(dir, 'hero', 'section.html'), ''); + const secs = await listSections(dir); + const cards = secs.find((s) => s.id === 'cards'); + assert.equal(cards.html, ''); // sanitized preferred + assert.equal(cards.css, '.c{}'); + const hero = secs.find((s) => s.id === 'hero'); + assert.equal(hero.html, ''); + assert.equal(hero.css, ''); // missing css → empty +}); + +test('generate POSTs the payload and returns config+sessionId', async () => { + const fetchImpl = async (url, opts) => { + assert.match(url, /\/api\/generate$/); + const body = JSON.parse(opts.body); + assert.ok(body.user_input && body.system_rules); + return { ok: true, json: async () => ({ config: '{"x":1}', sessionId: 'sess1' }) }; + }; + const out = await generate({ html: '', css: 'c', guideline: 'g' }, + { playgroundUrl: 'http://x', fetchImpl, buildGenerateImpl: () => ({ system: 'S', user: 'U' }), schemaImpl: {} }); + assert.deepEqual(out, { config: '{"x":1}', sessionId: 'sess1' }); +}); + +test('pingStatus is false when the server is unreachable', async () => { + const fetchImpl = async () => { throw new Error('ECONNREFUSED'); }; + assert.equal(await pingStatus({ playgroundUrl: 'http://127.0.0.1:59999', fetchImpl }), false); +}); +``` + +- [ ] **Step 2b: Run to verify failure** + +Run: `cd validator && node --test test/playground.test.js` +Expected: FAIL — `Cannot find module '../lib/playground.js'`. + +- [ ] **Step 3: Implement `validator/lib/playground.js`** + +```js +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PLAYGROUND_REPO, PLAYGROUND_URL, SECTION_INSTRUCTION } from './constants.js'; + +const SECTIONS_DIR = join(PLAYGROUND_REPO, 'apps/playground/src/sections'); +const PROMPT_DIST = join(PLAYGROUND_REPO, 'packages/interact-experience-prompt/dist/es/index.js'); +const SCHEMA_PATH = new URL('../vendor/experience.schema.json', import.meta.url); + +// Pure: given the playground's buildGenerate + schema, produce the request body. +export function assemblePayload({ buildGenerate, schema, html, css, guideline }) { + const prompt = buildGenerate({ html, css, userPrompt: SECTION_INSTRUCTION, userPromptExample: guideline, schema }); + return { user_input: prompt.user, system_rules: prompt.system }; +} + +export async function listSections(sectionsDir = SECTIONS_DIR) { + let entries; + try { entries = await readdir(sectionsDir, { withFileTypes: true }); } + catch { return []; } + const out = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const dir = join(sectionsDir, e.name); + const read = async (f) => { try { return await readFile(join(dir, f), 'utf8'); } catch { return null; } }; + const html = (await read('section.sanitized.html')) ?? (await read('section.html')); + if (html === null) continue; + out.push({ id: e.name, html, css: (await read('section.css')) ?? '' }); + } + return out.sort((a, b) => a.id.localeCompare(b.id)); +} + +async function loadBuildGenerate() { + const mod = await import(pathToFileURL(PROMPT_DIST).href); + return mod.buildGenerate; +} +async function loadSchema() { + return JSON.parse(await readFile(SCHEMA_PATH, 'utf8')); +} + +export async function buildPayload({ html, css, guideline }) { + const [buildGenerate, schema] = await Promise.all([loadBuildGenerate(), loadSchema()]); + return assemblePayload({ buildGenerate, schema, html, css, guideline }); +} + +export async function generate({ html, css, guideline }, + { playgroundUrl = PLAYGROUND_URL, fetchImpl = fetch, buildGenerateImpl, schemaImpl } = {}) { + const buildGenerate = buildGenerateImpl || (await loadBuildGenerate()); + const schema = schemaImpl || (await loadSchema()); + const body = assemblePayload({ buildGenerate, schema, html, css, guideline }); + const res = await fetchImpl(`${playgroundUrl}/api/generate`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error(`playground /api/generate returned ${res.status}`); + const data = await res.json(); + return { config: data.config, sessionId: data.sessionId }; +} + +export async function pingStatus({ playgroundUrl = PLAYGROUND_URL, fetchImpl = fetch } = {}) { + try { const res = await fetchImpl(playgroundUrl, { method: 'GET' }); return !!res && (res.ok || res.status < 500); } + catch { return false; } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd validator && node --test test/playground.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/constants.js validator/lib/playground.js validator/test/playground.test.js +git commit -m "feat(validator): playground client (sections, payload, generate, status)" +``` + +--- + +### Task 3: Loop history store (`loop-store.js`) + raw prompt writer + +**Files:** +- Modify: `validator/lib/prompts.js` (add `writePromptRaw`) +- Create: `validator/lib/loop-store.js` +- Test: `validator/test/loop-store.test.js` + +**Interfaces:** +- Consumes: `readPrompt`, `writePromptRaw` from `prompts.js`; `PROMPTS_DIR` from constants. +- Produces: + - `writePromptRaw(rootDir, promptRel, content) -> Promise` (writes `PROMPTS_DIR/promptRel`, path-safe). + - `readLoop(rootDir, promptRel) -> Promise<{ working, rounds }>` — `working` defaults to the prompt's current `.md` text, `rounds` defaults to `[]`. + - `recordRound(rootDir, promptRel, { guideline, sections, score, notes, newWorking }) -> Promise<{ round }>`. + - `rollback(rootDir, promptRel, round) -> Promise<{ working }>`. + - `finalize(rootDir, promptRel) -> Promise` — writes `working` to the prompt's `.md`. + - Round shape: `{ round: number, guideline: string, sections: [{ id, config }], score: number, notes: string }`. + +- [ ] **Step 1: Add `writePromptRaw` to `prompts.js`** + +In `validator/lib/prompts.js`, after `readPrompt`, add (reusing the existing private `promptAbs` + `mkdir`/`dirname` already imported): + +```js +export async function writePromptRaw(rootDir, rel, content) { + const abs = promptAbs(rootDir, rel); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, 'utf8'); +} +``` + +(If `mkdir`/`dirname`/`writeFile` aren't already imported in prompts.js, add them to its `node:fs/promises` / `node:path` imports — `writePrompt` already uses them, so they are.) + +- [ ] **Step 2: Write the failing tests** + +```js +// validator/test/loop-store.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writePrompt, readPrompt } from '../lib/prompts.js'; +import { readLoop, recordRound, rollback, finalize } from '../lib/loop-store.js'; + +async function repoWithPrompt() { + const root = await mkdtemp(join(tmpdir(), 'iv-loop-')); + await writePrompt(root, 'G/Card.html', '# V0 guideline'); // creates G/Card.md + return root; +} + +test('readLoop defaults working to the .md and rounds to []', async () => { + const root = await repoWithPrompt(); + const loop = await readLoop(root, 'G/Card.md'); + assert.equal(loop.working, '# V0 guideline'); + assert.deepEqual(loop.rounds, []); +}); + +test('recordRound appends a round and updates working', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { + guideline: '# V0 guideline', sections: [{ id: 'cards', config: '{}' }], score: 6, notes: 'more spread', newWorking: '# V1 guideline' }); + const loop = await readLoop(root, 'G/Card.md'); + assert.equal(loop.working, '# V1 guideline'); + assert.equal(loop.rounds.length, 1); + assert.equal(loop.rounds[0].round, 1); + assert.equal(loop.rounds[0].score, 6); + assert.equal(loop.rounds[0].sections[0].id, 'cards'); +}); + +test('rollback sets working back to a round guideline', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { guideline: '# V0 guideline', sections: [], score: 5, notes: '', newWorking: '# V1' }); + await recordRound(root, 'G/Card.md', { guideline: '# V1', sections: [], score: 7, notes: '', newWorking: '# V2' }); + const { working } = await rollback(root, 'G/Card.md', 1); + assert.equal(working, '# V0 guideline'); // round 1's guideline field + assert.equal((await readLoop(root, 'G/Card.md')).working, '# V0 guideline'); +}); + +test('finalize writes working back to the .md', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { guideline: '# V0 guideline', sections: [], score: 9, notes: '', newWorking: '# FINAL' }); + await finalize(root, 'G/Card.md'); + assert.equal(await readPrompt(root, 'G/Card.md'), '# FINAL'); +}); +``` + +- [ ] **Step 2b: Run to verify failure** + +Run: `cd validator && node --test test/loop-store.test.js` +Expected: FAIL — `Cannot find module '../lib/loop-store.js'`. + +- [ ] **Step 3: Implement `validator/lib/loop-store.js`** + +```js +import { readPrompt, writePromptRaw } from './prompts.js'; + +const historyRel = (promptRel) => `${promptRel}.history.json`; + +export async function readLoop(rootDir, promptRel) { + const raw = await readPrompt(rootDir, historyRel(promptRel)); + if (raw !== null) { + try { + const parsed = JSON.parse(raw); + return { working: parsed.working, rounds: parsed.rounds || [] }; + } catch { /* fall through to defaults */ } + } + const md = await readPrompt(rootDir, promptRel); + return { working: md ?? '', rounds: [] }; +} + +async function save(rootDir, promptRel, loop) { + await writePromptRaw(rootDir, historyRel(promptRel), JSON.stringify(loop, null, 2)); +} + +export async function recordRound(rootDir, promptRel, { guideline, sections, score, notes, newWorking }) { + const loop = await readLoop(rootDir, promptRel); + const round = loop.rounds.length + 1; + loop.rounds.push({ round, guideline, sections: sections || [], score, notes }); + loop.working = newWorking; + await save(rootDir, promptRel, loop); + return { round }; +} + +export async function rollback(rootDir, promptRel, round) { + const loop = await readLoop(rootDir, promptRel); + const target = loop.rounds.find((r) => r.round === round); + if (!target) throw new Error(`no round ${round}`); + loop.working = target.guideline; + await save(rootDir, promptRel, loop); + return { working: loop.working }; +} + +export async function finalize(rootDir, promptRel) { + const loop = await readLoop(rootDir, promptRel); + await writePromptRaw(rootDir, promptRel, loop.working); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd validator && node --test test/loop-store.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/prompts.js validator/lib/loop-store.js validator/test/loop-store.test.js +git commit -m "feat(validator): loop history store (rounds, rollback, finalize)" +``` + +--- + +### Task 4: Guideline refiner (`refine.js`) + +**Files:** +- Create: `validator/lib/refine.js` +- Test: `validator/test/refine.test.js` + +**Interfaces:** +- Consumes: `runAgent` from `agent.js` (injectable for tests). +- Produces: + - `buildRefinePrompt({ guideline, score, notes }) -> { system, user }`. + - `refineGuideline({ guideline, score, notes, onDelta, runAgent }) -> Promise` (fence-stripped markdown). + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/refine.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRefinePrompt, refineGuideline } from '../lib/refine.js'; + +test('buildRefinePrompt forbids overfitting and embeds score+notes+guideline', () => { + const { system, user } = buildRefinePrompt({ guideline: '# G', score: 6, notes: 'more spread' }); + assert.match(system, /general/i); + assert.match(system, /do not overfit|not overfit/i); + assert.match(system, /ONLY the (full )?updated guideline/i); + assert.match(user, /6\/10/); + assert.match(user, /more spread/); + assert.match(user, /# G/); +}); + +test('refineGuideline returns fence-stripped markdown from the agent', async () => { + const out = await refineGuideline({ guideline: '# G', score: 5, notes: 'n', + runAgent: async () => '```markdown\n# G v2\nbody\n```' }); + assert.equal(out, '# G v2\nbody'); +}); + +test('refineGuideline passes an onDelta through to runAgent', async () => { + let sawOpts = null; + await refineGuideline({ guideline: '# G', score: 5, notes: 'n', onDelta: () => {}, + runAgent: async (s, u, opts) => { sawOpts = opts; return '# ok'; } }); + assert.equal(typeof sawOpts.onDelta, 'function'); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd validator && node --test test/refine.test.js` +Expected: FAIL — `Cannot find module '../lib/refine.js'`. + +- [ ] **Step 3: Implement `validator/lib/refine.js`** + +```js +import { runAgent as realRunAgent } from './agent.js'; + +const SYSTEM = `You refine a GENERAL @wix/interact animation guideline based on holistic, cross-section feedback from a reviewer who applied it to several different sections. + +RULES: +- The guideline must stay GENERAL and reusable across many sections. Do NOT overfit to any single generated output or section. +- Keep every section of the guideline intact and general (Summary, Selector Contract, Role Guidance, Adaptation Notes, Required Elements, Required Styles, Suggested Controls, Interact Template). +- Improve it to address the feedback at the pattern level — adjust roles, formulas, adaptation notes, controls, or the interact template as needed. + +OUTPUT CONTRACT: Return ONLY the full updated guideline as raw markdown — no code fence around the whole document, no preamble, no commentary. Begin with the "# " H1.`; + +function stripFence(text) { + const t = String(text).trim(); + const m = t.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + return (m ? m[1] : t).trim(); +} + +export function buildRefinePrompt({ guideline, score, notes }) { + const user = `Reviewer score: ${score}/10 + +Reviewer notes (holistic, not specific to one output): +${notes || '(none)'} + +Current guideline to improve: +${guideline}`; + return { system: SYSTEM, user }; +} + +export async function refineGuideline({ guideline, score, notes, onDelta, model, runAgent = realRunAgent }) { + const { system, user } = buildRefinePrompt({ guideline, score, notes }); + return stripFence(await runAgent(system, user, { model, onDelta })); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd validator && node --test test/refine.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/refine.js validator/test/refine.test.js +git commit -m "feat(validator): guideline refiner (general, no-overfit)" +``` + +--- + +### Task 5: Server endpoints + serve vendor + +**Files:** +- Modify: `validator/server.js` +- Test: `validator/test/server.test.js` (append) + +**Interfaces:** +- Consumes: everything from Tasks 2–4; `listPrompts`/`readPrompt` (existing). +- Produces endpoints: + - `GET /api/playground/status` → `{ up }` + - `GET /api/playground/sections` → `{ sections: [{ id }] }` + - `GET /api/loop?promptPath=` → `{ working, rounds }` + - `POST /api/loop/run` `{ promptPath, sections }` → SSE (`start`/`result {id,config|error}`/`log`/`done`) using the loop's **working** guideline + - `POST /api/loop/refine` `{ promptPath, score, notes, sections, configs }` → SSE (`log`/`done {guideline}`); records the round + - `POST /api/loop/finalize` `{ promptPath }` → `{ ok: true }` + - Static: `validator/vendor/` served at `/vendor/`. + +- [ ] **Step 1: Add imports + static mount + endpoints in `server.js`** + +Add imports near the others: + +```js +import { listSections, generate, pingStatus } from './lib/playground.js'; +import { readLoop, recordRound, rollback, finalize } from './lib/loop-store.js'; +import { refineGuideline } from './lib/refine.js'; +import { readPrompt } from './lib/prompts.js'; +``` + +After the existing `express.static(join(__dirname, 'public'))` line, add: + +```js + app.use('/vendor', express.static(join(__dirname, 'vendor'))); +``` + +Before `return app;`, add: + +```js + app.get('/api/playground/status', async (_req, res) => { res.json({ up: await pingStatus({}) }); }); + + app.get('/api/playground/sections', async (_req, res) => { + const sections = await listSections(); + res.json({ sections: sections.map((s) => ({ id: s.id })) }); + }); + + app.get('/api/loop', async (req, res) => { + try { res.json(await readLoop(root, String(req.query.promptPath))); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/loop/run', async (req, res) => { + const { promptPath, sections } = req.body; + if (!promptPath || !Array.isArray(sections) || !sections.length) return bad(res, 'promptPath and sections required'); + const { working } = await readLoop(root, promptPath); + const all = await listSections(); + const chosen = all.filter((s) => sections.includes(s.id)); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + send('start', { sections: chosen.map((s) => s.id) }); + await Promise.all(chosen.map(async (s) => { + try { + const { config } = await generate({ html: s.html, css: s.css, guideline: working }); + send('result', { id: s.id, config, html: s.html, css: s.css }); + } catch (err) { + send('result', { id: s.id, error: String(err.message || err) }); + } + })); + send('done', { ok: true }); + res.end(); + }); + + app.post('/api/loop/refine', async (req, res) => { + const { promptPath, score, notes, sections, configs } = req.body; + if (!promptPath) return bad(res, 'promptPath required'); + const { working } = await readLoop(root, promptPath); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + try { + const guideline = await refineGuideline({ guideline: working, score, notes, onDelta: (t) => send('log', { text: t }) }); + await recordRound(root, promptPath, { guideline: working, sections: configs || [], score, notes, newWorking: guideline }); + send('done', { guideline }); + } catch (err) { send('error', { error: String(err.message || err) }); } + res.end(); + }); + + app.post('/api/loop/finalize', async (req, res) => { + try { await finalize(root, String(req.body.promptPath)); res.json({ ok: true }); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/loop/rollback', async (req, res) => { + try { res.json(await rollback(root, String(req.body.promptPath), Number(req.body.round))); } + catch (err) { bad(res, String(err.message || err)); } + }); +``` + +- [ ] **Step 2: Write failing integration tests (append to `server.test.js`)** + +```js +test('GET /api/loop returns working (defaults to the prompt md) and empty rounds', async () => { + const root = await repo(); + const { writePrompt } = await import('../lib/prompts.js'); + await writePrompt(root, 'G/A.html', '# Guide v0'); // → G/A.md + const { base, server } = await start(root); + const loop = await (await fetch(`${base}/api/loop?promptPath=${encodeURIComponent('G/A.md')}`)).json(); + assert.equal(loop.working, '# Guide v0'); + assert.deepEqual(loop.rounds, []); + server.close(); +}); + +test('POST /api/loop/finalize writes working back to the prompt md', async () => { + const root = await repo(); + const { writePrompt, readPrompt } = await import('../lib/prompts.js'); + const { recordRound } = await import('../lib/loop-store.js'); + await writePrompt(root, 'G/A.html', '# v0'); + await recordRound(root, 'G/A.md', { guideline: '# v0', sections: [], score: 8, notes: '', newWorking: '# FINAL' }); + const { base, server } = await start(root); + const r = await fetch(`${base}/api/loop/finalize`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: 'G/A.md' }) }); + assert.equal(r.status, 200); + assert.equal(await readPrompt(root, 'G/A.md'), '# FINAL'); + server.close(); +}); +``` + +(These avoid live playground/agent calls. `/api/loop/run` and `/refine` live behavior is covered by the Task 6 manual smoke.) + +- [ ] **Step 3: Run to verify failure** + +Run: `cd validator && node --test test/server.test.js` +Expected: FAIL — the two new tests fail (endpoints/behavior missing) until Step 1 is in place; if Step 1 already added, they PASS. Run the full suite next. + +- [ ] **Step 4: Run the full suite** + +Run: `cd validator && node --test` +Expected: PASS — all tests including the two new server tests. + +- [ ] **Step 5: Commit** + +```bash +git add validator/server.js validator/test/server.test.js +git commit -m "feat(validator): loop endpoints (status, sections, run, refine, finalize) + serve vendor" +``` + +--- + +### Task 6: Loop UI (view, section picker, preview grid, feedback, rounds rail) + +**Files:** +- Modify: `validator/public/index.html` (loop view container + render-iframe template) +- Modify: `validator/public/app.js` (loop state + flow) +- Modify: `validator/public/styles.css` (loop layout) +- Create: `validator/public/render-frame.js` (builds the iframe srcdoc that applies a config) +- Test: `validator/test/render-frame.test.js` + +**Interfaces:** +- Consumes: `/api/playground/status`, `/api/playground/sections`, `/api/loop`, `/api/loop/run`, `/api/loop/refine`, `/api/loop/finalize`; `/vendor/render-runtime.js`; existing `streamSSE`, activity modal, `state`. +- Produces: `buildRenderDoc({ html, css, config }) -> string` (in `render-frame.js`) — a full HTML doc string that injects the section html+css, imports `/vendor/render-runtime.js`, parses the config JSON, and calls `createExperience(config, { root })`. + +- [ ] **Step 1: Write the failing test for `buildRenderDoc`** + +```js +// validator/test/render-frame.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRenderDoc } from '../public/render-frame.js'; + +test('buildRenderDoc embeds section html, css, config, and imports the runtime', () => { + const doc = buildRenderDoc({ html: '
            x
            ', css: '.card{color:red}', config: '{"schema":"interact-experience/1.0"}' }); + assert.match(doc, /
            x<\/div>/); + assert.match(doc, /\.card\{color:red\}/); + assert.match(doc, /\/vendor\/render-runtime\.js/); + assert.match(doc, /createExperience/); + assert.match(doc, /interact-experience\\?\/1\.0|interact-experience/); +}); + +test('buildRenderDoc escapes a closing script tag in the config to avoid breakout', () => { + const doc = buildRenderDoc({ html: '', css: '', config: '{"x":""}' }); + assert.doesNotMatch(doc, /<\/script>\s*<\/script>/); // the payload's must be escaped + assert.match(doc, /<\\\/script>/); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd validator && node --test test/render-frame.test.js` +Expected: FAIL — `Cannot find module '../public/render-frame.js'`. + +- [ ] **Step 3: Implement `validator/public/render-frame.js`** + +```js +// Build a self-contained HTML document that renders a section with a generated +// @wix/interact-experience config, using the vendored renderer. The config is +// embedded as a JSON string in a data attribute (script-tag-safe). +export function buildRenderDoc({ html, css, config }) { + const safeConfig = String(config).replace(/<\/script>/gi, '<\\/script>'); + return ` + + +
            ${html || ''}
            + + +`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd validator && node --test test/render-frame.test.js` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the loop view container to `index.html`** + +Inside `#viewport` (after `#markdown`), add: + +```html + +``` + +In the Prompts side of the panel, add a loop launcher button in the fix panel (after `#convertBtn`): + +```html + +
            +``` + +- [ ] **Step 6: Add loop styles to `styles.css`** + +```css +#loopView { inset: 68px 332px 16px 322px; overflow: auto; padding: 16px; background: var(--glass-bg); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--hair); + border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; } +.loop-sections { display: flex; flex-wrap: wrap; gap: 6px; } +.loop-sections .chip { font-size: 12px; padding: 5px 10px; border-radius: 980px; background: var(--fill-1); + color: var(--text-2); cursor: pointer; border: 1px solid transparent; } +.loop-sections .chip.on { background: var(--accent-soft); color: #fff; border-color: var(--accent); } +.loop-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; } +.loop-cell { border: 1px solid var(--hair); border-radius: var(--radius-xs); overflow: hidden; background: #0e0e0f; } +.loop-cell .cap { font-size: 11px; color: var(--text-2); padding: 5px 8px; border-bottom: 1px solid var(--hair); } +.loop-cell iframe { width: 100%; height: 220px; border: 0; background: #fff; display: block; } +.loop-cell .err { color: #fca5a5; font-family: var(--mono); font-size: 11px; padding: 8px; } +.loop-feedback { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--hair); padding-top: 12px; } +.loop-feedback input[type=range] { width: 100%; } +#loopNotes { min-height: 60px; background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); + color: var(--text); padding: 8px 10px; font-family: inherit; font-size: 12.5px; resize: vertical; } +.loop-actions { display: flex; gap: 8px; } .loop-actions .btn { flex: 1; } +#roundsRail { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } +.round-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--radius-xs); + background: var(--fill-1); cursor: pointer; } +.round-row:hover { background: var(--fill-2); } +.round-row .sc { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text-2); } +``` + +- [ ] **Step 7: Wire the loop flow in `app.js`** + +Add near the top (imports): + +```js +import { buildRenderDoc } from './render-frame.js'; +``` + +Add loop state to the `state` object: `loop: { promptPath: null, sections: [], available: [], configs: {}, active: false }`. + +Add these functions and event wiring (place before the final `loadFiles()` calls): + +```js +async function openLoop() { + const p = state.currentPrompt; + if (!p) return; + state.loop = { promptPath: p, sections: [], available: [], configs: {}, active: true }; + $('markdown').hidden = true; $('code').hidden = true; $('preview').hidden = true; $('diff').hidden = true; + $('placeholder').hidden = true; $('loopView').hidden = false; + const [{ up }, { sections }, loop] = await Promise.all([ + api('/api/playground/status'), + api('/api/playground/sections'), + api(`/api/loop?promptPath=${encodeURIComponent(p)}`), + ]); + state.loop.available = sections.map((s) => s.id); + if (!up) { $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; return; } + renderSectionChips(); + renderRounds(loop.rounds); +} + +function renderSectionChips() { + $('loopSections').innerHTML = state.loop.available.map((id) => + `${esc(id)}`).join('') + + ''; +} + +function renderGrid() { + const cells = state.loop.sections.map((id) => { + const c = state.loop.configs[id]; + const inner = c === undefined ? '
            …generating
            ' + : c.error ? `
            ${esc(c.error)}
            ` + : ``; + return `
            ${esc(id)}
            ${inner}
            `; + }).join(''); + $('loopGrid').innerHTML = cells; + $('loopFeedback').hidden = !state.loop.sections.length || Object.keys(state.loop.configs).length === 0; +} + +async function loopGenerate() { + const secs = state.loop.sections; + if (!secs.length) return; + state.loop.configs = {}; + state.logs = new Map(); + renderGrid(); + const res = await fetch('/api/loop/run', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, sections: secs }) }); + await streamSSE(res, (type, d) => { + if (type === 'result') { + state.loop.configs[d.id] = d.error ? { error: d.error } : { config: d.config, html: d.html, css: d.css }; + renderGrid(); + } else if (type === 'log') appendLog(d.id || 'agent', d.text); + }); + renderGrid(); +} +``` + +(`/api/loop/run` already includes `html`/`css` in each `result` — see Task 5.) + +```js +async function loopRefine() { + const score = Number($('scoreRange').value); + const notes = $('loopNotes').value; + const configs = Object.entries(state.loop.configs).filter(([, c]) => c && c.config) + .map(([id, c]) => ({ id, config: c.config, html: c.html, css: c.css })); + state.logs = new Map(); + const res = await fetch('/api/loop/refine', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); + await streamSSE(res, (type, d) => { + if (type === 'log') appendLog('refine', d.text); + else if (type === 'done') { $('loopNotes').value = ''; loopRefreshRounds(); } + }); +} + +async function loopRefreshRounds() { + const loop = await api(`/api/loop?promptPath=${encodeURIComponent(state.loop.promptPath)}`); + renderRounds(loop.rounds); +} + +function renderRounds(rounds) { + state.loop.rounds = rounds || []; + $('roundsRail').innerHTML = state.loop.rounds.map((r) => + `
            Round ${r.round} + ${r.score}/10 +
            `).join('') + + (state.loop.rounds.length ? '' : ''); +} + +// Load a past round's stored outputs + feedback back into the view (read-only look). +function viewRound(round) { + const r = (state.loop.rounds || []).find((x) => x.round === round); + if (!r) return; + state.loop.configs = {}; + for (const s of r.sections) state.loop.configs[s.id] = { config: s.config, html: s.html, css: s.css }; + $('scoreRange').value = r.score; $('scoreVal').textContent = r.score; $('loopNotes').value = r.notes || ''; + renderGrid(); +} + +// event delegation +$('loopSections').addEventListener('click', (e) => { + if (e.target.id === 'genBtn') return loopGenerate(); + const chip = e.target.closest('.chip'); if (!chip) return; + const id = chip.dataset.sec; + const i = state.loop.sections.indexOf(id); + if (i >= 0) state.loop.sections.splice(i, 1); + else if (state.loop.sections.length < 4) state.loop.sections.push(id); + renderSectionChips(); +}); +$('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); +$('regenBtn').onclick = loopGenerate; +$('refineBtn').onclick = async () => { await loopRefine(); await loopGenerate(); }; +$('roundsRail').addEventListener('click', async (e) => { + if (e.target.id === 'finalizeBtn') { + await api('/api/loop/finalize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath }) }); + $('applyStatus').textContent = 'Loop closed — final guideline written to the .md.'; + return; + } + const rb = e.target.closest('.rollback-btn'); + if (rb) { + await api('/api/loop/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath, round: Number(rb.dataset.round) }) }); + $('applyStatus').textContent = `Rolled back to round ${rb.dataset.round}'s guideline (working version).`; + return; + } + const row = e.target.closest('.round-row'); + if (row) viewRound(Number(row.dataset.round)); +}); +$('loopBtn').onclick = openLoop; +``` + +Also: in the prompt-selection path (`render()` / prompt row click), show `#loopBtn` when `state.view === 'prompts'` and a prompt is selected: set `$('loopBtn').hidden = !(state.view === 'prompts' && state.currentPrompt)`. And when switching away from a prompt/loop, set `$('loopView').hidden = true`. + +- [ ] **Step 8: Full suite green** + +Run: `cd validator && node --test` +Expected: PASS — all tests (including render-frame + the adjusted server run payload). + +- [ ] **Step 9: Manual smoke (needs the playground running)** + +1. In a separate terminal: `cd ~/Documents/Dev/Wix/interact-xp/apps/playground && npm run dev` (user action; confirms :5173). +2. `cd validator && npm start`; open `http://localhost:4500`; Prompts tab; pick a prompt that exists (generate one via Convert first if needed). +3. Click **Start refine loop** → pick 2–3 sections → **Generate**. + Expected: each cell renders the section with the animation applied (or a clear per-cell error); the Agent-activity modal streams reasoning. +4. Set a score + notes → **Refine prompt** → then **Generate again**. + Expected: a new round appears in the rail with the score; outputs reflect the refined guideline. +5. **Close loop** → confirm the prompt's `.md` now equals the working guideline (`Prompts` tab → Raw), and `Ani-Mate Prompts/.md.history.json` exists. + +- [ ] **Step 10: Commit** + +```bash +git add validator/public/index.html validator/public/app.js validator/public/styles.css validator/public/render-frame.js validator/test/render-frame.test.js validator/server.js +git commit -m "feat(validator): prompt refinement loop UI (sections, previews, score, refine, rounds)" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** integration via `/api/generate` (Task 2) ✔; bundled renderer + serve (Tasks 1, 5) ✔; 2–4 sections per round with side-by-side render (Task 6) ✔; holistic score 1–10 + notes (Task 6) ✔; general no-overfit refine (Task 4) ✔; full round history + rollback + finalize-to-.md (Task 3) ✔; playground-down + per-section-failure handling (Tasks 5, 6) ✔; read-only interact-xp (all tasks; only reads/imports/esbuild-into-validator/HTTP) ✔; SSE mirrors /api/fix (Tasks 5, 6) ✔. +- **Deferred per spec:** repair loop, auto-launch playground, browser automation — not implemented. +- **Render payload:** `/api/loop/run` includes `html`/`css` in each `result` (Task 5) so the iframe can render; `loopRefine` stores `{id,config,html,css}` in history so a past round can be re-viewed (Task 6). +- **Rollback:** included — `rollback` in loop-store (Task 3), `/api/loop/rollback` endpoint (Task 5), and per-round rollback button + click-to-view in the rounds rail (Task 6). +- **Type consistency:** round shape `{round,guideline,sections:[{id,config,html,css}],score,notes}` is identical across loop-store.js, server.js, and app.js; `generate()` returns `{config,sessionId}` consumed by `/api/loop/run`; `buildRenderDoc({html,css,config})` matches its call site; `refineGuideline` returns markdown consumed by `recordRound(newWorking)`. From c238cd0bbce1936d48a7306289d1c469acfcc1af Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 6 Jul 2026 13:06:40 +0300 Subject: [PATCH 34/62] =?UTF-8?q?feat(validator):=20vendor=20build=20?= =?UTF-8?q?=E2=80=94=20bundle=20interact-experience=20renderer=20+=20schem?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles @wix/interact-experience-renderer into a browser ESM module and emits the canonical experience JSON Schema, both read directly from the interact-xp source tree (read-only) via esbuild. Required aliasing `@wix/interact-experience` to its `src/index.ts` since that workspace package's `dist/` (referenced by its package.json `exports`) is never built in this checkout — mirrors the alias interact-xp's own Vite configs already use. --- validator/package-lock.json | 487 ++ validator/package.json | 6 +- validator/scripts/build-vendor.mjs | 49 + validator/vendor/experience.schema.json | 1367 ++++ validator/vendor/render-runtime.js | 7878 +++++++++++++++++++++++ 5 files changed, 9786 insertions(+), 1 deletion(-) create mode 100644 validator/scripts/build-vendor.mjs create mode 100644 validator/vendor/experience.schema.json create mode 100644 validator/vendor/render-runtime.js diff --git a/validator/package-lock.json b/validator/package-lock.json index 9a5abfb..ce3a261 100644 --- a/validator/package-lock.json +++ b/validator/package-lock.json @@ -10,6 +10,451 @@ "dependencies": { "diff": "^7.0.0", "express": "^4.21.0" + }, + "devDependencies": { + "esbuild": "^0.28.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/accepts": { @@ -225,6 +670,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", diff --git a/validator/package.json b/validator/package.json index 6138d27..d1cd475 100644 --- a/validator/package.json +++ b/validator/package.json @@ -5,10 +5,14 @@ "type": "module", "scripts": { "start": "node server.js", - "test": "node --test" + "test": "node --test", + "build:vendor": "node scripts/build-vendor.mjs" }, "dependencies": { "diff": "^7.0.0", "express": "^4.21.0" + }, + "devDependencies": { + "esbuild": "^0.28.1" } } diff --git a/validator/scripts/build-vendor.mjs b/validator/scripts/build-vendor.mjs new file mode 100644 index 0000000..98a3c8a --- /dev/null +++ b/validator/scripts/build-vendor.mjs @@ -0,0 +1,49 @@ +// validator/scripts/build-vendor.mjs +import { build } from 'esbuild'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const XP = process.env.PLAYGROUND_REPO || join(homedir(), 'Documents/Dev/Wix/interact-xp'); +const OUT = new URL('../vendor/', import.meta.url).pathname; + +// `@wix/interact-experience`'s package.json `exports` points at a `dist/` +// that is never built in this checkout (it's a workspace-only, source-only +// package here). The interact-xp Vite configs work around this with a +// resolve alias pointing straight at the package's `src/index.ts` — mirror +// that alias here so esbuild resolves the same way the real build does. +const INTERACT_EXPERIENCE_ALIAS = join(XP, 'packages/interact-experience/src/index.ts'); + +async function buildRenderRuntime() { + await build({ + entryPoints: [join(XP, 'packages/interact-experience-renderer/src/index.ts')], + bundle: true, format: 'esm', platform: 'browser', + outfile: join(OUT, 'render-runtime.js'), + define: { 'process.env.NODE_ENV': '"production"' }, + alias: { '@wix/interact-experience': INTERACT_EXPERIENCE_ALIAS }, + conditions: ['module', 'import', 'default'], + logLevel: 'info', + }); + console.log('✓ render-runtime.js'); +} + +async function emitSchema() { + const tmp = join(tmpdir(), `iv-schema-${process.pid}.mjs`); + await build({ + entryPoints: [join(XP, 'apps/playground/src/lib/schema.ts')], + bundle: true, format: 'esm', platform: 'node', outfile: tmp, + alias: { '@wix/interact-experience': INTERACT_EXPERIENCE_ALIAS }, + conditions: ['module', 'import', 'default'], + logLevel: 'info', + }); + const mod = await import(pathToFileURL(tmp).href); + await writeFile(join(OUT, 'experience.schema.json'), JSON.stringify(mod.EXPERIENCE_SCHEMA, null, 2)); + await rm(tmp, { force: true }); + console.log('✓ experience.schema.json'); +} + +await mkdir(OUT, { recursive: true }); +await buildRenderRuntime(); +await emitSchema(); +console.log('vendor build complete'); diff --git a/validator/vendor/experience.schema.json b/validator/vendor/experience.schema.json new file mode 100644 index 0000000..55ba330 --- /dev/null +++ b/validator/vendor/experience.schema.json @@ -0,0 +1,1367 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "const": "interact-experience/1.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "elements": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/ElementEntry" + } + }, + "styles": { + "type": "array", + "items": { + "$ref": "#/$defs/StyleRule" + } + }, + "interact": { + "$ref": "#/$defs/ExperienceInteractConfig" + }, + "controls": { + "type": "array", + "items": { + "$ref": "#/$defs/Control" + } + }, + "disableWhen": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mediaQuery": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string" + } + }, + "required": [ + "mediaQuery" + ], + "additionalProperties": false + } + }, + "meta": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "previewUrl": { + "type": "string" + }, + "author": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "$schema", + "id", + "name", + "elements", + "interact", + "controls" + ], + "additionalProperties": false, + "$defs": { + "ElementEntry": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "minLength": 1 + }, + "styles": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "selector" + ], + "additionalProperties": false + }, + "StyleRule": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "minLength": 1 + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "mediaQuery": { + "type": "string" + } + }, + "required": [ + "selector", + "properties" + ], + "additionalProperties": false + }, + "ExperienceInteractConfig": { + "type": "object", + "properties": { + "effects": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/Effect" + } + }, + "sequences": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/Sequence" + } + }, + "conditions": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/InteractCondition" + } + }, + "interactions": { + "type": "array", + "items": { + "$ref": "#/$defs/ExperienceInteraction" + } + } + }, + "required": [ + "effects", + "interactions" + ], + "additionalProperties": false + }, + "Effect": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "effectId": { + "type": "string" + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "namedEffect": { + "$ref": "#/$defs/NamedEffect" + }, + "keyframeEffect": { + "$ref": "#/$defs/KeyframeEffect" + }, + "duration": { + "type": "number" + }, + "easing": { + "type": "string" + }, + "iterations": { + "type": "number" + }, + "alternate": { + "type": "boolean" + }, + "reversed": { + "type": "boolean" + }, + "delay": { + "type": "number" + }, + "fill": { + "type": "string", + "enum": [ + "none", + "forwards", + "backwards", + "both" + ] + }, + "composite": { + "type": "string", + "enum": [ + "replace", + "add", + "accumulate" + ] + }, + "triggerType": { + "$ref": "#/$defs/EffectTriggerType" + }, + "rangeStart": { + "$ref": "#/$defs/RangeOffset" + }, + "rangeEnd": { + "$ref": "#/$defs/RangeOffset" + }, + "centeredToTarget": { + "type": "boolean" + }, + "transitionDuration": { + "type": "number" + }, + "transitionDelay": { + "type": "number" + }, + "transitionEasing": { + "type": "string", + "enum": [ + "linear", + "hardBackOut", + "easeOut", + "elastic", + "bounce" + ] + }, + "stateAction": { + "type": "string", + "enum": [ + "add", + "remove", + "toggle", + "clear" + ] + }, + "transition": { + "type": "object", + "properties": { + "duration": { + "type": "number" + }, + "delay": { + "type": "number" + }, + "easing": { + "type": "string" + }, + "styleProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "styleProperties" + ], + "additionalProperties": false + }, + "transitionProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "delay": { + "type": "number" + }, + "easing": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "NamedEffect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type" + ], + "additionalProperties": {} + }, + "KeyframeEffect": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "keyframes": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/Keyframe" + } + } + }, + "required": [ + "name", + "keyframes" + ], + "additionalProperties": false + }, + "Keyframe": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "EffectTriggerType": { + "type": "string", + "enum": [ + "once", + "repeat", + "alternate", + "state" + ] + }, + "RangeOffset": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "entry", + "exit", + "contain", + "cover", + "entry-crossing", + "exit-crossing" + ] + }, + "offset": { + "$ref": "#/$defs/LengthPercentage" + } + }, + "additionalProperties": false + }, + "LengthPercentage": { + "anyOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "unit": { + "type": "string", + "enum": [ + "px", + "em", + "rem", + "vh", + "vw", + "vmin", + "vmax" + ] + } + }, + "required": [ + "value", + "unit" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "unit": { + "type": "string", + "const": "percentage" + } + }, + "required": [ + "value", + "unit" + ], + "additionalProperties": false + } + ] + }, + "Sequence": { + "type": "object", + "properties": { + "effects": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TimeEffect" + }, + { + "$ref": "#/$defs/EffectRef" + } + ] + } + }, + "delay": { + "type": "number" + }, + "offset": { + "type": "number" + }, + "offsetEasing": { + "type": "string" + }, + "triggerType": { + "$ref": "#/$defs/SequenceTriggerType" + }, + "sequenceId": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "effects" + ], + "additionalProperties": false + }, + "TimeEffect": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "effectId": { + "type": "string" + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "namedEffect": { + "$ref": "#/$defs/NamedEffect" + }, + "keyframeEffect": { + "$ref": "#/$defs/KeyframeEffect" + }, + "duration": { + "type": "number" + }, + "easing": { + "type": "string" + }, + "iterations": { + "type": "number" + }, + "alternate": { + "type": "boolean" + }, + "reversed": { + "type": "boolean" + }, + "delay": { + "type": "number" + }, + "fill": { + "type": "string", + "enum": [ + "none", + "forwards", + "backwards", + "both" + ] + }, + "composite": { + "type": "string", + "enum": [ + "replace", + "add", + "accumulate" + ] + }, + "triggerType": { + "$ref": "#/$defs/EffectTriggerType" + }, + "rangeStart": { + "$ref": "#/$defs/RangeOffset" + }, + "rangeEnd": { + "$ref": "#/$defs/RangeOffset" + }, + "centeredToTarget": { + "type": "boolean" + }, + "transitionDuration": { + "type": "number" + }, + "transitionDelay": { + "type": "number" + }, + "transitionEasing": { + "type": "string", + "enum": [ + "linear", + "hardBackOut", + "easeOut", + "elastic", + "bounce" + ] + }, + "stateAction": { + "type": "string", + "enum": [ + "add", + "remove", + "toggle", + "clear" + ] + }, + "transition": { + "type": "object", + "properties": { + "duration": { + "type": "number" + }, + "delay": { + "type": "number" + }, + "easing": { + "type": "string" + }, + "styleProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "styleProperties" + ], + "additionalProperties": false + }, + "transitionProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "delay": { + "type": "number" + }, + "easing": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$ref": "#/$defs/Effect" + }, + "EffectRef": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "effectId": { + "type": "string", + "minLength": 1 + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "effectId" + ], + "additionalProperties": false + }, + "SequenceTriggerType": { + "type": "string", + "enum": [ + "once", + "repeat", + "alternate", + "state" + ] + }, + "InteractCondition": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "media", + "selector" + ] + }, + "predicate": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "ExperienceInteraction": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "effects": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Effect" + }, + { + "$ref": "#/$defs/EffectRef" + } + ] + } + }, + "sequences": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Sequence" + }, + { + "$ref": "#/$defs/SequenceRef" + } + ] + } + }, + "trigger": { + "type": "string", + "enum": [ + "viewEnter", + "pageVisible" + ] + }, + "params": { + "$ref": "#/$defs/ViewEnterParams" + } + }, + "required": [ + "key", + "trigger" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "effects": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Effect" + }, + { + "$ref": "#/$defs/EffectRef" + } + ] + } + }, + "sequences": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Sequence" + }, + { + "$ref": "#/$defs/SequenceRef" + } + ] + } + }, + "trigger": { + "type": "string", + "const": "pointerMove" + }, + "params": { + "$ref": "#/$defs/PointerMoveParams" + } + }, + "required": [ + "key", + "trigger" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "effects": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Effect" + }, + { + "$ref": "#/$defs/EffectRef" + } + ] + } + }, + "sequences": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Sequence" + }, + { + "$ref": "#/$defs/SequenceRef" + } + ] + } + }, + "trigger": { + "type": "string", + "const": "animationEnd" + }, + "params": { + "$ref": "#/$defs/AnimationEndParams" + } + }, + "required": [ + "key", + "trigger", + "params" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "selector": { + "type": "string" + }, + "listContainer": { + "type": "string" + }, + "listItemSelector": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "effects": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Effect" + }, + { + "$ref": "#/$defs/EffectRef" + } + ] + } + }, + "sequences": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Sequence" + }, + { + "$ref": "#/$defs/SequenceRef" + } + ] + } + }, + "trigger": { + "type": "string", + "enum": [ + "hover", + "click", + "interest", + "activate", + "viewProgress" + ] + } + }, + "required": [ + "key", + "trigger" + ], + "additionalProperties": false + } + ] + }, + "SequenceRef": { + "type": "object", + "properties": { + "sequenceId": { + "type": "string", + "minLength": 1 + }, + "delay": { + "type": "number" + }, + "offset": { + "type": "number" + }, + "offsetEasing": { + "type": "string" + }, + "triggerType": { + "$ref": "#/$defs/SequenceTriggerType" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "sequenceId" + ], + "additionalProperties": false + }, + "ViewEnterParams": { + "type": "object", + "properties": { + "threshold": { + "type": "number" + }, + "inset": { + "type": "string" + }, + "useSafeViewEnter": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PointerMoveParams": { + "type": "object", + "properties": { + "hitArea": { + "type": "string", + "enum": [ + "root", + "self" + ] + }, + "axis": { + "type": "string", + "enum": [ + "x", + "y" + ] + } + }, + "additionalProperties": false + }, + "AnimationEndParams": { + "type": "object", + "properties": { + "effectId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "effectId" + ], + "additionalProperties": false + }, + "Control": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "group": { + "type": "string" + }, + "type": { + "$ref": "#/$defs/ControlType" + }, + "defaultValue": { + "$ref": "#/$defs/ControlValue" + }, + "constraints": { + "$ref": "#/$defs/ControlConstraints" + }, + "bindings": { + "type": "array", + "items": { + "$ref": "#/$defs/ControlBinding" + } + } + }, + "required": [ + "id", + "label", + "type", + "defaultValue", + "bindings" + ], + "additionalProperties": false + }, + "ControlType": { + "type": "string", + "enum": [ + "range", + "select", + "color", + "toggle", + "text" + ] + }, + "ControlValue": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "ControlConstraints": { + "type": "object", + "properties": { + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "step": { + "type": "number" + }, + "unit": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ControlOption" + } + } + }, + "additionalProperties": false + }, + "ControlOption": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "value", + "label" + ], + "additionalProperties": false + }, + "ControlBinding": { + "type": "object", + "properties": { + "target": { + "$ref": "#/$defs/BindingTarget" + }, + "targetId": { + "type": "string", + "minLength": 1 + }, + "property": { + "type": "string" + }, + "transform": { + "$ref": "#/$defs/ValueTransform" + } + }, + "required": [ + "target", + "targetId" + ], + "additionalProperties": false + }, + "BindingTarget": { + "type": "string", + "enum": [ + "effect", + "sequence", + "style", + "element", + "interaction", + "variable" + ] + }, + "ValueTransform": { + "anyOf": [ + { + "$ref": "#/$defs/DirectTransform" + }, + { + "$ref": "#/$defs/LinearTransform" + }, + { + "$ref": "#/$defs/InverseTransform" + }, + { + "$ref": "#/$defs/MapTransform" + }, + { + "$ref": "#/$defs/TemplateTransform" + } + ] + }, + "DirectTransform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "direct" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "LinearTransform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "linear" + }, + "factor": { + "type": "number" + }, + "offset": { + "type": "number" + } + }, + "required": [ + "type", + "factor" + ], + "additionalProperties": false + }, + "InverseTransform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "inverse" + }, + "numerator": { + "type": "number" + } + }, + "required": [ + "type", + "numerator" + ], + "additionalProperties": false + }, + "MapTransform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "map" + }, + "entries": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/ControlValue" + } + } + }, + "required": [ + "type", + "entries" + ], + "additionalProperties": false + }, + "TemplateTransform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "template" + }, + "template": { + "type": "string" + } + }, + "required": [ + "type", + "template" + ], + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/validator/vendor/render-runtime.js b/validator/vendor/render-runtime.js new file mode 100644 index 0000000..fc57682 --- /dev/null +++ b/validator/vendor/render-runtime.js @@ -0,0 +1,7878 @@ +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/conditions.ts +function evaluateConditions(conditions, onChange) { + if (!conditions || conditions.length === 0 || typeof window === "undefined" || typeof window.matchMedia !== "function") { + return { disabled: false, cleanup: () => { + } }; + } + const mqls = conditions.map((c) => window.matchMedia(c.mediaQuery)); + let disabled = mqls.some((m) => m.matches); + const handler = () => { + const next = mqls.some((m) => m.matches); + if (next !== disabled) { + disabled = next; + onChange(disabled); + } + }; + for (const mql of mqls) { + mql.addEventListener("change", handler); + } + return { + get disabled() { + return disabled; + }, + cleanup() { + for (const mql of mqls) { + mql.removeEventListener("change", handler); + } + } + }; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience/src/resolve/transforms.ts +function applyTransform(value, transform) { + if (!transform || transform.type === "direct") return value; + switch (transform.type) { + case "linear": { + if (typeof value !== "number") return value; + return transform.factor * value + (transform.offset ?? 0); + } + case "inverse": { + if (typeof value !== "number" || value === 0) return value; + return transform.numerator / value; + } + case "map": { + const key = String(value); + return key in transform.entries ? transform.entries[key] : value; + } + case "template": { + return transform.template.replaceAll("${value}", String(value)); + } + } +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience/src/resolve/path.ts +function splitPath(path) { + return path.split(".").filter((s) => s.length > 0).map((s) => /^\d+$/.test(s) ? Number(s) : s); +} +function setPath(obj, path, value) { + const segments = splitPath(path); + if (segments.length === 0 || obj === null || typeof obj !== "object") return; + let target = obj; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + const next = target[seg]; + if (next === null || typeof next !== "object") { + const created = typeof segments[i + 1] === "number" ? [] : {}; + target[seg] = created; + target = created; + } else { + target = next; + } + } + target[segments[segments.length - 1]] = value; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience/src/resolve/resolve.ts +function resolveTarget(experience, target, targetId) { + switch (target) { + case "element": + return experience.elements[targetId]; + case "effect": + return experience.interact.effects[targetId]; + case "sequence": + return experience.interact.sequences?.[targetId]; + case "style": + return (experience.styles ?? []).find((s) => s.selector === targetId); + case "interaction": + return experience.interact.interactions.find((i) => i.id === targetId); + case "variable": + return null; + } +} +function resolveExperience(experience, userValues) { + const resolved = structuredClone(experience); + const variables = {}; + for (const control of resolved.controls) { + const value = userValues[control.id] ?? control.defaultValue; + for (const binding of control.bindings) { + const final = applyTransform(value, binding.transform); + if (binding.target === "variable") { + if (typeof final === "boolean") variables[binding.targetId] = String(final); + else variables[binding.targetId] = final; + continue; + } + const target = resolveTarget(resolved, binding.target, binding.targetId); + if (target && binding.property) { + setPath(target, binding.property, final); + } + } + } + return { experience: resolved, variables }; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/resolve.ts +function resolveControls(experience, options) { + if (options.store) return options.store.resolved(); + return resolveExperience(experience, options.controlValues ?? {}); +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/elements.ts +function selectElements(elements, root) { + const map = /* @__PURE__ */ new Map(); + for (const [key, entry] of Object.entries(elements)) { + const nodes = Array.from(root.querySelectorAll(entry.selector)); + for (const el of nodes) { + el.dataset.interactKey = key; + } + map.set(key, nodes); + } + return map; +} +function clearElementAttributes(elements) { + for (const nodes of elements.values()) { + for (const el of nodes) { + delete el.dataset.interactKey; + } + } +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/styles.ts +var supportsAdoptedStyleSheets = typeof CSSStyleSheet !== "undefined" && "replaceSync" in CSSStyleSheet.prototype && typeof document !== "undefined" && "adoptedStyleSheets" in document; +function buildStylesheetText(resolved, scopeId, variableNames = /* @__PURE__ */ new Set()) { + const scope = `[data-experience-id="${scopeId}"]`; + const sections = []; + const declarations = (styles) => Object.entries(styles).filter(([prop]) => !variableNames.has(prop)).map(([prop, val]) => ` ${prop}: ${val};`).join("\n"); + for (const [key, entry] of Object.entries(resolved.elements)) { + if (!entry.styles || Object.keys(entry.styles).length === 0) continue; + const props = declarations(entry.styles); + if (!props) continue; + if (entry.selector.includes("::")) { + sections.push(`${scope} ${entry.selector} { +${props} +}`); + } else { + sections.push(`${scope} [data-interact-key="${key}"] { +${props} +}`); + } + } + if (resolved.styles) { + for (const rule of resolved.styles) { + const ruleSelector = `${scope} ${rule.selector}`; + const props = declarations(rule.properties); + if (!props) continue; + if (rule.mediaQuery) { + sections.push(`@media ${rule.mediaQuery} { + ${ruleSelector} { + ${props} + } +}`); + } else { + sections.push(`${ruleSelector} { +${props} +}`); + } + } + } + return sections.join("\n\n"); +} +function applyVariables(scopeElement, variables) { + const style = scopeElement.style; + for (const [name, value] of Object.entries(variables)) { + style.setProperty(name, String(value)); + } +} +function clearVariables(scopeElement, variables) { + const style = scopeElement.style; + for (const name of Object.keys(variables)) { + style.removeProperty(name); + } +} +function renderStyles(resolved, variables, scopeElement) { + const scopeId = resolved.id; + let currentVariables = { ...variables }; + const varNames = (vars) => new Set(Object.keys(vars)); + applyVariables(scopeElement, variables); + if (supportsAdoptedStyleSheets) { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(buildStylesheetText(resolved, scopeId, varNames(variables))); + const root2 = scopeElement.getRootNode(); + root2.adoptedStyleSheets = [...root2.adoptedStyleSheets, sheet]; + return { + update(newResolved, newVars) { + sheet.replaceSync(buildStylesheetText(newResolved, scopeId, varNames(newVars))); + applyVariables(scopeElement, newVars); + currentVariables = { ...newVars }; + }, + setVariables(newVars) { + applyVariables(scopeElement, newVars); + currentVariables = { ...newVars }; + }, + destroy() { + const r = scopeElement.getRootNode(); + r.adoptedStyleSheets = r.adoptedStyleSheets.filter((s) => s !== sheet); + clearVariables(scopeElement, currentVariables); + } + }; + } + const styleEl = document.createElement("style"); + styleEl.dataset.experienceId = scopeId; + styleEl.textContent = buildStylesheetText(resolved, scopeId, varNames(variables)); + const root = scopeElement.getRootNode(); + (root.head ?? root).appendChild(styleEl); + return { + update(newResolved, newVars) { + styleEl.textContent = buildStylesheetText(newResolved, scopeId, varNames(newVars)); + applyVariables(scopeElement, newVars); + currentVariables = { ...newVars }; + }, + setVariables(newVars) { + applyVariables(scopeElement, newVars); + currentVariables = { ...newVars }; + }, + destroy() { + styleEl.remove(); + clearVariables(scopeElement, currentVariables); + } + }; +} + +// ../../../Documents/Dev/Wix/interact-xp/node_modules/@wix/interact/dist/index-C6u4q815.mjs +function vt(t) { + return [...t.matchAll(/\[([-\w]+)]/g)].map(([e, n]) => n); +} +function $(t, e) { + const n = vt(e); + let s = 0; + return n.length ? t.replace(/\[]/g, () => { + const i = n[s++]; + return i !== void 0 ? `[${i}]` : "[]"; + }) : t; +} +var V = class { + animations; + options; + ready; + isCSS; + longestAnimation; + constructor(e, n) { + this.animations = e, this.options = n, this.ready = n?.measured || Promise.resolve(), this.isCSS = e[0] instanceof CSSAnimation, this.longestAnimation = this._getAnimationWithLongestEndTime(); + } + _getAnimationWithLongestEndTime() { + return this.animations.reduce((e, n) => { + const s = e.effect?.getComputedTiming().endTime ?? 0, i = n.effect?.getComputedTiming().endTime ?? 0; + return s > i ? e : n; + }, this.animations[0]); + } + getProgress() { + return this.longestAnimation?.effect?.getComputedTiming().progress || 0; + } + async play(e) { + await this.ready; + for (const n of this.animations) + n.play(); + await Promise.all(this.animations.map((n) => n.ready)), e && e(); + } + pause() { + for (const e of this.animations) + e.pause(); + } + async reverse(e) { + await this.ready; + for (const n of this.animations) + n.reverse(); + await Promise.all(this.animations.map((n) => n.ready)), e && e(); + } + progress(e) { + for (const n of this.animations) { + const { delay: s, duration: i, iterations: r } = n.effect.getTiming(), o = (Number.isFinite(i) ? i : 0) * (Number.isFinite(r) ? r : 1); + n.currentTime = ((s || 0) + o) * e; + } + } + cancel() { + for (const e of this.animations) + e.cancel(); + } + setPlaybackRate(e) { + for (const n of this.animations) + n.playbackRate = e; + } + async onFinish(e) { + try { + await Promise.all(this.animations.map((s) => s.finished)); + const n = this.animations[0]; + if (n && !this.isCSS) { + const s = n.effect?.target; + if (s) { + const i = this.options?.effectId || n.id, r = new CustomEvent("animationend", { detail: { effectId: i } }); + s.dispatchEvent(r); + } + } + e(); + } catch (n) { + console.warn("animation was interrupted - aborting onFinish callback - ", n); + } + } + async onAbort(e) { + try { + await Promise.all(this.animations.map((n) => n.finished)); + } catch (n) { + if (n.name === "AbortError") { + const s = this.animations[0]; + if (s && !this.isCSS) { + const i = s.effect?.target; + if (i) { + const r = new Event("animationcancel"); + i.dispatchEvent(r); + } + } + e(); + } + } + } + get finished() { + return Promise.all(this.animations.map((e) => e.finished)); + } + get playState() { + return this.animations.some((e) => e.playState === "running") ? "running" : this.animations[0]?.playState; + } + hasAnimationName(e) { + return this.animations.some((n) => n.animationName === e); + } + hasAnimationId(e) { + return this.animations.some((n) => n.id === e); + } + getTimingOptions() { + return this.animations.map((e) => { + const n = e.effect?.getTiming(), s = n?.delay ?? 0, i = Number(n?.duration) || 0, r = n?.iterations ?? 1; + return { + delay: s, + duration: i, + iterations: r + }; + }); + } +}; +var je = (t) => t; +var Et = (t) => 1 - Math.cos(t * Math.PI / 2); +var wt = (t) => Math.sin(t * Math.PI / 2); +var bt = (t) => -(Math.cos(Math.PI * t) - 1) / 2; +var St = (t) => t ** 2; +var Tt = (t) => 1 - (1 - t) ** 2; +var It = (t) => t < 0.5 ? 2 * t ** 2 : 1 - (-2 * t + 2) ** 2 / 2; +var Ot = (t) => t ** 3; +var At = (t) => 1 - (1 - t) ** 3; +var Ct = (t) => t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2; +var kt = (t) => t ** 4; +var $t = (t) => 1 - (1 - t) ** 4; +var _t = (t) => t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2; +var qt = (t) => t ** 5; +var Mt = (t) => 1 - (1 - t) ** 5; +var xt = (t) => t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2; +var Pt = (t) => t === 0 ? 0 : 2 ** (10 * t - 10); +var Lt = (t) => t === 1 ? 1 : 1 - 2 ** (-10 * t); +var Rt = (t) => t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2; +var Ft = (t) => 1 - Math.sqrt(1 - t ** 2); +var Nt = (t) => Math.sqrt(1 - (t - 1) ** 2); +var zt = (t) => t < 0.5 ? (1 - Math.sqrt(1 - 4 * t ** 2)) / 2 : (Math.sqrt(-(2 * t - 3) * (2 * t - 1)) + 1) / 2; +var Ht = (t) => 2.70158 * t ** 3 - 1.70158 * t ** 2; +var jt = (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2; +var Dt = (t, e = 1.70158 * 1.525) => t < 0.5 ? (2 * t) ** 2 * ((e + 1) * 2 * t - e) / 2 : ((2 * t - 2) ** 2 * ((e + 1) * (t * 2 - 2) + e) + 2) / 2; +var Te = { + linear: je, + sineIn: Et, + sineOut: wt, + sineInOut: bt, + quadIn: St, + quadOut: Tt, + quadInOut: It, + cubicIn: Ot, + cubicOut: At, + cubicInOut: Ct, + quartIn: kt, + quartOut: $t, + quartInOut: _t, + quintIn: qt, + quintOut: Mt, + quintInOut: xt, + expoIn: Pt, + expoOut: Lt, + expoInOut: Rt, + circIn: Ft, + circOut: Nt, + circInOut: zt, + backIn: Ht, + backOut: jt, + backInOut: Dt +}; +var Ie = { + linear: "linear", + ease: "ease", + easeIn: "ease-in", + easeOut: "ease-out", + easeInOut: "ease-in-out", + sineIn: "cubic-bezier(0.47, 0, 0.745, 0.715)", + sineOut: "cubic-bezier(0.39, 0.575, 0.565, 1)", + sineInOut: "cubic-bezier(0.445, 0.05, 0.55, 0.95)", + quadIn: "cubic-bezier(0.55, 0.085, 0.68, 0.53)", + quadOut: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", + quadInOut: "cubic-bezier(0.455, 0.03, 0.515, 0.955)", + cubicIn: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + cubicOut: "cubic-bezier(0.215, 0.61, 0.355, 1)", + cubicInOut: "cubic-bezier(0.645, 0.045, 0.355, 1)", + quartIn: "cubic-bezier(0.895, 0.03, 0.685, 0.22)", + quartOut: "cubic-bezier(0.165, 0.84, 0.44, 1)", + quartInOut: "cubic-bezier(0.77, 0, 0.175, 1)", + quintIn: "cubic-bezier(0.755, 0.05, 0.855, 0.06)", + quintOut: "cubic-bezier(0.23, 1, 0.32, 1)", + quintInOut: "cubic-bezier(0.86, 0, 0.07, 1)", + expoIn: "cubic-bezier(0.95, 0.05, 0.795, 0.035)", + expoOut: "cubic-bezier(0.19, 1, 0.22, 1)", + expoInOut: "cubic-bezier(1, 0, 0, 1)", + circIn: "cubic-bezier(0.6, 0.04, 0.98, 0.335)", + circOut: "cubic-bezier(0.075, 0.82, 0.165, 1)", + circInOut: "cubic-bezier(0.785, 0.135, 0.15, 0.86)", + backIn: "cubic-bezier(0.6, -0.28, 0.735, 0.045)", + backOut: "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + backInOut: "cubic-bezier(0.68, -0.55, 0.265, 1.55)" +}; +function Gt(t) { + return t === "percentage" ? "%" : t || "px"; +} +function J(t) { + return t ? Ie[t] || t : Ie.linear; +} +function Wt(t, e, n, s) { + const i = 3 * t, r = 3 * (n - t) - i, o = 1 - i - r, a2 = 3 * e, c = 3 * (s - e) - a2, l = 1 - a2 - c, f = (p) => ((o * p + r) * p + i) * p, u = (p) => ((l * p + c) * p + a2) * p, d = (p) => (3 * o * p + 2 * r) * p + i; + function g(p) { + let m = p; + for (let y = 0; y < 8; y++) { + const E = f(m) - p; + if (Math.abs(E) < 1e-7) return m; + const w2 = d(m); + if (Math.abs(w2) < 1e-6) break; + m -= E / w2; + } + let h2 = 0, v = 1; + for (m = (h2 + v) / 2; v - h2 > 1e-7; ) { + const y = f(m); + if (Math.abs(y - p) < 1e-7) return m; + p > y ? h2 = m : v = m, m = (h2 + v) / 2; + } + return m; + } + return (p) => p <= 0 ? 0 : p >= 1 ? 1 : u(g(p)); +} +function Vt(t) { + const e = t.match( + /^cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)$/ + ); + if (!e) return; + const n = parseFloat(e[1]), s = parseFloat(e[2]), i = parseFloat(e[3]), r = parseFloat(e[4]); + if (![n, s, i, r].some(isNaN)) + return Wt(n, s, i, r); +} +function Yt(t) { + const e = t.match(/^linear\((.+)\)$/); + if (!e) return; + const n = e[1].split(",").map((o) => o.trim()).filter(Boolean); + if (n.length === 0) return; + const s = []; + for (const o of n) { + const a2 = o.split(/\s+/), c = parseFloat(a2[0]); + if (isNaN(c)) return; + const l = []; + for (let f = 1; f < a2.length; f++) + if (a2[f].endsWith("%")) { + const u = parseFloat(a2[f]) / 100; + if (isNaN(u)) return; + l.push(u); + } + l.length === 0 ? s.push({ output: c, pos: null }) : l.length === 1 ? s.push({ output: c, pos: l[0] }) : (s.push({ output: c, pos: l[0] }), s.push({ output: c, pos: l[1] })); + } + if (s.length === 0) return; + s[0].pos === null && (s[0].pos = 0), s[s.length - 1].pos === null && (s[s.length - 1].pos = 1); + let i = 0; + for (; i < s.length; ) + if (s[i].pos === null) { + const o = i - 1; + let a2 = i; + for (; a2 < s.length && s[a2].pos === null; ) a2++; + const c = s[o].pos, l = s[a2].pos, f = a2 - o; + for (let u = o + 1; u < a2; u++) + s[u].pos = c + (l - c) * (u - o) / f; + i = a2 + 1; + } else + i++; + for (let o = 1; o < s.length; o++) + s[o].pos < s[o - 1].pos && (s[o].pos = s[o - 1].pos); + const r = s; + return (o) => { + if (o <= r[0].pos) return r[0].output; + const a2 = r[r.length - 1]; + if (o >= a2.pos) return a2.output; + let c = 0, l = r.length - 1; + for (; c < l - 1; ) { + const d = c + l >>> 1; + r[d].pos <= o ? c = d : l = d; + } + const f = r[c], u = r[l]; + return u.pos === f.pos ? u.output : f.output + (u.output - f.output) * (o - f.pos) / (u.pos - f.pos); + }; +} +function be(t) { + if (!t) return; + const e = Te[t]; + return e || (Vt(t) ?? Yt(t) ?? Te.linear); +} +var Bt = class extends V { + animationGroups; + delay; + offset; + offsetEasing; + timingOptions; + constructor(e, n = {}) { + const s = e.flatMap((i) => [...i.animations]); + super(s), this.animationGroups = e, this.delay = n.delay ?? 0, this.offset = n.offset ?? 0, this.offsetEasing = typeof n.offsetEasing == "function" ? n.offsetEasing : be(n.offsetEasing) ?? je, this.timingOptions = this.animationGroups.map((i) => i.getTimingOptions().map(({ delay: r, duration: o, iterations: a2 }) => ({ + delay: r, + duration: Number.isFinite(o) ? o : 0, + iterations: Number.isFinite(a2) ? a2 : 1 + }))), this.applyOffsets(), this.ready = Promise.all(e.map((i) => i.ready)).then(() => { + }); + } + /** + * Calculates stagger delay offsets for each animation group using the formula: + * easing(i / last) * last * offset + * where i is the group index and last is the index of the final group. + */ + calculateOffsets() { + const e = this.animationGroups.length; + if (e <= 1) return [0]; + const n = e - 1; + return Array.from( + { length: e }, + (s, i) => this.offsetEasing(i / n) * n * this.offset | 0 + ); + } + applyOffsets() { + if (this.animationGroups.length === 0 || this.animations.length === 0) return; + const e = this.calculateOffsets(), n = this.getSequenceActiveDuration(e); + this.animationGroups.forEach((s, i) => { + s.animations.forEach((r, o) => { + const a2 = r.effect; + if (!a2) return; + const { delay: c, duration: l, iterations: f } = this.timingOptions[i][o], u = c + e[i], d = n - (u + l * f); + a2.updateTiming({ delay: u + this.delay, endDelay: d }); + }); + }); + } + getSequenceActiveDuration(e) { + const n = []; + for (let s = 0; s < this.timingOptions.length; s++) { + const i = this.timingOptions[s].reduce((r, o) => { + if (!o) return r; + const { delay: a2, duration: c, iterations: l } = o; + return Math.max(r, a2 + c * l); + }, 0); + n.push(e[s] + i); + } + return Math.max(...n); + } + /** + * Inserts new AnimationGroups at specified indices, then recalculates + * stagger offsets for all groups. Each entry specifies the target index + * in the animationGroups array where the group should be inserted. + */ + addGroups(e) { + if (e.length === 0) return; + const n = [...e].sort((s, i) => i.index - s.index); + for (const { index: s, group: i } of n) { + const r = Math.min(s, this.animationGroups.length); + this.animationGroups.splice(r, 0, i), this.timingOptions.splice(r, 0, i.getTimingOptions()); + const o = [...i.animations], a2 = this.animationGroups.slice(0, r).reduce((c, l) => c + l.animations.length, 0); + this.animations.splice(a2, 0, ...o); + } + this.applyOffsets(), this.ready = Promise.all(this.animationGroups.map((s) => s.ready)).then(() => { + }); + } + /** + * Removes AnimationGroups that match the predicate, then recalculates + * stagger offsets for remaining groups. Cancelled animations in removed + * groups are returned. + */ + removeGroups(e) { + const n = [], s = [], i = []; + for (let r = 0; r < this.animationGroups.length; r++) + e(this.animationGroups[r]) ? n.push(this.animationGroups[r]) : (s.push(this.animationGroups[r]), i.push(this.timingOptions[r])); + if (n.length === 0) return n; + for (const r of n) + r.cancel(); + return this.animationGroups = s, this.timingOptions = i, this.animations = s.flatMap((r) => [...r.animations]), this.applyOffsets(), this.ready = Promise.all(this.animationGroups.map((r) => r.ready)).then(() => { + }), n; + } + async onFinish(e) { + try { + await Promise.all(this.animationGroups.map((n) => n.finished)), e(); + } catch (n) { + console.warn("animation was interrupted - aborting onFinish callback - ", n); + } + } +}; +var Kt = class { + _animation; + customEffect; + progress; + _tickCbId; + _finishHandler; + constructor(e, n, s, i) { + const r = new KeyframeEffect(n, [], { + ...s, + composite: "add" + }), { timeline: o } = i; + this._animation = new Animation(r, o), this._tickCbId = null, this.progress = null, this.customEffect = (a2) => e(r.target, a2), this._finishHandler = (a2) => { + this.effect.target?.getAnimations().find((c) => c === this._animation) || this.cancel(); + }, this.addEventListener("finish", this._finishHandler), this.addEventListener("remove", this._finishHandler); + } + // private tick method for customEffect loop implementation + _tick() { + try { + const e = this.effect?.getComputedTiming().progress ?? null; + e !== this.progress && (this.customEffect?.(e), this.progress = e), this._tickCbId = requestAnimationFrame(() => { + this._tick(); + }); + } catch (e) { + this._tickCbId = null, console.error( + `failed to run customEffect! effectId: ${this.id}, error: ${e instanceof Error ? e.message : e}` + ); + } + } + // Animation timing properties + get currentTime() { + return this._animation.currentTime; + } + set currentTime(e) { + this._animation.currentTime = e; + } + get startTime() { + return this._animation.startTime; + } + set startTime(e) { + this._animation.startTime = e; + } + get playbackRate() { + return this._animation.playbackRate; + } + set playbackRate(e) { + this._animation.playbackRate = e; + } + // Animation basic properties + get id() { + return this._animation.id; + } + set id(e) { + this._animation.id = e; + } + get effect() { + return this._animation.effect; + } + set effect(e) { + this._animation.effect = e; + } + get timeline() { + return this._animation.timeline; + } + set timeline(e) { + this._animation.timeline = e; + } + // Animation readonly state properties + get finished() { + return this._animation.finished; + } + get pending() { + return this._animation.pending; + } + get playState() { + return this._animation.playState; + } + get ready() { + return this._animation.ready; + } + get replaceState() { + return this._animation.replaceState; + } + // Animation event handlers + get oncancel() { + return this._animation.oncancel; + } + set oncancel(e) { + this._animation.oncancel = e; + } + get onfinish() { + return this._animation.onfinish; + } + set onfinish(e) { + this._animation.onfinish = e; + } + get onremove() { + return this._animation.onremove; + } + set onremove(e) { + this._animation.onremove = e; + } + // CustomAnimation overridden methods + play() { + this._animation.play(), cancelAnimationFrame(this._tickCbId), this._tickCbId = requestAnimationFrame(() => this._tick()); + } + pause() { + this._animation.pause(), cancelAnimationFrame(this._tickCbId), this._tickCbId = null; + } + cancel() { + this.removeEventListener("finish", this._finishHandler), this.removeEventListener("remove", this._finishHandler), this._animation.cancel(), this.customEffect(null), cancelAnimationFrame(this._tickCbId), this._tickCbId = null; + } + commitStyles() { + console.warn( + "CustomEffect animations do not support commitStyles method as they have no style to commit" + ); + } + // Animation methods without override + finish() { + this._animation.finish(); + } + persist() { + this._animation.persist(); + } + reverse() { + this._animation.reverse(); + } + updatePlaybackRate(e) { + this._animation.updatePlaybackRate(e); + } + // Animation events API + addEventListener(e, n, s) { + this._animation.addEventListener(e, n, s); + } + removeEventListener(e, n, s) { + this._animation.removeEventListener(e, n, s); + } + dispatchEvent(e) { + return this._animation.dispatchEvent(e); + } +}; +function Qt(t) { + return t && t.__esModule && Object.prototype.hasOwnProperty.call(t, "default") ? t.default : t; +} +var ee = { exports: {} }; +var Oe = ee.exports; +var Ae; +function Ut() { + return Ae || (Ae = 1, (function(t) { + (function(e) { + var n = function() { + }, s = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.msRequestAnimationFrame || function(f) { + return setTimeout(f, 16); + }; + function i() { + var f = this; + f.reads = [], f.writes = [], f.raf = s.bind(e); + } + i.prototype = { + constructor: i, + /** + * We run this inside a try catch + * so that if any jobs error, we + * are able to recover and continue + * to flush the batch until it's empty. + * + * @param {Array} tasks + */ + runTasks: function(f) { + for (var u; u = f.shift(); ) u(); + }, + /** + * Adds a job to the read batch and + * schedules a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + measure: function(f, u) { + var d = u ? f.bind(u) : f; + return this.reads.push(d), r(this), d; + }, + /** + * Adds a job to the + * write batch and schedules + * a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + mutate: function(f, u) { + var d = u ? f.bind(u) : f; + return this.writes.push(d), r(this), d; + }, + /** + * Clears a scheduled 'read' or 'write' task. + * + * @param {Object} task + * @return {Boolean} success + * @public + */ + clear: function(f) { + return a2(this.reads, f) || a2(this.writes, f); + }, + /** + * Extend this FastDom with some + * custom functionality. + * + * Because fastdom must *always* be a + * singleton, we're actually extending + * the fastdom instance. This means tasks + * scheduled by an extension still enter + * fastdom's global task queue. + * + * The 'super' instance can be accessed + * from `this.fastdom`. + * + * @example + * + * var myFastdom = fastdom.extend({ + * initialize: function() { + * // runs on creation + * }, + * + * // override a method + * measure: function(fn) { + * // do extra stuff ... + * + * // then call the original + * return this.fastdom.measure(fn); + * }, + * + * ... + * }); + * + * @param {Object} props properties to mixin + * @return {FastDom} + */ + extend: function(f) { + if (typeof f != "object") throw new Error("expected object"); + var u = Object.create(this); + return c(u, f), u.fastdom = this, u.initialize && u.initialize(), u; + }, + // override this with a function + // to prevent Errors in console + // when tasks throw + catch: null + }; + function r(f) { + f.scheduled || (f.scheduled = true, f.raf(o.bind(null, f))); + } + function o(f) { + var u = f.writes, d = f.reads, g; + try { + n("flushing reads", d.length), f.runTasks(d), n("flushing writes", u.length), f.runTasks(u); + } catch (p) { + g = p; + } + if (f.scheduled = false, (d.length || u.length) && r(f), g) + if (n("task errored", g.message), f.catch) f.catch(g); + else throw g; + } + function a2(f, u) { + var d = f.indexOf(u); + return !!~d && !!f.splice(d, 1); + } + function c(f, u) { + for (var d in u) + u.hasOwnProperty(d) && (f[d] = u[d]); + } + var l = e.fastdom = e.fastdom || new i(); + t.exports = l; + })(typeof window < "u" ? window : typeof Oe < "u" ? Oe : globalThis); + })(ee)), ee.exports; +} +var Xt = Ut(); +var O = /* @__PURE__ */ Qt(Xt); +var de = {}; +function Zt(t) { + Object.assign(de, t); +} +function Jt(t) { + return t in de ? de[t] : (console.warn( + `${t} not found in registry. Please make sure to import and register the preset.` + ), null); +} +function N(t, e) { + return t ? (e || document).getElementById(t) : null; +} +function en(t, e) { + return t?.matches(`[data-motion-part~="${e}"]`) ? t : t?.querySelector(`[data-motion-part~="${e}"]`); +} +function tn(t) { + const e = t.alternate ? "alternate" : ""; + return t.reversed ? `${e ? `${e}-` : ""}reverse` : e || "normal"; +} +function ce(t) { + return `${t.value}${Gt(t.unit)}`; +} +function Ce(t, e, n) { + return `${t.name || "cover"} ${n && t.offset.unit !== "percentage" ? `calc(100% + ${ce(t.offset)}${e ? ` + ${e}` : ""})` : e ? `calc(${ce(t.offset)} + ${e})` : ce(t.offset)}`; +} +function De(t) { + return { + start: Ce(t.startOffset, t.startOffsetAdd), + end: Ce(t.endOffset, t.endOffsetAdd, true) + }; +} +function Ge(t) { + return (e) => O.measure(() => e(t)); +} +function We(t) { + return (e) => O.mutate(() => e(t)); +} +function W(t) { + if (t.namedEffect) { + const e = t.namedEffect.type; + return typeof e == "string" ? Jt(e) : null; + } else if (t.keyframeEffect) { + const e = (s) => { + const { name: i, keyframes: r } = s.keyframeEffect; + return [{ ...s, name: i, keyframes: r }]; + }; + return { web: e, style: e, getNames: (s) => { + const { effectId: i } = s, { name: r } = s.keyframeEffect, o = r || i; + return o ? [o] : []; + } }; + } else if (t.customEffect) + return (e) => [{ ...e, keyframes: [] }]; + return null; +} +function Ve(t, e, n, s) { + return t.map((i, r) => { + const o = { + fill: i.fill, + easing: J(i.easing), + iterations: i.iterations === 0 ? 1 / 0 : i.iterations || 1, + composite: i.composite, + direction: tn(i) + }; + return Se(e) ? (o.duration = i.duration, o.delay = i.delay || 0) : e?.trigger === "view-progress" && (s || window.ViewTimeline) ? o.duration = "auto" : (o.duration = 99.99, o.delay = 0.01), { + effect: i, + options: o, + id: n && `${n}-${r + 1}`, + part: i.part + }; + }); +} +function Se(t) { + return !t || t.trigger !== "pointer-move" && t.trigger !== "view-progress"; +} +function ke(t, e, n, s, i) { + if (t) { + if (Se(s) && (e.duration = e.duration || 1, i?.reducedMotion)) + if (e.iterations === 1 || e.iterations == null) + e = { ...e, duration: 1 }; + else + return []; + let r; + return n instanceof HTMLElement && (r = { measure: Ge(n), mutate: We(n) }), t.web ? t.web(e, r, i) : t(e, r, i); + } + return []; +} +function Ye(t, e, n, s, i) { + const r = t instanceof HTMLElement ? t : N(t, i); + if (n?.trigger === "pointer-move" && !e.keyframeEffect) { + let d = e; + e.customEffect && (d = { + ...e, + namedEffect: { id: "", type: "CustomMouse" } + }); + const g = W( + d + ), p = ke( + g, + e, + r, + n, + s + ); + return typeof p != "function" ? null : p(r); + } + const o = W(e), a2 = ke( + o, + e, + r, + n, + s + ); + if (!a2 || a2.length === 0) + return null; + const c = Ve(a2, n, e.effectId); + let l; + const f = n?.trigger === "view-progress"; + f && window.ViewTimeline && (l = new ViewTimeline({ + subject: n.element || N(n.componentId) + })); + const u = c.map(({ effect: d, options: g, id: p, part: m }) => { + const h2 = m ? en(r, m) : r, v = new KeyframeEffect(h2 || null, [], g); + O.mutate(() => { + "timing" in d && v.updateTiming(d.timing), v.setKeyframes(d.keyframes); + }); + const y = f && l ? { timeline: l } : {}, E = typeof d.customEffect == "function" ? new Kt( + d.customEffect, + h2 || null, + g, + y + ) : new Animation(v, y.timeline); + if (f) + if (l) + O.mutate(() => { + const { start: w2, end: S2 } = De(d); + E.rangeStart = w2, E.rangeEnd = S2, E.play(); + }); + else { + const { startOffset: w2, endOffset: S2 } = e; + O.mutate(() => { + const T = d.startOffset || w2, I2 = d.endOffset || S2; + Object.assign(E, { + start: { + name: T.name, + offset: T.offset?.value, + add: d.startOffsetAdd + }, + end: { + name: I2.name, + offset: I2.offset?.value, + add: d.endOffsetAdd + } + }); + }); + } + return p && (E.id = p), E; + }); + return new V(u, { + ...e, + trigger: { ...n || {} }, + // make sure the group is ready after all animation targets are measured and mutated + measured: new Promise((d) => O.mutate(d)) + }); +} +function an(t, e, n) { + const s = W(e), i = t instanceof HTMLElement ? t : N(t); + if (s && s.prepare && i) { + const r = { measure: Ge(i), mutate: We(i) }; + s.prepare(e, r); + } + n && O.mutate(n); +} +function Be(t, e) { + const n = W(e); + if (!n) + return null; + if (!n.style) + return e.effectId && t ? cn(t, e.effectId) : null; + const s = n.getNames(e), r = (typeof t == "string" ? N(t) : t)?.getAnimations(), o = r?.map((c) => c.animationName) || [], a2 = []; + return s.forEach((c) => { + o.includes(c) && a2.push( + r?.find((l) => l.animationName === c) + ); + }), a2?.length ? new V(a2) : null; +} +function cn(t, e) { + const s = (typeof t == "string" ? N(t) : t)?.getAnimations().filter((i) => { + const r = i.id || i.animationName; + return r ? r.startsWith(e) : true; + }); + return s?.length ? new V(s) : null; +} +function Ke(t, e, n, s = {}) { + const { disabled: i, allowActiveEvent: r, ...o } = s, a2 = Ye(t, e, n, o); + if (!a2) + return null; + let c = {}; + if (n.trigger === "view-progress" && !window.ViewTimeline) { + const l = n.element || N(n.componentId), { ready: f } = a2; + return a2.animations.map((u) => ({ + /* we use getters for start and end in order to access the animation's start and end + only when initializing the scrub scene rather than immediately */ + get start() { + return u.start; + }, + get end() { + return u.end; + }, + viewSource: l, + ready: f, + getProgress() { + return a2.getProgress(); + }, + effect(d, g) { + const { activeDuration: p } = u.effect.getComputedTiming(), { delay: m } = u.effect.getTiming(); + u.currentTime = ((m || 0) + (p || 0)) * g; + }, + disabled: i, + destroy() { + u.cancel(); + } + })); + } else if (n.trigger === "pointer-move") { + const l = e, { centeredToTarget: f, transitionDuration: u, transitionEasing: d } = l, g = n.axis; + if (l.keyframeEffect) { + const p = a2; + return p.animations?.length === 0 ? null : { + target: void 0, + centeredToTarget: f, + ready: p.ready, + _currentProgress: 0, + getProgress() { + return this._currentProgress; + }, + effect(h2, v) { + const y = g === "x" ? v.x : v.y; + this._currentProgress = y, p.progress(y); + }, + disabled: i ?? false, + destroy() { + p.cancel(); + } + }; + } + c = { + centeredToTarget: f, + allowActiveEvent: r + }, e.customEffect && u && (c.transitionDuration = u, c.transitionEasing = be(d)), c.target = a2.target; + } + return { + ...c, + getProgress() { + return a2.getProgress(); + }, + effect(l, f, u, d) { + a2.progress( + u ? { + // @ts-expect-error spread error on p + ...f, + v: u, + active: d + } : f + ); + }, + disabled: i, + destroy() { + a2.cancel(); + } + }; +} +function Y(t, e, n, s = false) { + const i = Be(t, e); + return i ? (i.ready = new Promise((r) => { + an(t, e, r); + }), i) : Ye(t, e, n, { reducedMotion: s }); +} +function fn(t) { + return t === null ? [null] : typeof t == "string" ? Array.from(document.querySelectorAll(t)) : Array.isArray(t) ? t : [t]; +} +function Qe(t, e) { + const n = []; + for (const { target: s, options: i } of t) { + const r = fn(s); + for (const o of r) { + const a2 = Y( + o, + i, + void 0, + e?.reducedMotion + ); + a2 instanceof V && n.push(a2); + } + } + return n; +} +function ln(t, e, n) { + const s = Qe(e, n); + return new Bt(s, t); +} +function te(t, e) { + return e.includes("&") ? e.replace(/&/g, t) : `${t}${e}`; +} +function k() { + return "wi-12343210".replace( + /\d/g, + (t) => String.fromCharCode( + (+t ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +t / 4) + 97 + ) + // 97 for "a" + ); +} +function Ue(t) { + let { transition: e, transitionProperties: n } = t, s = []; + if (e?.styleProperties) { + const { duration: i, easing: r, delay: o } = e; + i && (e.styleProperties.some( + (c) => c.name.startsWith("--") + ) ? s = [ + `all ${i}ms ${J(r || "ease")}${o ? ` ${o}ms` : ""}`, + "visibility 0s" + ] : s = e.styleProperties.map( + (c) => `${c.name} ${i}ms ${J( + r || "ease" + )}${o ? ` ${o}ms` : ""}` + )); + } else + s = n?.filter((i) => i.duration).map( + (i) => `${i.name} ${i.duration}ms ${J(i.easing) || "ease"}${i.delay ? ` ${i.delay}ms` : ""}` + ) || []; + return s; +} +function gn({ + key: t, + effectId: e, + transition: n, + transitionProperties: s, + childSelector: i = "> :first-child", + selectorCondition: r +}) { + const o = Ue({ + transition: n, + transitionProperties: s + }), a2 = (n?.styleProperties || s)?.map( + (p) => `${p.name}: ${p.value};` + ) || [], c = t.replace(/"/g, "'"), l = `:is(:state(${e}), :--${e}) ${i}`, f = `[data-interact-effect~="${e}"] ${i}`, u = r ? te(l, r) : l, d = r ? te(f, r) : f, g = [ + `${u}, + ${d} { + ${a2.join(` + `)} + }` + ]; + if (o.length) { + const p = `[data-interact-key="${c}"] ${i}`, m = r ? te(p, r) : p; + g.push(`@media (prefers-reduced-motion: no-preference) { ${m} { + transition: ${o.join(", ")}; + } }`); + } + return g; +} +function ae(t, e, n) { + const s = (t || []).filter((i) => e[i]?.type === n && e[i].predicate).map((i) => e[i].predicate).join(") and ("); + return s && `(${s})`; +} +function _(t, e) { + const n = ae(t, e, "media"); + return n && window.matchMedia(n); +} +function L(t, e) { + return (t || []).filter((n) => e[n]?.type === "selector" && e[n].predicate).map((n) => `:is(${e[n].predicate})`).join(""); +} +var K = { + rangeStart: { name: "cover", offset: { value: 0, unit: "percentage" } }, + rangeEnd: { name: "cover", offset: { value: 100, unit: "percentage" } } +}; +function vn(t, e) { + const n = t?.name ?? K.rangeStart.name, s = e?.name ?? t?.name ?? K.rangeEnd.name, i = { + name: n, + offset: t?.offset || K.rangeStart.offset + }, r = { + name: s, + offset: e?.offset || K.rangeEnd.offset + }; + return { startOffset: i, endOffset: r }; +} +function q(t) { + if ("keyframeEffect" in t && !t.keyframeEffect.name && "effectId" in t && (t.keyframeEffect.name = t.effectId), "duration" in t) + return { + id: "", + ...t + }; + const { rangeStart: e, rangeEnd: n, ...s } = t, { startOffset: i, endOffset: r } = vn(e, n); + return { + id: "", + startOffset: i, + endOffset: r, + ...s + }; +} +function C(t, e, n) { + let s = t.get(e); + s || (s = /* @__PURE__ */ new Set(), t.set(e, s)), s.add(n); +} +function B(t, e) { + t.get(e)?.forEach((s) => { + const { source: i, target: r, cleanup: o } = s; + o(); + const a2 = i === e ? r : i; + t.get(a2)?.delete(s); + }), t.delete(e); +} +var yn = { + root: null, + rootMargin: "0px 0px -10% 0px", + threshold: [0] +}; +var En = { + root: null, + rootMargin: "0px", + threshold: [0] +}; +var wn = 0.2; +function bn(t) { + const e = t.trim().split(/\s+/), n = e[0], s = e.length > 1 ? e[1] : e[0], i = (r) => r.startsWith("-") ? r.slice(1) : parseFloat(r) ? `-${r}` : r; + return `${i(n)} 0px ${i(s)}`; +} +var D = {}; +var M = /* @__PURE__ */ new WeakMap(); +var re = /* @__PURE__ */ new WeakSet(); +var z = /* @__PURE__ */ new WeakMap(); +var Xe = {}; +var H = null; +function Sn(t) { + Xe = t; +} +function Ze(t, e, n) { + M.get(t)?.forEach(({ source: i, handler: r }) => { + i === t && r(e, n); + }); +} +function $e() { + return H || (H = new IntersectionObserver((t) => { + t.forEach((e) => { + const n = e.target; + e.isIntersecting || Ze(n, false, true); + }); + }, En), H); +} +function Je(t, e = false) { + const n = JSON.stringify({ ...t, isSafeMode: e }); + if (D[n]) + return D[n]; + const s = t.threshold ?? wn, i = e ? yn : { + root: null, + rootMargin: t.inset ? bn(t.inset) : "0px", + threshold: s + }, r = new IntersectionObserver((o) => { + o.forEach((a2) => { + const c = a2.target, l = !re.has(c); + if (l && (re.add(c), t.useSafeViewEnter && !a2.isIntersecting)) { + O.measure(() => { + const f = a2.boundingClientRect.height, u = a2.rootBounds?.height; + if (!u) + return; + const d = Array.isArray(t.threshold) ? Math.min(...t.threshold) : t.threshold; + d && f * d > u && O.mutate(() => { + r.unobserve(c); + const p = Je(t, true); + z.set(c, p), p.observe(c); + }); + }); + return; + } + (a2.isIntersecting || !l) && Ze(c, a2.isIntersecting); + }); + }, i); + return D[n] = r, r; +} +function Tn(t, e, n, s = {}, { reducedMotion: i, selectorCondition: r, animation: o } = {}) { + const a2 = { ...Xe, ...s }, c = n.triggerType || "once", l = o || Y( + e, + q(n), + void 0, + i + ); + if (!l) + return; + const f = Je(a2); + c !== "once" && l.persist?.(); + let u = true, d = false, g; + g = { source: t, target: e, handler: (h2, v) => { + if (!(r && !e.matches(r))) + if (c === "once") { + if (h2 && !d) { + d = true, M.get(t)?.delete(g), M.get(e)?.delete(g); + const y = M.get(t); + (!y || y.size === 0) && ((z.get(t) || f).unobserve(t), re.delete(t)), l.play(() => { + const E = () => { + e.dataset.interactEnter = "start"; + }; + if (l.isCSS) { + O.mutate(() => { + requestAnimationFrame(E); + }); + const w2 = () => { + O.mutate(() => { + e.dataset.interactEnter = "done"; + }); + }; + l.onFinish(w2), l.onAbort(w2); + } else + O.mutate(E); + }); + } + } else c === "alternate" ? u && h2 ? (u = false, l.play()) : u || l.reverse() : c === "repeat" ? h2 ? (l.progress(0), l.play()) : v && (l.pause(), l.progress(0)) : c === "state" && (h2 ? l.play() : v && l.pause()); + }, cleanup: () => { + (z.get(t) || f).unobserve(t), (c === "repeat" || c === "state") && $e().unobserve(t), l.cancel(), re.delete(t), z.delete(t); + } }, C(M, t, g), C(M, e, g), z.set(t, f), f.observe(t), (c === "repeat" || c === "state") && $e().observe(t); +} +function In(t) { + B(M, t); +} +function On() { + H = null, Object.keys(D).forEach((t) => delete D[t]); +} +var _e = { + add: Tn, + remove: In, + setOptions: Sn, + reset: On +}; +function et(t, e) { + return Object.assign(Object.create(e), t); +} +function An(t, e, n, s) { + let i = t * (1 - n) + e * n; + if (s) { + const r = i - t; + Math.abs(r) < s && (i = t + s * Math.sign(r)); + const o = e - i; + if (Math.abs(o) < s) + return e; + } + return i; +} +function Cn(t) { + let e = false; + return function() { + e || (e = true, window.requestAnimationFrame(() => { + e = false, t(); + })); + }; +} +function qe(t, e) { + let n = 0; + return function() { + n && window.clearTimeout(n), n = window.setTimeout(() => { + n = 0, t(); + }, e); + }; +} +function kn(t, e) { + const n = t.match(/^calc\s*\(\s*(-?\d+((px)|([lsd]?vh)|([lsd]?vw)))\s*\+\s*(-?\d+((px)|([lsd]?vh)|([lsd]?vw)))\s*\)\s*$/); + return oe(n[1], e) + oe(n[6], e); +} +function oe(t, e) { + return t ? /^-?\d+px$/.test(t) ? parseInt(t) : /^-?\d+[lsd]?vh$/.test(t) ? parseInt(t) * e.viewportHeight / 100 : /^-?\d+[lsd]?vw$/.test(t) ? parseInt(t) * e.viewportWidth / 100 : /^calc\s*\(\s*-?\d+((px)|([lsd]?vh)|([lsd]?vw))\s*\+\s*-?\d+((px)|([lsd]?vh)|([lsd]?vw))\s*\)\s*$/.test(t) ? kn(t, e) : parseInt(t) || 0 : 0; +} +function R(t, e, n) { + const { name: s, offset: i = 0 } = t, { start: r, end: o } = n, a2 = o - r, c = i / 100; + let l, f; + return s === "entry" ? (l = r - e, f = Math.min(e, a2)) : s === "entry-crossing" ? (l = r - e, f = a2) : s === "contain" ? (l = Math.min(o - e, r), f = Math.abs(e - a2)) : s === "exit" ? (l = Math.max(r, o - e), f = Math.min(e, a2)) : s === "exit-crossing" ? (l = r, f = a2) : s === "cover" && (l = r - e, f = a2 + e), l + c * f | 0; +} +function fe(t, e, n, s, i) { + let r = 0; + const o = { start: e, end: n }; + return t.forEach((a2, c) => { + r += a2.offset; + const l = a2.sticky; + if (l) { + if ("end" in l && t[c - 1]?.element) { + const d = ((i ? a2.element.offsetWidth : a2.element.offsetHeight) || 0) + l.end - s, g = r + d - a2.offset, p = g < o.start, m = !p && g <= n; + let h2 = 0; + (p || m) && (h2 = a2.offset, o.end += h2), p && (o.start += h2); + } + if ("start" in l) { + const f = r - l.start, u = f < o.start, d = !u && f <= o.end; + let g = 0; + const p = t[c - 1]?.element; + if (p) { + if (u || d) { + const m = (i ? p.offsetWidth : p.offsetHeight) || 0, h2 = a2.offset, v = (i ? a2.element.offsetWidth : a2.element.offsetHeight) || 0; + g = m - (h2 + v), r += g, o.end += g; + } + u && (o.start += g); + } + } + } + }), o; +} +function $n(t, e, n, s, i, r) { + const { start: o, end: a2, duration: c } = t; + let l = o, f = a2, u = t.startRange, d = t.endRange, g; + if (typeof c == "string") { + u = { name: c, offset: 0 }, d = { name: c, offset: 100 }, l = R(u, n, e), f = R(d, n, e), g = f - l; + const p = fe(r, l, f, n, s); + l = p.start, f = p.end; + } else { + if (u || o?.name) { + u = u || o; + const p = oe(u.add, i), m = R({ ...u, offset: 0 }, n, e), h2 = R({ ...u, offset: 100 }, n, e), v = fe(r, m, h2, n, s); + l = v.start + u.offset / 100 * (v.end - v.start) + p; + } + if (d || a2?.name) { + d = d || a2; + const p = oe(d.add, i), m = R({ ...d, offset: 0 }, n, e), h2 = R({ ...d, offset: 100 }, n, e), v = fe(r, m, h2, n, s); + f = v.start + d.offset / 100 * (v.end - v.start) + p; + } else typeof c == "number" && (f = l + c); + } + return !g && !c && (g = f - l), { ...t, start: l, end: f, startRange: u, endRange: d, duration: g || c }; +} +function _n(t) { + return t.position === "sticky"; +} +function qn(t, e, n) { + return t.position === "fixed" && (!e || e === window.document.body || e === n); +} +function Mn(t, e) { + return parseInt(e ? t.left : t.top); +} +function xn(t, e) { + return parseInt(e ? t.right : t.bottom); +} +function Pn(t, e, n) { + n && (t.style.position = "static"); + const s = (e ? t.offsetLeft : t.offsetTop) || 0; + return n && (t.style.position = null), s; +} +function Ln(t, e) { + let n; + const s = Mn(t, e), i = xn(t, e), r = !isNaN(s), o = !isNaN(i); + return (r || o) && (n = {}, r && (n.start = s), o && (n.end = i)), n; +} +function Q(t, e, n, s, i) { + const r = t[0].viewSource, o = []; + let a2 = (s ? r.offsetWidth : r.offsetHeight) || 0, c = 0, l = r; + for (; l; ) { + const u = window.getComputedStyle(l), d = _n(u), g = d ? Ln(u, s) : void 0, p = Pn(l, s, d); + if ((!g || !("end" in g)) && (c += p), o.push({ element: l, offset: p, sticky: g }), l = l.offsetParent, qn(u, l, e)) + break; + if (l === e) { + o.push({ element: l, offset: 0 }); + break; + } + } + return o.reverse(), t.map((u) => ({ + ...$n( + u, + { start: c, end: c + a2 }, + n, + s, + i, + o + ) + })); +} +var Me = 100; +var Rn = { + horizontal: false, + observeViewportEntry: true, + viewportRootMargin: "7% 7%", + observeViewportResize: false, + observeSourcesResize: false, + observeContentResize: false +}; +function Fn(t, e, n, s) { + let i = 0; + return t >= e && t <= n ? i = s ? (t - e) / s : 1 : t > n && (i = 1), i; +} +function xe(t, e) { + return t === window ? e ? window.document.documentElement.clientWidth : window.document.documentElement.clientHeight : e ? t.clientWidth : t.clientHeight; +} +function Nn() { + return { + viewportWidth: window.document.documentElement.clientWidth, + viewportHeight: window.document.documentElement.clientHeight + }; +} +function zn(t) { + const e = et(t, Rn), n = e.root, s = e.horizontal, i = /* @__PURE__ */ new WeakMap(); + let r = xe(n, s), o, a2, c, l, f; + const u = [], d = Nn(); + if (e.scenes = Object.values( + // TODO(ameerf): find a polyfill and use groupBy instead of following reduce + t.scenes.reduce( + (m, h2, v) => { + const y = h2.groupId ? `group-${h2.groupId}` : String(v); + return m[y] ? m[y].push(h2) : m[y] = [h2], m; + }, + {} + ) + ).flatMap((m) => (m.every((h2) => h2.viewSource && (typeof h2.duration == "string" || h2.start?.name)) ? (m = Q(m, n, r, s, d), (e.observeSourcesResize || e.observeContentResize) && u.push(m)) : m.forEach((h2) => { + h2.end == null && (h2.end = h2.start + h2.duration), h2.duration == null && (h2.duration = h2.end - h2.start); + }), m)), e.scenes.forEach((m, h2) => { + m.index = h2; + }), u.length) { + const m = /* @__PURE__ */ new Map(); + window.ResizeObserver && (c = new window.ResizeObserver(function(h2) { + h2.forEach((v) => { + const y = m.get(v.target), E = Q(y, n, r, s, d); + E.forEach((w2, S2) => { + e.scenes[w2.index] = E[S2]; + }), u.splice(u.indexOf(y), 1, E); + }); + }), u.forEach((h2) => { + c.observe(h2[0].viewSource, { box: "border-box" }), m.set(h2[0].viewSource, h2); + }), e.observeContentResize && e.contentRoot && new window.ResizeObserver(qe(() => { + const v = u.map((y) => { + const E = Q(y, n, r, s, d); + return E.forEach((w2, S2) => { + e.scenes[w2.index] = E[S2]; + }), E; + }); + u.length = 0, u.push(...v), u.forEach((y) => { + m.set(y[0].viewSource, y); + }); + }, Me)).observe(e.contentRoot, { box: "border-box" })), e.observeViewportResize && (l = qe(function() { + r = xe(n, s); + const h2 = u.map((v) => { + const y = Q(v, n, r, s, d); + return y.forEach((E, w2) => { + e.scenes[E.index] = y[w2]; + }), y; + }); + u.length = 0, u.push(...h2), u.forEach((v) => { + m.set(v[0].viewSource, v); + }); + }, Me), n === window ? window.addEventListener("resize", l) : window.ResizeObserver && (f = new window.ResizeObserver(l), f.observe(n, { box: "border-box" }))); + } + e.observeViewportEntry && window.IntersectionObserver && (a2 = new window.IntersectionObserver(function(m) { + m.forEach((h2) => { + (i.get(h2.target) || []).forEach((v) => { + v.disabled = !h2.isIntersecting; + }); + }); + }, { + root: n === window ? window.document : n, + rootMargin: e.viewportRootMargin, + threshold: 0 + }), e.scenes.forEach((m) => { + if (m.viewSource) { + let h2 = i.get(m.viewSource); + h2 || (h2 = [], i.set(m.viewSource, h2), a2.observe(m.viewSource)), h2.push(m); + } + })); + function g({ p: m, vp: h2 }) { + m = +m.toFixed(1); + const v = +h2.toFixed(4); + if (m !== o) { + for (let y of e.scenes) + if (!y.disabled) { + const { start: E, end: w2, duration: S2 } = y, T = Fn(m, E, w2, S2); + y.effect(y, T, v); + } + o = m; + } + } + function p() { + e.scenes.forEach((m) => m.destroy?.()), a2 && (a2.disconnect(), a2 = null), c && (c.disconnect(), c = null), l && (f ? (f.disconnect(), f = null) : window.removeEventListener("resize", l)); + } + return { + tick: g, + destroy: p + }; +} +var Hn = { + transitionActive: false, + transitionFriction: 0.9, + transitionEpsilon: 1, + velocityActive: false, + velocityMax: 1 +}; +var jn = class { + constructor(e = {}) { + this.config = et(e, Hn), this.progress = { + p: 0, + prevP: 0, + vp: 0 + }, this.currentProgress = { + p: 0, + prevP: 0, + vp: 0 + }, this._lerpFrameId = 0, this.effect = null; + const n = !this.config.root || this.config.root === window.document.body; + this.config.root = n ? window : this.config.root, this.config.contentRoot = this.config.contentRoot || (n ? window.document.body : this.config.root.firstElementChild), this.config.resetProgress = this.config.resetProgress || this.resetProgress.bind(this), this._measure = this.config.measure || (() => { + const s = this.config.root; + this.progress.p = this.config.horizontal ? s.scrollX || s.scrollLeft || 0 : s.scrollY || s.scrollTop || 0; + }), this._trigger = Cn(() => { + this._measure?.(), this.tick(true); + }); + } + /** + * Setup event and effect, and reset progress and frame. + */ + start() { + this.setupEffect(), this.setupEvent(), this.resetProgress(), this.tick(); + } + /** + * Removes event listener. + */ + pause() { + this.removeEvent(); + } + /** + * Reset progress in the DOM and inner state to given x and y. + * + * @param {Object} [scrollPosition] + * @param {number} [scrollPosition.x] + * @param {number} [scrollPosition.y] + */ + resetProgress(e = {}) { + const n = this.config.root, s = e.x || e.x === 0 ? e.x : n.scrollX || n.scrollLeft || 0, i = e.y || e.y === 0 ? e.y : n.scrollY || n.scrollTop || 0, r = this.config.horizontal ? s : i; + this.progress.p = r, this.progress.prevP = r, this.progress.vp = 0, this.config.transitionActive && (this.currentProgress.p = r, this.currentProgress.prevP = r, this.currentProgress.vp = 0), e && this.config.root.scrollTo(s, i); + } + /** + * Handle animation frame work. + * + * @param {boolean} [clearLerpFrame] whether to cancel an existing lerp frame + */ + tick(e) { + const n = this.config.transitionActive; + n && this.lerp(); + const s = n ? this.currentProgress : this.progress; + if (this.config.velocityActive) { + const i = s.p - s.prevP, r = i < 0 ? -1 : 1; + s.vp = Math.min(this.config.velocityMax, Math.abs(i)) / this.config.velocityMax * r; + } + this.effect.tick(s), n && s.p !== this.progress.p && (e && this._lerpFrameId && window.cancelAnimationFrame(this._lerpFrameId), this._lerpFrameId = window.requestAnimationFrame(() => this.tick())), s.prevP = s.p; + } + /** + * Calculate current progress. + */ + lerp() { + this.currentProgress.p = An(this.currentProgress.p, this.progress.p, +(1 - this.config.transitionFriction).toFixed(3), this.config.transitionEpsilon); + } + /** + * Stop the event and effect, and remove all DOM side-effects. + */ + destroy() { + this.pause(), this.removeEffect(); + } + /** + * Register to scroll for triggering update. + */ + setupEvent() { + this.removeEvent(), this.config.root.addEventListener("scroll", this._trigger); + } + /** + * Remove scroll handler. + */ + removeEvent() { + this.config.root.removeEventListener("scroll", this._trigger); + } + /** + * Reset registered effect. + */ + setupEffect() { + this.removeEffect(), this.effect = zn(this.config); + } + /** + * Remove registered effect. + */ + removeEffect() { + this.effect && this.effect.destroy(), this.effect = null; + } +}; +var he = /* @__PURE__ */ new WeakMap(); +var tt = () => ({}); +function Dn(t) { + tt = t; +} +function Gn(t, e, n, s, { reducedMotion: i }) { + if (i) + return; + const r = { + trigger: "view-progress", + element: t + }, o = q(n); + let a2; + if ("ViewTimeline" in window) { + const l = Y( + e, + o, + r + ); + l && !l.isCSS && (l.play(), a2 = () => { + l.ready.then(() => { + l.cancel(); + }); + }); + } else { + const l = Ke(e, o, r); + if (l) { + const f = Array.isArray(l) ? l : [l], u = new jn({ + viewSource: t, + scenes: f, + observeViewportEntry: false, + observeViewportResize: false, + observeSourcesResize: true, + root: document.body, + ...tt() + }); + a2 = () => { + u.destroy(); + }, Promise.all(f.map((d) => d.ready || Promise.resolve())).then( + () => { + u.start(); + } + ); + } + } + if (!a2) return; + const c = { source: t, target: e, cleanup: a2 }; + C(he, t, c), C(he, e, c); +} +function Wn(t) { + B(he, t); +} +var Vn = { + add: Gn, + remove: Wn, + registerOptionsGetter: Dn +}; +function Pe(t, e, n) { + return Math.min(Math.max(t, n), e); +} +function Le(t) { + let e = false; + return function() { + if (!e) + return e = true, window.requestAnimationFrame(() => { + e = false, t(); + }); + }; +} +function Yn(t) { + let e = t, n = 0, s = 0; + if (e.offsetParent) + do + n += e.offsetLeft, s += e.offsetTop, e = e.offsetParent; + while (e); + return { + left: n, + top: s, + width: t.offsetWidth, + height: t.offsetHeight + }; +} +function Bn() { + const t = window.devicePixelRatio; + let e = false; + if (t === 1) + return false; + document.body.addEventListener("pointerdown", (s) => { + e = s.offsetX !== 10; + }, { once: true }); + const n = new PointerEvent("pointerdown", { + clientX: 10 + }); + return document.body.dispatchEvent(n), e; +} +function Kn() { + return new Promise((t) => { + const e = window.scrollY; + let n = false, s; + function i() { + document.body.addEventListener("pointerdown", (a2) => { + s === void 0 ? s = a2.offsetY : n = a2.offsetY === s; + }, { once: true }); + const o = new PointerEvent("pointerdown", { + clientY: 500 + }); + document.body.dispatchEvent(o); + } + function r() { + window.scrollY !== e && (window.removeEventListener("scroll", r), i(), t(n)); + } + i(), window.addEventListener("scroll", r), window.scrollY > 0 && window.scrollBy(0, -1); + }); +} +function Qn(t) { + Kn().then((e) => { + t.fixRequired = e, e && (window.addEventListener("scroll", t.scrollHandler), t.scrollHandler()); + }); +} +var U = 0; +var ne = /* @__PURE__ */ new Set(); +function Un() { + const t = (n) => { + for (let s of n.changedTouches) + ne.add(s.identifier); + }, e = (n) => { + for (let s of n.changedTouches) + ne.delete(s.identifier); + }; + return document.addEventListener("touchstart", t, { passive: true }), document.addEventListener("touchend", e, { passive: true }), function() { + ne.clear(), document.removeEventListener("touchstart", t), document.removeEventListener("touchend", e); + }; +} +function Xn(t, e) { + if ("onscrollend" in window) + return t.addEventListener("scrollend", e), function() { + t.removeEventListener("scrollend", e); + }; + let n = 0, s; + U || (s = Un()), U += 1; + function i(r) { + clearTimeout(n), n = setTimeout(() => { + ne.size ? setTimeout(i, 100) : (e(r), n = 0); + }, 100); + } + return t.addEventListener("scroll", i), function() { + t.removeEventListener("scroll", i), U -= 1, U || s(); + }; +} +function Zn(t, e, n) { + return { + x(s) { + const i = t.left - n.x + t.width / 2, r = i >= e.width / 2, o = (r ? i : e.width - i) * 2, a2 = r ? 0 : i - o / 2; + return (s - a2) / o; + }, + y(s) { + const i = t.top - n.y + t.height / 2, r = i >= e.height / 2, o = (r ? i : e.height - i) * 2, a2 = r ? 0 : i - o / 2; + return (s - a2) / o; + } + }; +} +function Jn(t, e) { + this.x = window.scrollX, this.y = window.scrollY, requestAnimationFrame(() => t && t(e)); +} +function es(t) { + t.rect.width = window.document.documentElement.clientWidth, t.rect.height = window.document.documentElement.clientHeight; +} +function ts(t) { + const e = new ResizeObserver((n) => { + n.forEach((s) => { + t.rect.width = s.borderBoxSize[0].inlineSize, t.rect.height = s.borderBoxSize[0].blockSize; + }); + }); + return e.observe(t.root, { box: "border-box" }), e; +} +function ns(t) { + let e = false, n = { x: t.rect.width / 2, y: t.rect.height / 2, vx: 0, vy: 0 }, s, i, r, o, a2; + const c = { x: 0, y: 0 }; + t.scenes.forEach((f) => { + f.target && f.centeredToTarget && (f.transform = Zn(Yn(f.target), t.rect, c), e = true), t.root ? i = ts(t) : (r = es.bind(null, t), window.addEventListener("resize", r)); + }), s = function(f) { + for (let u of t.scenes) + if (!u.disabled) { + const d = u.transform?.x(f.x) || f.x / t.rect.width, g = u.transform?.y(f.y) || f.y / t.rect.height, p = +Pe(0, 1, d).toPrecision(4), m = +Pe(0, 1, g).toPrecision(4), h2 = { x: f.vx, y: f.vy }; + t.allowActiveEvent && (f.active = d <= 1 && g <= 1 && d >= 0 && g >= 0), u.effect(u, { x: p, y: m }, h2, f.active); + } + Object.assign(n, f); + }, e && (o = Jn.bind(c, s, n), a2 = Xn(document, o)); + function l() { + t.scenes.forEach((f) => f.destroy?.()), a2?.(), i ? (i.disconnect(), i = null) : (window.removeEventListener("resize", r), r = null), s = null, n = null; + } + return { + tick: s, + destroy: l + }; +} +var ss = 1e3 / 60 * 3; +var X; +function is() { + F.x = window.scrollX, F.y = window.scrollY; +} +var F = { x: 0, y: 0, scrollHandler: is, fixRequired: void 0 }; +var rs = class { + constructor(e = {}) { + this.config = { ...e }, this.effect = null, this._nextTick = null, this._nextTransitionTick = null, this._startTime = 0; + let n; + this.config.transitionDuration ? n = this.config.noThrottle ? () => this.transition() : Le(() => this.transition()) : n = this.config.noThrottle ? () => (this.tick(), null) : Le(() => { + this.tick(); + }), this.config.rect = this.config.root ? { + width: this.config.root.offsetWidth, + height: this.config.root.offsetHeight + } : { + width: window.document.documentElement.clientWidth, + height: window.document.documentElement.clientHeight + }, this.progress = { + x: this.config.rect.width / 2, + y: this.config.rect.height / 2, + vx: 0, + vy: 0 + }, this.previousProgress = { ...this.progress }, this.currentProgress = null; + const s = (i) => { + const r = this.config.root ? i.offsetX : i.x, o = this.config.root ? i.offsetY : i.y; + this.progress.vx = r - this.progress.x, this.progress.vy = o - this.progress.y, this.progress.x = r, this.progress.y = o, this._nextTick = n(); + }; + if (this._pointerLeave = () => { + this.progress.active = false, this.progress.vx = 0, this.progress.vy = 0, this._nextTick = n(); + }, this._pointerEnter = () => { + this.progress.active = true, this._nextTick = n(); + }, this.config.root) { + X = typeof X == "boolean" ? X : Bn(); + const i = X ? window.devicePixelRatio : 1; + typeof F.fixRequired > "u" && Qn(F), this._measure = (r) => { + if (r.target !== this.config.root) { + const o = new PointerEvent("pointermove", { + bubbles: true, + cancelable: true, + clientX: r.x * i + F.x, + clientY: r.y * i + F.y + }); + r.stopPropagation(), this.config.root.dispatchEvent(o); + } else + s(r); + }; + } else + this._measure = s; + } + /** + * Setup event and effect, and reset progress and frame. + */ + start() { + this.setupEffect(), this.setupEvent(); + } + /** + * Removes event listener. + */ + pause() { + this.removeEvent(); + } + /** + * Handle animation frame work. + */ + tick() { + this.effect.tick(this.progress); + } + /** + * Starts a transition from the previous progress to the current progress. + * + * @returns {number} the requestAnimationFrame id for the transition tick. + */ + transition() { + const e = this.config.transitionDuration, n = this.config.transitionEasing || ((o) => o), s = performance.now(); + let i = false; + const r = (o) => { + const a2 = (o - this._startTime) / e, c = n(Math.min(1, a2)); + i && (this.progress.vx = 0, this.progress.vy = 0, i = false), this.currentProgress = Object.entries(this.progress).reduce((l, [f, u]) => (f === "active" ? l[f] = u : l[f] = this.previousProgress[f] + (u - this.previousProgress[f]) * c, l), this.currentProgress || {}), a2 < 1 && (this._nextTransitionTick = requestAnimationFrame(r), i = o - this._startTime > ss), this.effect.tick(this.currentProgress); + }; + return this._startTime ? (this._nextTransitionTick && cancelAnimationFrame(this._nextTransitionTick), Object.assign(this.previousProgress, this.currentProgress), this._startTime = s, r(s)) : this._startTime = s, this._nextTransitionTick; + } + /** + * Stop the event and effect, and remove all DOM side effects. + */ + destroy() { + this.pause(), this.removeEffect(), this._nextTick && cancelAnimationFrame(this._nextTick), this._nextTransitionTick && cancelAnimationFrame(this._nextTransitionTick); + } + /** + * Register to pointermove for triggering update. + */ + setupEvent() { + this.removeEvent(); + const e = this.config.root || window; + e.addEventListener("pointermove", this._measure, { passive: true }), this.config.eventSource && this.config.eventSource.addEventListener("pointermove", this._measure, { passive: true }), this.config.allowActiveEvent && (e.addEventListener("pointerleave", this._pointerLeave, { passive: true }), e.addEventListener("pointerenter", this._pointerEnter, { passive: true }), this.config.eventSource && (this.config.eventSource.addEventListener("pointerleave", this._pointerLeave, { passive: true }), this.config.eventSource.addEventListener("pointerenter", this._pointerEnter, { passive: true }))); + } + /** + * Remove pointermove handler. + */ + removeEvent() { + const e = this.config.root || window; + e.removeEventListener("pointermove", this._measure), this.config.eventSource && this.config.eventSource.removeEventListener("pointermove", this._measure), this.config.allowActiveEvent && (e.removeEventListener("pointerleave", this._pointerLeave), e.removeEventListener("pointerenter", this._pointerEnter), this.config.eventSource && (this.config.eventSource.removeEventListener("pointerleave", this._pointerLeave), this.config.eventSource.removeEventListener("pointerenter", this._pointerEnter))); + } + /** + * Reset registered effect. + */ + setupEffect() { + this.removeEffect(), this.effect = ns(this.config); + } + /** + * Remove registered effect. + */ + removeEffect() { + this.effect && this.effect.destroy(), this.effect = null; + } +}; +var me = /* @__PURE__ */ new WeakMap(); +var nt = () => ({}); +function os(t) { + nt = t; +} +function as(t, e, n, s = {}, { reducedMotion: i }) { + if (i) + return; + const r = { + trigger: "pointer-move", + element: t, + axis: s.axis ?? "y" + }, o = Ke(e, q(n), r); + if (o) { + const a2 = Array.isArray(o) ? o : [o], c = new rs({ + root: s.hitArea === "self" ? t : void 0, + scenes: a2, + ...nt() + }), f = { source: t, target: e, cleanup: () => { + c.destroy(); + } }; + C(me, t, f), C(me, e, f), Promise.all( + a2.map((u) => u.ready || Promise.resolve()) + ).then(() => { + c.start(); + }); + } +} +function cs(t) { + B(me, t); +} +var fs = { + add: as, + remove: cs, + registerOptionsGetter: os +}; +var pe = /* @__PURE__ */ new WeakMap(); +function ls(t, e, n, s, { + reducedMotion: i, + selectorCondition: r, + animation: o, + sourceAnimationOptions: a2 +}) { + const c = o || Y( + e, + q(n), + void 0, + i + ); + if (!c) + return; + const { effectId: l } = s, f = (g) => { + if (r && !e.matches(r)) return; + const p = g.animationName, m = g.detail?.effectId, h2 = a2 ? Be(t, a2) : null; + if (h2) { + if (h2.playState === "running" || p && !h2.hasAnimationName(p)) + return; + if (m && m !== l && !h2.hasAnimationId(m)) + return; + } + c.play(); + }, d = { source: t, target: e, cleanup: () => { + c.cancel(), t.removeEventListener("animationend", f); + } }; + C(pe, t, d), C(pe, e, d), t.addEventListener("animationend", f); +} +function us(t) { + B(pe, t); +} +var ds = { + add: ls, + remove: us +}; +function hs(t, e, n = false, s, i, r) { + const o = r || Y( + t, + q(e), + void 0, + n + ); + if (!o) + return null; + let a2 = true; + const c = e.triggerType || "alternate"; + return (l) => { + if (s && !t.matches(s)) return; + const f = !i, u = i?.enter?.includes(l.type), d = i?.leave?.includes(l.type); + if (u || f) { + if (c === "alternate" || c === "state") + a2 ? (a2 = false, o.play()) : c === "alternate" ? o.reverse() : c === "state" && (o.playState === "running" ? o.pause() : o.playState !== "finished" && o.play()); + else { + if (o.progress(0), delete t.dataset.interactEnter, o.isCSS) { + const g = () => { + O.mutate(() => { + t.dataset.interactEnter = "done"; + }); + }; + o.onFinish(g), o.onAbort(g); + } + o.play(); + } + return; + } + d && (c === "alternate" ? o.reverse() : c === "repeat" ? (o.cancel(), O.mutate(() => { + delete t.dataset.interactEnter; + })) : c === "state" && o.playState === "running" && o.pause()); + }; +} +function ms(t, e, { + effectId: n, + listContainer: s, + listItemSelector: i, + stateAction: r +}, o, a2) { + const c = !!s, l = r ?? "toggle", f = l === "toggle"; + return (u) => { + if (o && !t.matches(o)) return; + const d = c ? t.closest( + `${s} > ${i || ""}:has(:scope)` + ) : void 0, g = !a2, p = a2?.enter?.includes(u.type), m = a2?.leave?.includes(u.type); + g ? e.toggleEffect(n, l, d) : (p && e.toggleEffect(n, f ? "add" : l, d), m && f && e.toggleEffect(n, "remove", d)); + }; +} +var ge = /* @__PURE__ */ new WeakMap(); +function Re(t, e) { + return (n) => { + const s = n; + t.contains(s.relatedTarget) || e(s); + }; +} +function ps(t) { + return (e) => { + const n = e; + n.pointerType && t(n); + }; +} +function gs(t) { + return (e) => { + const n = e; + n.code === "Space" ? (n.preventDefault(), t(n)) : n.code === "Enter" && t(n); + }; +} +var vs = { + focusin: (t, e) => Re(t, e), + focusout: (t, e) => Re(t, e), + click: (t, e) => ps(e), + keydown: (t, e) => gs(e) +}; +function ys(t, e, n) { + const s = vs[t]; + return s ? s(e, n) : (i) => n(i); +} +function Es(t) { + return typeof t == "object" && !Array.isArray(t) && ("enter" in t || "leave" in t); +} +function ws(t) { + if (typeof t == "string") + return { toggle: [t] }; + if (Array.isArray(t)) + return { toggle: [...t] }; + if (Es(t)) { + const e = t.enter ? [...t.enter] : [], n = t.leave ? [...t.leave] : []; + return { enter: e, leave: n }; + } + return {}; +} +function bs(t) { + return !!(t.enter?.length || t.leave?.length); +} +function Ss(t) { + return bs(t) ? { enter: t.enter ?? [], leave: t.leave ?? [] } : void 0; +} +function Ts(t, e, n, s, { + reducedMotion: i, + targetController: r, + selectorCondition: o, + animation: a2 +}) { + const c = ws(s.eventConfig), l = n.transition || n.transitionProperties, f = Ss(c); + let u, d = false; + if (l ? u = ms( + e, + r, + n, + o, + f + ) : (u = hs( + e, + n, + i, + o, + f, + a2 + ), d = n.triggerType === "once"), !u) + return; + const g = u, p = new AbortController(); + function m(y, E, w2) { + const S2 = ys(E, t, g); + y.addEventListener(E, S2, { ...w2, signal: p.signal }); + } + const v = { source: t, target: e, cleanup: () => { + p.abort(); + } }; + if (C(ge, t, v), C(ge, e, v), f) { + const y = c.enter, E = c.leave; + y.forEach((T) => { + T === "focusin" && (t.tabIndex = 0), m(t, T, { passive: true, once: d }); + }); + const w2 = !n.stateAction || n.stateAction === "toggle"; + (l ? w2 : n.triggerType !== "once") && E.forEach((T) => { + if (T === "focusout") { + m(t, T, { once: d }); + return; + } + m(t, T, { passive: true }); + }); + } else + (c.toggle ?? []).forEach((E) => { + m(t, E, { once: d, passive: E !== "keydown" }); + }); +} +function Is(t) { + B(ge, t); +} +var j = { + add: Ts, + remove: Is +}; +var ve = { + click: ["click"], + activate: ["click", "keydown"], + hover: { enter: ["mouseenter"], leave: ["mouseleave"] }, + interest: { + enter: ["mouseenter", "focusin"], + leave: ["mouseleave", "focusout"] + } +}; +var Fe = { + click: ve.activate, + hover: ve.interest +}; +function Z(t) { + const e = ve[t]; + return (n, s, i, r, o) => { + const a2 = o?.allowA11yTriggers && t in Fe ? Fe[t] : e; + j.add(n, s, i, { eventConfig: a2 }, o ?? {}); + }; +} +var x = { + viewEnter: _e, + hover: { + add: Z("hover"), + remove: j.remove + }, + click: { + add: Z("click"), + remove: j.remove + }, + pageVisible: _e, + animationEnd: ds, + viewProgress: Vn, + pointerMove: fs, + activate: { + add: Z("activate"), + remove: j.remove + }, + interest: { + add: Z("interest"), + remove: j.remove + } +}; +function Os(t) { + return t.replace(/\[([-\w]+)]/g, "[]"); +} +var b = class _b { + static defineInteractElement; + dataCache; + addedInteractions; + mediaQueryListeners; + listInteractionsCache; + controllers; + static forceReducedMotion = false; + static allowA11yTriggers = true; + static instances = []; + static controllerCache = /* @__PURE__ */ new Map(); + static sequenceCache = /* @__PURE__ */ new Map(); + static elementSequenceMap = /* @__PURE__ */ new WeakMap(); + constructor() { + this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }, this.addedInteractions = {}, this.mediaQueryListeners = /* @__PURE__ */ new Map(), this.listInteractionsCache = {}, this.controllers = /* @__PURE__ */ new Set(); + } + init(e, n) { + if (typeof window > "u" || !window.customElements) + return; + const s = n?.useCustomElement ?? !!_b.defineInteractElement; + this.dataCache = Cs(e, s); + const i = _b.defineInteractElement?.(); + s && i === false ? document.querySelectorAll("interact-element").forEach((r) => { + r.connect(); + }) : _b.controllerCache.forEach( + (r, o) => r.connect(o) + ); + } + destroy() { + for (const e of this.controllers) + e.disconnect(); + for (const [, e] of this.mediaQueryListeners.entries()) + e.mql.removeEventListener("change", e.handler); + this.mediaQueryListeners.clear(), this.addedInteractions = {}, this.listInteractionsCache = {}, this.controllers.clear(), this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }, _b.instances.splice(_b.instances.indexOf(this), 1); + } + setController(e, n) { + this.controllers.add(n), _b.setController(e, n); + } + deleteController(e, n = false) { + const s = _b.controllerCache.get(e); + this.clearInteractionStateForKey(e), this.clearMediaQueryListenersForKey(e), s && n && (this.controllers.delete(s), _b.deleteController(e)); + } + has(e) { + return !!this.get(e); + } + get(e) { + const n = Os(e); + return this.dataCache.interactions[n]; + } + clearMediaQueryListenersForKey(e) { + for (const [n, s] of this.mediaQueryListeners.entries()) + s.key === e && (s.mql.removeEventListener("change", s.handler), this.mediaQueryListeners.delete(n)); + } + clearInteractionStateForKey(e) { + (this.get(e)?.interactionIds || []).forEach((i) => { + const r = $(i, e); + delete this.addedInteractions[r]; + }); + const s = `${e}::seq::`; + for (const i of _b.sequenceCache.keys()) + i.startsWith(s) && (_b.sequenceCache.delete(i), delete this.addedInteractions[i]); + } + setupMediaQueryListener(e, n, s, i) { + this.mediaQueryListeners.has(e) || (n.addEventListener("change", i), this.mediaQueryListeners.set(e, { + mql: n, + handler: i, + key: s + })); + } + static create(e, n) { + const s = new _b(); + return _b.instances.push(s), s.init(e, n), s; + } + static destroy() { + _b.controllerCache.forEach((e) => { + e.disconnect(); + }), _b.instances.length = 0, _b.controllerCache.clear(), _b.sequenceCache.clear(), _b.elementSequenceMap = /* @__PURE__ */ new WeakMap(); + } + static setup(e) { + e.scrollOptionsGetter && x.viewProgress.registerOptionsGetter?.( + e.scrollOptionsGetter + ), e.pointerOptionsGetter && x.pointerMove.registerOptionsGetter?.( + e.pointerOptionsGetter + ), e.viewEnter && x.viewEnter.setOptions( + e.viewEnter + ), e.allowA11yTriggers !== void 0 && (_b.allowA11yTriggers = e.allowA11yTriggers); + } + static getInstance(e) { + const n = _b.instances.find((s) => s.has(e)); + return n || console.warn(`Interact: Instance for key "${e}" not found`), n; + } + static getController(e) { + const n = e ? _b.controllerCache.get(e) : void 0; + return n || console.warn(`Interact: Controller for key "${e}" not found`), n; + } + static setController(e, n) { + _b.controllerCache.set(e, n); + } + static deleteController(e) { + _b.controllerCache.delete(e); + } + static registerEffects = Zt; + static getSequence(e, n, s, i) { + const r = _b.sequenceCache.get(e); + if (r) return r; + const o = ln(n, s, i); + return _b.sequenceCache.set(e, o), _b._registerSequenceElements(s, o), o; + } + static addToSequence(e, n, s, i) { + const r = _b.sequenceCache.get(e); + if (!r) return false; + const a2 = Qe(n, i).map((c, l) => ({ + index: s[l] ?? r.animationGroups.length, + group: c + })); + return r.addGroups(a2), _b._registerSequenceElements(n, r), true; + } + static _registerSequenceElements(e, n) { + for (const { target: s } of e) { + const i = Array.isArray(s) ? s : s instanceof HTMLElement ? [s] : []; + for (const r of i) { + let o = _b.elementSequenceMap.get(r); + o || (o = /* @__PURE__ */ new Set(), _b.elementSequenceMap.set(r, o)), o.add(n); + } + } + } + static removeFromSequences(e) { + for (const n of e) { + const s = _b.elementSequenceMap.get(n); + if (s) { + for (const i of s) + i.removeGroups( + (r) => r.animations.some((o) => o.effect?.target === n) + ); + _b.elementSequenceMap.delete(n); + } + } + } +}; +var As = 0; +function P(t, { + asCombinator: e = false, + addItemFilter: n = false, + useFirstChild: s = false +} = {}) { + if (t.listContainer) { + const i = `${n && t.listItemSelector ? ` > ${t.listItemSelector}` : ""}`; + return t.selector ? `${t.listContainer}${i} ${t.selector}` : `${t.listContainer}${i || " > *"}`; + } else if (t.selector) + return t.selector; + return s ? e ? "> :first-child" : ":scope > :first-child" : ""; +} +function Ne(t) { + return "sequenceId" in t && !("effects" in t); +} +function le(t, e) { + return t[e] || (t[e] = { + triggers: [], + effects: {}, + sequences: {}, + interactionIds: /* @__PURE__ */ new Set(), + selectors: /* @__PURE__ */ new Set() + }), t[e]; +} +function Cs(t, e = false) { + const { effects: n = {}, sequences: s = {}, conditions: i = {} } = t, r = {}; + return t.interactions?.forEach((o) => { + const a2 = o.key, c = ++As, { effects: l, sequences: f, ...u } = o; + if (!a2) { + console.error(`Interaction ${c} is missing a key for source element.`); + return; + } + le(r, a2); + const d = l ? Array.from(l) : []; + d.reverse(); + const g = f?.map((h2) => { + if (Ne(h2)) { + const y = s[h2.sequenceId]; + return y ? { ...y, ...h2 } : (console.warn(`Interact: Sequence "${h2.sequenceId}" not found in config`), h2); + } + const v = h2; + return v.sequenceId || (v.sequenceId = k()), v; + }), p = { + ...u, + effects: d.length > 0 ? d : void 0, + sequences: g + }; + r[a2].triggers.push(p), r[a2].selectors.add( + P(p, { useFirstChild: e }) + ); + const m = p.listContainer; + d.forEach((h2) => { + let v = h2.key; + if (!v && h2.effectId) { + const S2 = n[h2.effectId]; + S2 && (v = S2.key); + } + h2.effectId || (h2.effectId = k()), v = v || a2, h2.key = v; + const y = h2.effectId; + if (m && h2.listContainer && (v !== a2 || h2.listContainer !== m)) + return; + const E = `${a2}::${v}::${y}::${c}`; + if (h2.interactionId = E, r[a2].interactionIds.add(E), v === a2) + return; + const w2 = le(r, v); + w2.effects[E] || (w2.effects[E] = [], w2.interactionIds.add(E)), w2.effects[E].push({ ...u, effect: h2 }), w2.selectors.add(P(h2, { useFirstChild: e })); + }), g?.forEach((h2) => { + if (!h2 || Ne(h2)) return; + const v = h2, y = v.sequenceId || k(), E = v.effects; + for (const w2 of E) { + w2.effectId || (w2.effectId = k()); + let S2 = w2.key; + if (!S2 && w2.effectId) { + const I2 = n[w2.effectId]; + I2 && (S2 = I2.key); + } + S2 = S2 || a2; + const T = P(w2, { useFirstChild: e }); + if (T && r[a2].selectors.add(T), S2 !== a2) { + const I2 = le(r, S2), A3 = `${S2}::seq::${y}::${c}`; + I2.sequences[A3] || (I2.sequences[A3] = [], I2.interactionIds.add(A3)), I2.sequences[A3].push({ + ...u, + sequence: v + }), I2.selectors.add(T); + } + } + }); + }), { + effects: n, + sequences: s, + conditions: i, + interactions: r + }; +} +function ye(t, e, n) { + if (t.listContainer) { + const s = e.querySelector(t.listContainer); + return s ? t.selector ? Array.from(s.querySelectorAll(t.selector)) : Array.from(s.children) : (console.warn(`Interact: No container found for list container "${t.listContainer}"`), []); + } + if (t.selector) { + const s = e.querySelectorAll(t.selector); + if (s.length > 0) + return Array.from(s); + console.warn(`Interact: No elements found for selector "${t.selector}"`); + } + return n ? e.firstElementChild : e; +} +function Ee(t, e) { + return e.map((n) => t.selector ? n.querySelector(t.selector) : n).filter(Boolean); +} +function st(t, e, n, s, i, r, o, a2) { + return [ + o ? Ee(t, o) : ye(t, n, s), + a2 ? Ee(e, a2) : ye(e, i, r) + ]; +} +function it(t, e, n, s, i, r, o, a2) { + const c = Array.isArray(s), l = Array.isArray(i); + c ? s.forEach((f, u) => { + const d = l ? i[u] : i; + d && ze( + t, + f, + e.trigger, + d, + n, + e.params, + r, + o, + a2 + ); + }) : (l ? i : [i]).forEach((u) => { + ze( + t, + s, + e.trigger, + u, + n, + e.params, + r, + o, + a2 + ); + }); +} +function rt(t, e, n, s, i) { + const r = {}, o = []; + (s.effects || []).forEach((a2) => { + const c = a2.effectId, l = { + ...n.dataCache.effects[c] || {}, + ...a2, + effectId: c + }, f = l.key, u = $(a2.interactionId, t); + if (r[u] || n.addedInteractions[u] && !i) + return; + const d = _(l.conditions || [], n.dataCache.conditions); + if (d && n.setupMediaQueryListener(u, d, t, () => { + e.update(); + }), !d || d.matches) { + r[u] = true; + const g = f && $(f, t); + let p; + if (g) { + if (p = b.getController(g), !p) + return; + l.listContainer && p.watchChildList(l.listContainer); + } else + p = e; + const [m, h2] = st( + s, + l, + e.element, + e.useFirstChild, + p.element, + p.useFirstChild, + i + ); + if (!m || !h2) + return; + n.addedInteractions[u] = true; + const v = g || s.key, y = L( + l.conditions || [], + n.dataCache.conditions + ); + o.push([ + v, + s, + l, + m, + h2, + y, + p.useFirstChild, + t + ]); + } + }), o.reverse().forEach((a2) => { + it(...a2); + }), $s(t, e, n, s, i); +} +function ks(t) { + return "sequenceId" in t && !("effects" in t); +} +function ot(t, e, n, s, i, r, o) { + const a2 = _(t.conditions || [], i.dataCache.conditions); + if (a2 && i.setupMediaQueryListener(e, a2, r.updateKey, r.onUpdate), a2 && !a2.matches) return null; + const c = t.effects || [], l = []; + let f = false; + for (const u of c) { + const d = u.effectId, p = { + ...d ? i.dataCache.effects[d] || {} : {}, + ...u + }, m = _(p.conditions || [], i.dataCache.conditions); + if (m) { + const T = `${e}::${d || "eff"}`; + i.setupMediaQueryListener( + T, + m, + r.updateKey, + r.onUpdate + ); + } + if (m && !m.matches) continue; + const h2 = p.key, v = h2 && $(h2, n); + let y; + if (v) { + if (y = b.getController(v), !y) return null; + } else + y = s; + const E = v || n; + let w2; + if (o && E === o.controllerKey && p.listContainer === o.listContainer ? (w2 = Ee(p, o.elements), w2.length > 0 && (f = true)) : w2 = ye( + p, + y.element, + y.useFirstChild + ), !w2 || Array.isArray(w2) && w2.length === 0) return null; + const S2 = q(p); + l.push({ target: w2, options: S2 }); + } + return o && !f ? null : l.length > 0 ? l : null; +} +function at(t, e, n) { + const r = (t.useFirstChild ? t.element.firstElementChild : t.element)?.querySelector(e); + if (!r) return n.map((a2, c) => c); + const o = Array.from(r.children); + return n.map((a2) => { + const c = o.indexOf(a2); + return c >= 0 ? c : o.length; + }); +} +function $s(t, e, n, s, i) { + s.sequences?.forEach((r) => { + let o; + if (ks(r)) { + const g = n.dataCache.sequences[r.sequenceId]; + if (!g) { + console.warn(`Interact: Sequence "${r.sequenceId}" not found in cache`); + return; + } + o = { ...g, ...r }; + } else + o = r; + const a2 = o.sequenceId || k(), c = $(`${t}::seq::${a2}`, t); + if (n.addedInteractions[c] && !i) return; + const l = i && s.listContainer ? { controllerKey: t, listContainer: s.listContainer, elements: i } : void 0, f = ot( + o, + c, + t, + e, + n, + { updateKey: t, onUpdate: () => e.update() }, + l + ); + if (!f) return; + if (i && n.addedInteractions[c]) { + const g = at( + e, + s.listContainer, + i + ); + b.addToSequence(c, f, g, { + reducedMotion: b.forceReducedMotion + }); + return; + } + const u = b.getSequence(c, o, f, { + reducedMotion: b.forceReducedMotion + }); + n.addedInteractions[c] = true; + const d = L( + s.conditions || [], + n.dataCache.conditions + ); + x[s.trigger]?.add( + e.element, + e.element, + { triggerType: o.triggerType }, + s.params || {}, + { + reducedMotion: b.forceReducedMotion, + selectorCondition: d, + animation: u, + allowA11yTriggers: b.allowA11yTriggers + } + ); + }); +} +function _s(t, e, n, s, i) { + const r = n.get(t)?.sequences || {}; + Object.keys(r).forEach((a2) => { + r[a2].some(({ sequence: l, ...f }) => { + const u = _( + f.conditions || [], + n.dataCache.conditions + ); + if (u && !u.matches) + return false; + const d = f.key && $(f.key, t), g = b.getController(d); + if (!g) + return true; + const p = l.sequenceId || k(), m = $(`${d}::seq::${p}`, d); + if (n.addedInteractions[m] && !i) + return true; + const v = ot( + l, + m, + d, + g, + n, + { updateKey: t, onUpdate: () => e.update() }, + i && s ? { controllerKey: t, listContainer: s, elements: i } : void 0 + ); + if (!v) return true; + if (i && n.addedInteractions[m]) { + const w2 = at(e, s, i); + return b.addToSequence(m, v, w2, { + reducedMotion: b.forceReducedMotion + }), true; + } + const y = b.getSequence(m, l, v, { + reducedMotion: b.forceReducedMotion + }); + n.addedInteractions[m] = true; + const E = L( + f.conditions || [], + n.dataCache.conditions + ); + return x[f.trigger]?.add( + g.element, + g.element, + { triggerType: l.triggerType }, + f.params || {}, + { + reducedMotion: b.forceReducedMotion, + selectorCondition: E, + animation: y, + allowA11yTriggers: b.allowA11yTriggers + } + ), true; + }); + }); +} +function ct(t, e, n, s, i) { + const r = n.get(t), o = r?.effects || {}, a2 = Object.keys(o), c = []; + a2.forEach((f) => { + const u = $(f, t); + if (n.addedInteractions[u] && !i) + return; + o[f].some(({ effect: g, ...p }) => { + const m = _( + p.conditions || [], + n.dataCache.conditions + ); + if (m && !m.matches) + return false; + const h2 = g.effectId, v = { + ...n.dataCache.effects[h2] || {}, + ...g, + effectId: h2 + }; + if (s && v.listContainer !== s) + return false; + const y = _(v.conditions || [], n.dataCache.conditions); + if (y && n.setupMediaQueryListener(u, y, t, () => { + e.update(); + }), !y || y.matches) { + const E = p.key && $(p.key, t), w2 = b.getController(E); + if (!w2) + return true; + v.listContainer && e.watchChildList(v.listContainer); + const [S2, T] = st( + p, + v, + w2.element, + w2.useFirstChild, + e.element, + e.useFirstChild, + void 0, + i + ); + if (!S2 || !T) + return true; + n.addedInteractions[u] = true; + const I2 = L( + v.conditions || [], + n.dataCache.conditions + ); + return c.push([ + t, + p, + v, + S2, + T, + I2, + e.useFirstChild, + E || void 0 + ]), true; + } + return false; + }); + }), c.reverse().forEach((f) => { + it(...f); + }), _s(t, e, n, s, i); + const l = Object.keys(r?.sequences || {}).length > 0; + return a2.length > 0 || l; +} +function ze(t, e, n, s, i, r, o, a2, c) { + let l; + if (i.transition || i.transitionProperties) { + const u = { + key: t, + effectId: i.effectId, + transition: i.transition, + transitionProperties: i.transitionProperties, + childSelector: P(i, { + asCombinator: true, + addItemFilter: true, + useFirstChild: a2 + }), + selectorCondition: o + }; + if (l = b.getController(t), !l) + return; + l.renderStyle(gn(u)); + } + let f; + if (n === "animationEnd") { + const u = r.effectId, g = (c ? b.getInstance(c) : void 0)?.dataCache.effects[u]; + g && (f = q(g)); + } + x[n]?.add(e, s, i, r, { + reducedMotion: b.forceReducedMotion, + targetController: l, + selectorCondition: o, + allowA11yTriggers: b.allowA11yTriggers, + sourceAnimationOptions: f + }); +} +function qs(t) { + const e = t.key, n = b.getInstance(e); + if (!n) + return console.warn(`No instance found for key: ${e}`), b.setController(e, t), false; + const { triggers: s = [] } = n?.get(e) || {}, i = s.length > 0; + n.setController(e, t), s.forEach((o, a2) => { + const c = _(o.conditions, n.dataCache.conditions); + if (c) { + const l = `${e}::trigger::${a2}`; + n.setupMediaQueryListener(l, c, e, () => { + t.update(); + }); + } + (!c || c.matches) && (o.listContainer && t.watchChildList(o.listContainer), rt(e, t, n, o)); + }); + let r = false; + return n && (r = ct(e, t, n)), i || r; +} +function Ms(t, e, n) { + const s = t.key, i = b.getInstance(s); + if (i) { + const { triggers: r = [] } = i?.get(s) || {}; + r.forEach((o, a2) => { + if (o.listContainer !== e) + return; + const c = _(o.conditions, i.dataCache.conditions); + if (c) { + const l = `${s}::listTrigger::${e}::${a2}`; + i.setupMediaQueryListener(l, c, s, () => { + t.update(); + }); + } + (!c || c.matches) && rt(s, t, i, o, n); + }), ct(s, t, i, e, n); + } +} +function xs(t, e = false) { + const n = t.key, s = b.getInstance(n); + if (!s) + return; + const i = [...s.get(n)?.selectors.values() || []].filter(Boolean).join(","); + let r; + i ? (r = [...t.element.querySelectorAll(i)], t.useFirstChild || r.push(t.element)) : r = [t.element], ft(r), s.deleteController(n, e); +} +function ft(t) { + const e = Object.values(x); + for (const n of t) + for (const s of e) + s.remove(n); + b.removeFromSequences(t); +} +var ue = "interactEffect"; +var Ps = class { + element; + key; + connected; + sheet; + useFirstChild; + _observers; + constructor(e, n, s) { + this.element = e, this.key = n, this.connected = false, this.sheet = null, this._observers = /* @__PURE__ */ new WeakMap(), this.useFirstChild = s?.useFirstChild ?? false; + } + connect(e) { + if (this.connected) + return; + const n = this.element.dataset.interactKey; + if (e = e || this.key || n, !e) { + console.warn("Interact: No key provided"); + return; + } + n !== e && (n && console.warn( + `Interact: Key mismatch between element ${n} and parameter ${e}, updating element key` + ), this.element.dataset.interactKey = e), this.key = e, this.connected = qs(this); + } + disconnect({ removeFromCache: e = false } = {}) { + if ((this.key || this.element.dataset.interactKey) && xs(this, e), this.sheet) { + const s = this.element?.getRootNode(), i = s.host ? s : document; + i.adoptedStyleSheets.indexOf(this.sheet) !== -1 && (i.adoptedStyleSheets = i.adoptedStyleSheets.filter( + (o) => o !== this.sheet + )); + } + this._observers = /* @__PURE__ */ new WeakMap(), this.sheet = null, this.connected = false; + } + update() { + this.disconnect(), this.connect(); + } + renderStyle(e) { + const n = this.element?.getRootNode(), s = n.host ? n : document; + if (!this.sheet) + this.sheet = new CSSStyleSheet(), this.sheet.replaceSync(e.join(` +`)), s.adoptedStyleSheets = [...s.adoptedStyleSheets || [], this.sheet]; + else { + let i = this.sheet.cssRules.length; + for (const r of e) + try { + this.sheet.insertRule(r, i), i++; + } catch (o) { + console.error(o); + } + } + } + toggleEffect(e, n, s, i) { + if (s === null) + return; + if (!i && this.element.toggleEffect) { + this.element.toggleEffect(e, n, s); + return; + } + const r = new Set( + this.element.dataset[ue]?.split(" ") || [] + ); + n === "toggle" ? r.has(e) ? r.delete(e) : r.add(e) : n === "add" ? r.add(e) : n === "remove" ? r.delete(e) : n === "clear" && r.clear(), (s || this.element).dataset[ue] = Array.from(r).join(" "); + } + getActiveEffects() { + const n = (this.element.dataset[ue] || "").trim(); + return n ? n.split(/\s+/) : []; + } + watchChildList(e) { + const n = this.element.querySelector(e); + if (n) { + let s = this._observers.get(n); + s || (s = new MutationObserver(this._childListChangeHandler.bind(this, e)), this._observers.set(n, s), s.observe(n, { childList: true })); + } + } + _childListChangeHandler(e, n) { + const s = this.key || this.element.dataset.interactKey, i = [], r = []; + n.forEach((o) => { + o.removedNodes.forEach((a2) => { + a2 instanceof HTMLElement && i.push(a2); + }), o.addedNodes.forEach((a2) => { + a2 instanceof HTMLElement && r.push(a2); + }); + }), ft(i), s && Ms(this, e, r); + } +}; +function Us(t, e) { + new Ps(t, e).connect(); +} +var se = [ + "animation", + "animation-composition", + "animation-timeline", + "animation-range" +]; +var we = ["transition", ...se]; + +// ../../../Documents/Dev/Wix/interact-xp/node_modules/@wix/motion-presets/dist/es/motion-presets.js +var motion_presets_exports = {}; +__export(motion_presets_exports, { + AiryMouse: () => yi, + ArcIn: () => yc, + ArcScroll: () => Qi, + BgCloseUp: () => bi, + BgFade: () => Ai, + BgFadeBack: () => wi, + BgFake3D: () => Ni, + BgPan: () => Di, + BgParallax: () => ki, + BgPullBack: () => Fi, + BgReveal: () => Pi, + BgRotate: () => Ri, + BgSkew: () => Mi, + BgZoom: () => Yi, + BlobMouse: () => vi, + BlurIn: () => vc, + BlurMouse: () => _i, + BlurScroll: () => Wi, + Bounce: () => Ci, + BounceIn: () => hc, + BounceMouse: () => hi, + Breathe: () => zi, + Cross: () => Li, + CurveIn: () => Ec, + CustomMouse: () => pi, + DropIn: () => Oc, + ExpandIn: () => xc, + FadeIn: () => Ic, + FadeScroll: () => tc, + Flash: () => Xi, + Flip: () => Ui, + FlipIn: () => Sc, + FlipScroll: () => ec, + FloatIn: () => Tc, + Fold: () => Bi, + FoldIn: () => bc, + GlideIn: () => Ac, + GrowScroll: () => oc, + ImageParallax: () => ji, + Jello: () => Zi, + MoveScroll: () => nc, + PanScroll: () => rc, + ParallaxScroll: () => ac, + Poke: () => Gi, + Pulse: () => Ki, + RevealIn: () => Nc, + RevealScroll: () => sc, + Rubber: () => Vi, + ScaleMouse: () => Ei, + ShapeIn: () => wc, + ShapeScroll: () => ic, + ShrinkScroll: () => lc, + ShuttersIn: () => _c, + ShuttersScroll: () => cc, + SkewMouse: () => Oi, + SkewPanScroll: () => fc, + SlideIn: () => Dc, + SlideScroll: () => mc, + Spin: () => Hi, + Spin3dScroll: () => uc, + SpinIn: () => kc, + SpinMouse: () => xi, + SpinScroll: () => dc, + StretchScroll: () => gc, + Swing: () => qi, + SwivelMouse: () => Ii, + Tilt3DMouse: () => Si, + TiltIn: () => Fc, + TiltScroll: () => $c, + Track3DMouse: () => Ti, + TrackMouse: () => Sn2, + TurnIn: () => Pc, + TurnScroll: () => pc, + Wiggle: () => Ji, + WinkIn: () => Rc +}); + +// ../../../Documents/Dev/Wix/interact-xp/node_modules/@wix/motion/dist/es/motion.js +var U2 = (e) => e < 0.5 ? 2 * e ** 2 : 1 - (-2 * e + 2) ** 2 / 2; +var mt = (e) => e < 0.5 ? (1 - Math.sqrt(1 - 4 * e ** 2)) / 2 : (Math.sqrt(-(2 * e - 3) * (2 * e - 1)) + 1) / 2; +var z2 = { + linear: "linear", + ease: "ease", + easeIn: "ease-in", + easeOut: "ease-out", + easeInOut: "ease-in-out", + sineIn: "cubic-bezier(0.47, 0, 0.745, 0.715)", + sineOut: "cubic-bezier(0.39, 0.575, 0.565, 1)", + sineInOut: "cubic-bezier(0.445, 0.05, 0.55, 0.95)", + quadIn: "cubic-bezier(0.55, 0.085, 0.68, 0.53)", + quadOut: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", + quadInOut: "cubic-bezier(0.455, 0.03, 0.515, 0.955)", + cubicIn: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + cubicOut: "cubic-bezier(0.215, 0.61, 0.355, 1)", + cubicInOut: "cubic-bezier(0.645, 0.045, 0.355, 1)", + quartIn: "cubic-bezier(0.895, 0.03, 0.685, 0.22)", + quartOut: "cubic-bezier(0.165, 0.84, 0.44, 1)", + quartInOut: "cubic-bezier(0.77, 0, 0.175, 1)", + quintIn: "cubic-bezier(0.755, 0.05, 0.855, 0.06)", + quintOut: "cubic-bezier(0.23, 1, 0.32, 1)", + quintInOut: "cubic-bezier(0.86, 0, 0.07, 1)", + expoIn: "cubic-bezier(0.95, 0.05, 0.795, 0.035)", + expoOut: "cubic-bezier(0.19, 1, 0.22, 1)", + expoInOut: "cubic-bezier(1, 0, 0, 1)", + circIn: "cubic-bezier(0.6, 0.04, 0.98, 0.335)", + circOut: "cubic-bezier(0.075, 0.82, 0.165, 1)", + circInOut: "cubic-bezier(0.785, 0.135, 0.15, 0.86)", + backIn: "cubic-bezier(0.6, -0.28, 0.735, 0.045)", + backOut: "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + backInOut: "cubic-bezier(0.68, -0.55, 0.265, 1.55)" +}; +var A = { exports: {} }; +var F2 = A.exports; +var x2; +function It2() { + return x2 || (x2 = 1, (function(e) { + (function(t) { + var n = function() { + }, i = t.requestAnimationFrame || t.webkitRequestAnimationFrame || t.mozRequestAnimationFrame || t.msRequestAnimationFrame || function(o) { + return setTimeout(o, 16); + }; + function s() { + var o = this; + o.reads = [], o.writes = [], o.raf = i.bind(t); + } + s.prototype = { + constructor: s, + /** + * We run this inside a try catch + * so that if any jobs error, we + * are able to recover and continue + * to flush the batch until it's empty. + * + * @param {Array} tasks + */ + runTasks: function(o) { + for (var u; u = o.shift(); ) u(); + }, + /** + * Adds a job to the read batch and + * schedules a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + measure: function(o, u) { + var f = u ? o.bind(u) : o; + return this.reads.push(f), r(this), f; + }, + /** + * Adds a job to the + * write batch and schedules + * a new frame if need be. + * + * @param {Function} fn + * @param {Object} ctx the context to be bound to `fn` (optional). + * @public + */ + mutate: function(o, u) { + var f = u ? o.bind(u) : o; + return this.writes.push(f), r(this), f; + }, + /** + * Clears a scheduled 'read' or 'write' task. + * + * @param {Object} task + * @return {Boolean} success + * @public + */ + clear: function(o) { + return a2(this.reads, o) || a2(this.writes, o); + }, + /** + * Extend this FastDom with some + * custom functionality. + * + * Because fastdom must *always* be a + * singleton, we're actually extending + * the fastdom instance. This means tasks + * scheduled by an extension still enter + * fastdom's global task queue. + * + * The 'super' instance can be accessed + * from `this.fastdom`. + * + * @example + * + * var myFastdom = fastdom.extend({ + * initialize: function() { + * // runs on creation + * }, + * + * // override a method + * measure: function(fn) { + * // do extra stuff ... + * + * // then call the original + * return this.fastdom.measure(fn); + * }, + * + * ... + * }); + * + * @param {Object} props properties to mixin + * @return {FastDom} + */ + extend: function(o) { + if (typeof o != "object") throw new Error("expected object"); + var u = Object.create(this); + return m(u, o), u.fastdom = this, u.initialize && u.initialize(), u; + }, + // override this with a function + // to prevent Errors in console + // when tasks throw + catch: null + }; + function r(o) { + o.scheduled || (o.scheduled = true, o.raf(c.bind(null, o))); + } + function c(o) { + var u = o.writes, f = o.reads, p; + try { + n("flushing reads", f.length), o.runTasks(f), n("flushing writes", u.length), o.runTasks(u); + } catch (h2) { + p = h2; + } + if (o.scheduled = false, (f.length || u.length) && r(o), p) + if (n("task errored", p.message), o.catch) o.catch(p); + else throw p; + } + function a2(o, u) { + var f = o.indexOf(u); + return !!~f && !!o.splice(f, 1); + } + function m(o, u) { + for (var f in u) + u.hasOwnProperty(f) && (o[f] = u[f]); + } + var l = t.fastdom = t.fastdom || new s(); + e.exports = l; + })(typeof window < "u" ? window : typeof F2 < "u" ? F2 : globalThis); + })(A)), A.exports; +} +var Ot2 = It2(); + +// ../../../Documents/Dev/Wix/interact-xp/node_modules/@wix/motion-presets/dist/es/motion-presets.js +function h(t, e, o, n, r) { + return (r - t) * (n - o) / (e - t) + o; +} +function on([t, e], [o, n]) { + return Math.sqrt((o - t) ** 2 + (n - e) ** 2); +} +function nn(t = [0, 0], e = [0, 0], o = 0) { + const n = Math.atan2(e[1] - t[1], e[0] - t[0]) * 180 / Math.PI; + return (360 + o + n) % 360; +} +var rn = { + initial: ({ top: t, bottom: e, left: o, right: n }) => `${o}% ${t}%, ${n}% ${t}%, ${n}% ${e}%, ${o}% ${e}%`, + top: ({ top: t, left: e, right: o, minimum: n }) => `${e}% ${t}%, ${o}% ${t}%, ${o}% ${t + n}%, ${e}% ${t + n}%`, + right: ({ top: t, bottom: e, right: o, minimum: n }) => `${o - n}% ${t}%, ${o}% ${t}%, ${o}% ${e}%, ${o - n}% ${e}%`, + center: ({ centerX: t, centerY: e, minimum: o }) => `${t - o / 2}% ${e - o / 2}%, ${t + o / 2}% ${e - o / 2}%, ${t + o / 2}% ${e + o / 2}%, ${t - o / 2}% ${e + o / 2}%`, + bottom: ({ bottom: t, left: e, right: o, minimum: n }) => `${e}% ${t - n}%, ${o}% ${t - n}%, ${o}% ${t}%, ${e}% ${t}%`, + left: ({ top: t, bottom: e, left: o, minimum: n }) => `${o}% ${t}%, ${o + n}% ${t}%, ${o + n}% ${e}%, ${o}% ${e}%`, + vertical: ({ top: t, bottom: e, left: o, right: n, minimum: r }) => `${o}% ${t + r / 2}%, ${n}% ${t + r / 2}%, ${n}% ${e - r / 2}%, ${o}% ${e - r / 2}%`, + horizontal: ({ top: t, bottom: e, left: o, right: n, minimum: r }) => `${o + r / 2}% ${t}%, ${n - r / 2}% ${t}%, ${n - r / 2}% ${e}%, ${o + r / 2}% ${e}%` +}; +function R2({ + direction: t, + scaleX: e = 1, + scaleY: o = 1, + minimum: n = 0 +}) { + const r = (1 - o) / 2 * 100, s = (1 - e) / 2 * 100, i = 100 + s - (1 - e) * 100, l = 100 + r - (1 - o) * 100, f = (i + s) / 2, m = (l + r) / 2; + return `polygon(${rn[t]({ + top: r, + bottom: l, + left: s, + right: i, + centerX: f, + centerY: m, + minimum: n + })})`; +} +var G = "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"; +var z3 = ["bottom", "left", "top", "right"]; +function V2(t, e) { + const o = Math.max(0, t.indexOf(e)), n = t.length; + return t[(o + (n >> 1)) % n]; +} +function vt2(t, e) { + return e === "out" ? G : R2({ + direction: V2(z3, t) + }); +} +function _t2(t, e) { + return e === "in" ? G : R2({ + direction: e === "out" ? V2(z3, t) : t + }); +} +function an2(t, e) { + const o = t * Math.PI / 180, n = Math.cos(o) * e, r = Math.sin(o) * e; + return [n, r]; +} +function N2(t) { + return t === "percentage" ? "%" : t || "px"; +} +function S(t) { + return t ? z2[t] || t : z2.linear; +} +function K2(t) { + if (!z2[t]) + return { + in: t, + inOut: t, + out: t + }; + const e = t.replace(/In|Out/g, ""); + return e === "linear" ? { + in: "linear", + inOut: "linear", + out: "linear" + } : { + in: `${e}In`, + inOut: `${e}InOut`, + out: `${e}Out` + }; +} +var sn = { + linear: "linear", + easeOut: "ease-out", + hardBackOut: "cubic-bezier(0.58, 2.5, 0, 0.95)", + elastic: "linear( 0, 0.2178 2.1%, 1.1144 8.49%, 1.2959 10.7%, 1.3463 11.81%, 1.3705 12.94%, 1.3726, 1.3643 14.48%, 1.3151 16.2%, 1.0317 21.81%, 0.941 24.01%, 0.8912 25.91%, 0.8694 27.84%, 0.8698 29.21%, 0.8824 30.71%, 1.0122 38.33%, 1.0357, 1.046 42.71%, 1.0416 45.7%, 0.9961 53.26%, 0.9839 57.54%, 0.9853 60.71%, 1.0012 68.14%, 1.0056 72.24%, 0.9981 86.66%, 1 )", + bounce: "linear( 0, 0.0039, 0.0157, 0.0352, 0.0625 9.09%, 0.1407, 0.25, 0.3908, 0.5625, 0.7654, 1, 0.8907, 0.8125 45.45%, 0.7852, 0.7657, 0.7539, 0.75, 0.7539, 0.7657, 0.7852, 0.8125 63.64%, 0.8905, 1 72.73%, 0.9727, 0.9532, 0.9414, 0.9375, 0.9414, 0.9531, 0.9726, 1, 0.9883, 0.9844, 0.9883, 1 )" +}; +function k2(t) { + return t && sn[t] || "linear"; +} +function cn2(t, e) { + let o = t.offsetLeft, n = t.offsetTop, r = t.offsetParent; + for (; r && !(e && r === e); ) + o += r.offsetLeft, n += r.offsetTop, r = r.offsetParent; + return { left: o, top: n }; +} +var ln2 = (t, e, o) => { + const n = t === "top" || t === "left", r = n ? e : 0, s = n ? 0 : e, i = n ? -1 : 1, l = t === "top" || t === "bottom", f = [], m = []; + for (let c = r; c !== s; c += i) { + const d = 100 * ((c + i) / e), u = 100 * (c / e) | 0; + let g; + if (o) { + const p = n ? 1 + (e - c) / e : 1 + c / e; + g = n ? 100 - (100 - d) * p : d * p; + } else + g = d; + g |= 0, l ? (f.push( + `0% ${u}%, 100% ${u}%, 100% ${u}%, 0% ${u}%` + ), m.push(`0% ${u}%, 100% ${u}%, 100% ${g}%, 0% ${g}%`)) : (f.push( + `${u}% 0%, ${u}% 100%, ${u}% 100%, ${u}% 0%` + ), m.push(`${u}% 0%, ${u}% 100%, ${g}% 100%, ${g}% 0%`)); + } + return { start: f, end: m }; +}; +function tt2(t, e, o, n) { + const { start: r, end: s } = ln2(t, e, o); + return n && (r.reverse(), s.reverse()), { + clipStart: `polygon(${r.join(", ")})`, + clipEnd: `polygon(${s.join(", ")})` + }; +} +function D2(t, e = 2) { + return parseFloat(t.toFixed(e)); +} +function a(t, e, o = false, n = void 0) { + return o ? t[e] : `var(${e}${n !== void 0 ? `, ${n}` : ""})`; +} +function I(t, e, o = false) { + const n = t || 1, s = D2(n / (n + (e || 0))); + return o ? s.toString().replace(/\./g, "") : s; +} +var fn2 = /^(-?\d*\.?\d+)(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)$/i; +function rt2(t) { + const e = t.toLowerCase(); + return e === "%" ? "percentage" : e; +} +function A2(t, e) { + if (t == null) + return e; + if (typeof t == "number") + return { value: t, unit: e.unit }; + if (typeof t == "object" && "value" in t && "unit" in t) { + const o = typeof t.value == "string" ? parseFloat(t.value) : t.value; + return typeof o == "number" && !isNaN(o) && typeof t.unit == "string" ? { value: o, unit: rt2(t.unit) } : e; + } + if (typeof t == "string") { + const o = t.trim(), n = o.match(fn2); + if (n) + return { value: parseFloat(n[1]), unit: rt2(n[2]) }; + if (o !== "") { + const r = Number(o); + if (!isNaN(r)) + return { value: r, unit: e.unit }; + } + } + return e; +} +function _2(t, e, o, n = false) { + if (t == null) + return o; + if (typeof t == "number") + return n ? t : o; + if (typeof t == "string") { + const r = t.trim().toLowerCase(); + if (e.includes(r)) + return r; + if (n) { + const s = r.match(/^(-?\d*\.?\d+)deg$/i); + if (s) + return parseFloat(s[1]); + if (r !== "") { + const i = Number(r); + if (!isNaN(i)) + return i; + } + } + } + return o; +} +var F3 = class { + target; + options; + currentProgress; + constructor(e, o) { + this.target = e, this.options = o || {}, this.currentProgress = { x: 0.5, y: 0.5, v: { x: 0, y: 0 }, active: true }, this.play(); + } + progress({ x: e, y: o, v: n, active: r }) { + this.currentProgress = { x: e, y: o, v: n, active: r }, typeof this.options.customEffect == "function" && this.options.customEffect(this.target, this.currentProgress); + } + cancel() { + this.currentProgress = { x: 0.5, y: 0.5, v: { x: 0, y: 0 } }; + } + getProgress() { + return this.currentProgress; + } + play() { + this.options.transition && this.target && (this.target.style.transition = this.options.transition); + } +}; +function pi(t) { + return (e) => new F3(e, t); +} +var mn = { value: 200, unit: "px" }; +var un = 30; +var dn = "both"; +var gn2 = ["both", "horizontal", "vertical"]; +var $n2 = class extends F3 { + progress({ x: e, y: o }) { + let n = 0, r = 0; + const { distance: s, invert: i, angle: l, axis: f } = this.options; + f !== "vertical" && (n = h(0, 1, -s.value, s.value, e) * i), f !== "horizontal" && (r = h(0, 1, -s.value, s.value, o) * i); + const m = h(0, 1, -l, l, e) * i, c = N2(s.unit); + this.target.style.transform = `translateX(${n}${c}) translateY(${r}${c}) rotate(calc(${m}deg + var(--motion-rotate, 0deg)))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function yi(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, mn), i = _2(n.angle, [], un, true), l = _2(n.axis, gn2, dn), f = r ? -1 : 1, m = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: f, + distance: s, + angle: i, + axis: l + }; + return (c) => new $n2(c, m); +} +var pn = { value: 200, unit: "px" }; +var yn2 = class extends F3 { + progress({ x: e, y: o }) { + const { distance: n, scale: r, invert: s } = this.options, i = h(0, 1, -n.value, n.value, e) * s, l = h(0, 1, -n.value, n.value, o) * s, f = e < 0.5 ? h(0, 0.5, r, 1, e) : h(0.5, 1, 1, r, e), m = o < 0.5 ? h(0, 0.5, r, 1, o) : h(0.5, 1, 1, r, o), c = N2(n.unit); + this.target.style.transform = `translateX(${i}${c}) translateY(${l}${c}) scale(${f}, ${m}) rotate(var(--motion-rotate, 0deg))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function vi(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, { inverted: r = false, scale: s = 1.4 } = n, i = A2(n.distance, pn), l = r ? -1 : 1, f = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: l, + distance: i, + scale: s + }; + return (m) => new yn2(m, f); +} +var vn2 = { value: 80, unit: "px" }; +var _n2 = 5; +var hn = class extends F3 { + progress({ x: e, y: o }) { + const { distance: n, angle: r, scale: s, invert: i, blur: l, perspective: f } = this.options, m = h(0, 1, -n.value, n.value, e) * i, c = h(0, 1, -n.value, n.value, o) * i, d = e < 0.5 ? h(0, 0.5, s, 1, e) : h(0.5, 1, 1, s, e), u = o < 0.5 ? h(0, 0.5, s, 1, o) : h(0.5, 1, 1, s, o), g = Math.min(d, u), p = h(0, 1, -r, r, o) * i, $2 = h(0, 1, r, -r, e) * i, v = N2(n.unit), y = `perspective(${f}px) translateX(${m}${v}) translateY(${c}${v}) scale(${g}, ${g}) rotateX(${p}deg) rotateY(${$2}deg) rotate(var(--motion-rotate, 0deg))`, O2 = on([0.5, 0.5], [e, o]), T = `blur(${Math.round(h(0, 1, 0, l, U2(O2)))}px)`; + this.target.style.transform = y, this.target.style.filter = T; + } + cancel() { + this.target.style.transform = "", this.target.style.filter = "", this.target.style.transition = ""; + } +}; +function _i(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, vn2), i = _2(n.angle, [], _n2, true), { scale: l = 0.3, blur: f = 20, perspective: m = 600 } = n, c = r ? -1 : 1, d = { + transition: e ? `transform ${e}ms ${k2( + o + )}, filter ${e}ms ${k2(o)}` : "", + distance: s, + angle: i, + scale: l, + blur: f, + perspective: m, + invert: c + }; + return (u) => new hn(u, d); +} +var En2 = { value: 200, unit: "px" }; +var On2 = "both"; +var xn2 = ["both", "horizontal", "vertical"]; +var In2 = class extends F3 { + progress({ x: e, y: o }) { + const { invert: n, distance: r, axis: s } = this.options; + let i = 0, l = 0; + (s === "both" || s === "horizontal") && (i = h(0, 1, -r.value, r.value, e) * n), (s === "both" || s === "vertical") && (l = h(0, 1, -r.value, r.value, o) * n); + const f = N2(r.unit); + this.target.style.transform = `translateX(${i}${f}) translateY(${l}${f}) rotate(var(--motion-rotate, 0deg))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Sn2(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, En2), i = _2(n.axis, xn2, On2), l = r ? -1 : 1, f = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: l, + distance: s, + axis: i + }; + return (m) => new In2(m, f); +} +var Tn2 = { value: 80, unit: "px" }; +function hi(t) { + const e = t.namedEffect, o = A2(e.distance, Tn2), { transitionEasing: n = "elastic" } = t; + return Sn2({ + ...t, + transitionEasing: n, + namedEffect: { ...t.namedEffect, distance: o } + }); +} +var bn2 = { value: 80, unit: "px" }; +var An2 = "both"; +var wn2 = ["both", "horizontal", "vertical"]; +var Nn2 = class extends F3 { + progress({ x: e, y: o }) { + const { distance: n, scale: r, invert: s, axis: i } = this.options; + let l = 0, f = 0, m = 1, c = 1; + (i === "both" || i === "horizontal") && (l = h(0, 1, -n.value, n.value, e) * s, m = e < 0.5 ? h(0, 0.5, r, 1, e) : h(0.5, 1, 1, r, e)), (i === "both" || i === "vertical") && (f = h(0, 1, -n.value, n.value, o) * s, c = o < 0.5 ? h(0, 0.5, r, 1, o) : h(0.5, 1, 1, r, o)); + const d = r < 1 ? Math.min(m, c) : Math.max(m, c), u = N2(n.unit); + this.target.style.transform = `translateX(${l}${u}) translateY(${f}${u}) scale(${d}) rotate(var(--motion-rotate, 0deg))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Ei(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, bn2), i = _2(n.axis, wn2, An2), { scale: l = 1.4 } = n, f = r ? -1 : 1, m = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: f, + distance: s, + axis: i, + scale: l + }; + return (c) => new Nn2(c, m); +} +var Dn2 = { value: 200, unit: "px" }; +var kn2 = 25; +var Fn2 = "both"; +var Pn2 = ["both", "horizontal", "vertical"]; +var Rn2 = class extends F3 { + progress({ x: e, y: o }) { + let n = 0, r = 0, s = 0, i = 0; + const { distance: l, angle: f, axis: m, invert: c } = this.options; + m !== "vertical" && (n = h(0, 1, -l.value, l.value, e) * c, s = h(0, 1, f, -f, e) * c), m !== "horizontal" && (r = h(0, 1, -l.value, l.value, o) * c, i = h(0, 1, f, -f, o) * c), m === "both" && (s *= h(0, 1, 1, -1, mt(o)), i *= h(0, 1, 1, -1, mt(e))); + const d = N2(l.unit), u = `translateX(${n}${d}) translateY(${r}${d}) skew(${s}deg, ${i}deg) rotate(var(--motion-rotate, 0deg))`; + this.target.style.transform = u; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Oi(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, Dn2), i = _2(n.angle, [], kn2, true), l = _2(n.axis, Pn2, Fn2), f = r ? -1 : 1, m = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: f, + distance: s, + angle: i, + axis: l + }; + return (c) => new Rn2(c, m); +} +var Mn2 = "both"; +var Yn2 = ["both", "horizontal", "vertical"]; +var jn2 = class extends F3 { + progress({ x: e, y: o }) { + const { invert: n, axis: r } = this.options, s = nn( + [0.5, 0.5], + [r === "vertical" ? 0 : e, r === "horizontal" ? 0 : o], + 90 + ) * n; + this.target.style.transform = `rotate(calc(${s}deg + var(--motion-rotate, 0deg)))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function xi(t) { + const { transitionDuration: e, transitionEasing: o = "linear" } = t, n = t.namedEffect, r = n.inverted ?? false, s = _2(n.axis, Yn2, Mn2), i = r ? -1 : 1, l = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: i, + axis: s + }; + return (f) => new jn2(f, l); +} +var Cn2 = 5; +var zn2 = "center-horizontal"; +var Ln2 = ["top", "bottom", "right", "left", "center-horizontal", "center-vertical"]; +var Xn2 = { + top: [0, -50], + bottom: [0, 50], + right: [50, 0], + left: [-50, 0], + "center-horizontal": [0, 0], + "center-vertical": [0, 0] +}; +var Un2 = class extends F3 { + progress({ x: e, y: o }) { + let n = "rotateX", r = o, s = -1; + const { pivotAxis: i, angle: l, invert: f, perspective: m } = this.options; + (i === "center-horizontal" || i === "right" || i === "left") && (n = "rotateY", r = e, s = 1); + const c = h(0, 1, -l, l, r) * s * f, [d, u] = Xn2[i], g = `perspective(${m}px) translateX(${d}%) translateY(${u}%) ${n}(${c}deg) translateX(${-d}%) translateY(${-u}%) rotate(var(--motion-rotate, 0deg))`; + this.target.style.transform = g; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Ii(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = _2(n.angle, [], Cn2, true), i = _2( + n.pivotAxis, + Ln2, + zn2 + ), { perspective: l = 800 } = n, f = r ? -1 : 1, m = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: f, + angle: s, + perspective: l, + pivotAxis: i + }; + return (c) => new Un2(c, m); +} +var Bn2 = 5; +var Zn2 = class extends F3 { + progress({ x: e, y: o }) { + const { invert: n, angle: r, perspective: s } = this.options, i = h(0, 1, r, -r, o) * n, l = h(0, 1, -r, r, e) * n; + this.target.style.transform = `perspective(${s}px) rotateX(${i}deg) rotateY(${l}deg) rotate(var(--motion-rotate, 0deg))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Si(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = _2(n.angle, [], Bn2, true), { perspective: i = 800 } = n, l = r ? -1 : 1, f = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: l, + angle: s, + perspective: i + }; + return (m) => new Zn2(m, f); +} +var Gn2 = { value: 200, unit: "px" }; +var Vn2 = 5; +var Kn2 = "both"; +var Hn2 = ["both", "horizontal", "vertical"]; +var qn2 = class extends F3 { + progress({ x: e, y: o }) { + const { invert: n, distance: r, angle: s, axis: i, perspective: l } = this.options; + let f = 0, m = 0, c = 0, d = 0; + (i === "both" || i === "horizontal") && (f = h(0, 1, -r.value, r.value, e), d = h(0, 1, -s, s, e) * n), (i === "both" || i === "vertical") && (m = h(0, 1, -r.value, r.value, o), c = h(0, 1, s, -s, o) * n); + const u = N2(r.unit); + this.target.style.transform = `perspective(${l}px) translateX(${f}${u}) translateY(${m}${u}) rotateX(${c}deg) rotateY(${d}deg) rotate(var(--motion-rotate, 0deg))`; + } + cancel() { + this.target.style.transform = "", this.target.style.transition = ""; + } +}; +function Ti(t) { + const { transitionDuration: e, transitionEasing: o } = t, n = t.namedEffect, r = n.inverted ?? false, s = A2(n.distance, Gn2), i = _2(n.angle, [], Vn2, true), l = _2(n.axis, Hn2, Kn2), { perspective: f = 800 } = n, m = r ? -1 : 1, c = { + transition: e ? `transform ${e}ms ${k2(o)}` : "", + invert: m, + distance: s, + axis: l, + angle: i, + perspective: f + }; + return (d) => new qn2(d, c); +} +function P2(t, e, o) { + e.measure((n) => { + n && (t["--motion-comp-height"] = `${n.offsetHeight}px`, t["--motion-comp-half-height"] && (t["--motion-comp-half-height"] = `${Math.round(0.5 * n.offsetHeight)}px`)); + }), e.mutate((n) => { + n?.style.setProperty("--motion-comp-height", t["--motion-comp-height"]), t["--motion-comp-half-height"] && n?.style.setProperty( + "--motion-comp-half-height", + t["--motion-comp-half-height"] + ); + }); +} +var Jn2 = () => window.document.getElementById("masterPage"); +var Qn2 = () => { + const t = window.document.getElementById("WIX_ADS"); + return t ? t.offsetHeight : 0; +}; +var Wn2 = () => { + const t = Jn2(); + return t ? t.offsetHeight + Qn2() : 0; +}; +function tr(t, e, o) { + e.measure(() => { + t["--motion-site-height"] = `${Wn2()}px`; + }), e.mutate((n) => { + n?.style.setProperty("--motion-site-height", t["--motion-site-height"]); + }); +} +function ht(t, e) { + return t > e ? 0 : 1 / (1 - t / e); +} +function Et2(t) { + return ["motion-bgCloseUpOpacity", "motion-bgCloseUpZoom"]; +} +function Ot3(t, e) { + const o = { + "--motion-comp-height": "0px", + "--motion-comp-half-height": "0px" + }; + return e && P2(o, e), o; +} +function er(t, e) { + return t.measures = Ot3(t, e), xt2(t, true); +} +function xt2(t, e = false) { + const o = "linear", { scale: n = 80 } = t.namedEffect, r = { "--motion-trans-z": `${n}px` }, [s, i] = Et2(); + return [ + { + ...t, + name: s, + easing: o, + part: "BG_LAYER", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get startOffsetAdd() { + return `calc(50vh + ${a( + t.measures || {}, + "--motion-comp-half-height", + e + )})`; + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + opacity: 1 + }, + { + opacity: 0 + } + ] + }, + { + ...t, + name: i, + easing: o, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + transform: "perspective(100px) translateZ(0px)" + }, + { + transform: `perspective(100px) translateZ(${a( + r, + "--motion-trans-z", + e + )})` + } + ] + } + ]; +} +var bi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Et2, prepare: Ot3, style: xt2, web: er }, Symbol.toStringTag, { value: "Module" })); +function It3(t) { + return ["motion-bgFade"]; +} +function St2(t, e) { + const o = { + "--motion-comp-height": "0px", + "--motion-comp-half-height": "0px" + }; + return e && P2(o, e), o; +} +function or(t, e) { + return t.measures = St2(t, e), Tt2(t, true); +} +function Tt2(t, e = false) { + const { range: o = "in" } = t.namedEffect, n = o === "out", r = n ? "sineOut" : "sineIn", s = { + "--motion-bg-fade-from": n ? 1 : 0, + "--motion-bg-fade-to": n ? 0 : 1 + }, [i] = It3(); + return [ + { + ...t, + name: i, + part: "BG_LAYER", + easing: r, + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + startOffsetAdd: n ? "100vh" : "0px", + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return n ? `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})` : `calc(50vh + ${a( + t.measures || {}, + "--motion-comp-half-height", + e + )})`; + }, + keyframes: [ + { + opacity: a(s, "--motion-bg-fade-from", e) + }, + { + opacity: a(s, "--motion-bg-fade-to", e) + } + ] + } + ]; +} +var Ai = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: It3, prepare: St2, style: Tt2, web: or }, Symbol.toStringTag, { value: "Module" })); +function bt2(t) { + return ["motion-bgFadeBackOpacity", "motion-bgFadeBackScale"]; +} +function At2(t, e) { + const o = { + "--motion-comp-height": "0px", + "--motion-comp-half-height": "0px" + }; + return e && P2(o, e), o; +} +function nr(t, e) { + return t.measures = At2(t, e), wt2(t, true); +} +function wt2(t, e = false) { + const o = "sineOut", { scale: n = 0.7 } = t.namedEffect, r = { "--motion-scale": n }, [s, i] = bt2(); + return [ + { + ...t, + name: s, + easing: "linear", + part: "BG_LAYER", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + startOffsetAdd: "100vh", + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + opacity: 1 + }, + { + opacity: 0 + } + ] + }, + { + ...t, + name: i, + easing: o, + part: "BG_LAYER", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + startOffsetAdd: "100vh", + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-half-height", + e + )})`; + }, + keyframes: [ + { + scale: 1 + }, + { + scale: a(r, "--motion-scale", e) + } + ] + } + ]; +} +var wi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: bt2, prepare: At2, style: wt2, web: nr }, Symbol.toStringTag, { value: "Module" })); +var J2 = 100; +function Nt2(t) { + return ["motion-bgFake3DParallax", "motion-bgFake3DStretch", "motion-bgFake3DZoom"]; +} +function Dt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function rr(t, e) { + return t.measures = Dt2(t, e), kt2(t, true); +} +function kt2(t, e = false) { + const { stretch: o = 1.3, zoom: n = 100 / 6 } = t.namedEffect, r = ht(n, J2), s = { + "--motion-scale-y": o, + "--motion-trans-z": `${D2(n)}px`, + "--motion-trans-y-factor": D2(-0.1 * (2 - r)) + }, [i, l, f] = Nt2(), { measures: m = { "--motion-comp-height": "0px" } } = t; + return [ + { + ...t, + name: i, + part: "BG_IMG", + easing: "sineOut", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a(m, "--motion-comp-height", e)})`; + }, + get keyframes() { + return [ + { + transform: "translateY(10svh)" + }, + { + transform: `translateY(calc(${a( + s, + "--motion-trans-y-factor", + e + )} * ${a( + m, + "--motion-comp-height", + false, + m["--motion-comp-height"] + )}))` + } + ]; + } + }, + { + ...t, + name: l, + part: "BG_IMG", + easing: "linear", + composite: "add", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a(m, "--motion-comp-height", e)})`; + }, + keyframes: [ + { + transform: `scaleY(${a(s, "--motion-scale-y", e)})` + }, + { + transform: "scaleY(1)" + } + ] + }, + { + ...t, + name: f, + part: "BG_IMG", + easing: "sineIn", + composite: "add", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a(m, "--motion-comp-height", e)})`; + }, + keyframes: [ + { + transform: `perspective(${J2}px) translateZ(0px)` + }, + { + transform: `perspective(${J2}px) translateZ(${a( + s, + "--motion-trans-z", + e + )})` + } + ] + } + ]; +} +var Ni = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Nt2, prepare: Dt2, style: kt2, web: rr }, Symbol.toStringTag, { value: "Module" })); +function Ft2(t) { + return ["motion-bgPan"]; +} +function Pt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function ar(t, e) { + return t.measures = Pt2(t, e), Rt2(t, true); +} +function Rt2(t, e = false) { + const { direction: o = "left", speed: n = 0.2 } = t.namedEffect, r = 50 * n / (1 + n) | 0, s = { + "--motion-trans-x": o === "left" ? `${r}%` : `${-r}%` + }, [i] = Ft2(); + return [ + { + ...t, + name: i, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + transform: `translateX(${a(s, "--motion-trans-x", e)})` + }, + { + transform: `translateX(calc(-1 * ${a(s, "--motion-trans-x", e)}))` + } + ] + } + ]; +} +var Di = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ft2, prepare: Pt2, style: Rt2, web: ar }, Symbol.toStringTag, { value: "Module" })); +function Mt2(t) { + return ["motion-bgParallax"]; +} +function Yt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function sr(t, e) { + return t.measures = Yt2(t, e), jt2(t, true); +} +function jt2(t, e = false) { + const { speed: o = 0.2 } = t.namedEffect, n = { + "--motion-parallax-speed": o + }, [r] = Mt2(); + return [ + { + ...t, + name: r, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + transform: `translateY(calc(${a( + n, + "--motion-parallax-speed", + e + )} * 100svh))` + }, + { + transform: `translateY(calc((200lvh - 100%) * ${a( + n, + "--motion-parallax-speed", + e + )}))` + } + ] + } + ]; +} +var ki = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Mt2, prepare: Yt2, style: jt2, web: sr }, Symbol.toStringTag, { value: "Module" })); +function Ct2(t) { + return ["motion-bgPullBack"]; +} +function zt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function ir(t, e) { + return t.measures = zt2(t, e), Lt2(t, true); +} +function Lt2(t, e = false) { + const o = "linear", { scale: n = 50 } = t.namedEffect, r = { + "--motion-trans-z": `${n}px`, + // TODO: (ameerf) - remove and use only scale once CSS round is widely available + "--motion-trans-y": `-${n / 3 | 0}%` + }, [s] = Ct2(); + return [ + { + ...t, + name: s, + easing: o, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `${a(t.measures || {}, "--motion-comp-height", e)}`; + }, + keyframes: [ + { + transform: `perspective(100px) translate3d(0px, ${a( + r, + "--motion-trans-y", + e + )}, ${a(r, "--motion-trans-z", e)})` + }, + { + transform: "perspective(100px) translate3d(0px, 0px, 0px)" + } + ] + } + ]; +} +var Fi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ct2, prepare: zt2, style: Lt2, web: ir }, Symbol.toStringTag, { value: "Module" })); +function cr(t) { + return []; +} +function Xt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function lr(t, e) { + return Xt2(t, e), Ut2(); +} +function Ut2(t) { + return []; +} +var Pi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: cr, prepare: Xt2, style: Ut2, web: lr }, Symbol.toStringTag, { value: "Module" })); +function Bt2(t) { + return ["motion-bgRotate"]; +} +function fr(t) { + return Zt2(t, true); +} +function Zt2(t, e = false) { + const o = "sineOut", { angle: n = 22, direction: r = "counter-clockwise" } = t.namedEffect, s = { + "--motion-rot-from": `${r === "counter-clockwise" ? n : -n}deg` + }, [i] = Bt2(); + return [ + { + ...t, + name: i, + easing: o, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffsetAdd: "100vh", + keyframes: [ + { + transform: `rotate(${a(s, "--motion-rot-from", e)})` + }, + { + transform: "rotate(0deg)" + } + ] + } + ]; +} +var Ri = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Bt2, style: Zt2, web: fr }, Symbol.toStringTag, { value: "Module" })); +function Gt2(t) { + return ["motion-bgSkew"]; +} +function Vt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function mr(t, e) { + return t.measures = Vt2(t, e), Kt2(t, true); +} +function Kt2(t, e = false) { + const { angle: o = 20, direction: n = "counter-clockwise" } = t.namedEffect, r = { + "--motion-skew": `${n === "counter-clockwise" ? o : -o}deg` + }, [s] = Gt2(); + return [ + { + ...t, + name: s, + part: "BG_MEDIA", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + transform: `skewY(${a(r, "--motion-skew", e)})` + }, + { + transform: `skewY(calc(-1 * ${a(r, "--motion-skew", e)}))` + } + ] + } + ]; +} +var Mi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Gt2, prepare: Vt2, style: Kt2, web: mr }, Symbol.toStringTag, { value: "Module" })); +var Z2 = 100; +var ur = 40; +var dr = 0.375; +var gr = { + in: { + easing: "sineIn", + fromY: "20svh" + }, + out: { + easing: "sineInOut", + fromY: "0px" + } +}; +function Ht2(t) { + const { direction: e = "in" } = t.namedEffect, o = ["motion-bgZoomMedia", "motion-bgZoomImg"]; + return e === "in" && o.splice(1, 0, "motion-bgZoomParallax"), o; +} +function qt2(t, e) { + const o = { "--motion-comp-height": "0px" }; + return e && P2(o, e), o; +} +function $r(t, e) { + return t.measures = qt2(t, e), Jt2(t, true); +} +function Jt2(t, e = false) { + let { direction: o = "in", zoom: n = ur } = t.namedEffect; + const r = o === "in"; + r || (o = "out", n *= dr); + const { easing: s, fromY: i } = gr[o], l = r ? 0 : n / 1.3, f = r ? n : -n, m = D2(ht(f, Z2)), c = { + "--motion-zoom-over-pers": 0.5 * n / Z2, + "--motion-scale-to": m, + "--motion-trans-y-from": i, + "--motion-trans-z-from": `${D2(l)}px`, + "--motion-trans-z-to": `${D2(f)}px` + }, { measures: d = { "--motion-comp-height": "0px" } } = t, u = [ + { + ...t, + part: "BG_MEDIA", + easing: "linear", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + keyframes: [ + { + transform: "translate3d(0, 0, 0)" + }, + { + transform: "translate3d(0, 0, 0)" + } + ] + }, + { + ...t, + easing: s, + part: "BG_IMG", + composite: r ? "add" : "replace", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a(d, "--motion-comp-height", e)})`; + }, + keyframes: [ + { + transform: `perspective(${Z2}px) translateZ(${a( + c, + "--motion-trans-z-from", + e + )})` + }, + { + transform: `perspective(${Z2}px) translateZ(${a( + c, + "--motion-trans-z-to", + e + )})` + } + ] + } + ]; + r && u.splice(1, 0, { + ...t, + part: "BG_IMG", + easing: "linear", + startOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return `calc(100svh + ${a(d, "--motion-comp-height", e)})`; + }, + get keyframes() { + return [ + { + transform: `translateY(${a(c, "--motion-trans-y-from", e)})` + }, + { + transform: `translateY(calc(${a( + c, + "--motion-scale-to", + e + )} * (-0.2 * ${a( + d, + "--motion-comp-height", + false, + d["--motion-comp-height"] + )} + ${a( + c, + "--motion-zoom-over-pers", + e + )} * max(0px, 100lvh - ${a( + d, + "--motion-comp-height", + false, + d["--motion-comp-height"] + )}))))` + } + ]; + } + }); + const g = Ht2(t); + return u.forEach((p, $2) => { + p.name = g[$2]; + }), u; +} +var Yi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ht2, prepare: qt2, style: Jt2, web: $r }, Symbol.toStringTag, { value: "Module" })); +function Qt2(t) { + return ["motion-imageParallax"]; +} +function Wt2(t, e) { + const o = { + "--motion-comp-height": "0px", + "--motion-site-height": "0" + }, { isPage: n = false } = t.namedEffect; + return e && (n ? tr(o, e) : P2(o, e)), o; +} +function pr(t, e) { + return t.measures = Wt2(t, e), te2(t, true); +} +function te2(t, e = false) { + const { speed: o = 1.5, reverse: n = false, isPage: r = false } = t.namedEffect; + let s = -100 * (o - 1); + r || (s = s / o); + let i = 0; + n && ([s, i] = [i, s]); + const l = { + "--motion-trans-y-from": `${s | 0}%`, + "--motion-trans-y-to": `${i | 0}%` + }, [f] = Qt2(); + return [ + { + ...t, + name: f, + part: "BG_MEDIA", + startOffset: { + name: r ? "contain" : "cover", + offset: { unit: "percentage", value: 0 } + }, + endOffset: { + name: "cover", + offset: { unit: "percentage", value: 0 } + }, + get endOffsetAdd() { + return r ? `${a(t.measures || {}, "--motion-site-height", e)}` : `calc(100vh + ${a( + t.measures || {}, + "--motion-comp-height", + e + )})`; + }, + keyframes: [ + { + transform: `translateY(${a(l, "--motion-trans-y-from", e)})` + }, + { + transform: `translateY(${a(l, "--motion-trans-y-to", e)})` + } + ] + } + ]; +} +var ji = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Qt2, prepare: Wt2, style: te2, web: pr }, Symbol.toStringTag, { value: "Module" })); +var yr = 1; +var vr = 3; +var _r = [ + { keyframe: 0, translateY: 0 }, + { keyframe: 8.8, translateY: -55 }, + { keyframe: 17.6, translateY: -87 }, + { keyframe: 26.5, translateY: -98 }, + { keyframe: 35.3, translateY: -87 }, + { keyframe: 44.1, translateY: -55 }, + { keyframe: 53.1, translateY: 0 }, + { keyframe: 66.2, translateY: -23 }, + { keyframe: 81, translateY: 0 }, + { keyframe: 86.8, translateY: -5 }, + { keyframe: 94.1, translateY: 0 }, + { keyframe: 97.1, translateY: -2 }, + { keyframe: 100, translateY: 0 } +]; +function hr(t, e) { + return ee2(t, true); +} +function ee2(t, e = false) { + const o = t.namedEffect, { intensity: n = 0 } = o, r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = oe2(t), f = h(0, 1, yr, vr, n), m = S("sineOut"), c = { + "--motion-bounce-factor": f + }, d = _r.map(({ keyframe: u, translateY: g }) => ({ + offset: u / 100 * i, + translate: `0px calc(${g / 2}px * ${a( + c, + "--motion-bounce-factor", + e + )})`, + easing: m + })); + return [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: c, + keyframes: d + } + ]; +} +function oe2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-bounce-${I(t.duration, e, true)}`]; +} +var Ci = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: oe2, style: ee2, web: hr }, Symbol.toStringTag, { value: "Module" })); +var w = ["top", "right", "bottom", "left"]; +var B2 = ["horizontal", "vertical"]; +var H2 = ["clockwise", "counter-clockwise"]; +var L2 = ["left", "right"]; +var Er = [ + "top", + "right", + "bottom", + "left", + "top-left", + "top-right", + "bottom-left", + "bottom-right" +]; +var ne2 = [ + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", + "top-left", + "center" +]; +var Or = ["top-left", "top-right", "bottom-left", "bottom-right"]; +var xr = { value: 25, unit: "px" }; +var Ir = [...B2, "center"]; +var Sr = "vertical"; +var Tr = { + vertical: { x: 0, y: 1, z: 0 }, + horizontal: { x: 1, y: 0, z: 0 }, + center: { x: 0, y: 0, z: 1 } +}; +var br = [ + { translateFactor: 1, timeFactor: 0.1 }, + { translateFactor: -1, timeFactor: 0.302 }, + { translateFactor: 1, timeFactor: 0.504 }, + { translateFactor: -0.7, timeFactor: 0.705 }, + { translateFactor: 0.6, timeFactor: 0.839 } +]; +function Ar(t, e) { + return re2(t, true); +} +function re2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, Ir, Sr), r = A2(o.distance, xr), { perspective: s = 800 } = o, i = t.easing || "sineInOut", l = t.duration || 1, f = o?.iterationDelay || 0, m = l + f, c = I(l, f), [d] = ae2(t), { x: u, y: g, z: p } = Tr[n], $2 = K2(i), y = { + "--motion-breathe-perspective": n === "center" ? `perspective(${s}px)` : "", + "--motion-breathe-distance": `${r.value}${N2(r.unit || "px")}`, + "--motion-breathe-x": u, + "--motion-breathe-y": g, + "--motion-breathe-z": p + }, O2 = `${a(y, "--motion-breathe-x", e)}`, x3 = `${a(y, "--motion-breathe-y", e)}`, T = `${a(y, "--motion-breathe-z", e)}`, E = `${a( + y, + "--motion-breathe-perspective", + e, + "" + )}`, b2 = `${a(y, "--motion-breathe-distance", e)}`, X2 = f ? br.map(({ translateFactor: ot2, timeFactor: Wo }) => { + const tn2 = Wo * c, q2 = `${b2} * ${ot2}`; + return { + offset: tn2, + easing: S($2.inOut), + transform: `${E} translate3d(calc(${O2} * ${q2}), calc(${x3} * ${q2}), calc(${T} * ${q2})) rotateZ(var(--motion-rotate, 0deg))` + }; + }) : [ + { + offset: 0.25, + easing: S($2.inOut), + transform: `${E} translate3d(calc(${O2} * ${b2}), calc(${x3} * ${b2}), calc(${T} * ${b2})) rotateZ(var(--motion-rotate, 0deg))` + }, + { + offset: 0.75, + easing: S($2.in), + transform: `${E} translate3d(calc(${O2} * -1 * ${b2}), calc(${x3} * -1 * ${b2}), calc(${T} * -1 * ${b2})) rotateZ(var(--motion-rotate, 0deg))` + } + ]; + return [ + { + ...t, + name: d, + easing: "linear", + duration: m, + custom: y, + keyframes: [ + { + offset: 0, + easing: S($2.out), + transform: `${E} translate3d(0, 0, 0) rotateZ(var(--motion-rotate, 0deg))` + }, + ...X2, + { + offset: 1, + transform: `${E} translate3d(0, 0, 0) rotateZ(var(--motion-rotate, 0deg))` + } + ] + } + ]; +} +function ae2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-breathe-${I(t.duration, e, true)}`]; +} +var zi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ae2, style: re2, web: Ar }, Symbol.toStringTag, { value: "Module" })); +var wr = "right"; +var Nr = { + // 100cqw - left + RIGHT: "calc(var(--motion-parent-width, 100vw) - var(--motion-left, 0px))", + // left * -1 - width + LEFT: "calc(var(--motion-left, 0px) * -1 - var(--motion-width, 100%))", + // top * -1 - height + TOP: "calc(var(--motion-top, 0px) * -1 - var(--motion-height, 100%))", + // 100cqh - top + BOTTOM: "calc(var(--motion-parent-height, 100vh) - var(--motion-top, 0px))" +}; +var { RIGHT: M2, LEFT: Y2, TOP: j2, BOTTOM: C2 } = Nr; +var at2 = { + "top-left": { + // min(100cqw - left, 100cqh - top) + from: `min(${M2}, ${C2})`, + // min(abs(left * -1 - width), abs(top * -1 - height)) + to: `min(calc(${Y2} * -1), calc(${j2} * -1))` + }, + "top-right": { + // min(abs(left * -1 - width), 100cqh - top) + from: `min(calc(${Y2} * -1), ${C2})`, + // min(100cqw - left, abs(top * -1 - height)) + to: `min(${M2}, calc(${j2} * -1))` + }, + "bottom-left": { + // min(100cqw - left, abs(top * -1 - height)) + from: `min(${M2}, calc(${j2} * -1))`, + // min(abs(left * -1 - width), 100cqh - top) + to: `min(calc(${Y2} * -1), ${C2})` + }, + "bottom-right": { + // min(abs(left * -1 - width), abs(top * -1 - height)) + from: `min(calc(${Y2} * -1), calc(${j2} * -1))`, + // min(100cqw - left, 100cqh - top) + to: `min(${M2}, ${C2})` + } +}; +var Q2 = { + left: { + from: `${M2} 0`, + to: `${Y2} 0` + }, + right: { + from: `${Y2} 0`, + to: `${M2} 0` + }, + top: { + from: `0 ${C2}`, + to: `0 ${j2}` + }, + bottom: { + from: `0 ${j2}`, + to: `0 ${C2}` + } +}; +var Dr = { + // (width + left) / (100cqw + width) + left: ({ left: t, width: e, parentWidth: o }) => (e + t) / (o + e || 1), + // (100cqw - left) / (100cqw + width) + right: ({ left: t, width: e, parentWidth: o }) => (o - t) / (o + e || 1), + // (100cqh - top) / (100cqh + height) + bottom: ({ top: t, height: e, parentHeight: o }) => (o - t) / (o + e || 1), + // (height + top) / (100cqh + height) + top: ({ top: t, height: e, parentHeight: o }) => (e + t) / (o + e || 1), + // min(, ) + "bottom-right": ({ + left: t, + top: e, + width: o, + height: n, + parentWidth: r, + parentHeight: s + }) => { + const i = o + t, l = s - e; + return i < l ? i / (r + o || 1) : l / (s + n || 1); + }, + // min(, ) + "bottom-left": ({ + left: t, + top: e, + width: o, + height: n, + parentWidth: r, + parentHeight: s + }) => { + const i = r - t, l = s - e; + return i < l ? i / (r + o || 1) : l / (s + n || 1); + }, + // min(, ) + "top-right": ({ + left: t, + top: e, + width: o, + height: n, + parentWidth: r, + parentHeight: s + }) => { + const i = r - t, l = n + e; + return i < l ? i / (r + o || 1) : l / (s + n || 1); + }, + // min(, ) + "top-left": ({ + left: t, + top: e, + width: o, + height: n, + parentWidth: r, + parentHeight: s + }) => { + const i = r - t, l = n + e; + return i < l ? i / (r + o || 1) : l / (s + n || 1); + } +}; +function kr(t) { + const e = at2[t].from, o = at2[t].to, n = t.startsWith("top") ? 1 : -1, r = -n, s = t.endsWith("left") ? 1 : -1, i = -s; + return { + from: `calc(${e} * ${s}) calc(${e} * ${n})`, + to: `calc(${o} * ${i}) calc(${o} * ${r})` + }; +} +function Fr(t, e) { + const o = t.namedEffect, n = _2(o?.direction, Er, wr), r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = se2(), f = { + "--motion-left": "0px", + "--motion-top": "0px", + "--motion-width": "100%", + "--motion-height": "100%", + "--motion-parent-width": "100vw", + "--motion-parent-height": "100vh" + }; + let m = 0, c = 0, d = 0, u = 0, g = 0, p = 0; + return e && (e.measure(($2) => { + if (!$2) + return; + const { width: v, height: y } = $2.getBoundingClientRect(), O2 = $2.offsetParent, x3 = O2?.getBoundingClientRect() || {}, T = cn2($2, O2); + m = T.left, c = T.top, d = v, u = y, g = x3.width, p = x3.height; + }), e.mutate(($2) => { + $2?.style.setProperty("--motion-left", `${m}px`), $2?.style.setProperty("--motion-top", `${c}px`), $2?.style.setProperty("--motion-width", `${d}px`), $2?.style.setProperty("--motion-height", `${u}px`), $2?.style.setProperty("--motion-parent-width", `${g}px`), $2?.style.setProperty("--motion-parent-height", `${p}px`); + })), [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: f, + get keyframes() { + const $2 = Dr[n]({ + left: m, + top: c, + width: d, + height: u, + parentWidth: g, + parentHeight: p + }) * i; + let v, y; + if (n in Q2) + v = Q2[n].from, y = Q2[n].to; + else { + const O2 = kr( + n + ); + v = O2.from, y = O2.to; + } + return [ + { + offset: 0, + translate: "0 0" + }, + { + offset: $2, + translate: y, + easing: "step-start" + }, + { + offset: $2, + translate: v + }, + { + offset: i, + translate: "0 0" + }, + { + offset: 1, + translate: "0 0" + } + ]; + } + } + ]; +} +function se2(t) { + return ["motion-cross"]; +} +var Li = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: se2, web: Fr }, Symbol.toStringTag, { value: "Module" })); +function Pr(t, e) { + return ie(t, true); +} +function ie(t, e = false) { + const o = t.namedEffect, n = t.duration || 1, r = o?.iterationDelay || 0, s = S(t.easing || "cubicInOut"), i = I(n, r), [l] = ce2(t), f = [ + { + offset: 0, + opacity: 1, + easing: s + }, + { + offset: 0.5 * i, + opacity: 0, + easing: s + }, + { + offset: i, + opacity: 1 + }, + { + offset: 1, + opacity: 1 + } + ]; + return [ + { + ...t, + name: l, + easing: "linear", + duration: n + r, + keyframes: f + } + ]; +} +function ce2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-flash-${I(t.duration, e, true)}`]; +} +var Xi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ce2, style: ie, web: Pr }, Symbol.toStringTag, { value: "Module" })); +var Rr = "horizontal"; +var Mr = { + vertical: { x: "1", y: "0" }, + horizontal: { x: "0", y: "1" } +}; +function Yr(t, e) { + return le2(t, true); +} +function le2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, B2, Rr), { perspective: r = 800 } = o, s = t.duration || 1, i = o?.iterationDelay || 0, l = I(s, i), [f] = fe2(t), m = Mr[n], c = t.easing || "linear", d = { + "--motion-perspective": `${r}px`, + "--motion-rotate-x": m.x, + "--motion-rotate-y": m.y + }, u = `rotate3d(${a( + d, + "--motion-rotate-x", + e + )}, ${a(d, "--motion-rotate-y", e)}, 0, 0deg)`, g = `rotate3d(${a( + d, + "--motion-rotate-x", + e + )}, ${a(d, "--motion-rotate-y", e)}, 0, 360deg)`; + return [ + { + ...t, + name: f, + easing: "linear", + duration: s + i, + custom: d, + keyframes: [ + { + offset: 0, + transform: `perspective(${a(d, "--motion-perspective", e)}) rotateZ(var(--motion-rotate, 0deg)) ${u}`, + easing: S(c) + }, + { + offset: l, + transform: `perspective(${a(d, "--motion-perspective", e)}) rotateZ(var(--motion-rotate, 0deg)) ${g}` + }, + { + offset: 1, + transform: `perspective(${a(d, "--motion-perspective", e)}) rotateZ(var(--motion-rotate, 0deg)) ${g}` + } + ] + } + ]; +} +function fe2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-flip-${I(t.duration, e, true)}`]; +} +var Ui = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: fe2, style: le2, web: Yr }, Symbol.toStringTag, { value: "Module" })); +var jr = "top"; +var Cr = { + top: { + rotation: { x: 1, y: 0 }, + origin: { x: 0, y: -50 } + }, + right: { + rotation: { x: 0, y: 1 }, + origin: { x: 50, y: 0 } + }, + bottom: { + rotation: { x: 1, y: 0 }, + origin: { x: 0, y: 50 } + }, + left: { + rotation: { x: 0, y: 1 }, + origin: { x: -50, y: 0 } + } +}; +var zr = 15; +var Lr = [ + { fold: 1, frameFactor: 0.1 }, + { fold: -0.7, frameFactor: 0.302 }, + { fold: 0.6, frameFactor: 0.504 }, + { fold: -0.3, frameFactor: 0.686 }, + { fold: 0.2, frameFactor: 0.847 }, + { fold: -0.05, frameFactor: 1.049 }, + { fold: 0, frameFactor: 1.189 } +]; +function Xr(t, e) { + return me2(t, true); +} +function me2(t, e = false) { + const o = t.namedEffect, n = _2( + o.direction, + w, + jr + ), { angle: r = zr } = o, s = t.easing || "cubicInOut", i = t.duration || 1, l = +(o?.iterationDelay || 0), [f] = ue2(t), { rotation: m, origin: c } = Cr[n], { x: d, y: u } = c, g = K2(s), p = i + l, $2 = I(i, l), v = { + "--motion-origin-x": `${d}%`, + "--motion-origin-y": `${u}%`, + "--motion-rotate-angle": `${r}deg`, + "--motion-rotate-x": `${m.x}`, + "--motion-rotate-y": `${m.y}` + }, y = `rotateZ(var(--motion-rotate, 0deg)) translateX(${a( + v, + "--motion-origin-x", + e + )}) translateY(${a(v, "--motion-origin-y", e)}) perspective(800px)`, O2 = `translateX(calc(-1 * ${a( + v, + "--motion-origin-x", + e + )})) translateY(calc(-1 * ${a(v, "--motion-origin-y", e)}))`, x3 = (b2) => `${y} rotateX(calc(${a( + v, + "--motion-rotate-x", + e + )} * ${b2} * ${r}deg)) rotateY(calc(${a( + v, + "--motion-rotate-y", + e + )} * ${b2} * ${r}deg)) ${O2}`, T = l ? Lr.map(({ fold: b2, frameFactor: X2 }) => ({ + offset: X2 * $2, + easing: S("sineInOut"), + transform: x3(b2) + })) : [ + { + offset: 0.25, + easing: S(g.inOut), + transform: x3(1) + }, + { + offset: 0.75, + easing: S(g.in), + transform: x3(-1) + } + ], E = x3(0); + return [ + { + ...t, + name: f, + easing: "linear", + duration: p, + custom: v, + keyframes: [ + { + offset: 0, + easing: S(g.out), + transform: E + }, + ...T, + { + offset: 1, + transform: E + } + ] + } + ]; +} +function ue2(t) { + const e = t.duration || 1, o = +(t.namedEffect?.iterationDelay || 0); + return o ? [`motion-fold-${I(e, o, true)}`] : ["motion-fold"]; +} +var Bi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ue2, style: me2, web: Xr }, Symbol.toStringTag, { value: "Module" })); +var Ur = 1; +var Br = 4; +var Zr = [ + { keyframe: 24, skewY: 7 }, + { keyframe: 38, skewY: -2 }, + { keyframe: 58, skewY: 4 }, + { keyframe: 80, skewY: -2 }, + { keyframe: 100, skewY: 0 } +]; +function Gr(t, e) { + return de2(t, true); +} +function de2(t, e = false) { + const o = t.namedEffect, { intensity: n = 0.25 } = o, r = t.duration || 1, s = o?.iterationDelay || 0, [i] = ge2(t), l = I(r, s), m = { + "--motion-skew-y": h(0, 1, Ur, Br, n) + }, c = Zr.map(({ keyframe: d, skewY: u }) => ({ + offset: d / 100 * l, + transform: `rotateZ(var(--motion-rotate, 0deg)) skewY(calc(${a( + m, + "--motion-skew-y", + e + )} * ${u}deg))` + })); + return [ + { + ...t, + name: i, + easing: "linear", + duration: r + s, + custom: m, + keyframes: c + } + ]; +} +function ge2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-jello-${I(t.duration, e, true)}`]; +} +var Zi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ge2, style: de2, web: Gr }, Symbol.toStringTag, { value: "Module" })); +var Vr = "right"; +var Kr = [ + { keyframe: 17, translate: 7 }, + { keyframe: 32, translate: 25 }, + { keyframe: 48, translate: 8 }, + { keyframe: 56, translate: 11 }, + { keyframe: 66, translate: 25 }, + { keyframe: 83, translate: 4 }, + { keyframe: 100, translate: 0 } +]; +var Hr = 1; +var qr = 4; +var Jr = { + top: { x: 0, y: -1 }, + bottom: { x: 0, y: 1 }, + right: { x: 1, y: 0 }, + left: { x: -1, y: 0 } +}; +function Qr(t, e) { + return $e2(t, true); +} +function $e2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, Vr), { intensity: r = 0.5 } = o, s = t.duration || 1, i = +(o?.iterationDelay || 0), { x: l, y: f } = Jr[n], m = I(s, i), [c] = pe2(t), d = h(0, 1, Hr, qr, r), u = { + "--motion-translate-x": l * d, + "--motion-translate-y": f * d + }, g = Kr.map(({ keyframe: p, translate: $2 }) => { + const v = `calc(${a( + u, + "--motion-translate-x", + e + )} * ${$2}px) calc(${a( + u, + "--motion-translate-y", + e + )} * ${$2}px)`; + return { + offset: p / 100 * m, + translate: v + }; + }); + return [ + { + ...t, + name: c, + easing: "linear", + duration: s + i, + custom: u, + keyframes: g + } + ]; +} +function pe2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-poke-${I(t.duration, e, true)}`]; +} +var Gi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: pe2, style: $e2, web: Qr }, Symbol.toStringTag, { value: "Module" })); +var Wr = 0; +var ta = 0.1; +var st2 = [ + { keyframe: 45, scaleX: 1.03, scaleY: 0.93 }, + { keyframe: 56, scaleX: 0.9, scaleY: 1.03 }, + { keyframe: 66, scaleX: 1.02, scaleY: 0.96 }, + { keyframe: 78, scaleX: 0.98, scaleY: 1.02 }, + { keyframe: 89, scaleX: 1.005, scaleY: 0.9995 }, + { keyframe: 100, scaleX: 1, scaleY: 1 } +]; +function ea(t, e) { + return ye2(t, true); +} +function ye2(t, e = false) { + const o = t.namedEffect, { intensity: n = 0.5 } = o, r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = ve2(t), f = h(0, 1, Wr, ta, n), m = {}, c = st2.map(({ keyframe: d, scaleX: u, scaleY: g }, p) => { + const $2 = p === st2.length - 1, v = p % 2 === 0, y = f * ($2 ? 0 : v ? 1 : -0.5), O2 = D2(u + y, 4), x3 = D2(g - y, 4), T = `--motion-scale-x-${d}`, E = `--motion-scale-y-${d}`; + return m[T] = O2, m[E] = x3, { + offset: d / 100 * i, + transform: `rotateZ(var(--motion-rotate, 0deg)) scale(${a( + m, + T, + e + )}, ${a(m, E, e)})` + }; + }); + return [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: m, + keyframes: c + } + ]; +} +function ve2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-rubber-${I(t.duration, e, true)}`]; +} +var Vi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ve2, style: ye2, web: ea }, Symbol.toStringTag, { value: "Module" })); +var oa = 0; +var na = 0.12; +var ra = [ + { keyframe: 27, scale: 0.96 }, + { keyframe: 45, scale: 1 }, + { keyframe: 72, scale: 0.93 }, + { keyframe: 100, scale: 1 } +]; +function aa(t, e) { + return _e2(t, true); +} +function _e2(t, e = false) { + const o = t.namedEffect, { intensity: n = 0 } = o, r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = he2(t), m = { + "--motion-pulse-offset": h(0, 1, oa, na, n) + }, c = ra.map(({ keyframe: d, scale: u }) => ({ + offset: d / 100 * i, + transform: `scale(${u < 1 ? `calc(${u} - ${a(m, "--motion-pulse-offset", e)})` : "1"})` + })); + return i < 1 && c.push({ + offset: 1, + transform: "scale(1)" + }), [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: m, + keyframes: c + } + ]; +} +function he2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-pulse-${I(t.duration, e, true)}`]; +} +var Ki = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: he2, style: _e2, web: aa }, Symbol.toStringTag, { value: "Module" })); +var sa = "clockwise"; +var ia = { + clockwise: -1, + "counter-clockwise": 1 +}; +function ca(t, e) { + return Ee2(t, true); +} +function Ee2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, H2, sa), r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = Oe2(t), f = t.easing || "linear", c = { + "--motion-rotate-start": `calc(var(--motion-rotate, 0deg) + ${(ia[n] > 0 ? 1 : -1) * 360}deg)` + }; + return [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: c, + keyframes: [ + { + offset: 0, + easing: S(f), + rotate: a(c, "--motion-rotate-start", e) + }, + { + offset: i, + rotate: "var(--motion-rotate, 0deg)" + } + ] + } + ]; +} +function Oe2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-spin-${I(t.duration, e, true)}`]; +} +var Hi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Oe2, style: Ee2, web: ca }, Symbol.toStringTag, { value: "Module" })); +var la = "top"; +var fa = { + top: { x: 0, y: -1 }, + right: { x: 1, y: 0 }, + bottom: { x: 0, y: 1 }, + left: { x: -1, y: 0 } +}; +var it2 = 50; +var ma = [ + { factor: 1, timeFactor: 0.0934 }, + { factor: -1, timeFactor: 0.28 }, + { factor: 0.6, timeFactor: 0.466 }, + { factor: -0.3, timeFactor: 0.653 }, + { factor: 0.2, timeFactor: 0.839 }, + { factor: -0.05, timeFactor: 1.026 }, + { factor: 0, timeFactor: 1.175 } +]; +function ua(t, e) { + return xe2(t, true); +} +function xe2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, la), { swing: r = 20 } = o, s = t.duration || 1, i = o?.iterationDelay || 0, l = t.easing || "sineInOut", f = K2(l), [m] = Ie2(t), { x: c, y: d } = fa[n], u = s + i, g = I(s, i), p = { + "--motion-swing-deg": `${r}deg`, + "--motion-trans-x": `${c * it2}%`, + "--motion-trans-y": `${d * it2}%`, + "--motion-ease-in": S(f.in), + "--motion-ease-inout": S(f.inOut), + "--motion-ease-out": S(f.out) + }, $2 = `translate(${a( + p, + "--motion-trans-x", + e + )}, ${a(p, "--motion-trans-y", e)})`, v = `translate(calc(${a( + p, + "--motion-trans-x", + e + )} * -1), calc(${a(p, "--motion-trans-y", e)} * -1))`, y = i ? ma.map(({ factor: O2, timeFactor: x3 }) => ({ + offset: x3 * g, + easing: a(p, "--motion-ease-inout", e), + transform: `rotate(var(--motion-rotate, 0deg)) ${$2} rotate(calc(${a( + p, + "--motion-swing-deg", + e + )} * ${O2})) ${v}` + })) : [ + { + offset: 0.25, + easing: a(p, "--motion-ease-inout", e), + transform: `rotate(var(--motion-rotate, 0deg)) ${$2} rotate(${a( + p, + "--motion-swing-deg", + e + )}) ${v}` + }, + { + offset: 0.75, + easing: a(p, "--motion-ease-in", e), + transform: `rotate(var(--motion-rotate, 0deg)) ${$2} rotate(calc(${a( + p, + "--motion-swing-deg", + e + )} * -1)) ${v}` + } + ]; + return [ + { + ...t, + name: m, + easing: "linear", + duration: u, + custom: p, + keyframes: [ + { + offset: 0, + easing: a(p, "--motion-ease-out", e), + transform: `rotateZ(var(--motion-rotate, 0deg)) ${$2} rotate(0deg) ${v}` + }, + ...y, + { + offset: 1, + transform: `rotateZ(var(--motion-rotate, 0deg)) ${$2} rotate(0deg) ${v}` + } + ] + } + ]; +} +function Ie2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-swing-${I(t.duration, e, true)}`]; +} +var qi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ie2, style: xe2, web: ua }, Symbol.toStringTag, { value: "Module" })); +var da = 1; +var ga = 4; +var $a = [ + { keyframe: 18, transY: -10, accRotate: 10 }, + { keyframe: 35, transY: 0, accRotate: -18 }, + { keyframe: 53, transY: 0, accRotate: 14 }, + { keyframe: 73, transY: 0, accRotate: -10 }, + { keyframe: 100, transY: 0, accRotate: 4 } +]; +function pa(t, e) { + return Se2(t, true); +} +function Se2(t, e = false) { + const o = t.namedEffect, { intensity: n = 0.5 } = o, r = t.duration || 1, s = o?.iterationDelay || 0, i = I(r, s), [l] = Te2(t), f = h(0, 1, da, ga, n); + let m = 0; + const c = { + "--motion-wiggle-factor": f + }, d = $a.map(({ keyframe: u, transY: g, accRotate: p }) => { + const $2 = u / 100 * i, v = `calc(var(--motion-rotate, 0deg) + ${D2( + m + p * f + )}deg)`, y = `${g * f}px`, O2 = `--motion-rotate-${u}`, x3 = `--motion-translate-y-${u}`; + return c[O2] = v, c[x3] = y, m += p * f, { + offset: $2, + transform: `rotate(${a( + c, + O2, + e + )}) translateY(${a(c, x3, e)})` + }; + }); + return [ + { + ...t, + name: l, + easing: "linear", + duration: r + s, + custom: c, + keyframes: d + } + ]; +} +function Te2(t) { + const e = t.namedEffect?.iterationDelay || 0; + return [`motion-wiggle-${I(t.duration, e, true)}`]; +} +var Ji = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Te2, style: Se2, web: pa }, Symbol.toStringTag, { value: "Module" })); +var ct2 = 68; +var ya = "horizontal"; +var va = { + vertical: "rotateX", + horizontal: "rotateY" +}; +function be2(t) { + return ["motion-arcScroll"]; +} +function _a(t, e) { + return Ae2(t, true); +} +function Ae2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, B2, ya), { range: r = "in", perspective: s = 500 } = o, i = r === "out" ? "forwards" : r === "in" ? "backwards" : t.fill, l = va[n], f = r === "out" ? 0 : -ct2, m = r === "in" ? 0 : ct2, c = "linear", [d] = be2(), u = { + "--motion-perspective": `${s}px`, + "--motion-arc-from": `${l}(${f}deg)`, + "--motion-arc-to": `${l}(${m}deg)` + }; + return [ + { + ...t, + name: d, + fill: i, + easing: c, + custom: u, + keyframes: [ + { + transform: `perspective(${a(u, "--motion-perspective", e)}) translateZ(-300px) ${a( + u, + "--motion-arc-from", + e + )} translateZ(300px) rotate(${a({}, "--motion-rotate", false, "0deg")})` + }, + { + transform: `perspective(${a(u, "--motion-perspective", e)}) translateZ(-300px) ${a( + u, + "--motion-arc-to", + e + )} translateZ(300px) rotate(${a({}, "--motion-rotate", false, "0deg")})` + } + ] + } + ]; +} +var Qi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: be2, style: Ae2, web: _a }, Symbol.toStringTag, { value: "Module" })); +function we2(t) { + return ["motion-blurScroll"]; +} +function ha(t, e) { + return Ne2(t, true); +} +function Ne2(t, e = false) { + const { blur: o = 6, range: n = "in" } = t.namedEffect, r = n === "out" ? 0 : o, s = n === "out" ? o : 0, i = "linear", l = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, [f] = we2(), m = { + "--motion-blur-from": `${r}px`, + "--motion-blur-to": `${s}px` + }; + return [ + { + ...t, + name: f, + fill: l, + easing: i, + composite: "add", + custom: m, + keyframes: [ + { + filter: `blur(${a(m, "--motion-blur-from", e)})` + }, + { + filter: `blur(${a(m, "--motion-blur-to", e)})` + } + ] + } + ]; +} +var Wi = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: we2, style: Ne2, web: ha }, Symbol.toStringTag, { value: "Module" })); +function De2(t) { + return ["motion-fadeScroll"]; +} +function Ea(t, e) { + return ke2(t, true); +} +function ke2(t, e = false) { + const { opacity: o = 0, range: n = "in" } = t.namedEffect, r = n === "out", s = r ? a({}, "--comp-opacity", false, "1") : o, i = r ? o : a({}, "--comp-opacity", false, "1"), l = "linear", f = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, [m] = De2(), c = { + "--motion-fade-from": s, + "--motion-fade-to": i + }; + return [ + { + ...t, + name: m, + fill: f, + easing: l, + custom: c, + keyframes: [ + { + opacity: a(c, "--motion-fade-from", e) + }, + { + opacity: a(c, "--motion-fade-to", e) + } + ] + } + ]; +} +var tc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: De2, style: ke2, web: Ea }, Symbol.toStringTag, { value: "Module" })); +var Oa = "horizontal"; +var xa = { + vertical: "rotateX", + horizontal: "rotateY" +}; +function Fe2(t) { + return ["motion-flipScroll"]; +} +function Ia(t, e) { + return Pe2(t, true); +} +function Pe2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, B2, Oa), { rotate: r = 240, range: s = "continuous", perspective: i = 800 } = o, l = xa[n], f = s === "out" ? 0 : -r, m = s === "in" ? 0 : r, c = "linear", d = s === "out" ? "forwards" : s === "in" ? "backwards" : t.fill, [u] = Fe2(), g = { + "--motion-perspective": `${i}px`, + "--motion-flip-from": `${l}(${f}deg)`, + "--motion-flip-to": `${l}(${m}deg)` + }; + return [ + { + ...t, + name: u, + fill: d, + easing: c, + custom: g, + keyframes: [ + { + transform: `perspective(${a(g, "--motion-perspective", e)}) ${a( + g, + "--motion-flip-from", + e + )} rotate(${a({}, "--motion-rotate", false, "0deg")})` + }, + { + transform: `perspective(${a(g, "--motion-perspective", e)}) ${a( + g, + "--motion-flip-to", + e + )} rotate(${a({}, "--motion-rotate", false, "0deg")})` + } + ] + } + ]; +} +var ec = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Fe2, style: Pe2, web: Ia }, Symbol.toStringTag, { value: "Module" })); +var Sa = 40; +var Ta = "center"; +var ba = { + top: [0, -50], + "top-right": [50, -50], + right: [50, 0], + "bottom-right": [50, 50], + bottom: [0, 50], + "bottom-left": [-50, 50], + left: [-50, 0], + "top-left": [-50, -50], + center: [0, 0] +}; +function Re2(t) { + return ["motion-growScroll"]; +} +function Aa(t, e) { + return Me2(t, true); +} +function Me2(t, e = false) { + const o = t.namedEffect, { range: n = "in", scale: r = n === "in" ? 0 : 4, speed: s = 0 } = o, i = _2(o?.direction, ne2, Ta), l = "linear", f = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, m = r, c = r, u = s * Sa, g = { + scale: n === "out" ? 1 : m, + travel: n === "out" ? 0 : -u + }, p = { + scale: n === "in" ? 1 : c, + travel: n === "in" ? 0 : u + }, $2 = Math.abs(u), v = n === "out" ? "0px" : `${-$2}vh`, y = n === "in" ? "0px" : `${$2}vh`, [O2, x3] = ba[i] || [0, 0], [T] = Re2(), E = { + "--motion-travel-from": `${g.travel}vh`, + "--motion-travel-to": `${p.travel}vh`, + "--motion-grow-from": g.scale, + "--motion-grow-to": p.scale, + "--motion-trans-x": `${O2}%`, + "--motion-trans-y": `${x3}%` + }; + return [ + { + ...t, + name: T, + fill: f, + easing: l, + startOffsetAdd: v, + endOffsetAdd: y, + custom: E, + keyframes: [ + { + transform: `translateY(${a( + E, + "--motion-travel-from", + e + )}) translate(${a(E, "--motion-trans-x", e)}, ${a( + E, + "--motion-trans-y", + e + )}) scale(${a( + E, + "--motion-grow-from", + e + )}) translate(calc(-1 * ${a( + E, + "--motion-trans-x", + e + )}), calc(-1 * ${a( + E, + "--motion-trans-y", + e + )})) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translateY(${a( + E, + "--motion-travel-to", + e + )}) translate(${a(E, "--motion-trans-x", e)}, ${a( + E, + "--motion-trans-y", + e + )}) scale(${a( + E, + "--motion-grow-to", + e + )}) translate(calc(-1 * ${a( + E, + "--motion-trans-x", + e + )}), calc(-1 * ${a( + E, + "--motion-trans-y", + e + )})) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var oc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Re2, style: Me2, web: Aa }, Symbol.toStringTag, { value: "Module" })); +var wa = 120; +var Na = { value: 400, unit: "px" }; +function Ye2(t) { + return ["motion-moveScroll"]; +} +function Da(t, e, o) { + return je2(t, o, true); +} +function je2(t, e, o = false) { + const n = t.namedEffect, r = _2(n?.angle, [], wa, true), { range: s = "in" } = n, i = "linear", l = s === "out" ? "forwards" : s === "in" ? "backwards" : t.fill, f = A2(n.distance, Na); + let [m, c] = an2(r, f.value); + const d = N2(f.unit); + let u = "", g = ""; + e?.ignoreScrollMoveOffsets || (c < 0 && s !== "out" && (u = `${c}${d}`, s !== "in" && (g = `${Math.abs(c)}${d}`)), c > 0 && s === "out" && (g = `${Math.abs(c)}${d}`)), [m, c] = [m, c].map(Math.round); + const p = { + x: s === "out" ? 0 : m, + y: s === "out" ? 0 : c + }, $2 = { + x: s === "in" ? 0 : s === "out" ? m : -m, + y: s === "in" ? 0 : s === "out" ? c : -c + }, [v] = Ye2(), y = { + "--motion-move-from-x": `${p.x}${d}`, + "--motion-move-from-y": `${p.y}${d}`, + "--motion-move-to-x": `${$2.x}${d}`, + "--motion-move-to-y": `${$2.y}${d}` + }; + return [ + { + ...t, + name: v, + fill: l, + easing: i, + startOffsetAdd: u, + endOffsetAdd: g, + custom: y, + keyframes: [ + { + transform: `translate(${a( + y, + "--motion-move-from-x", + o + )}, ${a( + y, + "--motion-move-from-y", + o + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translate(${a( + y, + "--motion-move-to-x", + o + )}, ${a( + y, + "--motion-move-to-y", + o + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var nc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ye2, style: je2, web: Da }, Symbol.toStringTag, { value: "Module" })); +var ka = "left"; +var Fa = { value: 400, unit: "px" }; +function Ce2(t) { + return ["motion-panScroll"]; +} +function ze2(t, e) { + if (t.namedEffect && t.namedEffect.startFromOffScreen && e) { + let o = 0; + e.measure((n) => { + n && (o = n.getBoundingClientRect().left); + }), e.mutate((n) => { + n?.style.setProperty("--motion-left", `${o}px`); + }); + } +} +function Pa(t, e) { + return ze2(t, e), Le2(t, true); +} +function Le2(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, L2, ka), { startFromOffScreen: r = true, range: s = "in" } = o, i = A2(o.distance, Fa), l = i.value * (n === "left" ? 1 : -1); + let f = `${-l}${N2(i.unit)}`, m = `${l}${N2(i.unit)}`; + if (r) { + const v = `calc(${a( + {}, + "--motion-left", + false, + "calc(100vw - 100%)" + )} * -1 - 100%)`, y = `calc(100vw - ${a({}, "--motion-left", false, "0px")})`; + [f, m] = n === "left" ? [v, y] : [y, v]; + } + const c = s === "out" ? 0 : f, d = s === "in" ? 0 : s === "out" ? f : m, u = "linear", g = s === "out" ? "forwards" : s === "in" ? "backwards" : t.fill, [p] = Ce2(), $2 = { + "--motion-pan-from": c, + "--motion-pan-to": d + }; + return [ + { + ...t, + name: p, + fill: g, + easing: u, + custom: $2, + keyframes: [ + { + transform: `translateX(${a( + $2, + "--motion-pan-from", + e + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translateX(${a( + $2, + "--motion-pan-to", + e + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var rc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ce2, prepare: ze2, style: Le2, web: Pa }, Symbol.toStringTag, { value: "Module" })); +var Ra = 0.5; +function Xe2(t) { + return ["motion-parallaxScroll"]; +} +function Ma(t, e) { + return Ue2(t, true); +} +function Ue2(t, e = false) { + const o = t.namedEffect, { parallaxFactor: n = Ra } = o, r = "linear", s = `${-50 * n}vh`, i = `${50 * n}vh`, [l] = Xe2(), f = { + "--motion-parallax-to": i + }; + return [ + { + ...t, + name: l, + fill: "both", + easing: r, + startOffsetAdd: s, + endOffsetAdd: i, + custom: f, + keyframes: [ + { + transform: `translateY(calc(-1 * ${a( + f, + "--motion-parallax-to", + e + )})) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translateY(${a( + f, + "--motion-parallax-to", + e + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var ac = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Xe2, style: Ue2, web: Ma }, Symbol.toStringTag, { value: "Module" })); +var Ya = "bottom"; +function Be2(t) { + const { range: e = "in" } = t.namedEffect; + return [`motion-revealScroll${e === "continuous" ? "-continuous" : ""}`]; +} +function ja(t, e) { + return Ze2(t); +} +function Ze2(t) { + const e = t.namedEffect, o = _2(e?.direction, w, Ya), { range: n = "in" } = e, r = "linear", s = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, [i] = Be2(t), l = { + "--motion-clip-from": vt2(o, n), + "--motion-clip-to": _t2(o, n) + }, f = [ + { + clipPath: a({}, "--motion-clip-from", false, l["--motion-clip-from"]) + }, + { + clipPath: a({}, "--motion-clip-to", false, l["--motion-clip-to"]) + } + ]; + return n === "continuous" && f.splice(1, 0, { clipPath: G }), [ + { + ...t, + name: i, + fill: s, + easing: r, + custom: l, + keyframes: f + } + ]; +} +var sc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Be2, style: Ze2, web: ja }, Symbol.toStringTag, { value: "Module" })); +var lt = { + diamond: (t) => { + const e = t / 2, o = 100 - e; + return [ + `polygon(50% ${e}%, ${o}% 50%, 50% ${o}%, ${e}% 50%)`, + "polygon(50% -50%, 150% 50%, 50% 150%, -50% 50%)" + ]; + }, + window: (t) => [ + `inset(${t / 2}% round 50% 50% 0% 0%)`, + "inset(-20% round 50% 50% 0% 0%)" + ], + rectangle: (t) => [`inset(${t}%)`, "inset(0%)"], + circle: (t) => [`circle(${100 - t}%)`, "circle(75%)"], + ellipse: (t) => { + const e = 50 - t / 2; + return [`ellipse(${e}% ${e}%)`, "ellipse(75% 75%)"]; + } +}; +function Ge2(t) { + const { range: e = "in" } = t.namedEffect; + return [`motion-shapeScroll${e === "continuous" ? "-continuous" : ""}`]; +} +function Ca(t, e) { + return Ve2(t, true); +} +function Ve2(t, e = false) { + const { intensity: o = 0.5, range: n = "in" } = t.namedEffect; + let { shape: r = "circle" } = t.namedEffect; + r in lt || (r = "circle"); + const s = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, [i, l] = lt[r](o * 100), [f] = Ge2(t), m = { + "--motion-clip-from": n === "out" ? l : i, + "--motion-clip-to": n === "out" ? i : l + }, c = S("circInOut"), d = [ + { + clipPath: a(m, "--motion-clip-from", e), + easing: c + }, + { clipPath: a(m, "--motion-clip-to", e) } + ]; + return n === "continuous" && (d[1].easing = c, d.push({ + clipPath: a(m, "--motion-clip-from", e) + })), [ + { + ...t, + name: f, + fill: s, + easing: "linear", + custom: m, + keyframes: d + } + ]; +} +var ic = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ge2, style: Ve2, web: Ca }, Symbol.toStringTag, { value: "Module" })); +var za = "right"; +function Ke2(t) { + const { range: e = "in" } = t.namedEffect; + return [`motion-shuttersScroll-${e === "continuous" ? "-continuous" : ""}`]; +} +function La(t, e) { + return He(t, true); +} +function He(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, z3, za), { shutters: r = 12, staggered: s = true, range: i = "in" } = o, l = i === "out" ? "forwards" : i === "in" ? "backwards" : t.fill, f = S(i === "in" ? "sineIn" : "sineOut"), m = V2(z3, n), { clipStart: c, clipEnd: d } = tt2( + i === "out" ? m : n, + r, + s + ), u = { + "--motion-shutters-clip-start": i === "out" ? d : c, + "--motion-shutters-clip-end": i === "out" ? c : d + }, [g] = Ke2(t), p = [ + { + clipPath: a(u, "--motion-shutters-clip-start", e), + easing: f + }, + { + clipPath: a(u, "--motion-shutters-clip-end", e) + } + ]; + if (i === "continuous") { + p[1].easing = f, p[1].offset = s ? 0.45 : 0.4; + const { clipStart: $2, clipEnd: v } = tt2( + m, + r, + s, + true + ); + Object.assign(u, { + "--motion-shutters-clip-opp-end": v, + "--motion-shutters-clip-opp-start": $2 + }); + const y = s ? 0.55 : 0.6; + p.push( + { + clipPath: a(u, "--motion-shutters-clip-end", e), + offset: y, + easing: f + }, + { + clipPath: a(u, "--motion-shutters-clip-opp-end", e), + offset: y, + easing: f + }, + { + clipPath: a(u, "--motion-shutters-clip-opp-start", e) + } + ); + } + return [ + { + ...t, + name: g, + fill: l, + easing: "linear", + custom: u, + keyframes: p + } + ]; +} +var cc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ke2, style: He, web: La }, Symbol.toStringTag, { value: "Module" })); +var Xa = 40; +var Ua = "center"; +var Ba = { + top: [0, -50], + "top-right": [50, -50], + right: [50, 0], + "bottom-right": [50, 50], + bottom: [0, 50], + "bottom-left": [-50, 50], + left: [-50, 0], + "top-left": [-50, -50], + center: [0, 0] +}; +function qe2(t) { + return ["motion-shrinkScroll"]; +} +function Za(t, e) { + return Je2(t, true); +} +function Je2(t, e = false) { + const o = t.namedEffect, { range: n = "in", scale: r = n === "in" ? 1.2 : 0.8, speed: s = 0 } = o, i = _2(o?.direction, ne2, Ua), l = "linear", f = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, m = r, c = r, u = s * Xa, g = { + scale: n === "out" ? 1 : m, + travel: n === "out" ? 0 : -u + }, p = { + scale: n === "in" ? 1 : c, + travel: n === "in" ? 0 : u + }, $2 = Math.abs(u), v = n === "out" ? "0px" : `${-$2}vh`, y = n === "in" ? "0px" : `${$2}vh`, [O2, x3] = Ba[i] || [0, 0], [T] = qe2(), E = { + "--motion-travel-from": `${g.travel}vh`, + "--motion-travel-to": `${p.travel}vh`, + "--motion-shrink-from": g.scale, + "--motion-shrink-to": p.scale, + "--motion-trans-x": `${O2}%`, + "--motion-trans-y": `${x3}%` + }; + return [ + { + ...t, + name: T, + fill: f, + easing: l, + custom: E, + startOffsetAdd: v, + endOffsetAdd: y, + keyframes: [ + { + transform: `translateY(${a( + E, + "--motion-travel-from", + e + )}) translate(${a(E, "--motion-trans-x", e)}, ${a( + E, + "--motion-trans-y", + e + )}) scale(${a( + E, + "--motion-shrink-from", + e + )}) translate(calc(-1 * ${a( + E, + "--motion-trans-x", + e + )}), calc(-1 * ${a( + E, + "--motion-trans-y", + e + )})) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translateY(${a( + E, + "--motion-travel-to", + e + )}) translate(${a(E, "--motion-trans-x", e)}, ${a( + E, + "--motion-trans-y", + e + )}) scale(${a( + E, + "--motion-shrink-to", + e + )}) translate(calc(-1 * ${a( + E, + "--motion-trans-x", + e + )}), calc(-1 * ${a( + E, + "--motion-trans-y", + e + )})) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var lc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: qe2, style: Je2, web: Za }, Symbol.toStringTag, { value: "Module" })); +var Ga = "right"; +var Va = { + right: -1, + left: 1 +}; +function Qe2(t) { + return ["motion-skewPanScroll"]; +} +function We2(t, e) { + if (e) { + let o = 0; + e.measure((n) => { + n && (o = n.getBoundingClientRect().left); + }), e.mutate((n) => { + n?.style.setProperty("--motion-left", `${o}px`); + }); + } +} +function Ka(t, e) { + return We2(t, e), to(t, true); +} +function to(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, L2, Ga), { skew: r = 10, range: s = "in" } = o, i = "linear", l = s === "out" ? "forwards" : s === "in" ? "backwards" : t.fill, f = r * Va[n], m = `calc(${a( + {}, + "--motion-left", + false, + "calc(100vw - 100%)" + )} * -1 - 100%)`, c = `calc(100vw - ${a({}, "--motion-left", false, "0px")})`, [d, u] = n === "left" ? [m, c] : [c, m], g = { + skew: s === "out" ? 0 : f, + translate: s === "out" ? 0 : d + }, p = { + skew: s === "in" ? 0 : -f, + translate: s === "in" ? 0 : s === "out" ? d : u + }, [$2] = Qe2(), v = { + "--motion-skewpan-start-x": g.translate, + "--motion-skewpan-end-x": p.translate, + "--motion-skewpan-from-skew": `${g.skew}deg`, + "--motion-skewpan-to-skew": `${p.skew}deg` + }; + return [ + { + ...t, + name: $2, + fill: l, + easing: i, + custom: v, + keyframes: [ + { + transform: `translateX(${a( + v, + "--motion-skewpan-start-x", + e + )}) skewX(${a( + v, + "--motion-skewpan-from-skew", + e + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + }, + { + transform: `translateX(${a( + v, + "--motion-skewpan-end-x", + e + )}) skewX(${a( + v, + "--motion-skewpan-to-skew", + e + )}) rotate(${a({}, "--motion-rotate", false, "0")})` + } + ] + } + ]; +} +var fc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Qe2, prepare: We2, style: to, web: Ka }, Symbol.toStringTag, { value: "Module" })); +var Ha = "bottom"; +var ft2 = { + bottom: { x: "0", y: "100%" }, + left: { x: "-100%", y: "0" }, + top: { x: "0", y: "-100%" }, + right: { x: "100%", y: "0" } +}; +function eo(t) { + const { range: e = "in" } = t.namedEffect; + return [`motion-slideScroll${e === "continuous" ? "-continuous" : ""}`]; +} +function qa(t, e) { + return oo(t, true); +} +function oo(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, z3, Ha), { range: r = "in" } = o, s = "linear", i = r === "out" ? "forwards" : r === "in" ? "backwards" : t.fill, l = V2(z3, n), f = r === "out" ? { x: "0", y: "0" } : ft2[n], m = r === "in" ? { x: "0", y: "0" } : ft2[r === "out" ? n : l], c = { + "--motion-clip-from": vt2(n, r), + "--motion-clip-to": _t2(n, r), + "--motion-translate-from-x": f.x, + "--motion-translate-from-y": f.y, + "--motion-translate-to-x": m.x, + "--motion-translate-to-y": m.y + }, d = [ + { + clipPath: a({}, "--motion-clip-from", false, c["--motion-clip-from"]), + transform: `rotate(${a( + {}, + "--motion-rotate", + false, + "0" + )}) translate(${a( + c, + "--motion-translate-from-x", + e + )}, ${a(c, "--motion-translate-from-y", e)})` + }, + { + clipPath: a({}, "--motion-clip-to", false, c["--motion-clip-to"]), + transform: `rotate(${a( + {}, + "--motion-rotate", + false, + "0" + )}) translate(${a( + c, + "--motion-translate-to-x", + e + )}, ${a(c, "--motion-translate-to-y", e)})` + } + ]; + r === "continuous" && d.splice(1, 0, { + clipPath: G, + transform: `rotate(${a({}, "--motion-rotate", false, "0")}) translate(0, 0)` + }); + const [u] = eo(t); + return [ + { + ...t, + name: u, + fill: i, + easing: s, + custom: c, + keyframes: d + } + ]; +} +var mc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: eo, style: oo, web: qa }, Symbol.toStringTag, { value: "Module" })); +var Ja = 40; +function no(t) { + return ["motion-spin3dScroll"]; +} +function Qa(t, e) { + return ro(t, true); +} +function ro(t, e = false) { + const { + rotate: o = -100, + speed: n = 0, + range: r = "in", + perspective: s = 1e3 + } = t.namedEffect, i = "linear", l = r === "out" ? "forwards" : r === "in" ? "backwards" : t.fill, f = n * Ja, m = { + rotationX: r === "out" ? 0 : -2 * o, + rotationY: r === "out" ? 0 : -o, + rotationZ: r === "out" ? 0 : -o, + travel: r === "out" ? 0 : -f + }, c = { + rotationX: o * (r === "in" ? 0 : r === "out" ? 3 : 1.8), + rotationY: o * (r === "in" ? 0 : r === "out" ? 2 : 1), + rotationZ: o * (r === "in" ? 0 : r === "out" ? 1 : 2), + travel: r === "in" ? 0 : f + }, d = Math.abs(f), u = r === "out" ? "0px" : `${-d}vh`, g = r === "in" ? "0px" : `${d}vh`, [p] = no(), $2 = { + "--motion-perspective": `${s}px`, + "--motion-travel-from": `${m.travel}vh`, + "--motion-travel-to": `${c.travel}vh`, + "--motion-rot-x-from": `${m.rotationX}deg`, + "--motion-rot-x-to": `${c.rotationX}deg`, + "--motion-rot-y-from": `${m.rotationY}deg`, + "--motion-rot-y-to": `${c.rotationY}deg`, + "--motion-rot-z-from": `${m.rotationZ}deg`, + "--motion-rot-z-to": `${c.rotationZ}deg` + }; + return [ + { + ...t, + name: p, + fill: l, + easing: i, + custom: $2, + startOffsetAdd: u, + endOffsetAdd: g, + keyframes: [ + { + transform: `perspective(${a($2, "--motion-perspective", e)}) translateY(${a( + $2, + "--motion-travel-from", + e + )}) rotateZ(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a($2, "--motion-rot-z-from", e)})) rotateY(${a( + $2, + "--motion-rot-y-from", + e + )}) rotateX(${a($2, "--motion-rot-x-from", e)})` + }, + { + transform: `perspective(${a($2, "--motion-perspective", e)}) translateY(${a( + $2, + "--motion-travel-to", + e + )}) rotateZ(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a($2, "--motion-rot-z-to", e)})) rotateY(${a( + $2, + "--motion-rot-y-to", + e + )}) rotateX(${a($2, "--motion-rot-x-to", e)})` + } + ] + } + ]; +} +var uc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: no, style: ro, web: Qa }, Symbol.toStringTag, { value: "Module" })); +var Wa = "clockwise"; +var ts2 = { + clockwise: 1, + "counter-clockwise": -1 +}; +function ao(t) { + return ["motion-spinScroll"]; +} +function es2(t, e) { + return so(t, true); +} +function so(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, H2, Wa), { spins: r = 0.15, scale: s = 1, range: i = "in" } = o, l = "linear", f = i === "out" ? "forwards" : i === "in" ? "backwards" : t.fill, m = ts2[n], c = r * 360, d = i === "in", u = d ? -c : i === "out" ? 0 : -c / 2, g = d ? 0 : i === "out" ? c : c / 2, [p] = ao(), $2 = { + "--motion-spin-from": `${m * u}deg`, + "--motion-spin-to": `${m * g}deg`, + "--motion-spin-scale-from": d ? s : 1, + "--motion-spin-scale-to": d ? 1 : s + }; + return [ + { + ...t, + name: p, + fill: f, + easing: l, + custom: $2, + keyframes: [ + { + transform: `scale(${a( + $2, + "--motion-spin-scale-from", + e + )}) rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a($2, "--motion-spin-from", e)}))` + }, + { + transform: `scale(${a( + $2, + "--motion-spin-scale-to", + e + )}) rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a($2, "--motion-spin-to", e)}))` + } + ] + } + ]; +} +var dc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: ao, style: so, web: es2 }, Symbol.toStringTag, { value: "Module" })); +var mt2 = { + in: [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 0.65 } + ], + out: [ + { opacity: 1, offset: 0.35 }, + { opacity: 0, offset: 1 } + ], + continuous: [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 0.325 }, + { opacity: 1, offset: 0.7 }, + { opacity: 0, offset: 1 } + ] +}; +function io(t) { + const { range: e = "out" } = t.namedEffect; + return [ + `motion-stretchScrollScale${e === "continuous" ? "-continuous" : ""}`, + `motion-stretchScrollOpacity-${e}` + ]; +} +function os2(t, e) { + return co(t, true); +} +function co(t, e = false) { + const { stretch: o = 0.6, range: n = "out" } = t.namedEffect, r = n === "continuous" ? "linear" : "backInOut", s = n === "out" ? "forwards" : n === "in" ? "backwards" : t.fill, i = 1 - o, l = 1 + o, [f, m] = io(t), c = n === "out", d = D2(i), u = D2(l), g = { + "--motion-stretch-scale-x-from": c ? 1 : d, + "--motion-stretch-scale-y-from": c ? 1 : u, + "--motion-stretch-scale-x-to": c ? d : 1, + "--motion-stretch-scale-y-to": c ? u : 1, + "--motion-stretch-trans-from": c ? 0 : `calc(-100% * (1 - ${u}))`, + "--motion-stretch-trans-to": c ? `calc(100% * (1 - ${u}))` : 0 + }, p = [ + { + scale: `${a( + g, + "--motion-stretch-scale-x-from", + e + )} ${a(g, "--motion-stretch-scale-y-from", e)}`, + translate: `0 ${a(g, "--motion-stretch-trans-from", e)}` + }, + { + scale: `${a( + g, + "--motion-stretch-scale-x-to", + e + )} ${a(g, "--motion-stretch-scale-y-to", e)}`, + translate: `0 ${a(g, "--motion-stretch-trans-to", e)}` + } + ]; + return n === "continuous" && (p.forEach(($2) => { + Object.assign($2, { easing: z2.backInOut }); + }), p.push({ + scale: `${a( + g, + "--motion-stretch-scale-x-from", + e + )} ${a(g, "--motion-stretch-scale-y-from", e)}`, + translate: `0 calc(100% * (1 - ${a( + g, + "--motion-stretch-scale-y-from", + e + )}))` + })), [ + { + ...t, + name: f, + fill: s, + easing: r, + custom: g, + keyframes: p + }, + { + ...t, + name: m, + fill: s, + easing: r, + keyframes: mt2[n] || mt2.out + } + ]; +} +var gc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: io, style: co, web: os2 }, Symbol.toStringTag, { value: "Module" })); +var ns2 = 40; +var [ut, dt, gt] = [10, 25, 25]; +var rs2 = "right"; +var as2 = { + right: 1, + left: -1 +}; +function lo(t) { + return ["motion-tiltScrollTranslate", "motion-tiltScrollRotate"]; +} +function ss2(t, e) { + return fo(t, true); +} +function fo(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, L2, rs2), { parallaxFactor: r = 0, perspective: s = 400 } = o, { range: i = "in" } = o, l = "linear", f = i === "out" ? "forwards" : i === "in" ? "backwards" : t.fill, m = ns2 * r, c = as2[n], d = { + x: ut * (i === "out" ? 0 : -1), + y: dt * (i === "out" ? 0 : -1), + z: gt * c * (i === "out" ? 0 : i === "in" ? 1 : -1), + transY: i === "out" ? 0 : m + }, u = { + x: ut * (i === "in" ? 0 : i === "out" ? -1 : 1), + y: dt * (i === "in" ? 0 : i === "out" ? -1 : 0.5), + z: gt * c * (i === "in" ? 0 : i === "out" ? 1 : 1.25), + transY: i === "in" ? 0 : -1 * m + }, g = i === "out" ? "0px" : `${-1 * Math.abs(m)}vh`, p = i === "in" ? "0px" : `${Math.abs(m)}vh`, [$2, v] = lo(), y = { + "--motion-perspective": `${s}px`, + "--motion-tilt-y-from": `${d.transY}vh`, + "--motion-tilt-y-to": `${u.transY}vh`, + "--motion-tilt-x-from": `${d.x}deg`, + "--motion-tilt-x-to": `${u.x}deg`, + "--motion-tilt-y-rot-from": `${d.y}deg`, + "--motion-tilt-y-rot-to": `${u.y}deg`, + "--motion-tilt-z-from": `${d.z}deg`, + "--motion-tilt-z-to": `${u.z}deg` + }; + return [ + { + ...t, + name: $2, + fill: f, + easing: l, + startOffsetAdd: g, + endOffsetAdd: p, + custom: y, + keyframes: [ + { + transform: `perspective(${a(y, "--motion-perspective", e)}) translateY(${a( + y, + "--motion-tilt-y-from", + e + )}) rotateX(${a( + y, + "--motion-tilt-x-from", + e + )}) rotateY(${a(y, "--motion-tilt-y-rot-from", e)})` + }, + { + transform: `perspective(${a(y, "--motion-perspective", e)}) translateY(${a( + y, + "--motion-tilt-y-to", + e + )}) rotateX(${a( + y, + "--motion-tilt-x-to", + e + )}) rotateY(${a(y, "--motion-tilt-y-rot-to", e)})` + } + ] + }, + { + ...t, + name: v, + fill: f, + easing: z2.sineInOut, + startOffsetAdd: g, + endOffsetAdd: p, + composite: "add", + // add this animation on top of the previous one + custom: y, + keyframes: [ + { + transform: `rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a(y, "--motion-tilt-z-from", e)}))` + }, + { + transform: `rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a(y, "--motion-tilt-z-to", e)}))` + } + ] + } + ]; +} +var $c = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: lo, style: fo, web: ss2 }, Symbol.toStringTag, { value: "Module" })); +var is2 = 45; +var cs2 = "right"; +var ls2 = "clockwise"; +var fs2 = { + clockwise: 1, + "counter-clockwise": -1 +}; +function mo(t) { + return ["motion-turnScroll"]; +} +function uo(t, e) { + if (e) { + let o = 0; + e.measure((n) => { + n && (o = n.getBoundingClientRect().left); + }), e.mutate((n) => { + n?.style.setProperty("--motion-left", `${o}px`); + }); + } +} +function ms2(t, e) { + return uo(t, e), go(t, true); +} +function go(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, L2, cs2), r = _2(o?.spin, H2, ls2), { scale: s = 1, range: i = "in" } = o, l = "linear", f = i === "out" ? "forwards" : i === "in" ? "backwards" : t.fill, m = `calc(-1 * ${a( + {}, + "--motion-left", + false, + "calc(100vw - 100%)" + )} - 100%)`, c = `calc(100vw - ${a({}, "--motion-left", false, "0px")})`, [d, u] = n === "left" ? [m, c] : [c, m], g = is2 * fs2[r], p = { + rotation: i === "out" ? 0 : -g, + scale: i === "out" ? 1 : s, + translate: i === "out" ? "0px" : d + }, $2 = { + rotation: i === "in" ? 0 : g, + scale: i === "in" ? 1 : s, + translate: i === "in" ? "0px" : u + }, [v] = mo(), y = { + "--motion-turn-translate-from": p.translate, + "--motion-turn-translate-to": $2.translate, + "--motion-turn-scale-from": p.scale, + "--motion-turn-scale-to": $2.scale, + "--motion-turn-rotation-from": `${p.rotation}deg`, + "--motion-turn-rotation-to": `${$2.rotation}deg` + }; + return [ + { + ...t, + name: v, + fill: f, + easing: l, + custom: y, + keyframes: [ + { + transform: `translateX(${a( + y, + "--motion-turn-translate-from", + e + )}) scale(${a( + y, + "--motion-turn-scale-from", + e + )}) rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a(y, "--motion-turn-rotation-from", e)}))` + }, + { + transform: `translateX(${a( + y, + "--motion-turn-translate-to", + e + )}) scale(${a( + y, + "--motion-turn-scale-to", + e + )}) rotate(calc(${a( + {}, + "--motion-rotate", + false, + "0deg" + )} + ${a(y, "--motion-turn-rotation-to", e)}))` + } + ] + } + ]; +} +var pc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: mo, prepare: uo, style: go, web: ms2 }, Symbol.toStringTag, { value: "Module" })); +var $t2 = 80; +var us2 = "right"; +var ds2 = { value: 200, unit: "px" }; +var gs2 = { + top: { x: 1, y: 0, sign: 1 }, + right: { x: 0, y: 1, sign: 1 }, + bottom: { x: 1, y: 0, sign: -1 }, + left: { x: 0, y: 1, sign: -1 } +}; +function $s2(t, e) { + return po(t, true); +} +function $o(t) { + return ["motion-fadeIn", "motion-arcIn"]; +} +function po(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, us2), r = A2(o.depth, ds2), { perspective: s = 800 } = o, [i, l] = $o(), f = t.easing || "quintInOut", { x: m, y: c, sign: d } = gs2[n], u = `${r.value}${r.unit === "percentage" ? "%" : r.unit}`, g = { + "--motion-perspective": `${s}px`, + "--motion-arc-x": `${m}`, + "--motion-arc-y": `${c}`, + "--motion-arc-sign": `${d}`, + "--motion-depth-negative": `calc(-1 * ${u} / 2)`, + "--motion-depth-positive": `calc(${u} / 2)` + }; + return [ + { + ...t, + name: i, + duration: t.duration * 0.7, + easing: "sineIn", + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: l, + easing: f, + custom: g, + keyframes: [ + { + transform: `perspective(${a(g, "--motion-perspective", e)}) translateZ(${a(g, "--motion-depth-negative", e)}) rotateX(calc(${a( + g, + "--motion-arc-x", + e + )} * ${a( + g, + "--motion-arc-sign", + e + )} * ${$t2}deg)) rotateY(calc(${a( + g, + "--motion-arc-y", + e + )} * ${a( + g, + "--motion-arc-sign", + e + )} * ${$t2}deg)) translateZ(${a(g, "--motion-depth-positive", e)}) rotate(var(--motion-rotate, 0deg))` + }, + { + transform: `perspective(${a(g, "--motion-perspective", e)}) translateZ(${a(g, "--motion-depth-negative", e)}) rotateX(0deg) rotateY(0deg) translateZ(${a(g, "--motion-depth-positive", e)}) rotate(var(--motion-rotate, 0deg))` + } + ] + } + ]; +} +var yc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: $o, style: po, web: $s2 }, Symbol.toStringTag, { value: "Module" })); +function yo(t) { + return ["motion-fadeIn", "motion-blurIn"]; +} +function ps2(t) { + return vo(t, true); +} +function vo(t, e = false) { + const { blur: o = 6 } = t.namedEffect, [n, r] = yo(), s = t.easing || "linear", i = { + "--motion-blur": `${o}px` + }; + return [ + { + ...t, + name: n, + duration: t.duration * 0.7, + easing: "sineIn", + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: r, + easing: s, + composite: "add", + // make sure we don't override existing filters on the component + custom: i, + keyframes: [ + { + filter: `blur(${a(i, "--motion-blur", e)})` + }, + { + filter: "blur(0px)" + } + ] + } + ]; +} +var vc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: yo, style: vo, web: ps2 }, Symbol.toStringTag, { value: "Module" })); +var ys2 = "right"; +function _o(t) { + return ["motion-shuttersIn", "motion-fadeIn"]; +} +function vs2(t) { + return ho(t, true); +} +function ho(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, ys2), { shutters: r = 12, staggered: s = true } = o, [i, l] = _o(), { clipStart: f, clipEnd: m } = tt2(n, r, s), c = { + "--motion-shutters-start": f, + "--motion-shutters-end": m + }, d = S(t.easing || "sineIn"); + return [ + { + ...t, + easing: d, + name: i, + custom: c, + keyframes: [ + { + clipPath: a(c, "--motion-shutters-start", e) + }, + { + clipPath: a(c, "--motion-shutters-end", e) + } + ] + }, + { + ...t, + name: l, + custom: {}, + keyframes: [{ opacity: 0, offset: 0, easing: "step-start" }] + } + ]; +} +var _c = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: _o, style: ho, web: vs2 }, Symbol.toStringTag, { value: "Module" })); +var _s2 = [...w, "center"]; +var hs2 = "bottom"; +function Eo(t) { + return ["motion-fadeIn", "motion-bounceIn"]; +} +var { in: Es2, out: Os2 } = K2("sineIn"); +var pt = [ + { offset: 0, translate: 100 }, + { offset: 30, translate: 0 }, + { offset: 42, translate: 35 }, + { offset: 54, translate: 0 }, + { offset: 62, translate: 21 }, + { offset: 74, translate: 0 }, + { offset: 82, translate: 9 }, + { offset: 90, translate: 0 }, + { offset: 95, translate: 2 }, + { offset: 100, translate: 0, isIn: true } +]; +var xs2 = { + top: { y: -1, x: 0, z: 0 }, + right: { y: 0, x: 1, z: 0 }, + bottom: { y: 1, x: 0, z: 0 }, + left: { y: 0, x: -1, z: 0 }, + center: { x: 0, y: 0, z: -1 } +}; +function Is2(t) { + return Oo(t, true); +} +function Oo(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, _s2, hs2), r = o?.distanceFactor || 1, { perspective: s = 800 } = o || {}, [i, l] = Eo(), f = n === "center" ? `perspective(${s}px)` : " ", { x: m, y: c, z: d } = xs2[n], u = { + "--motion-direction-x": m, + "--motion-direction-y": c, + "--motion-direction-z": d, + "--motion-distance-factor": r, + "--motion-perspective": f, + "--motion-ease-in": S(Os2), + "--motion-ease-out": S(Es2) + }, g = a(u, "--motion-ease-in", e), p = a(u, "--motion-ease-out", e), $2 = a(u, "--motion-distance-factor", e), v = a(u, "--motion-perspective", e, ""), y = a(u, "--motion-direction-x", e), O2 = a(u, "--motion-direction-y", e), x3 = a(u, "--motion-direction-z", e), T = pt.map(({ offset: E, translate: b2 }, X2) => ({ + offset: E / 100, + animationTimingFunction: X2 % 2 ? g : p, + transform: `${v.trim()} translate3d(calc(${y} * ${$2} * ${b2 / 2}px), calc(${O2} * ${$2} * ${b2 / 2}px), calc(${x3} * ${$2} * ${b2 / 2}px)) rotateZ(var(--motion-rotate, 0deg))` + })); + return [ + { + ...t, + name: i, + easing: "quadOut", + duration: t.duration * pt[3].offset / 100, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: l, + easing: "linear", + custom: u, + keyframes: T + } + ]; +} +var hc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Eo, style: Oo, web: Is2 }, Symbol.toStringTag, { value: "Module" })); +var Ss2 = { value: 300, unit: "px" }; +var Ts2 = [...L2, "pseudoLeft", "pseudoRight"]; +var bs2 = "right"; +function xo(t) { + return ["motion-curveIn", "motion-fadeIn"]; +} +var As2 = { + pseudoRight: { rotationX: "180", rotationY: "0" }, + right: { rotationX: "0", rotationY: "180" }, + pseudoLeft: { rotationX: "-180", rotationY: "0" }, + left: { rotationX: "0", rotationY: "-180" } +}; +function ws2(t, e) { + return Io(t, true); +} +function Io(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, Ts2, bs2), r = A2(o.depth, Ss2), { perspective: s = 200 } = o, [i, l] = xo(), { rotationX: f, rotationY: m } = As2[n], c = `${r.value}${r.unit === "percentage" ? "%" : r.unit}`, d = { + "--motion-perspective": `${s}px`, + "--motion-rotate-x": `${f}deg`, + "--motion-rotate-y": `${m}deg`, + "--motion-depth-negative": `calc(${c} * -3)`, + "--motion-depth-positive": `calc(${c} * 3)` + }, u = "quadOut"; + return [ + { + ...t, + name: i, + easing: u, + custom: d, + keyframes: [ + { + transform: `perspective(${a(d, "--motion-perspective", e)}) translateZ(${a(d, "--motion-depth-negative", e)}) rotateX(${a( + d, + "--motion-rotate-x", + e + )}) rotateY(${a( + d, + "--motion-rotate-y", + e + )}) translateZ(${a(d, "--motion-depth-positive", e)}) rotateZ(var(--motion-rotate, 0deg))` + }, + { + transform: `perspective(${a(d, "--motion-perspective", e)}) translateZ(${a(d, "--motion-depth-negative", e)}) rotateX(0deg) rotateY(0deg) translateZ(${a(d, "--motion-depth-positive", e)}) rotateZ(var(--motion-rotate, 0deg))` + } + ] + }, + { + ...t, + name: l, + easing: u, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + } + ]; +} +var Ec = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: xo, style: Io, web: ws2 }, Symbol.toStringTag, { value: "Module" })); +function So(t) { + return ["motion-fadeIn", "motion-dropIn"]; +} +function Ns(t) { + return To(t, true); +} +function To(t, e = false) { + const { initialScale: o = 1.6 } = t.namedEffect, [n, r] = So(), s = t.easing || "quintInOut", i = { + "--motion-scale": `${o}` + }; + return [ + { + ...t, + name: n, + easing: "quadOut", + duration: t.duration * 0.8, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: r, + easing: s, + custom: i, + keyframes: [ + { + scale: a(i, "--motion-scale", e) + }, + { + scale: "1" + } + ] + } + ]; +} +var Oc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: So, style: To, web: Ns }, Symbol.toStringTag, { value: "Module" })); +var Ds = 90; +var ks2 = { value: 120, unit: "percentage" }; +var Fs = { + top: 90, + right: 0, + bottom: 270, + left: 180 +}; +function bo(t) { + return ["motion-fadeIn", "motion-expandIn"]; +} +function Ps2(t) { + return Ao(t, true); +} +function Ao(t, e = false) { + const o = t.namedEffect, { initialScale: n = 0 } = o, r = _2( + o?.direction, + w, + Ds, + true + ), s = typeof r == "string" ? Fs[r] : r, i = A2(o.distance, ks2), [l, f] = bo(), m = t.easing || "cubicInOut", c = s * Math.PI / 180, d = N2(i.unit), u = `${Math.cos(c) * i.value | 0}${d}`, g = `${Math.sin(c) * i.value * -1 | 0}${d}`, p = { + "--motion-translate-x": `${u}`, + "--motion-translate-y": `${g}`, + "--motion-scale": `${n}` + }; + return [ + { + ...t, + easing: m, + duration: t.duration * 0.7, + name: l, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + easing: m, + name: f, + custom: p, + keyframes: [ + { + transform: `translate(${a( + p, + "--motion-translate-x", + e + )}, ${a( + p, + "--motion-translate-y", + e + )}) rotate(var(--motion-rotate, 0deg)) scale(${a( + p, + "--motion-scale", + e + )})` + }, + { + transform: "translate(0px, 0px) rotate(var(--motion-rotate, 0deg)) scale(1)" + } + ] + } + ]; +} +var xc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: bo, style: Ao, web: Ps2 }, Symbol.toStringTag, { value: "Module" })); +function wo(t) { + return ["motion-fadeIn"]; +} +function Rs(t) { + return No(t); +} +function No(t) { + const [e] = wo(); + return [ + { + ...t, + name: e, + easing: "sineInOut", + keyframes: [{ offset: 0, opacity: 0 }] + } + ]; +} +var Ic = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: wo, style: No, web: Rs }, Symbol.toStringTag, { value: "Module" })); +var Ms2 = "top"; +function Do(t) { + return ["motion-fadeIn", "motion-flipIn"]; +} +function Ys(t, e) { + return { + x: yt[t].x * e, + y: yt[t].y * e + }; +} +var yt = { + top: { x: 1, y: 0 }, + right: { x: 0, y: 1 }, + bottom: { x: -1, y: 0 }, + left: { x: 0, y: -1 } +}; +function js(t) { + return ko(t, true); +} +function ko(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, Ms2), { initialRotate: r = 90, perspective: s = 800 } = o, [i, l] = Do(), f = t.easing || "backOut", m = Ys(n, r), c = { + "--motion-perspective": `${s}px`, + "--motion-rotate-x": `${m.x}deg`, + "--motion-rotate-y": `${m.y}deg` + }; + return [ + { + ...t, + easing: "quadOut", + name: i, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + easing: f, + name: l, + custom: c, + keyframes: [ + { + transform: `perspective(${a(c, "--motion-perspective", e)}) rotate(var(--motion-rotate, 0deg)) rotateX(var(--motion-rotate-x, ${c["--motion-rotate-x"]})) rotateY(var(--motion-rotate-y, ${c["--motion-rotate-y"]}))` + }, + { + transform: `perspective(${a(c, "--motion-perspective", e)}) rotate(var(--motion-rotate, 0deg)) rotateX(0deg) rotateY(0deg)` + } + ] + } + ]; +} +var Sc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Do, style: ko, web: js }, Symbol.toStringTag, { value: "Module" })); +var Cs2 = "left"; +function Fo(t) { + return ["motion-floatIn", "motion-fadeIn"]; +} +var zs = { + top: { dx: 0, dy: -1, distance: 120 }, + right: { dx: 1, dy: 0, distance: 120 }, + bottom: { dx: 0, dy: 1, distance: 120 }, + left: { dx: -1, dy: 0, distance: 120 } +}; +function Ls(t) { + return Po(t, true); +} +function Po(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, Cs2), [r, s] = Fo(), i = zs[n], l = i.dx * i.distance, f = i.dy * i.distance, m = { + "--motion-translate-x": `${l}px`, + "--motion-translate-y": `${f}px` + }, c = "sineInOut"; + return [ + { + ...t, + name: r, + easing: c, + custom: m, + keyframes: [ + { + transform: `translate(${a( + m, + "--motion-translate-x", + e + )}, ${a( + m, + "--motion-translate-y", + e + )}) rotate(var(--motion-rotate, 0deg))` + }, + { + transform: "translate(0, 0) rotate(var(--motion-rotate, 0deg))" + } + ] + }, + { + ...t, + name: s, + easing: c, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + } + ]; +} +var Tc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Fo, style: Po, web: Ls }, Symbol.toStringTag, { value: "Module" })); +function Ro(t) { + return ["motion-fadeIn", "motion-foldIn"]; +} +var Xs2 = "top"; +var et2 = { + top: { x: -1, y: 0, origin: { x: 0, y: -50 } }, + right: { x: 0, y: -1, origin: { x: 50, y: 0 } }, + bottom: { x: 1, y: 0, origin: { x: 0, y: 50 } }, + left: { x: 0, y: 1, origin: { x: -50, y: 0 } } +}; +function Us2(t, e) { + return { + x: et2[t].x * e, + y: et2[t].y * e + }; +} +function Bs(t) { + return Mo(t, true); +} +function Mo(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, w, Xs2), { initialRotate: r = 90, perspective: s = 800 } = o, [i, l] = Ro(), f = t.easing || "backOut", { x: m, y: c } = et2[n].origin, d = Us2(n, r), u = { + "--motion-perspective": `${s}px`, + "--motion-origin-x": `${m}%`, + "--motion-origin-y": `${c}%`, + "--motion-rotate-x": `${d.x}deg`, + "--motion-rotate-y": `${d.y}deg` + }; + return [ + { + ...t, + easing: "quadOut", + name: i, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + easing: f, + name: l, + custom: u, + keyframes: [ + { + transform: `rotate(var(--motion-rotate, 0deg)) translate(var(--motion-origin-x, ${u["--motion-origin-x"]}), var(--motion-origin-y, ${u["--motion-origin-y"]})) perspective(${a(u, "--motion-perspective", e)}) rotateX(var(--motion-rotate-x, ${u["--motion-rotate-x"]})) rotateY(var(--motion-rotate-y, ${u["--motion-rotate-y"]})) translate(calc(-1 * var(--motion-origin-x, ${u["--motion-origin-x"]})), calc(-1 * var(--motion-origin-y, ${u["--motion-origin-y"]})))` + }, + { + transform: `rotate(var(--motion-rotate, 0deg)) translate(var(--motion-origin-x, ${u["--motion-origin-x"]}), var(--motion-origin-y, ${u["--motion-origin-y"]})) perspective(${a(u, "--motion-perspective", e)}) rotateX(0deg) rotateY(0deg) translate(calc(-1 * var(--motion-origin-x, ${u["--motion-origin-x"]})), calc(-1 * var(--motion-origin-y, ${u["--motion-origin-y"]})))` + } + ] + } + ]; +} +var bc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ro, style: Mo, web: Bs }, Symbol.toStringTag, { value: "Module" })); +var Zs2 = 180; +var Gs = { value: 100, unit: "percentage" }; +var Vs = { + top: 90, + right: 0, + bottom: 270, + left: 180 +}; +var Ks = true; +function Yo(t) { + return ["motion-glideIn", "motion-fadeIn"]; +} +function Hs(t) { + return jo(t, true); +} +function jo(t, e = false) { + const o = t.namedEffect, n = _2( + o?.direction, + w, + Zs2, + Ks + ), r = typeof n == "string" ? Vs[n] : n, s = A2(o.distance, Gs), i = r * Math.PI / 180, l = N2(s.unit), f = t.easing || "quintInOut", m = `${Math.cos(i) * s.value | 0}${l}`, c = `${Math.sin(i) * s.value * -1 | 0}${l}`, d = { + "--motion-translate-x": `${m}`, + "--motion-translate-y": `${c}` + }, [u, g] = Yo(); + return [ + { + ...t, + name: u, + easing: f, + custom: d, + keyframes: [ + { + transform: `translate(${a( + d, + "--motion-translate-x", + e + )}, ${a( + d, + "--motion-translate-y", + e + )}) rotate(var(--motion-rotate, 0deg))` + }, + { + transform: "translate(0, 0) rotate(var(--motion-rotate, 0deg))" + } + ] + }, + { + ...t, + name: g, + custom: {}, + keyframes: [{ opacity: 0, offset: 0, easing: "step-start" }] + } + ]; +} +var Ac = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Yo, style: jo, web: Hs }, Symbol.toStringTag, { value: "Module" })); +function Co(t) { + return ["motion-fadeIn", "motion-shapeIn"]; +} +var qs2 = { + diamond: { + start: "polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)", + end: "polygon(50% -50%, 150% 50%, 50% 150%, -50% 50%)" + }, + window: { + start: "inset(50% round 50% 50% 0% 0%)", + end: "inset(-20% round 50% 50% 0% 0%)" + }, + rectangle: { start: "inset(50%)", end: "inset(0%)" }, + circle: { start: "circle(0%)", end: "circle(75%)" }, + ellipse: { start: "ellipse(0% 0%)", end: "ellipse(75% 75%)" } +}; +function Js(t) { + return zo(t, true); +} +function zo(t, e = false) { + const { shape: o = "rectangle" } = t.namedEffect, [n, r] = Co(), s = t.easing || "cubicInOut", { start: i, end: l } = qs2[o], f = { + "--motion-shape-start": i, + "--motion-shape-end": l + }; + return [ + { + ...t, + name: n, + easing: "quadOut", + duration: t.duration * 0.8, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: r, + easing: s, + custom: f, + keyframes: [ + { + clipPath: a(f, "--motion-shape-start", e) + }, + { + clipPath: a(f, "--motion-shape-end", e) + } + ] + } + ]; +} +var wc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Co, style: zo, web: Js }, Symbol.toStringTag, { value: "Module" })); +var Qs = "left"; +function Lo(t) { + return ["motion-revealIn", "motion-fadeIn"]; +} +function Ws(t) { + return Xo(t); +} +function Xo(t) { + const e = t.namedEffect, o = _2(e?.direction, w, Qs), [n, r] = Lo(), s = t.easing || "cubicInOut", i = R2({ direction: o, minimum: 0 }), l = R2({ direction: "initial" }); + return [ + { + ...t, + easing: s, + name: n, + custom: { + "--motion-clip-start": i + }, + keyframes: [ + { + clipPath: `var(--motion-clip-start, ${i})` + }, + { + clipPath: l + } + ] + }, + { + ...t, + name: r, + easing: s, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + } + ]; +} +var Nc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Lo, style: Xo, web: Ws }, Symbol.toStringTag, { value: "Module" })); +var ti = "left"; +function Uo(t) { + return ["motion-slideIn", "motion-fadeIn"]; +} +var W2 = { + top: { dx: 0, dy: -1, clip: "bottom" }, + right: { dx: 1, dy: 0, clip: "left" }, + bottom: { dx: 0, dy: 1, clip: "top" }, + left: { dx: -1, dy: 0, clip: "right" } +}; +function ei(t) { + return Bo(t); +} +function Bo(t) { + const e = t.namedEffect, o = _2(e?.direction, w, ti), { initialTranslate: n = 1 } = e, [r, s] = Uo(), i = t.easing || "cubicInOut", l = 100 - n * 100, f = R2({ + direction: W2[o].clip, + minimum: l + }), m = R2({ direction: "initial" }), c = { + "--motion-clip-start": f, + "--motion-translate-x": `${W2[o].dx * 100}%`, + "--motion-translate-y": `${W2[o].dy * 100}%` + }; + return [ + { + ...t, + name: r, + easing: i, + custom: c, + keyframes: [ + { + transform: `rotate(var(--motion-rotate, 0deg)) translate(var(--motion-translate-x, ${c["--motion-translate-x"]}), var(--motion-translate-y, ${c["--motion-translate-y"]}))`, + clipPath: `var(--motion-clip-start, ${c["--motion-clip-start"]})` + }, + { + transform: "rotate(var(--motion-rotate, 0deg)) translate(0px, 0px)", + clipPath: m + } + ] + }, + { + ...t, + name: s, + easing: i, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + } + ]; +} +var Dc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Uo, style: Bo, web: ei }, Symbol.toStringTag, { value: "Module" })); +var oi = "clockwise"; +function Zo(t) { + return ["motion-fadeIn", "motion-spinIn"]; +} +var ni = { + clockwise: -1, + "counter-clockwise": 1 +}; +function ri(t) { + return Go(t, true); +} +function Go(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, H2, oi), { spins: r = 0.5, initialScale: s = 0 } = o, [i, l] = Zo(), f = t.easing || "cubicInOut", m = (ni[n] > 0 ? 1 : -1) * 360 * r, c = { + "--motion-scale": `${s}`, + "--motion-rotate": `${m}deg` + }; + return [ + { + ...t, + name: i, + easing: "cubicIn", + duration: t.duration * s, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: l, + easing: f, + custom: c, + keyframes: [ + { + scale: a(c, "--motion-scale", e), + rotate: a(c, "--motion-rotate", e) + }, + { + scale: "1", + rotate: "0deg" + } + ] + } + ]; +} +var kc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Zo, style: Go, web: ri }, Symbol.toStringTag, { value: "Module" })); +var ai = "left"; +var si = { value: 200, unit: "px" }; +function Vo(t) { + return ["motion-fadeIn", "motion-tiltInRotate", "motion-tiltInClip"]; +} +var ii = { + left: 30, + right: -30 +}; +function ci(t) { + return Ko(t, true); +} +function Ko(t, e = false) { + const o = t.namedEffect, n = _2(o?.direction, L2, ai), r = A2(o.depth, si), { perspective: s = 800 } = o, [i, l, f] = Vo(), m = t.easing || "cubicOut", c = R2({ direction: "top", minimum: 0 }), d = ii[n], u = R2({ direction: "initial" }), g = `${r.value}${r.unit === "percentage" ? "%" : r.unit}`, p = { + "--motion-perspective": `${s}px`, + "--motion-depth-negative": `calc(${g} / 2 * -1)`, + "--motion-depth-positive": `calc(${g} / 2)` + }, $2 = { + "--motion-rotate-z": `${d}deg`, + "--motion-clip-start": c + }; + return [ + { + ...t, + name: i, + duration: t.duration * 0.2, + easing: "cubicOut", + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: l, + easing: m, + custom: p, + keyframes: [ + { + transform: `perspective(${a(p, "--motion-perspective", e)}) translateZ(${a(p, "--motion-depth-negative", e)}) rotateX(-90deg) translateZ(${a(p, "--motion-depth-positive", e)}) rotate(var(--motion-rotate, 0deg))` + }, + { + transform: `perspective(${a(p, "--motion-perspective", e)}) translateZ(${a(p, "--motion-depth-negative", e)}) rotateX(0deg) translateZ(${a(p, "--motion-depth-positive", e)}) rotate(var(--motion-rotate, 0deg))` + } + ] + }, + { + ...t, + name: f, + easing: m, + composite: "add", + duration: t.duration * 0.8, + custom: $2, + keyframes: [ + { + clipPath: `var(--motion-clip-start, ${$2["--motion-clip-start"]})`, + transform: `rotateZ(${a($2, "--motion-rotate-z", e)})` + }, + { + clipPath: u, + transform: "rotateZ(0deg)" + } + ] + } + ]; +} +var Fc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Vo, style: Ko, web: ci }, Symbol.toStringTag, { value: "Module" })); +var li = "top-left"; +function Ho(t) { + return ["motion-fadeIn", "motion-turnIn"]; +} +var fi = { + "top-left": { angle: -50, x: -50, y: -50 }, + "top-right": { angle: 50, x: 50, y: -50 }, + "bottom-right": { angle: 50, x: 50, y: 50 }, + "bottom-left": { angle: -50, x: -50, y: 50 } +}; +function mi(t) { + return qo(t, true); +} +function qo(t, e = false) { + const o = t.namedEffect, n = _2( + o?.direction, + Or, + li + ), [r, s] = Ho(), i = t.easing || "backOut", { x: l, y: f, angle: m } = fi[n], c = { + "--motion-origin": `${l}%, ${f}%`, + "--motion-origin-invert": `${-l}%, ${-f}%`, + "--motion-rotate-z": `${m}deg` + }, d = a(c, "--motion-origin", e), u = a(c, "--motion-origin-invert", e); + return [ + { + ...t, + name: r, + duration: t.duration * 0.6, + easing: "sineIn", + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + name: s, + easing: i, + custom: c, + keyframes: [ + { + transform: `translate(${d}) rotate(${a( + c, + "--motion-rotate-z", + e + )}) translate(${u}) rotate(var(--motion-rotate, 0deg))` + }, + { + transform: `translate(${d}) rotate(0deg) translate(${u}) rotate(var(--motion-rotate, 0deg))` + } + ] + } + ]; +} +var Pc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Ho, style: qo, web: mi }, Symbol.toStringTag, { value: "Module" })); +var ui = "horizontal"; +function Jo(t) { + return ["motion-fadeIn", "motion-winkInClip", "motion-winkInRotate"]; +} +var di = { + vertical: { scaleY: 0, scaleX: 1 }, + horizontal: { scaleY: 1, scaleX: 0 } +}; +function gi(t) { + return Qo(t); +} +function Qo(t) { + const e = t.namedEffect, o = _2(e?.direction, B2, ui), [n, r, s] = Jo(), { scaleX: i, scaleY: l } = di[o], f = t.easing || "quintInOut", m = R2({ direction: o, minimum: 100 }), c = R2({ direction: "initial" }), d = { + "--motion-scale-x": i, + "--motion-scale-y": l, + "--motion-clip-start": m + }; + return [ + { + ...t, + easing: "quadOut", + name: n, + custom: {}, + keyframes: [{ offset: 0, opacity: 0 }] + }, + { + ...t, + easing: f, + name: r, + custom: d, + keyframes: [ + { + clipPath: `var(--motion-clip-start, ${d["--motion-clip-start"]})` + }, + { + clipPath: c + } + ] + }, + { + ...t, + duration: t.duration * 0.85, + easing: f, + name: s, + custom: d, + keyframes: [ + { + transform: `rotate(var(--motion-rotate, 0deg)) scale(var(--motion-scale-x, ${d["--motion-scale-x"]}), var(--motion-scale-y, ${d["--motion-scale-y"]}))` + }, + { + transform: "rotate(var(--motion-rotate, 0deg)) scale(1, 1)" + } + ] + } + ]; +} +var Rc = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, getNames: Jo, style: Qo, web: gi }, Symbol.toStringTag, { value: "Module" })); + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/interact.ts +var registeredEffects = /* @__PURE__ */ new Set(); +function collectNamedEffectTypes(config) { + const types = /* @__PURE__ */ new Set(); + for (const effect of Object.values(config.effects)) { + if (effect.namedEffect) types.add(effect.namedEffect.type); + } + if (config.sequences) { + for (const seq of Object.values(config.sequences)) { + for (const entry of seq.effects) { + const effect = entry; + if (effect.namedEffect) types.add(effect.namedEffect.type); + } + } + } + for (const interaction of config.interactions) { + if (interaction.effects) { + for (const entry of interaction.effects) { + const effect = entry; + if (effect.namedEffect) types.add(effect.namedEffect.type); + } + } + if (interaction.sequences) { + for (const seq of interaction.sequences) { + const seqConfig = seq; + if (seqConfig.effects) { + for (const entry of seqConfig.effects) { + const effect = entry; + if (effect.namedEffect) types.add(effect.namedEffect.type); + } + } + } + } + } + return types; +} +function registerNamedEffects(config) { + const types = collectNamedEffectTypes(config); + for (const type of types) { + if (registeredEffects.has(type)) continue; + const preset = motion_presets_exports[type]; + if (preset) { + b.registerEffects({ [type]: preset }); + registeredEffects.add(type); + } + } +} +function stripInteractionId(interaction) { + const { id: _3, ...rest } = interaction; + return rest; +} +function toInteractConfig(config) { + return { + effects: config.effects, + sequences: config.sequences, + conditions: config.conditions, + interactions: config.interactions.map(stripInteractionId) + }; +} +function createInteractInstance(config, elements) { + registerNamedEffects(config); + b.allowA11yTriggers = true; + const interactConfig = toInteractConfig(config); + const instance = b.create(interactConfig); + for (const nodes of elements.values()) { + for (const el of nodes) { + Us(el); + } + } + return { instance, currentConfig: config }; +} +function initInteract(config, elements) { + let state = null; + try { + state = createInteractInstance(config, elements); + } catch (err) { + if (typeof __DEV__ !== "undefined" && __DEV__) { + console.warn("Interact.create() failed:", err); + } + } + return { + update(newConfig, newElements) { + state?.instance.destroy(); + try { + state = createInteractInstance(newConfig, newElements); + } catch (err) { + state = null; + if (typeof __DEV__ !== "undefined" && __DEV__) { + console.warn("Interact.create() failed on update:", err); + } + } + }, + destroy() { + state?.instance.destroy(); + state = null; + } + }; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/teardown.ts +function teardown(interactSurface, styleSurface, elements, scopeElement) { + interactSurface?.destroy(); + styleSurface?.destroy(); + clearElementAttributes(elements); + if (scopeElement) delete scopeElement.dataset.experienceId; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/pipeline/diff.ts +function elementSelectors(elements) { + return Object.fromEntries(Object.entries(elements).map(([k3, v]) => [k3, v.selector])); +} +function elementStyles(elements) { + return Object.fromEntries(Object.entries(elements).map(([k3, v]) => [k3, v.styles])); +} +function diffConfigs(prev, next) { + const varsChanged = JSON.stringify(prev.variables) !== JSON.stringify(next.variables); + const interactChanged = JSON.stringify(prev.experience.interact) !== JSON.stringify(next.experience.interact); + const elementSelectorsChanged = JSON.stringify(elementSelectors(prev.experience.elements)) !== JSON.stringify(elementSelectors(next.experience.elements)); + if (interactChanged || elementSelectorsChanged) { + return { tier: "structural" }; + } + const elementStylesChanged = JSON.stringify(elementStyles(prev.experience.elements)) !== JSON.stringify(elementStyles(next.experience.elements)); + const styleRulesChanged = JSON.stringify(prev.experience.styles) !== JSON.stringify(next.experience.styles); + if (elementStylesChanged || styleRulesChanged) { + return { tier: "css-only" }; + } + if (varsChanged) { + return { tier: "variables-only" }; + } + return { tier: "variables-only" }; +} + +// ../../../Documents/Dev/Wix/interact-xp/packages/interact-experience-renderer/src/index.ts +function createExperience(experience, options = {}) { + const root = options.root ?? document; + const store = "store" in options ? options.store : void 0; + const scopeElement = root instanceof Document ? document.documentElement : root; + let userValues = store ? {} : "controlValues" in options && options.controlValues || {}; + let conditionState = null; + let prev = null; + let elements = /* @__PURE__ */ new Map(); + let styleSurface = null; + let interactSurface = null; + let storeUnsubscribe = null; + function mount() { + if (interactSurface || styleSurface) return; + scopeElement.dataset.experienceId = experience.id; + const snapshot = resolveControls(experience, { controlValues: userValues, store }); + elements = selectElements(snapshot.experience.elements, root); + styleSurface = renderStyles(snapshot.experience, snapshot.variables, scopeElement); + interactSurface = initInteract(snapshot.experience.interact, elements); + prev = snapshot; + } + function unmount() { + if (!interactSurface && !styleSurface) return; + teardown(interactSurface, styleSurface, elements, scopeElement); + interactSurface = null; + styleSurface = null; + elements = /* @__PURE__ */ new Map(); + prev = null; + } + function dispatchUpdate(next) { + if (!prev) return; + const diff = diffConfigs(prev, next); + switch (diff.tier) { + case "variables-only": + styleSurface?.setVariables(next.variables); + prev = next; + break; + case "css-only": + styleSurface?.update(next.experience, next.variables); + prev = next; + break; + case "structural": + unmount(); + mount(); + break; + } + } + conditionState = evaluateConditions(experience.disableWhen, (disabled) => { + if (disabled) { + unmount(); + } else { + mount(); + } + }); + if (store) { + storeUnsubscribe = store.subscribe(() => { + if (conditionState?.disabled) return; + const next = store.resolved(); + dispatchUpdate(next); + }); + } + if (!conditionState.disabled) { + mount(); + } + return { + destroy() { + storeUnsubscribe?.(); + storeUnsubscribe = null; + conditionState?.cleanup(); + conditionState = null; + unmount(); + }, + updateControls(values) { + if (store) { + store.set(values); + return; + } + Object.assign(userValues, values); + if (conditionState?.disabled) return; + const next = resolveControls(experience, { controlValues: userValues }); + dispatchUpdate(next); + } + }; +} +export { + createExperience +}; From aa1bbc6a3ffbbe4a2c351f658f9e9745401bf6bd Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 6 Jul 2026 13:22:43 +0300 Subject: [PATCH 35/62] feat(validator): playground client (sections, payload, generate, status) --- validator/lib/constants.js | 8 +++++ validator/lib/playground.js | 60 +++++++++++++++++++++++++++++++ validator/test/playground.test.js | 52 +++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 validator/lib/playground.js create mode 100644 validator/test/playground.test.js diff --git a/validator/lib/constants.js b/validator/lib/constants.js index 35914f0..9034dfb 100644 --- a/validator/lib/constants.js +++ b/validator/lib/constants.js @@ -1,3 +1,6 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + export const LATEST_VERSION = '2.5.1'; // The /web subpath is required — it exports the custom element. export const INTERACT_CDN = `https://esm.sh/@wix/interact@${LATEST_VERSION}/web`; @@ -15,3 +18,8 @@ export const IGNORED_DIRS = new Set([ // Files at any level that are not animations. export const IGNORED_FILES = new Set(['explorer.html']); + +export const PLAYGROUND_REPO = process.env.PLAYGROUND_REPO || join(homedir(), 'Documents/Dev/Wix/interact-xp'); +export const PLAYGROUND_URL = process.env.PLAYGROUND_URL || 'http://localhost:5173'; +export const SECTION_INSTRUCTION = + 'Apply the animation pattern described in the example to this section. Follow its Selector Contract and Interact Template, adapting the roles to this section’s DOM. Return only the experience config.'; diff --git a/validator/lib/playground.js b/validator/lib/playground.js new file mode 100644 index 0000000..26da5dd --- /dev/null +++ b/validator/lib/playground.js @@ -0,0 +1,60 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PLAYGROUND_REPO, PLAYGROUND_URL, SECTION_INSTRUCTION } from './constants.js'; + +const SECTIONS_DIR = join(PLAYGROUND_REPO, 'apps/playground/src/sections'); +const PROMPT_DIST = join(PLAYGROUND_REPO, 'packages/interact-experience-prompt/dist/es/index.js'); +const SCHEMA_PATH = new URL('../vendor/experience.schema.json', import.meta.url); + +// Pure: given the playground's buildGenerate + schema, produce the request body. +export function assemblePayload({ buildGenerate, schema, html, css, guideline }) { + const prompt = buildGenerate({ html, css, userPrompt: SECTION_INSTRUCTION, userPromptExample: guideline, schema }); + return { user_input: prompt.user, system_rules: prompt.system }; +} + +export async function listSections(sectionsDir = SECTIONS_DIR) { + let entries; + try { entries = await readdir(sectionsDir, { withFileTypes: true }); } + catch { return []; } + const out = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + const dir = join(sectionsDir, e.name); + const read = async (f) => { try { return await readFile(join(dir, f), 'utf8'); } catch { return null; } }; + const html = (await read('section.sanitized.html')) ?? (await read('section.html')); + if (html === null) continue; + out.push({ id: e.name, html, css: (await read('section.css')) ?? '' }); + } + return out.sort((a, b) => a.id.localeCompare(b.id)); +} + +async function loadBuildGenerate() { + const mod = await import(pathToFileURL(PROMPT_DIST).href); + return mod.buildGenerate; +} +async function loadSchema() { + return JSON.parse(await readFile(SCHEMA_PATH, 'utf8')); +} + +export async function buildPayload({ html, css, guideline }) { + const [buildGenerate, schema] = await Promise.all([loadBuildGenerate(), loadSchema()]); + return assemblePayload({ buildGenerate, schema, html, css, guideline }); +} + +export async function generate({ html, css, guideline }, + { playgroundUrl = PLAYGROUND_URL, fetchImpl = fetch, buildGenerateImpl, schemaImpl } = {}) { + const buildGenerate = buildGenerateImpl || (await loadBuildGenerate()); + const schema = schemaImpl || (await loadSchema()); + const body = assemblePayload({ buildGenerate, schema, html, css, guideline }); + const res = await fetchImpl(`${playgroundUrl}/api/generate`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) throw new Error(`playground /api/generate returned ${res.status}`); + const data = await res.json(); + return { config: data.config, sessionId: data.sessionId }; +} + +export async function pingStatus({ playgroundUrl = PLAYGROUND_URL, fetchImpl = fetch } = {}) { + try { const res = await fetchImpl(playgroundUrl, { method: 'GET' }); return !!res && (res.ok || res.status < 500); } + catch { return false; } +} diff --git a/validator/test/playground.test.js b/validator/test/playground.test.js new file mode 100644 index 0000000..7114afa --- /dev/null +++ b/validator/test/playground.test.js @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { assemblePayload, listSections, generate, pingStatus } from '../lib/playground.js'; + +test('assemblePayload routes guideline→userPromptExample, instruction→userPrompt, embeds schema', () => { + const calls = []; + const buildGenerate = (args) => { calls.push(args); return { system: 'SYS', user: 'USR' }; }; + const out = assemblePayload({ buildGenerate, schema: { s: 1 }, html: '', css: 'c', guideline: 'GUIDE' }); + assert.deepEqual(out, { user_input: 'USR', system_rules: 'SYS' }); + assert.equal(calls[0].userPromptExample, 'GUIDE'); + assert.equal(calls[0].html, ''); + assert.equal(calls[0].css, 'c'); + assert.deepEqual(calls[0].schema, { s: 1 }); + assert.match(calls[0].userPrompt, /Apply the animation pattern/); +}); + +test('listSections reads section html/css (sanitized preferred)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'iv-sec-')); + await mkdir(join(dir, 'cards'), { recursive: true }); + await writeFile(join(dir, 'cards', 'section.html'), ''); + await writeFile(join(dir, 'cards', 'section.sanitized.html'), ''); + await writeFile(join(dir, 'cards', 'section.css'), '.c{}'); + await mkdir(join(dir, 'hero'), { recursive: true }); + await writeFile(join(dir, 'hero', 'section.html'), ''); + const secs = await listSections(dir); + const cards = secs.find((s) => s.id === 'cards'); + assert.equal(cards.html, ''); // sanitized preferred + assert.equal(cards.css, '.c{}'); + const hero = secs.find((s) => s.id === 'hero'); + assert.equal(hero.html, ''); + assert.equal(hero.css, ''); // missing css → empty +}); + +test('generate POSTs the payload and returns config+sessionId', async () => { + const fetchImpl = async (url, opts) => { + assert.match(url, /\/api\/generate$/); + const body = JSON.parse(opts.body); + assert.ok(body.user_input && body.system_rules); + return { ok: true, json: async () => ({ config: '{"x":1}', sessionId: 'sess1' }) }; + }; + const out = await generate({ html: '', css: 'c', guideline: 'g' }, + { playgroundUrl: 'http://x', fetchImpl, buildGenerateImpl: () => ({ system: 'S', user: 'U' }), schemaImpl: {} }); + assert.deepEqual(out, { config: '{"x":1}', sessionId: 'sess1' }); +}); + +test('pingStatus is false when the server is unreachable', async () => { + const fetchImpl = async () => { throw new Error('ECONNREFUSED'); }; + assert.equal(await pingStatus({ playgroundUrl: 'http://127.0.0.1:59999', fetchImpl }), false); +}); From 5b355a9a9c66d4fb8eece26b27ec7002d7925552 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 6 Jul 2026 13:50:20 +0300 Subject: [PATCH 36/62] feat(validator): loop history store (rounds, rollback, finalize) --- validator/lib/loop-store.js | 42 +++++++++++++++++++++++++++ validator/lib/prompts.js | 6 ++++ validator/test/loop-store.test.js | 48 +++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 validator/lib/loop-store.js create mode 100644 validator/test/loop-store.test.js diff --git a/validator/lib/loop-store.js b/validator/lib/loop-store.js new file mode 100644 index 0000000..44c5046 --- /dev/null +++ b/validator/lib/loop-store.js @@ -0,0 +1,42 @@ +import { readPrompt, writePromptRaw } from './prompts.js'; + +const historyRel = (promptRel) => `${promptRel}.history.json`; + +export async function readLoop(rootDir, promptRel) { + const raw = await readPrompt(rootDir, historyRel(promptRel)); + if (raw !== null) { + try { + const parsed = JSON.parse(raw); + return { working: parsed.working, rounds: parsed.rounds || [] }; + } catch { /* fall through to defaults */ } + } + const md = await readPrompt(rootDir, promptRel); + return { working: md ?? '', rounds: [] }; +} + +async function save(rootDir, promptRel, loop) { + await writePromptRaw(rootDir, historyRel(promptRel), JSON.stringify(loop, null, 2)); +} + +export async function recordRound(rootDir, promptRel, { guideline, sections, score, notes, newWorking }) { + const loop = await readLoop(rootDir, promptRel); + const round = loop.rounds.length + 1; + loop.rounds.push({ round, guideline, sections: sections || [], score, notes }); + loop.working = newWorking; + await save(rootDir, promptRel, loop); + return { round }; +} + +export async function rollback(rootDir, promptRel, round) { + const loop = await readLoop(rootDir, promptRel); + const target = loop.rounds.find((r) => r.round === round); + if (!target) throw new Error(`no round ${round}`); + loop.working = target.guideline; + await save(rootDir, promptRel, loop); + return { working: loop.working }; +} + +export async function finalize(rootDir, promptRel) { + const loop = await readLoop(rootDir, promptRel); + await writePromptRaw(rootDir, promptRel, loop.working); +} diff --git a/validator/lib/prompts.js b/validator/lib/prompts.js index 9f4699e..5e76cb6 100644 --- a/validator/lib/prompts.js +++ b/validator/lib/prompts.js @@ -34,6 +34,12 @@ export async function readPrompt(rootDir, rel) { } } +export async function writePromptRaw(rootDir, rel, content) { + const abs = promptAbs(rootDir, rel); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, 'utf8'); +} + // List every .md guideline under the prompts dir, as { path, dir, file } // with paths relative to the prompts dir (mirrors the examples tree shape). export async function listPrompts(rootDir) { diff --git a/validator/test/loop-store.test.js b/validator/test/loop-store.test.js new file mode 100644 index 0000000..93a71e4 --- /dev/null +++ b/validator/test/loop-store.test.js @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writePrompt, readPrompt } from '../lib/prompts.js'; +import { readLoop, recordRound, rollback, finalize } from '../lib/loop-store.js'; + +async function repoWithPrompt() { + const root = await mkdtemp(join(tmpdir(), 'iv-loop-')); + await writePrompt(root, 'G/Card.html', '# V0 guideline'); // creates G/Card.md + return root; +} + +test('readLoop defaults working to the .md and rounds to []', async () => { + const root = await repoWithPrompt(); + const loop = await readLoop(root, 'G/Card.md'); + assert.equal(loop.working, '# V0 guideline'); + assert.deepEqual(loop.rounds, []); +}); + +test('recordRound appends a round and updates working', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { + guideline: '# V0 guideline', sections: [{ id: 'cards', config: '{}' }], score: 6, notes: 'more spread', newWorking: '# V1 guideline' }); + const loop = await readLoop(root, 'G/Card.md'); + assert.equal(loop.working, '# V1 guideline'); + assert.equal(loop.rounds.length, 1); + assert.equal(loop.rounds[0].round, 1); + assert.equal(loop.rounds[0].score, 6); + assert.equal(loop.rounds[0].sections[0].id, 'cards'); +}); + +test('rollback sets working back to a round guideline', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { guideline: '# V0 guideline', sections: [], score: 5, notes: '', newWorking: '# V1' }); + await recordRound(root, 'G/Card.md', { guideline: '# V1', sections: [], score: 7, notes: '', newWorking: '# V2' }); + const { working } = await rollback(root, 'G/Card.md', 1); + assert.equal(working, '# V0 guideline'); // round 1's guideline field + assert.equal((await readLoop(root, 'G/Card.md')).working, '# V0 guideline'); +}); + +test('finalize writes working back to the .md', async () => { + const root = await repoWithPrompt(); + await recordRound(root, 'G/Card.md', { guideline: '# V0 guideline', sections: [], score: 9, notes: '', newWorking: '# FINAL' }); + await finalize(root, 'G/Card.md'); + assert.equal(await readPrompt(root, 'G/Card.md'), '# FINAL'); +}); From d0299c7973a1b6935c16f14547c6a94a1b6d2d1b Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 6 Jul 2026 16:32:42 +0300 Subject: [PATCH 37/62] feat(validator): guideline refiner (general, no-overfit) --- validator/lib/refine.js | 32 ++++++++++++++++++++++++++++++++ validator/test/refine.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 validator/lib/refine.js create mode 100644 validator/test/refine.test.js diff --git a/validator/lib/refine.js b/validator/lib/refine.js new file mode 100644 index 0000000..b599e20 --- /dev/null +++ b/validator/lib/refine.js @@ -0,0 +1,32 @@ +import { runAgent as realRunAgent } from './agent.js'; + +const SYSTEM = `You refine a GENERAL @wix/interact animation guideline based on holistic, cross-section feedback from a reviewer who applied it to several different sections. + +RULES: +- The guideline must stay GENERAL and reusable across many sections. Do NOT overfit to any single generated output or section. +- Keep every section of the guideline intact and general (Summary, Selector Contract, Role Guidance, Adaptation Notes, Required Elements, Required Styles, Suggested Controls, Interact Template). +- Improve it to address the feedback at the pattern level — adjust roles, formulas, adaptation notes, controls, or the interact template as needed. + +OUTPUT CONTRACT: Return ONLY the full updated guideline as raw markdown — no code fence around the whole document, no preamble, no commentary. Begin with the "# " H1.`; + +function stripFence(text) { + const t = String(text).trim(); + const m = t.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + return (m ? m[1] : t).trim(); +} + +export function buildRefinePrompt({ guideline, score, notes }) { + const user = `Reviewer score: ${score}/10 + +Reviewer notes (holistic, not specific to one output): +${notes || '(none)'} + +Current guideline to improve: +${guideline}`; + return { system: SYSTEM, user }; +} + +export async function refineGuideline({ guideline, score, notes, onDelta, model, runAgent = realRunAgent }) { + const { system, user } = buildRefinePrompt({ guideline, score, notes }); + return stripFence(await runAgent(system, user, { model, onDelta })); +} diff --git a/validator/test/refine.test.js b/validator/test/refine.test.js new file mode 100644 index 0000000..6a1726b --- /dev/null +++ b/validator/test/refine.test.js @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRefinePrompt, refineGuideline } from '../lib/refine.js'; + +test('buildRefinePrompt forbids overfitting and embeds score+notes+guideline', () => { + const { system, user } = buildRefinePrompt({ guideline: '# G', score: 6, notes: 'more spread' }); + assert.match(system, /general/i); + assert.match(system, /do not overfit|not overfit/i); + assert.match(system, /ONLY the (full )?updated guideline/i); + assert.match(user, /6\/10/); + assert.match(user, /more spread/); + assert.match(user, /# G/); +}); + +test('refineGuideline returns fence-stripped markdown from the agent', async () => { + const out = await refineGuideline({ guideline: '# G', score: 5, notes: 'n', + runAgent: async () => '```markdown\n# G v2\nbody\n```' }); + assert.equal(out, '# G v2\nbody'); +}); + +test('refineGuideline passes an onDelta through to runAgent', async () => { + let sawOpts = null; + await refineGuideline({ guideline: '# G', score: 5, notes: 'n', onDelta: () => {}, + runAgent: async (s, u, opts) => { sawOpts = opts; return '# ok'; } }); + assert.equal(typeof sawOpts.onDelta, 'function'); +}); From 47cb0a5233d94c4074e3aa38e90a0d84feccd002 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 6 Jul 2026 16:37:54 +0300 Subject: [PATCH 38/62] feat(validator): loop endpoints (status, sections, run, refine, finalize) + serve vendor --- validator/server.js | 61 +++++++++++++++++++++++++++++++++++ validator/test/server.test.js | 25 ++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/validator/server.js b/validator/server.js index f8d5a99..f0f529a 100644 --- a/validator/server.js +++ b/validator/server.js @@ -10,6 +10,9 @@ import { listPrompts, readPrompt } from './lib/prompts.js'; import { loadConvertSkill } from './lib/skill.js'; import { FIX_OPTIONS } from './lib/prompt.js'; import { loadSpecText } from './lib/spec.js'; +import { listSections, generate, pingStatus } from './lib/playground.js'; +import { readLoop, recordRound, rollback, finalize } from './lib/loop-store.js'; +import { refineGuideline } from './lib/refine.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -18,6 +21,7 @@ export function createApp(rootDir) { const app = express(); app.use(express.json({ limit: '5mb' })); app.use(express.static(join(__dirname, 'public'))); + app.use('/vendor', express.static(join(__dirname, 'vendor'))); const bad = (res, msg) => res.status(400).json({ error: msg }); @@ -186,6 +190,63 @@ export function createApp(rootDir) { } catch (err) { res.status(500).json({ error: String(err.message || err) }); } }); + app.get('/api/playground/status', async (_req, res) => { res.json({ up: await pingStatus({}) }); }); + + app.get('/api/playground/sections', async (_req, res) => { + const sections = await listSections(); + res.json({ sections: sections.map((s) => ({ id: s.id })) }); + }); + + app.get('/api/loop', async (req, res) => { + try { res.json(await readLoop(root, String(req.query.promptPath))); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/loop/run', async (req, res) => { + const { promptPath, sections } = req.body; + if (!promptPath || !Array.isArray(sections) || !sections.length) return bad(res, 'promptPath and sections required'); + const { working } = await readLoop(root, promptPath); + const all = await listSections(); + const chosen = all.filter((s) => sections.includes(s.id)); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + send('start', { sections: chosen.map((s) => s.id) }); + await Promise.all(chosen.map(async (s) => { + try { + const { config } = await generate({ html: s.html, css: s.css, guideline: working }); + send('result', { id: s.id, config, html: s.html, css: s.css }); + } catch (err) { + send('result', { id: s.id, error: String(err.message || err) }); + } + })); + send('done', { ok: true }); + res.end(); + }); + + app.post('/api/loop/refine', async (req, res) => { + const { promptPath, score, notes, sections, configs } = req.body; + if (!promptPath) return bad(res, 'promptPath required'); + const { working } = await readLoop(root, promptPath); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + try { + const guideline = await refineGuideline({ guideline: working, score, notes, onDelta: (t) => send('log', { text: t }) }); + await recordRound(root, promptPath, { guideline: working, sections: configs || [], score, notes, newWorking: guideline }); + send('done', { guideline }); + } catch (err) { send('error', { error: String(err.message || err) }); } + res.end(); + }); + + app.post('/api/loop/finalize', async (req, res) => { + try { await finalize(root, String(req.body.promptPath)); res.json({ ok: true }); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/loop/rollback', async (req, res) => { + try { res.json(await rollback(root, String(req.body.promptPath), Number(req.body.round))); } + catch (err) { bad(res, String(err.message || err)); } + }); + return app; } diff --git a/validator/test/server.test.js b/validator/test/server.test.js index b3ee9a7..504c75a 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -101,3 +101,28 @@ test('GET /api/prompts lists generated guidelines and /api/prompt reads one', as assert.equal(missing.status, 404); server.close(); }); + +test('GET /api/loop returns working (defaults to the prompt md) and empty rounds', async () => { + const root = await repo(); + const { writePrompt } = await import('../lib/prompts.js'); + await writePrompt(root, 'G/A.html', '# Guide v0'); // → G/A.md + const { base, server } = await start(root); + const loop = await (await fetch(`${base}/api/loop?promptPath=${encodeURIComponent('G/A.md')}`)).json(); + assert.equal(loop.working, '# Guide v0'); + assert.deepEqual(loop.rounds, []); + server.close(); +}); + +test('POST /api/loop/finalize writes working back to the prompt md', async () => { + const root = await repo(); + const { writePrompt, readPrompt } = await import('../lib/prompts.js'); + const { recordRound } = await import('../lib/loop-store.js'); + await writePrompt(root, 'G/A.html', '# v0'); + await recordRound(root, 'G/A.md', { guideline: '# v0', sections: [], score: 8, notes: '', newWorking: '# FINAL' }); + const { base, server } = await start(root); + const r = await fetch(`${base}/api/loop/finalize`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: 'G/A.md' }) }); + assert.equal(r.status, 200); + assert.equal(await readPrompt(root, 'G/A.md'), '# FINAL'); + server.close(); +}); From 5666e3332ae75b6f5b4bb81fd979326a39cab79b Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 10:00:18 +0300 Subject: [PATCH 39/62] =?UTF-8?q?fix(validator):=20loop=20SSE=20endpoints?= =?UTF-8?q?=20=E2=80=94=20Accept-gate=20+=20defensive=20400=20(mirror=20/a?= =?UTF-8?q?pi/fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- validator/server.js | 84 ++++++++++++++++++++++++----------- validator/test/server.test.js | 18 ++++++++ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/validator/server.js b/validator/server.js index f0f529a..b076e51 100644 --- a/validator/server.js +++ b/validator/server.js @@ -205,36 +205,70 @@ export function createApp(rootDir) { app.post('/api/loop/run', async (req, res) => { const { promptPath, sections } = req.body; if (!promptPath || !Array.isArray(sections) || !sections.length) return bad(res, 'promptPath and sections required'); - const { working } = await readLoop(root, promptPath); - const all = await listSections(); - const chosen = all.filter((s) => sections.includes(s.id)); - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); - const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); - send('start', { sections: chosen.map((s) => s.id) }); - await Promise.all(chosen.map(async (s) => { - try { - const { config } = await generate({ html: s.html, css: s.css, guideline: working }); - send('result', { id: s.id, config, html: s.html, css: s.css }); - } catch (err) { - send('result', { id: s.id, error: String(err.message || err) }); - } - })); - send('done', { ok: true }); - res.end(); + // Resolve inputs defensively BEFORE committing to a response mode — a bad + // promptPath (e.g. path escape) yields a clean 400, not a hung stream. + let working, chosen; + try { + ({ working } = await readLoop(root, promptPath)); + const all = await listSections(); + chosen = all.filter((s) => sections.includes(s.id)); + } catch (err) { return bad(res, String(err.message || err)); } + + const runAll = async (onResult) => { + await Promise.all(chosen.map(async (s) => { + try { + const { config } = await generate({ html: s.html, css: s.css, guideline: working }); + onResult({ id: s.id, config, html: s.html, css: s.css }); + } catch (err) { + onResult({ id: s.id, error: String(err.message || err) }); + } + })); + }; + + // Streaming (opt-in via Accept), mirroring /api/fix. + if ((req.headers.accept || '').includes('text/event-stream')) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + send('start', { sections: chosen.map((s) => s.id) }); + await runAll((r) => send('result', r)); + send('done', { ok: true }); + return res.end(); + } + + // Non-streaming (default): one JSON response with all section results. + try { + const results = []; + await runAll((r) => results.push(r)); + res.json({ results }); + } catch (err) { res.status(500).json({ error: String(err.message || err) }); } }); app.post('/api/loop/refine', async (req, res) => { - const { promptPath, score, notes, sections, configs } = req.body; + const { promptPath, score, notes, configs } = req.body; if (!promptPath) return bad(res, 'promptPath required'); - const { working } = await readLoop(root, promptPath); - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); - const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + let working; + try { ({ working } = await readLoop(root, promptPath)); } + catch (err) { return bad(res, String(err.message || err)); } + const roundSections = Array.isArray(configs) ? configs : []; // defensive: never persist a non-array + + // Streaming (opt-in via Accept), mirroring /api/fix. + if ((req.headers.accept || '').includes('text/event-stream')) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + try { + const guideline = await refineGuideline({ guideline: working, score, notes, onDelta: (t) => send('log', { text: t }) }); + await recordRound(root, promptPath, { guideline: working, sections: roundSections, score, notes, newWorking: guideline }); + send('done', { guideline }); + } catch (err) { send('error', { error: String(err.message || err) }); } + return res.end(); + } + + // Non-streaming (default): one JSON response. try { - const guideline = await refineGuideline({ guideline: working, score, notes, onDelta: (t) => send('log', { text: t }) }); - await recordRound(root, promptPath, { guideline: working, sections: configs || [], score, notes, newWorking: guideline }); - send('done', { guideline }); - } catch (err) { send('error', { error: String(err.message || err) }); } - res.end(); + const guideline = await refineGuideline({ guideline: working, score, notes }); + await recordRound(root, promptPath, { guideline: working, sections: roundSections, score, notes, newWorking: guideline }); + res.json({ guideline }); + } catch (err) { res.status(500).json({ error: String(err.message || err) }); } }); app.post('/api/loop/finalize', async (req, res) => { diff --git a/validator/test/server.test.js b/validator/test/server.test.js index 504c75a..69246c0 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -126,3 +126,21 @@ test('POST /api/loop/finalize writes working back to the prompt md', async () => assert.equal(await readPrompt(root, 'G/A.md'), '# FINAL'); server.close(); }); + +test('POST /api/loop/run rejects a path-escaping promptPath with 400 (no hung stream)', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/loop/run`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ promptPath: '../../etc/passwd', sections: ['x'] }) }); + assert.equal(res.status, 400); + server.close(); +}); + +test('POST /api/loop/refine rejects a path-escaping promptPath with 400', async () => { + const { base, server } = await start(await repo()); + const res = await fetch(`${base}/api/loop/refine`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ promptPath: '../../etc/passwd', score: 5, notes: 'n' }) }); + assert.equal(res.status, 400); + server.close(); +}); From 0bb0c47565de6e045f51466f41123037c2f28a38 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 10:27:51 +0300 Subject: [PATCH 40/62] feat(validator): prompt refinement loop UI (sections, previews, score, refine, rounds) --- validator/public/app.js | 129 ++++++++++++++++++++++++++++ validator/public/index.html | 16 ++++ validator/public/render-frame.js | 22 +++++ validator/public/styles.css | 24 ++++++ validator/test/render-frame.test.js | 18 ++++ 5 files changed, 209 insertions(+) create mode 100644 validator/public/render-frame.js create mode 100644 validator/test/render-frame.test.js diff --git a/validator/public/app.js b/validator/public/app.js index b44f084..7ea91b0 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -1,5 +1,6 @@ import { injectBase } from './preview.js'; import { mdToHtml } from './md.js'; +import { buildRenderDoc } from './render-frame.js'; // Version state — mutually exclusive (green / yellow / red). const VER = { @@ -26,6 +27,7 @@ const state = { filter: '', mode: 'preview', version: 'current', progress: null, expanded: new Set(), logs: new Map(), activity: { open: false, file: null, follow: true }, view: 'examples', prompts: [], promptExpanded: new Set(), currentPrompt: null, promptMode: 'rendered', + loop: { promptPath: null, sections: [], available: [], configs: {}, active: false }, }; const $ = (id) => document.getElementById(id); const api = (path, opts) => fetch(path, opts).then((r) => r.json()); @@ -183,6 +185,8 @@ async function render() { } async function renderExampleView() { + $('loopView').hidden = true; + $('loopBtn').hidden = true; const { mode, version, current } = state; $('markdown').hidden = true; const has = !!current; @@ -198,8 +202,10 @@ async function renderExampleView() { } async function renderPromptView() { + $('loopView').hidden = true; $('preview').hidden = true; $('diff').hidden = true; const has = !!state.currentPrompt; + $('loopBtn').hidden = !(state.view === 'prompts' && has); $('placeholder').hidden = has; $('markdown').hidden = !(has && state.promptMode === 'rendered'); $('code').hidden = !(has && state.promptMode === 'raw'); @@ -372,6 +378,99 @@ function renderActivity() { function openActivity() { state.activity.open = true; $('activityModal').hidden = false; renderActivity(); } function closeActivity() { state.activity.open = false; $('activityModal').hidden = true; } +// ── Prompt refinement loop ────────────────────────── +async function openLoop() { + const p = state.currentPrompt; + if (!p) return; + state.loop = { promptPath: p, sections: [], available: [], configs: {}, active: true }; + $('markdown').hidden = true; $('code').hidden = true; $('preview').hidden = true; $('diff').hidden = true; + $('placeholder').hidden = true; $('loopView').hidden = false; + const [{ up }, { sections }, loop] = await Promise.all([ + api('/api/playground/status'), + api('/api/playground/sections'), + api(`/api/loop?promptPath=${encodeURIComponent(p)}`), + ]); + state.loop.available = sections.map((s) => s.id); + if (!up) { $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; return; } + renderSectionChips(); + renderRounds(loop.rounds); +} + +function renderSectionChips() { + $('loopSections').innerHTML = state.loop.available.map((id) => + `${esc(id)}`).join('') + + ''; +} + +function renderGrid() { + const cells = state.loop.sections.map((id) => { + const c = state.loop.configs[id]; + const inner = c === undefined ? '
            …generating
            ' + : c.error ? `
            ${esc(c.error)}
            ` + : ``; + return `
            ${esc(id)}
            ${inner}
            `; + }).join(''); + $('loopGrid').innerHTML = cells; + $('loopFeedback').hidden = !state.loop.sections.length || Object.keys(state.loop.configs).length === 0; +} + +async function loopGenerate() { + const secs = state.loop.sections; + if (!secs.length) return; + state.loop.configs = {}; + state.logs = new Map(); + renderGrid(); + const res = await fetch('/api/loop/run', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, sections: secs }) }); + await streamSSE(res, (type, d) => { + if (type === 'result') { + state.loop.configs[d.id] = d.error ? { error: d.error } : { config: d.config, html: d.html, css: d.css }; + renderGrid(); + } else if (type === 'log') appendLog(d.id || 'agent', d.text); + }); + renderGrid(); +} + +async function loopRefine() { + const score = Number($('scoreRange').value); + const notes = $('loopNotes').value; + const configs = Object.entries(state.loop.configs).filter(([, c]) => c && c.config) + .map(([id, c]) => ({ id, config: c.config, html: c.html, css: c.css })); + state.logs = new Map(); + const res = await fetch('/api/loop/refine', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); + await streamSSE(res, (type, d) => { + if (type === 'log') appendLog('refine', d.text); + else if (type === 'done') { $('loopNotes').value = ''; loopRefreshRounds(); } + }); +} + +async function loopRefreshRounds() { + const loop = await api(`/api/loop?promptPath=${encodeURIComponent(state.loop.promptPath)}`); + renderRounds(loop.rounds); +} + +function renderRounds(rounds) { + state.loop.rounds = rounds || []; + $('roundsRail').innerHTML = state.loop.rounds.map((r) => + `
            Round ${r.round} + ${r.score}/10 +
            `).join('') + + (state.loop.rounds.length ? '' : ''); +} + +// Load a past round's stored outputs + feedback back into the view (read-only look). +function viewRound(round) { + const r = (state.loop.rounds || []).find((x) => x.round === round); + if (!r) return; + state.loop.configs = {}; + for (const s of r.sections) state.loop.configs[s.id] = { config: s.config, html: s.html, css: s.css }; + $('scoreRange').value = r.score; $('scoreVal').textContent = r.score; $('loopNotes').value = r.notes || ''; + renderGrid(); +} + // ── events ────────────────────────────────────────── $('fileTree').addEventListener('click', (e) => { const folder = e.target.closest('.folder-row'); @@ -454,6 +553,36 @@ $('activityClose').onclick = closeActivity; $('activityModal').addEventListener('click', (e) => { if (e.target.id === 'activityModal') closeActivity(); }); $('activityFile').onchange = (e) => { state.activity.file = e.target.value; state.activity.follow = false; renderActivity(); }; +// event delegation +$('loopSections').addEventListener('click', (e) => { + if (e.target.id === 'genBtn') return loopGenerate(); + const chip = e.target.closest('.chip'); if (!chip) return; + const id = chip.dataset.sec; + const i = state.loop.sections.indexOf(id); + if (i >= 0) state.loop.sections.splice(i, 1); + else if (state.loop.sections.length < 4) state.loop.sections.push(id); + renderSectionChips(); +}); +$('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); +$('regenBtn').onclick = loopGenerate; +$('refineBtn').onclick = async () => { await loopRefine(); await loopGenerate(); }; +$('roundsRail').addEventListener('click', async (e) => { + if (e.target.id === 'finalizeBtn') { + await api('/api/loop/finalize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath }) }); + $('applyStatus').textContent = 'Loop closed — final guideline written to the .md.'; + return; + } + const rb = e.target.closest('.rollback-btn'); + if (rb) { + await api('/api/loop/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath, round: Number(rb.dataset.round) }) }); + $('applyStatus').textContent = `Rolled back to round ${rb.dataset.round}'s guideline (working version).`; + return; + } + const row = e.target.closest('.round-row'); + if (row) viewRound(Number(row.dataset.round)); +}); +$('loopBtn').onclick = openLoop; + loadFiles(); loadOptions(); loadPrompts(); diff --git a/validator/public/index.html b/validator/public/index.html index ad173f4..2a51c4d 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -13,6 +13,20 @@ +

            Select a file to preview

            @@ -59,6 +73,8 @@

            Fix options

            + +
            diff --git a/validator/public/render-frame.js b/validator/public/render-frame.js new file mode 100644 index 0000000..af1b747 --- /dev/null +++ b/validator/public/render-frame.js @@ -0,0 +1,22 @@ +// Build a self-contained HTML document that renders a section with a generated +// @wix/interact-experience config, using the vendored renderer. The config is +// embedded as a JSON string in a data attribute (script-tag-safe). +export function buildRenderDoc({ html, css, config }) { + const safeConfig = String(config).replace(/<\/script>/gi, '<\\/script>'); + return ` + + +
            ${html || ''}
            + + +`; +} diff --git a/validator/public/styles.css b/validator/public/styles.css index 05bf6e8..a481f4b 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -255,3 +255,27 @@ body { ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.16); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.26); background-clip: padding-box; } ::-webkit-scrollbar-track { background: transparent; } + +/* ── Prompt refinement loop ──────────────────────── */ +#loopView { inset: 68px 332px 16px 322px; overflow: auto; padding: 16px; background: var(--glass-bg); + backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--hair); + border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; } +.loop-sections { display: flex; flex-wrap: wrap; gap: 6px; } +.loop-sections .chip { font-size: 12px; padding: 5px 10px; border-radius: 980px; background: var(--fill-1); + color: var(--text-2); cursor: pointer; border: 1px solid transparent; } +.loop-sections .chip.on { background: var(--accent-soft); color: #fff; border-color: var(--accent); } +.loop-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; } +.loop-cell { border: 1px solid var(--hair); border-radius: var(--radius-xs); overflow: hidden; background: #0e0e0f; } +.loop-cell .cap { font-size: 11px; color: var(--text-2); padding: 5px 8px; border-bottom: 1px solid var(--hair); } +.loop-cell iframe { width: 100%; height: 220px; border: 0; background: #fff; display: block; } +.loop-cell .err { color: #fca5a5; font-family: var(--mono); font-size: 11px; padding: 8px; } +.loop-feedback { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--hair); padding-top: 12px; } +.loop-feedback input[type=range] { width: 100%; } +#loopNotes { min-height: 60px; background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); + color: var(--text); padding: 8px 10px; font-family: inherit; font-size: 12.5px; resize: vertical; } +.loop-actions { display: flex; gap: 8px; } .loop-actions .btn { flex: 1; } +#roundsRail { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } +.round-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--radius-xs); + background: var(--fill-1); cursor: pointer; } +.round-row:hover { background: var(--fill-2); } +.round-row .sc { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text-2); } diff --git a/validator/test/render-frame.test.js b/validator/test/render-frame.test.js new file mode 100644 index 0000000..ffa1df8 --- /dev/null +++ b/validator/test/render-frame.test.js @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRenderDoc } from '../public/render-frame.js'; + +test('buildRenderDoc embeds section html, css, config, and imports the runtime', () => { + const doc = buildRenderDoc({ html: '
            x
            ', css: '.card{color:red}', config: '{"schema":"interact-experience/1.0"}' }); + assert.match(doc, /
            x<\/div>/); + assert.match(doc, /\.card\{color:red\}/); + assert.match(doc, /\/vendor\/render-runtime\.js/); + assert.match(doc, /createExperience/); + assert.match(doc, /interact-experience\\?\/1\.0|interact-experience/); +}); + +test('buildRenderDoc escapes a closing script tag in the config to avoid breakout', () => { + const doc = buildRenderDoc({ html: '', css: '', config: '{"x":""}' }); + assert.doesNotMatch(doc, /<\/script>\s*<\/script>/); // the payload's must be escaped + assert.match(doc, /<\\\/script>/); +}); From 207913d7e6d1330b90f1a234c4fa43bd8ccf2a67 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 10:48:36 +0300 Subject: [PATCH 41/62] fix(validator): escape all end-tag variants in render-frame config Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/render-frame.js | 4 +++- validator/test/render-frame.test.js | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/validator/public/render-frame.js b/validator/public/render-frame.js index af1b747..b616581 100644 --- a/validator/public/render-frame.js +++ b/validator/public/render-frame.js @@ -2,7 +2,9 @@ // @wix/interact-experience config, using the vendored renderer. The config is // embedded as a JSON string in a data attribute (script-tag-safe). export function buildRenderDoc({ html, css, config }) { - const safeConfig = String(config).replace(/<\/script>/gi, '<\\/script>'); + // Neutralize any ". + const safeConfig = String(config).replace(/<\/script(?=[\s/>])/gi, '<\\/script'); return ` diff --git a/validator/test/render-frame.test.js b/validator/test/render-frame.test.js index ffa1df8..80610c6 100644 --- a/validator/test/render-frame.test.js +++ b/validator/test/render-frame.test.js @@ -16,3 +16,14 @@ test('buildRenderDoc escapes a closing script tag in the config to avoid breakou assert.doesNotMatch(doc, /<\/script>\s*<\/script>/); // the payload's must be escaped assert.match(doc, /<\\\/script>/); }); + +test('buildRenderDoc escapes variants (whitespace, tab, slash, case) in the config', () => { + for (const variant of ['', '', '', '']) { + const doc = buildRenderDoc({ html: '', css: '', config: `{"x":"${variant}"}` }); + // The raw, unescaped payload variant must not survive anywhere in the built doc + // (it would otherwise be recognized by the HTML tokenizer as a real closing tag). + assert.ok(!doc.includes(variant), `expected raw "${variant}" to be escaped, but found it unescaped in the doc`); + // The escaped form (backslash before the "/") must be present in its place. + assert.match(doc, new RegExp('<\\\\/script' + variant.slice(' Date: Wed, 8 Jul 2026 11:18:03 +0300 Subject: [PATCH 42/62] fix(validator): serve /vendor with CORS header + loop UI robustness (viewRound sections, refine error, stale grid) Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 16 +++++++++++++--- validator/server.js | 2 +- validator/test/server.test.js | 9 +++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 7ea91b0..bafe788 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -391,7 +391,12 @@ async function openLoop() { api(`/api/loop?promptPath=${encodeURIComponent(p)}`), ]); state.loop.available = sections.map((s) => s.id); - if (!up) { $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; return; } + if (!up) { + $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; + $('loopGrid').innerHTML = ''; + $('roundsRail').innerHTML = ''; + return; + } renderSectionChips(); renderRounds(loop.rounds); } @@ -438,13 +443,16 @@ async function loopRefine() { const configs = Object.entries(state.loop.configs).filter(([, c]) => c && c.config) .map(([id, c]) => ({ id, config: c.config, html: c.html, css: c.css })); state.logs = new Map(); + let ok = false; const res = await fetch('/api/loop/refine', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); await streamSSE(res, (type, d) => { if (type === 'log') appendLog('refine', d.text); - else if (type === 'done') { $('loopNotes').value = ''; loopRefreshRounds(); } + else if (type === 'done') { ok = true; $('loopNotes').value = ''; loopRefreshRounds(); } + else if (type === 'error') { $('applyStatus').textContent = `Refine failed: ${d.error}`; } }); + return ok; } async function loopRefreshRounds() { @@ -467,6 +475,8 @@ function viewRound(round) { if (!r) return; state.loop.configs = {}; for (const s of r.sections) state.loop.configs[s.id] = { config: s.config, html: s.html, css: s.css }; + state.loop.sections = r.sections.map((s) => s.id); + renderSectionChips(); $('scoreRange').value = r.score; $('scoreVal').textContent = r.score; $('loopNotes').value = r.notes || ''; renderGrid(); } @@ -565,7 +575,7 @@ $('loopSections').addEventListener('click', (e) => { }); $('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); $('regenBtn').onclick = loopGenerate; -$('refineBtn').onclick = async () => { await loopRefine(); await loopGenerate(); }; +$('refineBtn').onclick = async () => { if (await loopRefine()) await loopGenerate(); }; $('roundsRail').addEventListener('click', async (e) => { if (e.target.id === 'finalizeBtn') { await api('/api/loop/finalize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath }) }); diff --git a/validator/server.js b/validator/server.js index b076e51..3d93a45 100644 --- a/validator/server.js +++ b/validator/server.js @@ -21,7 +21,7 @@ export function createApp(rootDir) { const app = express(); app.use(express.json({ limit: '5mb' })); app.use(express.static(join(__dirname, 'public'))); - app.use('/vendor', express.static(join(__dirname, 'vendor'))); + app.use('/vendor', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); }, express.static(join(__dirname, 'vendor'))); const bad = (res, msg) => res.status(400).json({ error: msg }); diff --git a/validator/test/server.test.js b/validator/test/server.test.js index 69246c0..ae4d098 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -144,3 +144,12 @@ test('POST /api/loop/refine rejects a path-escaping promptPath with 400', async assert.equal(res.status, 400); server.close(); }); + +test('GET /vendor/* responds with an Access-Control-Allow-Origin header (sandboxed iframe can import the renderer)', async () => { + const { base, server } = await start(await repo()); + // The vendor dir/file exists in the real validator/vendor (committed in Task 1); request the runtime. + const res = await fetch(`${base}/vendor/render-runtime.js`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + server.close(); +}); From 29b19c2915856bdec551655f4246808b1b6f7fb6 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 12:57:19 +0300 Subject: [PATCH 43/62] fix(validator): render real section content + full-width loop previews, redesigned feedback dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listSections now returns raw section.html for rendering and the sanitized copy as promptHtml for the model (mirrors the playground app) — previews no longer show redacted text/image placeholders - loop previews: one full-width card per section, stacked, tall iframes - section picker docks sticky at top; feedback (score | notes | actions) docks sticky at bottom; empty/generating states with spinner Co-Authored-By: Claude Fable 5 --- validator/lib/playground.js | 9 ++++-- validator/public/app.js | 32 +++++++++++++------- validator/public/index.html | 8 +++-- validator/public/styles.css | 49 +++++++++++++++++++++---------- validator/server.js | 3 +- validator/test/playground.test.js | 8 +++-- 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/validator/lib/playground.js b/validator/lib/playground.js index 26da5dd..300ea41 100644 --- a/validator/lib/playground.js +++ b/validator/lib/playground.js @@ -22,9 +22,14 @@ export async function listSections(sectionsDir = SECTIONS_DIR) { if (!e.isDirectory()) continue; const dir = join(sectionsDir, e.name); const read = async (f) => { try { return await readFile(join(dir, f), 'utf8'); } catch { return null; } }; - const html = (await read('section.sanitized.html')) ?? (await read('section.html')); + // `html` is the real section markup (what we RENDER); `promptHtml` is the + // injection-safe sanitized copy (what the MODEL sees, mirroring the + // playground app itself). Same DOM shape, so generated selectors match. + const raw = await read('section.html'); + const sanitized = await read('section.sanitized.html'); + const html = raw ?? sanitized; if (html === null) continue; - out.push({ id: e.name, html, css: (await read('section.css')) ?? '' }); + out.push({ id: e.name, html, promptHtml: sanitized ?? html, css: (await read('section.css')) ?? '' }); } return out.sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/validator/public/app.js b/validator/public/app.js index bafe788..d5c012a 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -382,7 +382,7 @@ function closeActivity() { state.activity.open = false; $('activityModal').hidde async function openLoop() { const p = state.currentPrompt; if (!p) return; - state.loop = { promptPath: p, sections: [], available: [], configs: {}, active: true }; + state.loop = { promptPath: p, sections: [], available: [], configs: {}, generating: false, active: true }; $('markdown').hidden = true; $('code').hidden = true; $('preview').hidden = true; $('diff').hidden = true; $('placeholder').hidden = true; $('loopView').hidden = false; const [{ up }, { sections }, loop] = await Promise.all([ @@ -398,6 +398,7 @@ async function openLoop() { return; } renderSectionChips(); + renderGrid(); renderRounds(loop.rounds); } @@ -408,21 +409,29 @@ function renderSectionChips() { } function renderGrid() { - const cells = state.loop.sections.map((id) => { - const c = state.loop.configs[id]; - const inner = c === undefined ? '
            …generating
            ' - : c.error ? `
            ${esc(c.error)}
            ` - : ``; - return `
            ${esc(id)}
            ${inner}
            `; - }).join(''); - $('loopGrid').innerHTML = cells; - $('loopFeedback').hidden = !state.loop.sections.length || Object.keys(state.loop.configs).length === 0; + const secs = state.loop.sections; + const started = Object.keys(state.loop.configs).length > 0 || state.loop.generating; + if (!secs.length) { + $('loopGrid').innerHTML = '
            Pick a few sections above, then hit Generate.
            '; + } else if (!started) { + $('loopGrid').innerHTML = `
            ${secs.length} section${secs.length > 1 ? 's' : ''} selected — hit Generate to run the guideline.
            `; + } else { + $('loopGrid').innerHTML = secs.map((id) => { + const c = state.loop.configs[id]; + const inner = c === undefined ? '
            Generating…
            ' + : c.error ? `
            ${esc(c.error)}
            ` + : ``; + return `
            ${esc(id)}
            ${inner}
            `; + }).join(''); + } + $('loopFeedback').hidden = !secs.length || Object.keys(state.loop.configs).length === 0; } async function loopGenerate() { const secs = state.loop.sections; if (!secs.length) return; state.loop.configs = {}; + state.loop.generating = true; state.logs = new Map(); renderGrid(); const res = await fetch('/api/loop/run', { @@ -434,6 +443,7 @@ async function loopGenerate() { renderGrid(); } else if (type === 'log') appendLog(d.id || 'agent', d.text); }); + state.loop.generating = false; renderGrid(); } @@ -572,6 +582,8 @@ $('loopSections').addEventListener('click', (e) => { if (i >= 0) state.loop.sections.splice(i, 1); else if (state.loop.sections.length < 4) state.loop.sections.push(id); renderSectionChips(); + // Refresh the hint before a run starts; leave live previews alone mid-review. + if (!Object.keys(state.loop.configs).length && !state.loop.generating) renderGrid(); }); $('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); $('regenBtn').onclick = loopGenerate; diff --git a/validator/public/index.html b/validator/public/index.html index 2a51c4d..cb82b8a 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -17,13 +17,15 @@
            diff --git a/validator/public/styles.css b/validator/public/styles.css index a481f4b..091a8a2 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -257,23 +257,42 @@ body { ::-webkit-scrollbar-track { background: transparent; } /* ── Prompt refinement loop ──────────────────────── */ -#loopView { inset: 68px 332px 16px 322px; overflow: auto; padding: 16px; background: var(--glass-bg); +#loopView { inset: 68px 332px 16px 322px; overflow: auto; padding: 0; background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); border: 1px solid var(--hair); - border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; } -.loop-sections { display: flex; flex-wrap: wrap; gap: 6px; } -.loop-sections .chip { font-size: 12px; padding: 5px 10px; border-radius: 980px; background: var(--fill-1); - color: var(--text-2); cursor: pointer; border: 1px solid transparent; } + border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex-direction: column; } +/* Section picker docks to the top of the scroll area */ +.loop-sections { position: sticky; top: 0; z-index: 3; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + padding: 12px 16px; background: rgba(26,26,28,0.92); backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); border-bottom: 1px solid var(--hair); + border-radius: var(--radius) var(--radius) 0 0; } +.loop-sections .chip { font-size: 12px; padding: 5px 12px; border-radius: 980px; background: var(--fill-1); + color: var(--text-2); cursor: pointer; border: 1px solid transparent; transition: background .15s, color .15s; } +.loop-sections .chip:hover { background: var(--fill-2); color: var(--text); } .loop-sections .chip.on { background: var(--accent-soft); color: #fff; border-color: var(--accent); } -.loop-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; } -.loop-cell { border: 1px solid var(--hair); border-radius: var(--radius-xs); overflow: hidden; background: #0e0e0f; } -.loop-cell .cap { font-size: 11px; color: var(--text-2); padding: 5px 8px; border-bottom: 1px solid var(--hair); } -.loop-cell iframe { width: 100%; height: 220px; border: 0; background: #fff; display: block; } -.loop-cell .err { color: #fca5a5; font-family: var(--mono); font-size: 11px; padding: 8px; } -.loop-feedback { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--hair); padding-top: 12px; } -.loop-feedback input[type=range] { width: 100%; } -#loopNotes { min-height: 60px; background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); - color: var(--text); padding: 8px 10px; font-family: inherit; font-size: 12.5px; resize: vertical; } -.loop-actions { display: flex; gap: 8px; } .loop-actions .btn { flex: 1; } +/* Previews: one full-width card per section, stacked */ +.loop-grid { display: flex; flex-direction: column; gap: 16px; padding: 16px; flex: 1; } +.loop-empty { color: var(--text-3); font-size: 12.5px; text-align: center; padding: 56px 0; } +.loop-cell { width: 100%; flex-shrink: 0; border: 1px solid var(--hair); border-radius: var(--radius-sm); + overflow: hidden; background: #0e0e0f; } +.loop-cell .cap { display: flex; align-items: center; gap: 8px; font-size: 11.5px; font-weight: 500; + letter-spacing: .02em; color: var(--text-2); padding: 8px 12px; border-bottom: 1px solid var(--hair); } +.loop-cell iframe { width: 100%; height: clamp(340px, 52vh, 620px); border: 0; background: #fff; display: block; } +.loop-cell .gen { display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 12px; padding: 28px 16px; } +.loop-cell .err { color: #fca5a5; font-family: var(--mono); font-size: 11px; padding: 12px 16px; white-space: pre-wrap; } +/* Feedback: a dock pinned to the bottom of the scroll area — score | notes | actions */ +.loop-feedback { position: sticky; bottom: 0; z-index: 3; display: flex; align-items: stretch; gap: 14px; + padding: 12px 16px; background: rgba(26,26,28,0.94); backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); border-top: 1px solid var(--hair); + border-radius: 0 0 var(--radius) var(--radius); } +.score-box { display: flex; flex-direction: column; justify-content: center; gap: 7px; width: 168px; flex-shrink: 0; } +.score-num { font-size: 24px; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; } +.score-num .den { font-size: 12px; font-weight: 500; color: var(--text-3); margin-left: 2px; } +.score-cap { font-size: 11px; color: var(--text-3); } +.loop-feedback input[type=range] { width: 100%; accent-color: var(--accent); } +#loopNotes { flex: 1; min-height: 68px; background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); + color: var(--text); padding: 9px 11px; font-family: inherit; font-size: 12.5px; line-height: 1.5; resize: none; } +#loopNotes:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-2); } +.loop-actions { display: flex; flex-direction: column; justify-content: center; gap: 8px; width: 168px; flex-shrink: 0; } #roundsRail { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } .round-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--radius-xs); background: var(--fill-1); cursor: pointer; } diff --git a/validator/server.js b/validator/server.js index 3d93a45..0b0f2dc 100644 --- a/validator/server.js +++ b/validator/server.js @@ -217,7 +217,8 @@ export function createApp(rootDir) { const runAll = async (onResult) => { await Promise.all(chosen.map(async (s) => { try { - const { config } = await generate({ html: s.html, css: s.css, guideline: working }); + // Model sees the sanitized markup; the client renders the real one. + const { config } = await generate({ html: s.promptHtml || s.html, css: s.css, guideline: working }); onResult({ id: s.id, config, html: s.html, css: s.css }); } catch (err) { onResult({ id: s.id, error: String(err.message || err) }); diff --git a/validator/test/playground.test.js b/validator/test/playground.test.js index 7114afa..4342182 100644 --- a/validator/test/playground.test.js +++ b/validator/test/playground.test.js @@ -17,7 +17,7 @@ test('assemblePayload routes guideline→userPromptExample, instruction→userPr assert.match(calls[0].userPrompt, /Apply the animation pattern/); }); -test('listSections reads section html/css (sanitized preferred)', async () => { +test('listSections returns raw html for render and sanitized promptHtml for the model', async () => { const dir = await mkdtemp(join(tmpdir(), 'iv-sec-')); await mkdir(join(dir, 'cards'), { recursive: true }); await writeFile(join(dir, 'cards', 'section.html'), ''); @@ -27,11 +27,13 @@ test('listSections reads section html/css (sanitized preferred)', async () => { await writeFile(join(dir, 'hero', 'section.html'), ''); const secs = await listSections(dir); const cards = secs.find((s) => s.id === 'cards'); - assert.equal(cards.html, ''); // sanitized preferred + assert.equal(cards.html, ''); // real markup → rendered + assert.equal(cards.promptHtml, ''); // sanitized copy → model prompt assert.equal(cards.css, '.c{}'); const hero = secs.find((s) => s.id === 'hero'); assert.equal(hero.html, ''); - assert.equal(hero.css, ''); // missing css → empty + assert.equal(hero.promptHtml, ''); // no sanitized file → falls back to raw + assert.equal(hero.css, ''); // missing css → empty }); test('generate POSTs the payload and returns config+sessionId', async () => { From 0f198d0a34ae654e6c69e911b877a0b456c36794 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 13:30:18 +0300 Subject: [PATCH 44/62] feat(validator): expand-to-fullscreen previews + original-layout preview on pill select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clicking a section pill now renders its original (static) layout in the grid before generating — /api/playground/sections returns raw html+css, and buildRenderDoc renders statically when no config is given - each preview cell has an expand button opening a full-screen overlay (Esc / backdrop / ✕ to close); cells are tagged 'original' vs 'animated' Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 67 +++++++++++++++++++++-------- validator/public/index.html | 11 +++++ validator/public/render-frame.js | 22 ++++++---- validator/public/styles.css | 10 +++++ validator/server.js | 4 +- validator/test/render-frame.test.js | 11 +++++ 6 files changed, 97 insertions(+), 28 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index d5c012a..17c96a8 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -390,7 +390,7 @@ async function openLoop() { api('/api/playground/sections'), api(`/api/loop?promptPath=${encodeURIComponent(p)}`), ]); - state.loop.available = sections.map((s) => s.id); + state.loop.available = sections; // [{ id, html, css }] — html/css power the original-layout preview if (!up) { $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; $('loopGrid').innerHTML = ''; @@ -403,28 +403,45 @@ async function openLoop() { } function renderSectionChips() { - $('loopSections').innerHTML = state.loop.available.map((id) => - `${esc(id)}`).join('') + $('loopSections').innerHTML = state.loop.available.map((s) => + `${esc(s.id)}`).join('') + ''; } +// What to draw for a section cell: its animated config once generated, the +// spinner while a run is in flight, an error card, or — before generation — +// its original layout (static html/css) so picking a pill previews it. +function cellRender(id) { + const c = state.loop.configs[id]; + if (c && c.error) return { kind: 'error', error: c.error }; + if (c && c.config) return { kind: 'doc', original: false, html: c.html, css: c.css, config: c.config }; + if (c === undefined && state.loop.generating) return { kind: 'spinner' }; + const meta = state.loop.available.find((s) => s.id === id); + if (meta) return { kind: 'doc', original: true, html: meta.html, css: meta.css, config: null }; + return { kind: 'spinner' }; +} + function renderGrid() { const secs = state.loop.sections; - const started = Object.keys(state.loop.configs).length > 0 || state.loop.generating; if (!secs.length) { - $('loopGrid').innerHTML = '
            Pick a few sections above, then hit Generate.
            '; - } else if (!started) { - $('loopGrid').innerHTML = `
            ${secs.length} section${secs.length > 1 ? 's' : ''} selected — hit Generate to run the guideline.
            `; - } else { - $('loopGrid').innerHTML = secs.map((id) => { - const c = state.loop.configs[id]; - const inner = c === undefined ? '
            Generating…
            ' - : c.error ? `
            ${esc(c.error)}
            ` - : ``; - return `
            ${esc(id)}
            ${inner}
            `; - }).join(''); + $('loopGrid').innerHTML = '
            Pick sections above to preview their original layout, then hit Generate.
            '; + $('loopFeedback').hidden = true; + return; } - $('loopFeedback').hidden = !secs.length || Object.keys(state.loop.configs).length === 0; + $('loopGrid').innerHTML = secs.map((id) => { + const r = cellRender(id); + let inner = '', tag = '', expand = ''; + if (r.kind === 'spinner') inner = '
            Generating…
            '; + else if (r.kind === 'error') inner = `
            ${esc(r.error)}
            `; + else { + inner = ``; + tag = r.original ? 'original' : 'animated'; + expand = ``; + } + return `
            ${esc(id)}${tag}${expand}
            ${inner}
            `; + }).join(''); + // Feedback (score/refine) is only meaningful once a round has been generated. + $('loopFeedback').hidden = Object.keys(state.loop.configs).length === 0; } async function loopGenerate() { @@ -573,6 +590,12 @@ $('activityClose').onclick = closeActivity; $('activityModal').addEventListener('click', (e) => { if (e.target.id === 'activityModal') closeActivity(); }); $('activityFile').onchange = (e) => { state.activity.file = e.target.value; state.activity.follow = false; renderActivity(); }; +// Expand (full-screen preview) modal +function closeExpand() { $('expandModal').hidden = true; $('expandFrame').srcdoc = ''; } +$('expandClose').onclick = closeExpand; +$('expandModal').addEventListener('click', (e) => { if (e.target.id === 'expandModal') closeExpand(); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('expandModal').hidden) closeExpand(); }); + // event delegation $('loopSections').addEventListener('click', (e) => { if (e.target.id === 'genBtn') return loopGenerate(); @@ -582,8 +605,16 @@ $('loopSections').addEventListener('click', (e) => { if (i >= 0) state.loop.sections.splice(i, 1); else if (state.loop.sections.length < 4) state.loop.sections.push(id); renderSectionChips(); - // Refresh the hint before a run starts; leave live previews alone mid-review. - if (!Object.keys(state.loop.configs).length && !state.loop.generating) renderGrid(); + renderGrid(); // reflect the pick immediately — newly added sections show their original layout +}); +// Expand a preview cell to full screen. +$('loopGrid').addEventListener('click', (e) => { + const btn = e.target.closest('[data-expand]'); if (!btn) return; + const r = cellRender(btn.dataset.expand); + if (r.kind !== 'doc') return; + $('expandTitle').textContent = `${btn.dataset.expand} · ${r.original ? 'original' : 'animated'}`; + $('expandFrame').srcdoc = buildRenderDoc(r); + $('expandModal').hidden = false; }); $('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); $('regenBtn').onclick = loopGenerate; diff --git a/validator/public/index.html b/validator/public/index.html index cb82b8a..886a04f 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -100,6 +100,17 @@

            Fix options

            + + + diff --git a/validator/public/render-frame.js b/validator/public/render-frame.js index b616581..4e8869d 100644 --- a/validator/public/render-frame.js +++ b/validator/public/render-frame.js @@ -1,14 +1,14 @@ -// Build a self-contained HTML document that renders a section with a generated -// @wix/interact-experience config, using the vendored renderer. The config is -// embedded as a JSON string in a data attribute (script-tag-safe). +// Build a self-contained HTML document that renders a section. With a generated +// @wix/interact-experience config, the vendored renderer wires the animation +// onto the injected markup. With NO config (null/empty), the section is shown +// statically — its original layout, before any guideline is applied. The config +// is embedded as a JSON string in a data attribute (script-tag-safe). export function buildRenderDoc({ html, css, config }) { + const hasConfig = config !== null && config !== undefined && String(config).trim() !== ''; // Neutralize any ". - const safeConfig = String(config).replace(/<\/script(?=[\s/>])/gi, '<\\/script'); - return ` - - -
            ${html || ''}
            + const safeConfig = hasConfig ? String(config).replace(/<\/script(?=[\s/>])/gi, '<\\/script') : ''; + const animate = hasConfig ? ` +` : ''; + return ` + + +
            ${html || ''}
            ${animate} `; } diff --git a/validator/public/styles.css b/validator/public/styles.css index 091a8a2..cfd6f7d 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -249,6 +249,9 @@ body { .icon-btn { background: transparent; border: 0; color: var(--text-2); font-size: 15px; cursor: pointer; padding: 2px 6px; border-radius: 6px; } .icon-btn:hover { background: var(--fill-2); color: var(--text); } .modal-body { flex: 1; overflow: auto; margin: 0; padding: 16px 18px; font-family: var(--mono); font-size: 12px; line-height: 1.6; color: var(--text-2); white-space: pre-wrap; word-break: break-word; } +/* Full-screen section preview */ +.expand-shell { width: 95vw; height: 93vh; display: flex; flex-direction: column; overflow: hidden; padding: 0; } +#expandFrame { flex: 1; width: 100%; border: 0; background: #fff; display: block; } /* ── Scrollbars ──────────────────────────────────── */ ::-webkit-scrollbar { width: 9px; height: 9px; } @@ -276,6 +279,13 @@ body { overflow: hidden; background: #0e0e0f; } .loop-cell .cap { display: flex; align-items: center; gap: 8px; font-size: 11.5px; font-weight: 500; letter-spacing: .02em; color: var(--text-2); padding: 8px 12px; border-bottom: 1px solid var(--hair); } +.loop-cell .cap-id { color: var(--text); } +.cap-tag { font-size: 9.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; padding: 2px 7px; + border-radius: 980px; background: var(--fill-2); color: var(--text-3); } +.cap-tag.on { background: var(--accent-soft); color: #dbeafe; } +.cap-expand { margin-left: auto; background: transparent; border: 0; color: var(--text-2); cursor: pointer; + font-size: 14px; line-height: 1; padding: 3px 7px; border-radius: 6px; } +.cap-expand:hover { background: var(--fill-2); color: var(--text); } .loop-cell iframe { width: 100%; height: clamp(340px, 52vh, 620px); border: 0; background: #fff; display: block; } .loop-cell .gen { display: flex; align-items: center; gap: 10px; color: var(--text-2); font-size: 12px; padding: 28px 16px; } .loop-cell .err { color: #fca5a5; font-family: var(--mono); font-size: 11px; padding: 12px 16px; white-space: pre-wrap; } diff --git a/validator/server.js b/validator/server.js index 0b0f2dc..54ae073 100644 --- a/validator/server.js +++ b/validator/server.js @@ -194,7 +194,9 @@ export function createApp(rootDir) { app.get('/api/playground/sections', async (_req, res) => { const sections = await listSections(); - res.json({ sections: sections.map((s) => ({ id: s.id })) }); + // Include the real (raw) html + css so the UI can preview a section's + // original layout before any guideline is generated against it. + res.json({ sections: sections.map((s) => ({ id: s.id, html: s.html, css: s.css })) }); }); app.get('/api/loop', async (req, res) => { diff --git a/validator/test/render-frame.test.js b/validator/test/render-frame.test.js index 80610c6..07f3472 100644 --- a/validator/test/render-frame.test.js +++ b/validator/test/render-frame.test.js @@ -11,6 +11,17 @@ test('buildRenderDoc embeds section html, css, config, and imports the runtime', assert.match(doc, /interact-experience\\?\/1\.0|interact-experience/); }); +test('buildRenderDoc renders a static original (no runtime) when config is absent', () => { + for (const config of [null, undefined, '', ' ']) { + const doc = buildRenderDoc({ html: '
            hi
            ', css: '.card{color:blue}', config }); + assert.match(doc, /
            hi<\/div>/); // real markup shown + assert.match(doc, /\.card\{color:blue\}/); // css applied + assert.doesNotMatch(doc, /createExperience/); // no animation runtime + assert.doesNotMatch(doc, /render-runtime/); + assert.doesNotMatch(doc, /__config/); + } +}); + test('buildRenderDoc escapes a closing script tag in the config to avoid breakout', () => { const doc = buildRenderDoc({ html: '', css: '', config: '{"x":""}' }); assert.doesNotMatch(doc, /<\/script>\s*<\/script>/); // the payload's must be escaped From 80e6edebfdcf633678a95cb239315937a5f69ac9 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 15:19:38 +0300 Subject: [PATCH 45/62] feat(validator): live busy feedback for refine/generate + agent-thinking modal button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refine/Generate buttons show an inline spinner + disable while the agent runs - status line reports 'Refining the guideline…' → 'refined — regenerating…' - new 'Agent thinking' button in the loop bar pulses while streaming and opens the live reasoning modal (refine already streams to appendLog); modal follows the active stream Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 67 ++++++++++++++++++++++++++----------- validator/public/index.html | 1 + validator/public/styles.css | 8 +++++ 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 17c96a8..2f66a00 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -444,24 +444,45 @@ function renderGrid() { $('loopFeedback').hidden = Object.keys(state.loop.configs).length === 0; } +// Toggle the loop's running state: spinner-in-button, disabled controls, and a +// pulsing "Agent thinking" affordance that opens the live reasoning modal. +function setLoopBusy(action) { // action: 'refine' | 'generate' | null + const busy = !!action; + $('refineBtn').disabled = busy; + $('regenBtn').disabled = busy; + const gen = document.getElementById('genBtn'); if (gen) gen.disabled = busy; + $('refineBtn').innerHTML = action === 'refine' ? 'Refining…' : 'Refine prompt'; + $('regenBtn').innerHTML = action === 'generate' ? 'Generating…' : 'Generate again'; + const btn = $('loopActivityBtn'); + btn.classList.toggle('live', busy); + btn.querySelector('.live-dot').hidden = !busy; + btn.querySelector('.la-label').textContent = busy ? '◧ Agent thinking…' : '◧ Agent thinking'; +} + async function loopGenerate() { const secs = state.loop.sections; if (!secs.length) return; state.loop.configs = {}; state.loop.generating = true; state.logs = new Map(); + state.activity.follow = true; // modal tracks the newest stream renderGrid(); - const res = await fetch('/api/loop/run', { - method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, - body: JSON.stringify({ promptPath: state.loop.promptPath, sections: secs }) }); - await streamSSE(res, (type, d) => { - if (type === 'result') { - state.loop.configs[d.id] = d.error ? { error: d.error } : { config: d.config, html: d.html, css: d.css }; - renderGrid(); - } else if (type === 'log') appendLog(d.id || 'agent', d.text); - }); - state.loop.generating = false; - renderGrid(); + setLoopBusy('generate'); + try { + const res = await fetch('/api/loop/run', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, sections: secs }) }); + await streamSSE(res, (type, d) => { + if (type === 'result') { + state.loop.configs[d.id] = d.error ? { error: d.error } : { config: d.config, html: d.html, css: d.css }; + renderGrid(); + } else if (type === 'log') appendLog(d.id || 'agent', d.text); + }); + } finally { + state.loop.generating = false; + setLoopBusy(null); + renderGrid(); + } } async function loopRefine() { @@ -470,15 +491,22 @@ async function loopRefine() { const configs = Object.entries(state.loop.configs).filter(([, c]) => c && c.config) .map(([id, c]) => ({ id, config: c.config, html: c.html, css: c.css })); state.logs = new Map(); + state.activity.follow = true; // modal tracks the refine stream let ok = false; - const res = await fetch('/api/loop/refine', { - method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, - body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); - await streamSSE(res, (type, d) => { - if (type === 'log') appendLog('refine', d.text); - else if (type === 'done') { ok = true; $('loopNotes').value = ''; loopRefreshRounds(); } - else if (type === 'error') { $('applyStatus').textContent = `Refine failed: ${d.error}`; } - }); + setLoopBusy('refine'); + $('applyStatus').textContent = 'Refining the guideline from your score + notes…'; + try { + const res = await fetch('/api/loop/refine', { + method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); + await streamSSE(res, (type, d) => { + if (type === 'log') appendLog('refine', d.text); + else if (type === 'done') { ok = true; $('loopNotes').value = ''; $('applyStatus').textContent = 'Guideline refined — regenerating…'; loopRefreshRounds(); } + else if (type === 'error') { $('applyStatus').textContent = `Refine failed: ${d.error}`; } + }); + } finally { + setLoopBusy(null); + } return ok; } @@ -619,6 +647,7 @@ $('loopGrid').addEventListener('click', (e) => { $('scoreRange').addEventListener('input', (e) => { $('scoreVal').textContent = e.target.value; }); $('regenBtn').onclick = loopGenerate; $('refineBtn').onclick = async () => { if (await loopRefine()) await loopGenerate(); }; +$('loopActivityBtn').onclick = openActivity; $('roundsRail').addEventListener('click', async (e) => { if (e.target.id === 'finalizeBtn') { await api('/api/loop/finalize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath }) }); diff --git a/validator/public/index.html b/validator/public/index.html index 886a04f..fbaf7b2 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -26,6 +26,7 @@
            +
            diff --git a/validator/public/styles.css b/validator/public/styles.css index cfd6f7d..397ea7b 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -224,6 +224,14 @@ body { .btn-ghost { background: transparent; border: 1px solid var(--hair); color: var(--text-2); } .btn-ghost:hover { background: var(--fill-1); color: var(--text); } +/* "Agent thinking" affordance — pulses while the agent streams */ +.btn-activity { display: inline-flex; align-items: center; justify-content: center; gap: 6px; } +.btn-activity.live { border-color: var(--accent); color: #dbeafe; background: var(--accent-soft); } +.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; + box-shadow: 0 0 0 0 rgba(59,130,246,0.6); animation: livepulse 1.3s ease-out infinite; } +@keyframes livepulse { 0% { box-shadow: 0 0 0 0 rgba(59,130,246,0.55); } 70% { box-shadow: 0 0 0 7px rgba(59,130,246,0); } 100% { box-shadow: 0 0 0 0 rgba(59,130,246,0); } } +/* spinner sits inside buttons with a little breathing room */ +.btn .spinner { margin-right: 7px; vertical-align: -2px; } /* ── Panel collapse ──────────────────────────────── */ #listPane, #fixPane { transition: transform .28s cubic-bezier(0.4,0,0.2,1), opacity .2s; } From 19640a15e0f15e39e89a403649e2e681f65378d4 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 8 Jul 2026 15:39:53 +0300 Subject: [PATCH 46/62] =?UTF-8?q?feat(validator):=20loop=20iteration=20cla?= =?UTF-8?q?rity=20=E2=80=94=20round=20indicator,=20back-to-current,=20prom?= =?UTF-8?q?pt=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sticky loop header shows 'Round N' (or amber 'Viewing round K of N') with an inline status line; loop messages no longer land in the far-away right panel - rounds rail gains a 'Current · round N' row; viewing a past round snapshots the live state and 'Back to current →' restores it - 'Δ Prompt diff' modal diffs the original .md against the working guideline (or the viewed round's) via new GET /api/loop/diff (+ tests) - consistency: refine/generate/section picks locked while viewing history or mid-run; Esc closes any open modal; rollback message says what it did Co-Authored-By: Claude Fable 5 --- validator/public/app.js | 131 +++++++++++++++++++++++++++++----- validator/public/index.html | 16 ++++- validator/public/styles.css | 28 ++++++-- validator/server.js | 17 +++++ validator/test/server.test.js | 21 ++++++ 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 2f66a00..87dfb82 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -382,7 +382,8 @@ function closeActivity() { state.activity.open = false; $('activityModal').hidde async function openLoop() { const p = state.currentPrompt; if (!p) return; - state.loop = { promptPath: p, sections: [], available: [], configs: {}, generating: false, active: true }; + state.loop = { promptPath: p, sections: [], available: [], configs: {}, generating: false, active: true, + rounds: [], viewing: null, snapshot: null }; $('markdown').hidden = true; $('code').hidden = true; $('preview').hidden = true; $('diff').hidden = true; $('placeholder').hidden = true; $('loopView').hidden = false; const [{ up }, { sections }, loop] = await Promise.all([ @@ -392,6 +393,7 @@ async function openLoop() { ]); state.loop.available = sections; // [{ id, html, css }] — html/css power the original-layout preview if (!up) { + $('loopHead').innerHTML = ''; $('loopSections').innerHTML = '
            Playground not reachable at :5173 — start it (cd apps/playground && npm run dev), then reopen.
            '; $('loopGrid').innerHTML = ''; $('roundsRail').innerHTML = ''; @@ -402,10 +404,28 @@ async function openLoop() { renderRounds(loop.rounds); } +// The loop header: which iteration you're on (or viewing), inline status, and +// the prompt-diff / back-to-current controls. +function renderLoopHead() { + const n = (state.loop.rounds || []).length; + const v = state.loop.viewing; + const diffBtn = ''; + $('loopHead').innerHTML = v === null + ? `Round ${n + 1} + ${n === 0 ? 'first pass — original guideline' : `after ${n} refinement${n > 1 ? 's' : ''}`} + ${diffBtn}` + : `Viewing round ${v} of ${n} + read-only — a past iteration + ${diffBtn} + `; +} +function loopStatus(msg) { const el = document.getElementById('loopStatus'); if (el) el.textContent = msg; } + function renderSectionChips() { + const locked = state.loop.viewing !== null || state.loop.generating; $('loopSections').innerHTML = state.loop.available.map((s) => `${esc(s.id)}`).join('') - + ''; + + ``; } // What to draw for a section cell: its animated config once generated, the @@ -448,9 +468,10 @@ function renderGrid() { // pulsing "Agent thinking" affordance that opens the live reasoning modal. function setLoopBusy(action) { // action: 'refine' | 'generate' | null const busy = !!action; - $('refineBtn').disabled = busy; - $('regenBtn').disabled = busy; - const gen = document.getElementById('genBtn'); if (gen) gen.disabled = busy; + const lock = busy || state.loop.viewing !== null; // viewing history keeps actions locked + $('refineBtn').disabled = lock; + $('regenBtn').disabled = lock; + const gen = document.getElementById('genBtn'); if (gen) gen.disabled = lock; $('refineBtn').innerHTML = action === 'refine' ? 'Refining…' : 'Refine prompt'; $('regenBtn').innerHTML = action === 'generate' ? 'Generating…' : 'Generate again'; const btn = $('loopActivityBtn'); @@ -460,6 +481,7 @@ function setLoopBusy(action) { // action: 'refine' | 'generate' } async function loopGenerate() { + if (state.loop.viewing !== null) return; // history is read-only const secs = state.loop.sections; if (!secs.length) return; state.loop.configs = {}; @@ -494,15 +516,15 @@ async function loopRefine() { state.activity.follow = true; // modal tracks the refine stream let ok = false; setLoopBusy('refine'); - $('applyStatus').textContent = 'Refining the guideline from your score + notes…'; + loopStatus('Refining the guideline from your score + notes…'); try { const res = await fetch('/api/loop/refine', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ promptPath: state.loop.promptPath, score, notes, configs }) }); await streamSSE(res, (type, d) => { if (type === 'log') appendLog('refine', d.text); - else if (type === 'done') { ok = true; $('loopNotes').value = ''; $('applyStatus').textContent = 'Guideline refined — regenerating…'; loopRefreshRounds(); } - else if (type === 'error') { $('applyStatus').textContent = `Refine failed: ${d.error}`; } + else if (type === 'done') { ok = true; $('loopNotes').value = ''; loopRefreshRounds().then(() => loopStatus('Guideline refined — regenerating…')); } + else if (type === 'error') { loopStatus(`Refine failed: ${d.error}`); } }); } finally { setLoopBusy(null); @@ -517,23 +539,76 @@ async function loopRefreshRounds() { function renderRounds(rounds) { state.loop.rounds = rounds || []; - $('roundsRail').innerHTML = state.loop.rounds.map((r) => - `
            Round ${r.round} + const n = state.loop.rounds.length; + const cur = `
            + Current · round ${n + 1}working
            `; + const hist = [...state.loop.rounds].reverse().map((r) => + `
            Round ${r.round} ${r.score}/10 -
            `).join('') - + (state.loop.rounds.length ? '' : ''); +
            `).join(''); + $('roundsRail').innerHTML = cur + hist + + (n ? '' : ''); + renderLoopHead(); +} + +// Lock the feedback controls while viewing history (they belong to the past round). +function setFeedbackLocked(locked) { + $('scoreRange').disabled = locked; $('loopNotes').disabled = locked; + $('refineBtn').disabled = locked; $('regenBtn').disabled = locked; } // Load a past round's stored outputs + feedback back into the view (read-only look). function viewRound(round) { + if (state.loop.generating) { loopStatus('Generation in progress — wait for it to finish.'); return; } const r = (state.loop.rounds || []).find((x) => x.round === round); if (!r) return; + if (state.loop.viewing === null) { // leaving "current" — snapshot it for the way back + state.loop.snapshot = { sections: [...state.loop.sections], configs: { ...state.loop.configs }, + score: $('scoreRange').value, notes: $('loopNotes').value }; + } + state.loop.viewing = round; state.loop.configs = {}; for (const s of r.sections) state.loop.configs[s.id] = { config: s.config, html: s.html, css: s.css }; state.loop.sections = r.sections.map((s) => s.id); - renderSectionChips(); $('scoreRange').value = r.score; $('scoreVal').textContent = r.score; $('loopNotes').value = r.notes || ''; + setFeedbackLocked(true); + renderSectionChips(); + renderGrid(); + renderRounds(state.loop.rounds); +} + +// Return from a history view to the live working state. +function backToCurrent() { + const snap = state.loop.snapshot; + state.loop.viewing = null; + if (snap) { + state.loop.sections = snap.sections; state.loop.configs = snap.configs; + $('scoreRange').value = snap.score; $('scoreVal').textContent = snap.score; $('loopNotes').value = snap.notes; + state.loop.snapshot = null; + } + setFeedbackLocked(false); + renderSectionChips(); renderGrid(); + renderRounds(state.loop.rounds); +} + +// Diff the original .md against the guideline in view (working, or a past round's). +async function openPromptDiff() { + const v = state.loop.viewing; + const q = v === null ? '' : `&round=${v}`; + const d = await api(`/api/loop/diff?promptPath=${encodeURIComponent(state.loop.promptPath)}${q}`); + $('diffTitle').textContent = v === null + ? 'Original .md → current working guideline' + : `Original .md → guideline used in round ${v}`; + $('diffBody').innerHTML = d.error ? `${esc(d.error)}` + : !d.changed ? '
            No differences — this guideline matches the original .md.
            ' + : d.parts.map((p) => { + const safe = esc(p.value); + return p.added ? `${safe}` : p.removed ? `${safe}` : `${safe}`; + }).join(''); + $('diffModal').hidden = false; } // ── events ────────────────────────────────────────── @@ -622,12 +697,28 @@ $('activityFile').onchange = (e) => { state.activity.file = e.target.value; stat function closeExpand() { $('expandModal').hidden = true; $('expandFrame').srcdoc = ''; } $('expandClose').onclick = closeExpand; $('expandModal').addEventListener('click', (e) => { if (e.target.id === 'expandModal') closeExpand(); }); -document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('expandModal').hidden) closeExpand(); }); + +// Prompt-diff modal +$('diffClose').onclick = () => { $('diffModal').hidden = true; }; +$('diffModal').addEventListener('click', (e) => { if (e.target.id === 'diffModal') $('diffModal').hidden = true; }); + +// Esc closes whichever modal is open (expand, diff, activity — in that order). +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (!$('expandModal').hidden) return closeExpand(); + if (!$('diffModal').hidden) { $('diffModal').hidden = true; return; } + if (!$('activityModal').hidden) closeActivity(); +}); // event delegation $('loopSections').addEventListener('click', (e) => { if (e.target.id === 'genBtn') return loopGenerate(); const chip = e.target.closest('.chip'); if (!chip) return; + if (state.loop.generating) return; // don't churn a run in flight + if (state.loop.viewing !== null) { // history is read-only + loopStatus('Viewing a past round — go back to current to change sections.'); + return; + } const id = chip.dataset.sec; const i = state.loop.sections.indexOf(id); if (i >= 0) state.loop.sections.splice(i, 1); @@ -635,6 +726,10 @@ $('loopSections').addEventListener('click', (e) => { renderSectionChips(); renderGrid(); // reflect the pick immediately — newly added sections show their original layout }); +$('loopHead').addEventListener('click', (e) => { + if (e.target.id === 'promptDiffBtn') return openPromptDiff(); + if (e.target.id === 'backCurrentBtn') return backToCurrent(); +}); // Expand a preview cell to full screen. $('loopGrid').addEventListener('click', (e) => { const btn = e.target.closest('[data-expand]'); if (!btn) return; @@ -651,17 +746,19 @@ $('loopActivityBtn').onclick = openActivity; $('roundsRail').addEventListener('click', async (e) => { if (e.target.id === 'finalizeBtn') { await api('/api/loop/finalize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath }) }); - $('applyStatus').textContent = 'Loop closed — final guideline written to the .md.'; + loopStatus('Loop closed — final guideline written to the .md.'); return; } const rb = e.target.closest('.rollback-btn'); if (rb) { await api('/api/loop/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPath: state.loop.promptPath, round: Number(rb.dataset.round) }) }); - $('applyStatus').textContent = `Rolled back to round ${rb.dataset.round}'s guideline (working version).`; + loopStatus(`Working guideline set to round ${rb.dataset.round}'s version — Generate to see it.`); return; } const row = e.target.closest('.round-row'); - if (row) viewRound(Number(row.dataset.round)); + if (!row) return; + if (row.dataset.current) return backToCurrent(); + viewRound(Number(row.dataset.round)); }); $('loopBtn').onclick = openLoop; diff --git a/validator/public/index.html b/validator/public/index.html index fbaf7b2..1c64dd2 100644 --- a/validator/public/index.html +++ b/validator/public/index.html @@ -14,7 +14,10 @@ + + + diff --git a/validator/public/styles.css b/validator/public/styles.css index 93778aa..733631e 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -340,3 +340,27 @@ body { .diff-body ins { background: rgba(52,211,153,0.16); color: #6ee7b7; } .diff-body del { background: rgba(248,113,113,0.16); color: #fca5a5; } .diff-body span { color: var(--text-2); } + +/* ── Refinery ─────────────────────────────────────── */ +.jdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; } +.jdot.q { background: var(--text-3); } .jdot.run { background: var(--accent); animation: livepulse 1.3s ease-out infinite; } +.jdot.ok { background: var(--c-clean); } .jdot.warn { background: var(--c-outdated); } +.jdot.fail { background: var(--c-nointeract); } .jdot.done { background: var(--c-clean); box-shadow: 0 0 0 2px rgba(52,211,153,.25); } +.jcb { accent-color: var(--accent); } +.qw-head { font-size: 11px; font-weight: 600; letter-spacing: .03em; color: var(--text-3); text-transform: uppercase; margin: 10px 0 4px; } +.qw-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--radius-xs); background: var(--fill-1); cursor: pointer; margin-bottom: 4px; } +.qw-row:hover { background: var(--fill-2); } +.qw-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qw-st { margin-left: auto; color: var(--text-3); font-size: 11px; } +.iter-chips { display: inline-flex; gap: 4px; } +.iter-chip { width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--hair); background: var(--fill-1); color: var(--text-2); font-size: 11px; cursor: pointer; } +.iter-chip.on { background: var(--accent-soft); border-color: var(--accent); color: #fff; } +.judge-note { font-size: 12.5px; color: var(--text-2); background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); padding: 10px 12px; line-height: 1.5; } +.judge-note b { color: var(--text); } +.cell-issues { font-size: 11.5px; color: #fdba74; padding: 8px 12px; border-top: 1px solid var(--hair); line-height: 1.6; } +.launch-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; } +.launch-list { display: flex; flex-direction: column; gap: 4px; } +.launch-row { font-size: 12px; font-family: var(--mono); color: var(--text-2); background: var(--fill-1); border-radius: var(--radius-xs); padding: 6px 9px; } +.launch-sub { font-size: 11.5px; color: var(--text-3); } +.launch-err { color: #fca5a5; font-size: 12px; min-height: 16px; } +#jobBar textarea { flex: 1; min-height: 52px; background: var(--fill-1); border: 1px solid var(--hair); border-radius: var(--radius-xs); color: var(--text); padding: 9px 11px; font-family: inherit; font-size: 12.5px; resize: none; } From 89a8fbc6b46e4eef50420d406e4df6004e7ceba1 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Sun, 12 Jul 2026 14:59:44 +0300 Subject: [PATCH 59/62] fix(validator): guard refinery approve against running jobs + thread bound port to capture Co-Authored-By: Claude Sonnet 5 --- validator/server.js | 7 ++++--- validator/test/server.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/validator/server.js b/validator/server.js index c849bce..d5fc46d 100644 --- a/validator/server.js +++ b/validator/server.js @@ -22,7 +22,7 @@ import { buildRenderDoc } from './public/render-frame.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -export function createApp(rootDir) { +export function createApp(rootDir, { port } = {}) { const root = resolve(rootDir); const app = express(); app.use(express.json({ limit: '5mb' })); @@ -33,7 +33,7 @@ export function createApp(rootDir) { app.use('/runs', express.static(RUNS_DIR)); app.use('/repo', express.static(root, { index: false })); // read-only originals for capture + reference - const refinery = createRefinery({ runsDir: RUNS_DIR, rootDir: root, deps: { + const refinery = createRefinery({ runsDir: RUNS_DIR, rootDir: root, port, deps: { listSectionsImpl: listSections, generateImpl: generate, captureImpl: captureSweep, @@ -361,6 +361,7 @@ export function createApp(rootDir) { try { const job = await getRefineryJob(RUNS_DIR, String(req.body.id || '')); if (!job) return res.status(404).json({ error: 'no such job' }); + if (job.status === 'running' || job.status === 'queued') return bad(res, 'stop the job first'); const guideline = finalGuideline(job); if (!guideline) return bad(res, 'job has no scored iteration to approve'); await writePromptRaw(root, job.promptPath, guideline); @@ -421,7 +422,7 @@ export function createApp(rootDir) { if (process.argv[1] === fileURLToPath(import.meta.url)) { const root = resolve(__dirname, '..'); const port = process.env.PORT || 4500; - createApp(root).listen(port, () => { + createApp(root, { port }).listen(port, () => { console.log(`Interact Validator on http://localhost:${port} (root: ${root})`); }); } diff --git a/validator/test/server.test.js b/validator/test/server.test.js index e9c59ab..5f996c1 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -227,6 +227,32 @@ test('refinery endpoints: job listing, approve writes the md, reject returns to } }); +test('POST /api/refinery/approve refuses a running job (guard mirrors reject)', async () => { + const root = await repo(); + const { writePrompt, readPrompt } = await import('../lib/prompts.js'); + const { createJob, saveJob } = await import('../lib/jobs-store.js'); + await writePrompt(root, 'G/A.html', '# original'); + const runsDir = new URL('../runs', import.meta.url).pathname; + const { base, server } = await start(root); + // Seed the running job AFTER start() so the server's boot-time + // markInterrupted() scan (which flips stale running/queued jobs to amber) + // can't race with — and clobber — the status we're testing against. + const job = await createJob(runsDir, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + job.status = 'running'; + job.iterations = [{ iter: 1, guideline: '# HALF DONE', judge: { score: 9, notes: '' }, sections: [], refined: null }]; + await saveJob(runsDir, job); + try { + const r = await fetch(`${base}/api/refinery/approve`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: job.id }) }); + assert.equal(r.status, 400); + assert.equal(await readPrompt(root, 'G/A.md'), '# original'); // .md NOT overwritten + } finally { + const { rm } = await import('node:fs/promises'); + await rm(new URL(`../runs/${job.id}`, import.meta.url).pathname, { recursive: true, force: true }); + server.close(); + } +}); + test('GET /render serves a stored iteration section and 404s unknowns', async () => { const root = await repo(); const { createJob, saveJob } = await import('../lib/jobs-store.js'); From fe6ce60487121721e250a60c04b56e91bbb56b6d Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 13 Jul 2026 10:17:52 +0300 Subject: [PATCH 60/62] =?UTF-8?q?feat(validator):=20refinery=20UI=20?= =?UTF-8?q?=E2=80=94=20delete=20job,=20no-flash=20previews,=20prompt?= =?UTF-8?q?=E2=86=94job=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Delete a job (button in the job bar, two-step confirm) → removes the record + all frames/gifs via new POST /api/refinery/delete + deleteJob store fn (guarded against running/queued); the prompt drops back to a fresh start. 2. Fix preview flashing: renderJobView now rebuilds #jobBody only when the viewed iteration's content key changes, so 4s polls / SSE refreshes no longer recreate the `; - return `
            ${esc(s.id)} - ${s.error ? '' : ``}
            - ${inner}${issues.length ? `
            ${issues.map((i) => `· ${esc(i)}`).join('
            ')}
            ` : ''}
            `; - }).join(''); + const bodyKey = !it + ? `empty:${job.id}:${job.status}` + : JSON.stringify({ j: job.id, i: it.iter, + s: it.sections.map((x) => [x.id, !!x.config, x.error || '']), + v: it.judge?.error ? `e:${it.judge.error}` : it.judge ? `${it.judge.score}:${it.judge.notes}:${JSON.stringify(it.judge.sections || [])}` : '' }); + if (bodyKey !== state.refinery.bodyKey) { + state.refinery.bodyKey = bodyKey; + if (!it) { + $('jobBody').innerHTML = `
            ${job.status === 'queued' ? 'Waiting in the queue…' : 'No iterations yet — generating…'}
            `; + } else { + const judgeBlock = it.judge?.error ? `
            judge failed: ${esc(it.judge.error)}
            ` + : it.judge ? `
            ${it.judge.score}/10 — ${esc(it.judge.notes)}
            ` : ''; + $('jobBody').innerHTML = judgeBlock + it.sections.map((s) => { + const issues = (it.judge?.sections || []).find((x) => x.id === s.id)?.issues || []; + const inner = s.error ? `
            ${esc(s.error)}
            ` + : ``; + return `
            ${esc(s.id)} + ${s.error ? '' : ``}
            + ${inner}${issues.length ? `
            ${issues.map((i) => `· ${esc(i)}`).join('
            ')}
            ` : ''}
            `; + }).join(''); + } } - // bottom bar: approve flow / relaunch with notes + // bottom bar: approve flow / relaunch with notes / delete. Rebuild only when + // its shape changes (status + iteration count) — otherwise a 4s poll would + // recreate the textarea and wipe notes the user is mid-way through typing. const done = ['green', 'amber', 'failed', 'idle'].includes(job.status); $('jobBar').hidden = !done; - if (done) { + const barKey = `${job.status}:${job.iterations.length}`; + if (done && barKey !== state.refinery.barKey) { + state.refinery.barKey = barKey; $('jobBar').innerHTML = `
            @@ -551,7 +591,10 @@ function renderJobView(job) { ${job.iterations.length ? '' : ''} ${job.status !== 'idle' && job.status !== 'failed' ? '' : ''} +
            `; + } else if (!done) { + state.refinery.barKey = null; } } @@ -599,21 +642,41 @@ $('jobBar').addEventListener('click', async (e) => { : !d.changed ? '
            No differences.
            ' : d.parts.map((p) => p.added ? `${esc(p.value)}` : p.removed ? `${esc(p.value)}` : `${esc(p.value)}`).join(''); $('diffModal').hidden = false; + } else if (e.target.id === 'jobDelete') { + const btn = e.target; + // Destructive: first click arms, second confirms. + if (!btn.classList.contains('armed')) { + btn.classList.add('armed'); btn.textContent = 'Delete — sure?'; + document.getElementById('loopStatus').textContent = 'Deletes this run and its history — click again to confirm.'; + return; + } + const r = await post('/api/refinery/delete', { id: job.id }); + if (r.error) { document.getElementById('loopStatus').textContent = r.error; return; } + // Fresh start: forget the job, drop back to the prompt's markdown. + state.refinery.currentJob = null; state.refinery.bodyKey = null; state.refinery.barKey = null; + state.promptMode = 'rendered'; + $('loopView').hidden = true; + await refreshJobs(); + render(); } }); // SSE: stream logs into the activity modal; refresh the view on step/status. function subscribeJob(job) { + const live = job.status === 'running' || job.status === 'queued'; + // Already streaming this job? leave the connection alone (re-opening on every + // poll would drop in-flight log/step events). + if (live && state.refinery.esJob === job.id && state.refinery.es) return; state.refinery.es?.close(); - if (job.status !== 'running' && job.status !== 'queued') { state.refinery.es = null; return; } + if (!live) { state.refinery.es = null; state.refinery.esJob = null; return; } const es = new EventSource(`/api/refinery/events?id=${encodeURIComponent(job.id)}`); - state.refinery.es = es; + state.refinery.es = es; state.refinery.esJob = job.id; es.addEventListener('log', (e) => { const d = JSON.parse(e.data); appendLog(job.promptPath, d.text); }); es.addEventListener('step', (e) => { const d = JSON.parse(e.data); const el = document.getElementById('loopStatus'); if (el) el.textContent = `iter ${d.iter} · ${d.step}…`; }); es.addEventListener('iteration', () => openJobView(job.id, { keepIter: false })); es.addEventListener('status', () => { refreshJobs(); openJobView(job.id, { keepIter: true }); }); - es.addEventListener('end', () => { es.close(); state.refinery.es = null; }); + es.addEventListener('end', () => { es.close(); state.refinery.es = null; state.refinery.esJob = null; }); } // ── events ────────────────────────────────────────── @@ -643,7 +706,11 @@ $('fileTree').addEventListener('click', (e) => { $('refineSelBtn').hidden = state.view !== 'prompts' || !state.refinery.selected.size; return; } - state.currentPrompt = row.dataset.ppath; renderTree(); render(); + state.currentPrompt = row.dataset.ppath; + // Default to the Refinery tab when the prompt has jobs (else the prompt md). + const hasJobs = (state.refinery.jobsByPrompt[state.currentPrompt] || []).length > 0; + state.promptMode = hasJobs ? 'refinery' : (state.promptMode === 'refinery' ? 'rendered' : state.promptMode); + renderTree(); render(); } }); $('filter').addEventListener('input', (e) => { state.filter = e.target.value.trim(); renderTree(); }); @@ -689,7 +756,12 @@ document.addEventListener('keydown', (e) => { else if (e.key === 'ArrowDown') idx = Math.min(idx + 1, rows.length - 1); else idx = Math.max(idx - 1, 0); const p = rows[idx].dataset[attr]; - if (state.view === 'examples') state.current = p; else state.currentPrompt = p; + if (state.view === 'examples') state.current = p; + else { + state.currentPrompt = p; + const hasJobs = (state.refinery.jobsByPrompt[p] || []).length > 0; + state.promptMode = hasJobs ? 'refinery' : (state.promptMode === 'refinery' ? 'rendered' : state.promptMode); + } renderTree(); render(); requestAnimationFrame(() => { diff --git a/validator/public/styles.css b/validator/public/styles.css index 733631e..c8edeb4 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -333,7 +333,7 @@ body { .round-row.viewing { border-color: var(--accent); background: var(--accent-soft); } .round-row .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--c-clean); flex: none; } .round-row .sc { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text-2); } -.rollback-btn.armed { background: rgba(248,113,113,0.22); color: #fca5a5; border: 1px solid rgba(248,113,113,0.5); } +.rollback-btn.armed, #jobDelete.armed { background: rgba(248,113,113,0.22); color: #fca5a5; border: 1px solid rgba(248,113,113,0.5); } /* Prompt-diff modal */ .diff-shell { width: min(920px, 92vw); height: 88vh; display: flex; flex-direction: column; overflow: hidden; padding: 0; } .diff-body ins, .diff-body del, .diff-body span { display: block; text-decoration: none; padding: 0 8px; border-radius: 3px; } diff --git a/validator/server.js b/validator/server.js index d5fc46d..393c24a 100644 --- a/validator/server.js +++ b/validator/server.js @@ -15,7 +15,7 @@ import { readLoop, recordRound, rollback, finalize, roundRefined } from './lib/l import { getAgentState, setModelOverride, resetTotals } from './lib/agent-state.js'; import { refineGuideline } from './lib/refine.js'; import { createRefinery } from './lib/refinery.js'; -import { getJob as getRefineryJob, listJobs as listRefineryJobs, saveJob as saveRefineryJob, markInterrupted, finalGuideline } from './lib/jobs-store.js'; +import { getJob as getRefineryJob, listJobs as listRefineryJobs, saveJob as saveRefineryJob, deleteJob as deleteRefineryJob, markInterrupted, finalGuideline } from './lib/jobs-store.js'; import { captureSweep } from './lib/capture.js'; import { judgeIteration } from './lib/judge.js'; import { buildRenderDoc } from './public/render-frame.js'; @@ -382,6 +382,16 @@ export function createApp(rootDir, { port } = {}) { } catch (err) { bad(res, String(err.message || err)); } }); + app.post('/api/refinery/delete', async (req, res) => { + try { + const job = await getRefineryJob(RUNS_DIR, String(req.body.id || '')); + if (!job) return res.status(404).json({ error: 'no such job' }); + if (job.status === 'running' || job.status === 'queued') return bad(res, 'stop the job first'); + await deleteRefineryJob(RUNS_DIR, job.id); // removes the record + all frames/gifs + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + app.get('/api/refinery/diff', async (req, res) => { try { const job = await getRefineryJob(RUNS_DIR, String(req.query.id || '')); diff --git a/validator/test/jobs-store.test.js b/validator/test/jobs-store.test.js index fec5be4..f4087b9 100644 --- a/validator/test/jobs-store.test.js +++ b/validator/test/jobs-store.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { examplePathFor, createJob, saveJob, getJob, listJobs, jobDir, markInterrupted, finalGuideline } from '../lib/jobs-store.js'; +import { examplePathFor, createJob, saveJob, getJob, listJobs, jobDir, deleteJob, markInterrupted, finalGuideline } from '../lib/jobs-store.js'; const dir = () => mkdtemp(join(tmpdir(), 'iv-runs-')); @@ -64,3 +64,14 @@ test('finalGuideline picks the best-scoring iteration, latest on tie', () => { assert.equal(finalGuideline(job), 'G3'); assert.equal(finalGuideline({ iterations: [] }), null); }); + +test('deleteJob removes the job (getJob→null, gone from listJobs); missing id is a no-op', async () => { + const runs = await mkdtemp(join(tmpdir(), 'iv-runs-')); + const a = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + const b = await createJob(runs, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + await deleteJob(runs, a.id); + assert.equal(await getJob(runs, a.id), null); + const remaining = await listJobs(runs); + assert.deepEqual(remaining.map((j) => j.id), [b.id]); + await deleteJob(runs, 'jdoesnotexist'); // idempotent — no throw +}); diff --git a/validator/test/server.test.js b/validator/test/server.test.js index 5f996c1..c69c9e0 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -253,6 +253,34 @@ test('POST /api/refinery/approve refuses a running job (guard mirrors reject)', } }); +test('POST /api/refinery/delete removes a finished job but refuses a running one', async () => { + const root = await repo(); + const { createJob, saveJob, getJob } = await import('../lib/jobs-store.js'); + const runsDir = new URL('../runs', import.meta.url).pathname; + const { base, server } = await start(root); + const done = await createJob(runsDir, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + done.status = 'green'; + done.iterations = [{ iter: 1, guideline: '# g', judge: { score: 9, notes: '' }, sections: [], refined: null }]; + await saveJob(runsDir, done); + const running = await createJob(runsDir, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + running.status = 'running'; + await saveJob(runsDir, running); + try { + const del = await fetch(`${base}/api/refinery/delete`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: done.id }) }); + assert.equal(del.status, 200); + assert.equal(await getJob(runsDir, done.id), null); // gone → fresh start for the prompt + const busy = await fetch(`${base}/api/refinery/delete`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: running.id }) }); + assert.equal(busy.status, 400); // refuse while running + assert.ok(await getJob(runsDir, running.id)); // still there + } finally { + const { rm } = await import('node:fs/promises'); + await rm(new URL(`../runs/${running.id}`, import.meta.url).pathname, { recursive: true, force: true }); + server.close(); + } +}); + test('GET /render serves a stored iteration section and 404s unknowns', async () => { const root = await repo(); const { createJob, saveJob } = await import('../lib/jobs-store.js'); From 6fe7ddd86e1dbe3aa3c286e144217d908a364509 Mon Sep 17 00:00:00 2001 From: hassankettany Date: Mon, 13 Jul 2026 11:23:58 +0300 Subject: [PATCH 61/62] feat(validator): per-iteration breakdown in the refinery prompt diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/refinery/diff now also returns a steps array — one diff per iteration that produced a refinement (guideline to refined = next iterations guideline); stopping iterations (refined=null) contribute none. The diff modal renders the overall original-to-final diff, then a captioned section per iteration ("Iteration N to N+1") so you can see exactly what each round changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- validator/public/app.js | 22 ++++++++++++++++++---- validator/public/styles.css | 6 ++++++ validator/server.js | 10 +++++++++- validator/test/server.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/validator/public/app.js b/validator/public/app.js index 2683354..ee285c1 100644 --- a/validator/public/app.js +++ b/validator/public/app.js @@ -637,10 +637,24 @@ $('jobBar').addEventListener('click', async (e) => { refreshJobs(); openJobView(job.id); } else if (e.target.id === 'jobDiffBtn') { const d = await api(`/api/refinery/diff?id=${encodeURIComponent(job.id)}`); - $('diffTitle').textContent = `Original .md → job's best guideline`; - $('diffBody').innerHTML = d.error ? `${esc(d.error)}` - : !d.changed ? '
            No differences.
            ' - : d.parts.map((p) => p.added ? `${esc(p.value)}` : p.removed ? `${esc(p.value)}` : `${esc(p.value)}`).join(''); + $('diffTitle').textContent = `Prompt evolution — original .md → final`; + const renderParts = (parts) => parts.map((p) => + p.added ? `${esc(p.value)}` : p.removed ? `${esc(p.value)}` : `${esc(p.value)}`).join(''); + if (d.error) { + $('diffBody').innerHTML = `${esc(d.error)}`; + } else { + const section = (heading, changed, parts) => + `
            ${esc(heading)}
            ${ + changed ? renderParts(parts) : '
            no change
            '}
            `; + // Overall first, then each iteration's refine (iteration N → N+1). + let html = section('Overall · original .md → best guideline', d.changed, d.parts); + const steps = d.steps || []; + if (steps.length) { + html += '
            Per-iteration changes
            '; + html += steps.map((s) => section(`Iteration ${s.iter} → ${s.iter + 1}`, s.changed, s.parts)).join(''); + } + $('diffBody').innerHTML = html; + } $('diffModal').hidden = false; } else if (e.target.id === 'jobDelete') { const btn = e.target; diff --git a/validator/public/styles.css b/validator/public/styles.css index c8edeb4..1863ce5 100644 --- a/validator/public/styles.css +++ b/validator/public/styles.css @@ -340,6 +340,12 @@ body { .diff-body ins { background: rgba(52,211,153,0.16); color: #6ee7b7; } .diff-body del { background: rgba(248,113,113,0.16); color: #fca5a5; } .diff-body span { color: var(--text-2); } +.diff-section { margin-bottom: 14px; border: 1px solid var(--hair); border-radius: var(--radius-xs); overflow: hidden; } +.diff-head { font: 600 11.5px var(--mono); letter-spacing: .02em; color: var(--text); background: var(--fill-2); + padding: 7px 10px; border-bottom: 1px solid var(--hair); position: sticky; top: 0; } +.diff-none { color: var(--text-3); font-size: 12px; padding: 8px 10px; } +.diff-sub { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-3); + margin: 4px 0 10px; } /* ── Refinery ─────────────────────────────────────── */ .jdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; } diff --git a/validator/server.js b/validator/server.js index 393c24a..185d7ad 100644 --- a/validator/server.js +++ b/validator/server.js @@ -399,7 +399,15 @@ export function createApp(rootDir, { port } = {}) { const original = await readPrompt(root, job.promptPath); const final = finalGuideline(job); if (original === null || final === null) return bad(res, 'nothing to diff'); - res.json({ changed: original !== final, parts: computeDiff(original, final) }); + // Per-iteration steps: each iteration's refine turns `guideline` into + // `refined` (= the next iteration's guideline). Stopping iterations have + // refined=null (produced no change). The `from`/`to` labels let the UI + // caption each step (e.g. "Iteration 1 → 2"). + const steps = (job.iterations || []) + .filter((it) => typeof it.refined === 'string') + .map((it) => ({ iter: it.iter, changed: it.guideline !== it.refined, + parts: computeDiff(it.guideline, it.refined) })); + res.json({ changed: original !== final, parts: computeDiff(original, final), steps }); } catch (err) { bad(res, String(err.message || err)); } }); diff --git a/validator/test/server.test.js b/validator/test/server.test.js index c69c9e0..e1a24f3 100644 --- a/validator/test/server.test.js +++ b/validator/test/server.test.js @@ -253,6 +253,34 @@ test('POST /api/refinery/approve refuses a running job (guard mirrors reject)', } }); +test('GET /api/refinery/diff returns per-iteration steps (guideline → refined)', async () => { + const root = await repo(); + const { writePrompt } = await import('../lib/prompts.js'); + const { createJob, saveJob } = await import('../lib/jobs-store.js'); + await writePrompt(root, 'G/A.html', 'v0\n'); + const runsDir = new URL('../runs', import.meta.url).pathname; + const job = await createJob(runsDir, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + job.status = 'green'; + job.iterations = [ + { iter: 1, guideline: 'v0\n', refined: 'v1\n', judge: { score: 5, notes: '' }, sections: [] }, + { iter: 2, guideline: 'v1\n', refined: null, judge: { score: 8, notes: '' }, sections: [] }, // stopping iter → no step + ]; + await saveJob(runsDir, job); + const { base, server } = await start(root); + try { + const d = await (await fetch(`${base}/api/refinery/diff?id=${job.id}`)).json(); + assert.equal(d.steps.length, 1); // only iter 1 produced a refinement + assert.equal(d.steps[0].iter, 1); + assert.equal(d.steps[0].changed, true); + assert.ok(d.steps[0].parts.some((p) => p.removed && p.value.includes('v0'))); + assert.ok(d.steps[0].parts.some((p) => p.added && p.value.includes('v1'))); + } finally { + const { rm } = await import('node:fs/promises'); + await rm(new URL(`../runs/${job.id}`, import.meta.url).pathname, { recursive: true, force: true }); + server.close(); + } +}); + test('POST /api/refinery/delete removes a finished job but refuses a running one', async () => { const root = await repo(); const { createJob, saveJob, getJob } = await import('../lib/jobs-store.js'); From 44919e22cf5da71e1bc97a16880555dd21f3897a Mon Sep 17 00:00:00 2001 From: hassankettany Date: Wed, 15 Jul 2026 16:25:14 +0300 Subject: [PATCH 62/62] . --- .DS_Store | Bin 14340 -> 16388 bytes .../Gallery-and-Carousel/3DSmallCarousel.md | 232 +++++ .../Gallery-and-Carousel/CardSpread.md | 213 ++++ .../Gallery-and-Carousel/CardSpread_7.md | 201 ++++ .../Gallery-and-Carousel/DiagonalShuffle-2.md | 337 ++++++ .../Gallery-and-Carousel/DiagonalShuffle.md | 339 ++++++ .../DiagonalShuffle.md.history.json | 39 + .../HorizontalAndVerticalScroll.md | 201 ++++ .../Gallery-and-Carousel/HorizontalLanes.md | 213 ++++ .../Scroll_3D_Animation.md | 251 +++++ .../Gallery-and-Carousel/WheelCarousel.md | 243 +++++ .../Image_Background/column-squeeze-reveal.md | 222 ++++ .../Image_Background/lumina-orbit-scroll.md | 74 ++ .../manifest-expand-scroll.md | 181 ++++ .../cards-peel-off-scroll.md | 222 ++++ .../text-cards-slide-in.md | 261 +++++ Gallery-and-Carousel/3DSmallCarousel.html | 2 +- .../AccordionScrollHorizontal.html | 14 +- .../AccordionScrollVertical.html | 24 +- Gallery-and-Carousel/BlurFocus_Gallery.html | 18 +- Gallery-and-Carousel/CardSpread.html | 70 +- Gallery-and-Carousel/CardSpreadByHover.html | 71 +- Gallery-and-Carousel/CardSpread_7.html | 38 +- .../ClassicHorizontalScroll.html | 14 +- .../CornerFoldScrollAnimation.html | 100 +- Gallery-and-Carousel/Cornergallery01.html | 12 +- Gallery-and-Carousel/DiagonalShuffle.html | 48 +- Gallery-and-Carousel/DigitalJukebox.html | 58 +- Gallery-and-Carousel/EndlessParallax.html | 6 +- .../ExpandingHorizontalScroll.html | 207 ++-- Gallery-and-Carousel/FadeInGallery.html | 39 +- .../HorizontalAndVerticalScroll.html | 62 +- .../HorizontalCarouselPerspective.html | 48 +- Gallery-and-Carousel/HorizontalLanes.html | 33 +- .../HorizontallyScrollingGallery.html | 2 +- .../Looping_Sphere_Gallery.html | 2 +- Gallery-and-Carousel/MantaRay.html | 34 +- Gallery-and-Carousel/Mirror_Hover_Galery.html | 33 +- .../Mouse track infinite gallery.html | 2 +- Gallery-and-Carousel/Paragraph_Reaveal.html | 6 +- Gallery-and-Carousel/Scroll_3D_Animation.html | 36 +- Gallery-and-Carousel/ShapeScroll.html | 66 +- Gallery-and-Carousel/SmallCarousel.html | 6 +- Gallery-and-Carousel/SnakeAnimation.html | 38 +- Gallery-and-Carousel/SpecimenCardGallery.html | 2 +- Gallery-and-Carousel/StickyRepeaterStack.html | 6 +- .../TitleFoldsScrollAnimation.html | 34 +- Gallery-and-Carousel/VerticalLanes.html | 31 +- Gallery-and-Carousel/WheelCarousel.html | 969 +++++++++++------- Gallery-and-Carousel/WindowScroll.html | 62 +- Image_Background/3d-blinds-flip-reveal.html | 9 +- .../BG_Image_ShapeMask_Gallery.html | 2 +- Image_Background/BG_image_ShapeMask.html | 13 +- Image_Background/Diagonal_Slideshow.html | 6 +- Image_Background/Kinetic 155 Horizon.html | 4 +- Image_Background/TextMask2Image.html | 2 +- Image_Background/column-squeeze-reveal.html | 2 +- .../horizontal-stripe-cascade-reveal.html | 9 +- .../left-panel-slide-out-reveal.html | 2 +- Image_Background/lumina-orbit-scroll.html | 2 +- Image_Background/manifest-expand-scroll.html | 10 +- .../manifest-expand-scroll_02.html | 4 +- Image_Background/rift-slit-reveal-02.html | 8 +- Image_Background/rift-slit-reveal.html | 13 +- Image_Background/scroll-shape-morph.html | 2 +- .../scroll-shape-shift-interior.html | 2 +- .../staggered-stripes-reveal.html | 9 +- .../sticky-perspective-shrink.html | 2 +- Typographic_interactions/3D-Rolodex-Flip.html | 6 +- .../Accelerated3DSpin.html | 8 +- Typographic_interactions/Aura Stack.html | 30 +- .../Blurry_Transition.html | 21 +- .../Editorial Text Reveal.html | 30 +- Typographic_interactions/Gooey_text.html | 2 +- .../Headline_Images_Overlay.html | 18 +- .../IconText Pro gallery.html | 6 +- .../Mindshift Transition.html | 18 +- .../Paragraph_mask_scroll.html | 10 +- Typographic_interactions/Ripple_Hover.html | 4 +- Typographic_interactions/RiseOfTheDead.html | 8 +- .../Scroll_Mask_Reveal.html | 6 +- .../Scroll_Paragraph_Fade.html | 6 +- .../Scroll_Paragraph_Reveal.html | 6 +- Typographic_interactions/Scroll_Skew.html | 50 +- Typographic_interactions/Strobe_Headline.html | 28 +- Typographic_interactions/Tech_ Glitch.html | 7 +- Typographic_interactions/The Iris Gate.html | 30 +- Typographic_interactions/Vshape_Headline.html | 10 +- .../cards-peel-off-scroll.html | 10 +- .../stacked-text-cards-scroll.html | 8 +- .../text-cards-slide-in.html | 4 +- .../text-fade-3d-perspective.html | 2 +- .../textFoldTransition.html | 42 +- interact-UI-elements/dropdown-light.html | 10 +- interact-UI-elements/dropdown.html | 11 +- interact-UI-elements/label.html | 6 +- interact-UI-elements/lock-toggle.html | 19 +- interact-UI-elements/on-off-toggle.html | 13 +- interact-UI-elements/password-input.html | 18 +- interact-UI-elements/radio-buttons.html | 14 +- interact-UI-elements/search-input-light.html | 17 +- interact-UI-elements/search-input.html | 17 +- interact-UI-elements/smiley-nav-light.html | 10 +- interact-UI-elements/smiley-nav.html | 10 +- text_Image/BG_Color_Invert.html | 2 +- text_Image/Image_Stroll.html | 2 +- text_Image/pointer-scale-scroll-blur.html | 2 +- text_Image/pointer-track-scroll-fade.html | 2 +- text_Image/scroll-blur-split-layout.html | 2 +- text_Image/scroll-perspective-hero.html | 2 +- text_Image/scroll-tilt-reveal.html | 11 +- text_Image/shape-mask-parallax.html | 2 +- text_Image/single screen parallax .html | 2 +- validator/lib/drafts.js | 24 +- validator/public/app.js | 4 + validator/server.js | 8 +- validator/test/drafts.test.js | 12 +- validator/test/server.test.js | 12 + 118 files changed, 4835 insertions(+), 1290 deletions(-) create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/3DSmallCarousel.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/CardSpread.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/CardSpread_7.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle-2.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md.history.json create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/HorizontalAndVerticalScroll.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/HorizontalLanes.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/Scroll_3D_Animation.md create mode 100644 Ani-Mate Prompts/Gallery-and-Carousel/WheelCarousel.md create mode 100644 Ani-Mate Prompts/Image_Background/column-squeeze-reveal.md create mode 100644 Ani-Mate Prompts/Image_Background/lumina-orbit-scroll.md create mode 100644 Ani-Mate Prompts/Image_Background/manifest-expand-scroll.md create mode 100644 Ani-Mate Prompts/Typographic_interactions/cards-peel-off-scroll.md create mode 100644 Ani-Mate Prompts/Typographic_interactions/text-cards-slide-in.md diff --git a/.DS_Store b/.DS_Store index a7d5ef413e9cc6715a74f56bd0ba740a448dfcf3..e5c8492626752980edbe395fe4ce4818ba6c5f16 100644 GIT binary patch delta 427 zcmZoEXlYKX0YG}(ynL$%nj7@nt3w6i7yKyFb*cC Vm`t6#K~i<|13_cP&AxKm83F54Vrc*X delta 131 zcmZo^U~DN+W?*1obSh0TWMD7=GC6=4LP)?qZ<7_gh4k!LfH zKso!wvX0H%27lNWvnvS5vH?{CfdNnhgDaXw?#+T6ubC(F+xT)YLCm(8oM1C`v!Qwj K<7Qv8?Ti3LdKN4I diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/3DSmallCarousel.md b/Ani-Mate Prompts/Gallery-and-Carousel/3DSmallCarousel.md new file mode 100644 index 0000000..28d60a7 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/3DSmallCarousel.md @@ -0,0 +1,232 @@ +# 3D Small Carousel + +A scroll-driven 3D carousel: the track rotates while cards brighten near the front. + +## Summary + +- **ID:** `3d-small-carousel` +- **Name:** `3D Small Carousel` +- **Description:** Cards are arranged in a 3D ring and orbit on scroll while cards brighten as they face the viewer. +- **Best for:** `4-12` similarly sized image/card siblings that can be placed as absolute items around a `preserve-3d` carousel. + +## Demo HTML + +```html +
            + +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSection` owns runway, `stickyStage` owns sticky/clipping/perspective, `carousel` owns the stable centered 3D stage, and `repeatedCard` owns orbit transform plus brightness. +2. In Wix, `stickyStage` is the internal-container-root and `carousel` is `# > [data-testid="internal-container-content"]`. Those selectors must stay distinct. +3. Do not animate `carousel` with `rotateY` in Wix. Orbit each card root with combined transform/filter keyframes instead of rotating the wrapper. +4. Keep 3D placement on repeated card roots, not raw `img` descendants. Cards must become absolute centered items in a `preserve-3d` stage. +5. Compute angle step from item count and radius from card size; four cards are the minimum useful ring. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section whose `viewProgress` drives carousel rotation and card brightness. | +| `stickyStage` | The non-rotating sticky viewport-height stage that centers, clips, and provides perspective for the 3D carousel. | +| `perspectiveFrame` | Same non-rotating stage when no extra wrapper exists; never the rotating carousel. | +| `carousel` | The internal content child / stable `preserve-3d` stage containing the orbiting cards. | +| `repeatedCard` | Absolute card roots arranged around the carousel with static `rotateY`/`translateZ` placement. | + +## Adaptation Notes + +1. The source layout does not need to already be a carousel; repeated siblings can be reorganized into an absolute 3D ring with CSS. +2. Preserve the section root outer layout and keep card size close to the source composition instead of introducing viewport-height cards. +3. Make the carousel a stable centered anchor inside the sticky viewport, then reset grid/flex placement on cards and center them on that anchor. +4. Initial per-card placement is formula-driven: repeated card `i` starts at `rotateY(i * 360 / N) translateZ(radius)`. +5. Generate sampled per-card orbit keyframes that combine transform and filter. Do not add a separate wrapper spin effect. +6. If the orbit looks off-center, move the carousel anchor, not the cards one by one. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `.scroll-section` | The `viewProgress` source for the full 3D carousel scene. | +| `stickyStage` | `stickyStage` | `#carousel-stage` | The shared sticky viewport stage that pins, clips, centers, and provides perspective. Wix: internal-container-root `#comp` id. | +| `perspectiveFrame` | `perspectiveFrame` | `#carousel-stage` | The non-rotating perspective owner. Usually the same selector as `stickyStage`; never `carousel`. | +| `carousel` | `carousel` | `#carousel-stage > [data-testid="internal-container-content"]` | The stable `preserve-3d` stage that directly contains card roots. Wix: MUST be `# > [data-testid="internal-container-content"]`, never the same selector as `stickyStage`. | +| `card1` | `repeatedCard` | `.scroll-section #card-1` | Minimum repeated ring card; extend for `card5..cardN`. | +| `card2` | `repeatedCard` | `.scroll-section #card-2` | Repeated ring card. | +| `card3` | `repeatedCard` | `.scroll-section #card-3` | Repeated ring card. | +| `card4` | `repeatedCard` | `.scroll-section #card-4` | Repeated ring card. | + +## Required Styles + +### `scrollSource` + +Selector: `.scroll-section` + +```css +.scroll-section { + position: relative; + min-height: 400vh; +} +``` + +Reason: creates enough scroll distance for the carousel to rotate smoothly. + +### `stickyStage` + +Selector: `#carousel-stage` + +```css +#carousel-stage { + position: sticky; + top: 0; + height: 100vh; + width: 100%; + overflow: clip; +} +``` + +Reason: pins the scene and prevents horizontal overflow while the carousel rotates. This selector must never receive the carousel `rotateY` effect. + +### `perspectiveFrame` + +Selector: `#carousel-stage` + +```css +#carousel-stage { + perspective: 1200px; + perspective-origin: 50% 45%; + display: flex; + justify-content: center; + align-items: center; + transform-style: preserve-3d; +} +``` + +Reason: provides depth and centering for the carousel without rotating with it. + +### `carousel` + +Selector: `#carousel-stage > [data-testid="internal-container-content"]` + +```css +#carousel-stage > [data-testid="internal-container-content"] { + grid-area: auto; + justify-self: auto; + align-self: auto; + position: absolute; + top: 50%; + left: 50%; + display: block; + width: 0; + height: 0; + margin: 0; + padding: 0; + overflow: visible; + transform-style: preserve-3d; + transform-origin: center center; +} +``` + +Reason: creates a stable viewport-centered 3D anchor for the absolute cards; do not animate this selector in Wix. + +### `repeatedCard` + +Selector: `#carousel-stage > [data-testid="internal-container-content"] > .card` + +```css +#carousel-stage > [data-testid="internal-container-content"] > .card { + grid-area: auto; + justify-self: auto; + align-self: auto; + position: absolute; + top: 0; + left: 0; + width: 280px; + height: 420px; + margin-left: -140px; + margin-top: -210px; + backface-visibility: hidden; + transform-origin: center center; + will-change: transform, filter; +} +``` + +Reason: centers repeated card roots in the carousel stage before per-card orbit transforms are applied. Preserve the source card proportions here with measured px or stage-relative percentages; do not convert cards to viewport-height blocks. + +## Interact Template + +### Range + +```ts +const RANGE = { + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 0 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 100 } }, + fill: 'both' as const, + easing: 'linear', +}; +``` + +### Orbit Keyframes + +Per-card keyframes are generated by sampling the ring rotation (`turns * samplesPerTurn + 1` steps) and combining a `rotateY(...) translateZ(radius)` transform with a proximity-based `brightness(...)` filter. Each card `i` is offset by its static angle `i * 360 / cardCount`. + +```ts +const orbitKeyframes = ( + cardIndex: number, + cardCount = 4, + turns = 2, + samplesPerTurn = 8, + radius = '380px', +) => { + const steps = turns * samplesPerTurn + 1; + const stepDeg = 360 / samplesPerTurn; + const cardAngle = cardIndex * (360 / cardCount); + + return Array.from({ length: steps }, (_, step) => { + const rotation = step * stepDeg; + const worldAngle = (rotation + cardAngle) % 360; + const diff = Math.min(worldAngle, 360 - worldAngle); + const proximity = (Math.cos((diff * Math.PI) / 180) + 1) / 2; + const brightness = 0.3 + 0.8 * proximity; + + return { + offset: step / (steps - 1), + transform: `rotateY(${rotation + cardAngle}deg) translateZ(${radius})`, + filter: `brightness(${brightness.toFixed(2)})`, + }; + }); +}; +``` + +### Effect Pattern + +```ts +const cardOrbitEffect = (key: string, cardIndex: number) => ({ + key, + keyframeEffect: { + name: `${key}-orbit`, + keyframes: orbitKeyframes(cardIndex), + }, + ...RANGE, +}); +``` + +### Interaction + +```ts +{ + key: 'scrollSection', + trigger: 'viewProgress', + effects: RING_CARD_NUMBERS.map((cardNumber, index) => + cardOrbitEffect(`card${cardNumber}`, index), + ), +} +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread.md b/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread.md new file mode 100644 index 0000000..0f8f7dd --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread.md @@ -0,0 +1,213 @@ +# Card Spread + +Stacked cards fan out horizontally on scroll. + +## Summary + +- **ID:** `card-spread` +- **Target shape:** Best for **3+ similarly sized sibling image cards** stacked inside a single sticky stage. A title or short copy may sit alongside the cards, but there must still be **3+ real, comparably sized cards** to spread. +- **Not for:** Two-item sections, text+button pairs, or any layout where the only "siblings" are dissimilar wrappers (e.g. one image + one text/button block). There is nothing to fan out there — reject the pattern instead of forcing arbitrary siblings to translate. +- **Description:** Cards stacked at the center of the viewport fan out left/right and shrink slightly as the section scrolls past. + +## Demo HTML + +```html +
            +
            +
            +

            Title

            +
            1
            +
            2
            +
            3
            +
            4
            +
            5
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSection` owns runway, `stickyStage` owns sticky/clipping, `collection` owns the centered inner stage, and `repeatedCard` owns the overlapped card-stage layout plus spread transform. +2. `stickyStage` and `collection` must be **different selectors** — never collapse them into a single `stage` key. In Wix, `stickyStage` is the internal-container-root `#comp-...` and `collection` is its `[data-testid="internal-container-content"]` child. If you cannot resolve two distinct selectors, reject the pattern. +3. If `collection` also contains non-repeated siblings such as titles or copy, keep `collection` as a grid and overlap only the repeated cards in a shared card stage row. Do not convert the whole mixed wrapper to flex. +4. Keep card-spread layout styles on the repeated card roots, not on raw `img` descendants or broad selectors when concrete card component ids exist. +5. Repeated cards share one overlapped stage inside the collection, not sticky items. Use rendered `#comp-...` ids, not `DESKTOP--...` ids. +6. The elements you pick as `repeatedCard` must be the visible, centered content of a valid stage. If translating/hiding them would leave the sticky stage blank (no centered content ever renders), the selectors are wrong — reject rather than ship a blank frame. +7. Require **at least 3** `repeatedCard` selectors that are comparably sized. Fewer than 3, or mixed image/text wrappers, means the pattern does not apply. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section that drives the `viewProgress` trigger. | +| `stickyStage` | A sticky viewport-height wrapper that keeps the cards pinned during scroll. Distinct from `collection`. | +| `collection` | The grid layout owner that can keep static siblings in flow while repeated cards share one overlapped card stage. Distinct from `stickyStage`. | +| `repeatedCard` | 3+ comparably sized repeated sibling items that share one overlapped grid cell and then spread horizontally. | + +## Adaptation Notes + +1. Preserve the section root outer layout; the sticky stage and centered collection are inner roles, not section-root roles. +2. Use viewport units only for the outer runway and sticky stage. Size cards relative to the collection stage so their proportions stay close to the source composition. +3. If `collection` contains a title or other static siblings, leave them in their own normal grid row and place only the repeated cards into a shared lower grid row so the non-animated content stays untouched. +4. Animate spread with `translateX(...) scale(...)` on the card roots instead of resizing card height unless the real section truly depends on viewport-sized cards. +5. **Recompute translations from item count.** Center the stack and give card `i` (0-indexed, `N` cards total) a final translation of `unit * (i - (N - 1) / 2)`, where `unit` is the center-to-center gap in `vw`. Never copy the demo's five-card offsets literally. +6. **Size `unit` and `cardWidth` (both in vw) against two hard constraints:** + - *Separation:* `unit > cardWidth`, so adjacent cards — and their per-item labels/numbers — actually clear each other at full spread (edge gap `= unit - cardWidth > 0`). + - *Containment:* the outermost card must stay on the clipped stage: `((N - 1) / 2) * unit + cardWidth / 2 ≤ ~48`. + - These are only jointly satisfiable when the deck is narrow enough: aim for `N * cardWidth < ~90vw` (roughly `cardWidth ≤ 90 / N`). If the source cards are too wide (e.g. 6 × 15vw = 90vw), shrink `cardWidth` first, then pick `unit` in the window `(cardWidth, (96 - cardWidth) / (N - 1)]`. +7. **Reject the pattern** when any of these hold: fewer than 3 comparably sized cards; the "siblings" are dissimilar (one image + one text/button wrapper, or a single hero); no distinct sticky-stage vs. collection selectors; or the chosen movers would not leave centered content visible on the stage. Do not force a title, lone image, or button to spread. +8. **Verify visibility before returning.** After computing the layout, confirm the correct elements move and stay on-stage at both progress `0` and `1` — no blank frame, no cards clipped at both viewport edges, no items still overlapping at full spread. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `.scroll-section` | The `viewProgress` source for the entire pattern. | +| `stickyStage` | `stickyStage` | `.cards-container-wrapper` | Sticky pin only: `position: sticky`, `100vh`, `overflow: clip`. Wix: `#comp-...` with `data-testid="internal-container-root"` and not the collection. | +| `collection` | `collection` | `#cards-collection` | Centered mixed-content stage for the spread. Wix must be `# [data-testid="internal-container-content"]`, which must differ from `stickyStage`. | +| `card1` | `repeatedCard` | `.scroll-section #card-1` | Minimum repeated spread card; extend outward for `card4..cardN`. | +| `card2` | `repeatedCard` | `.scroll-section #card-2` | Repeated spread card. | +| `card3` | `repeatedCard` | `.scroll-section #card-3` | Repeated spread card (minimum viable count). | +| `card4` | `repeatedCard` | `.scroll-section #card-4` | Repeated spread card (optional). | +| `card5` | `repeatedCard` | `.scroll-section #card-5` | Repeated spread card (optional). | + +> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items. At least `card1..card3` must resolve to real, comparably sized cards or the pattern is rejected. + +## Required Styles + +### `scrollSource` — `.scroll-section` + +```css +.scroll-section { + height: 400vh; +} +``` + +Reason: creates enough scroll distance for the full `viewProgress` spread to play out. + +### `stickyStage` — `.cards-container-wrapper` + +```css +.cards-container-wrapper { + position: sticky; + top: 0; + height: 100vh; + overflow: clip; +} +``` + +Reason: pins the stage to the viewport and clips the spreading cards while the source section scrolls. + +### `collection` — `#cards-collection` + +```css +#cards-collection { + position: relative; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + width: 100%; + height: 100vh; + margin: 0 auto; + justify-items: center; +} +``` + +Reason: creates a mixed-content grid stage so static siblings stay in flow while repeated cards overlap in a shared card row. The collection owns the composition space; child card percentages resolve against this stage. + +### `repeatedCard` — `#cards-collection > .card` + +```css +#cards-collection > .card { + grid-column: 1; + grid-row: 2; + place-self: start center; + /* Keep the deck narrow enough that N * width < ~90vw + (roughly width <= 90 / N) so spread can both separate and stay on-stage. */ + width: 20vw; + height: 55%; + transform-origin: center center; + will-change: transform; +} +``` + +Reason: overlaps repeated cards in one shared grid cell with top alignment and centered placement before the animation distributes them, preserving their proportion relative to the collection stage. Width must be recomputed from item count so the spread constraints in Adaptation Note 6 are satisfiable. + +### `repeatedCard` — `.card` + +```css +.card { + margin: 0; +} +``` + +Reason: prevents repeated cards from drifting apart because of default spacing. + +## Suggested Controls + +Always expose at least the spread distance and ending scale; add more only when the adapted experience introduces new stable knobs. Note that the default `spread` value must be re-derived per section from item count and card width (Adaptation Note 6), not shipped blindly. + +### `spread` + +- **Label:** `Spread` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `20` +- **Description:** Center-to-center gap (in vw) between adjacent cards at the end of the scroll range. Must exceed card width so cards separate, and stay small enough that outer cards remain on the clipped stage. +- **Constraints:** `min: 8`, `max: 40`, `step: 1`, `unit: vw` +- **Binding:** `variable` `--card-spread-unit` using template `${value}vw` + +### `end-scale` + +- **Label:** `Card Scale` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `0.85` +- **Description:** Controls the ending scale of the cards at maximum spread. +- **Constraints:** `min: 0.7`, `max: 1`, `step: 0.01`, `unit: x` +- **Binding:** `variable` `--card-end-scale` using a direct value + +## Interact Template + +```ts +const RANGE = { + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 20 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 80 } }, + easing: 'cubic-bezier(0.42, 0, 0.58, 1)', + fill: 'both' as const, +}; + +// Derive translations from the REAL item count — never hardcode demo offsets. +// unit = center-to-center gap in vw (bind to --card-spread-unit). +// Constraints (see Adaptation Note 6), with cardWidth in vw: +// separation: unit > cardWidth +// containment: ((N - 1) / 2) * unit + cardWidth / 2 <= ~48 +const N = 5; // number of resolved repeatedCard selectors (>= 3) +const UNIT = 20; // vw, recomputed per section +const END_SCALE = 0.85; + +const spreadTranslation = (index: number) => + `${UNIT * (index - (N - 1) / 2)}vw`; + +// Combined per-card effect: translateX + scale shrink in a single keyframe pair. +const cardSpreadEffect = (key: string, endTranslate: string) => ({ + key, + keyframeEffect: { + name: `${key}-spread`, + keyframes: [ + { transform: 'translateX(0) scale(1)' }, + { transform: `translateX(${endTranslate}) scale(${END_SCALE})` }, + ], + }, + ...RANGE, +}); + +const interaction = { + key: 'scrollSection', + trigger: 'viewProgress', + effects: Array.from({ length: N }, (_, index) => + cardSpreadEffect(`card${index + 1}`, spreadTranslation(index)), + ), +}; +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread_7.md b/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread_7.md new file mode 100644 index 0000000..38f6515 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/CardSpread_7.md @@ -0,0 +1,201 @@ +# Card Fan + +Stacked cards pivot from a shared point below to fan out like a hand of cards on scroll. + +## Summary + +- **ID:** `card-fan` +- **Target shape:** Best for 5–9 similarly sized sibling cards inside a single sticky stage, where the cards can overlap in one absolutely-positioned deck and rotate around a shared pivot below them. +- **Description:** Seven cards stacked at the center of the viewport rotate around a common pivot point beneath the deck, fanning symmetrically left and right as the section scrolls past. + +## Demo HTML + +```html +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSource` owns the scroll runway, `stickyStage` owns sticky pinning and clipping, `collection` (the deck) owns the fixed card-sized coordinate box, and `repeatedCard` owns the absolute overlap, the shared pivot (`transform-origin`), and the fan rotation. +2. `stickyStage` and `collection` must be different selectors. The sticky stage is a full-viewport wrapper; the deck is a small card-sized box centered inside it. In Wix, `stickyStage` is the internal-container-root `#comp-...` and `collection` is its `[data-testid="internal-container-content"]` child. +3. Every `repeatedCard` must share the same `transform-origin` (a point below the deck) or the cards will not fan from a common pivot. +4. Cards are `position: absolute` and fully overlapped in the deck at rest — do not lay them out in a flex/grid row; the fan is created purely by rotation around the shared origin. +5. Keep the fan rotation on the card roots, not on `img` descendants, and use rendered `#comp-...` ids rather than `DESKTOP--...` ids. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall wrapper (multiples of viewport height) that drives the `viewProgress` trigger. | +| `stickyStage` | A sticky viewport-height wrapper that centers and clips the deck while the source scrolls. | +| `collection` | A small card-sized, `position: relative` deck that establishes the shared coordinate box for the overlapped cards. | +| `repeatedCard` | Overlapped sibling cards that share a pivot below the deck and rotate to fan out symmetrically. | + +## Adaptation Notes + +1. Preserve the section-root outer layout; the sticky stage and centered deck are inner roles, not section-root roles. +2. The deck should match one card's dimensions; cards are absolutely positioned to fill it, so they all stack at the same spot before rotating. +3. Set `transform-origin` to a point below the card (e.g. `center 140%`) so rotation swings cards around a hand-of-cards pivot rather than spinning each in place. Deeper pivots produce shallower, wider arcs. +4. Fan angles are index-relative: for `CARDS` items with middle index `MID = floor(CARDS/2)`, each card's offset is `off = index - MID`; end angle is `off * spreadAngle` and start angle is `off * smallRestAngle`. Recompute both when item count changes instead of copying demo angles. +5. `z-index` should increase with card order so the fan layers cleanly; the demo assigns `#card-1..7` z-index `1..7`. +6. If outer cards rotate past the visible/clipped stage, reduce the spread angle or increase pivot depth before returning the result. +7. Reject the pattern if you cannot keep a distinct sticky stage and card-sized deck, or cannot give all cards one shared pivot. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `#scroll-wrapper` | The `viewProgress` source for the whole pattern; owns the tall runway. | +| `stickyStage` | `stickyStage` | `.sticky-container` | Sticky pin + centering + clip: `position: sticky`, `100vh`, `overflow: clip`. Wix: `#comp-...` with `data-testid="internal-container-root"`, distinct from the deck. | +| `collection` | `collection` | `.deck` | Card-sized `position: relative` box that anchors the overlapped cards. Wix must be `# [data-testid="internal-container-content"]`, differing from `stickyStage`. | +| `card1` | `repeatedCard` | `#scroll-wrapper #card-1` | Minimum fan card; extend outward for `card8..cardN`. | +| `card2` | `repeatedCard` | `#scroll-wrapper #card-2` | Fan card. | +| `card3` | `repeatedCard` | `#scroll-wrapper #card-3` | Fan card. | +| `card4` | `repeatedCard` | `#scroll-wrapper #card-4` | Center card (no rotation at `off = 0`). | +| `card5` | `repeatedCard` | `#scroll-wrapper #card-5` | Fan card. | +| `card6` | `repeatedCard` | `#scroll-wrapper #card-6` | Fan card. | +| `card7` | `repeatedCard` | `#scroll-wrapper #card-7` | Fan card. | + +> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card8..cardN` for more items, and recompute each card's fan angle from its offset to the middle index. + +## Required Styles + +### `scrollSource` — `#scroll-wrapper` + +```css +#scroll-wrapper { + height: 600vh; + position: relative; +} +``` + +Reason: creates enough scroll distance for the full `viewProgress` fan to play out; recompute proportionally with item count and desired pacing. + +### `stickyStage` — `.sticky-container` + +```css +.sticky-container { + position: sticky; + top: 0; + height: 100vh; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: clip; +} +``` + +Reason: pins the deck to the viewport, centers it, and clips the fanning cards while the source section scrolls. Use `overflow: clip` (not `hidden`) to avoid breaking the ViewTimeline. + +### `collection` — `.deck` + +```css +.deck { + position: relative; + width: 280px; + height: 400px; +} +``` + +Reason: establishes a single card-sized coordinate box; absolutely-positioned cards resolve against it and stack in the same spot before rotating. + +### `repeatedCard` — `.deck > .card` + +```css +.deck > .card { + position: absolute; + width: 280px; + height: 400px; + transform-origin: center 140%; + will-change: transform; +} +``` + +Reason: overlaps all cards at one location and gives them a shared pivot below the deck so rotation fans them from a common point rather than spinning each in place. + +## Suggested Controls + +Always expose at least the spread angle; add pivot depth and scroll distance when the adapted section can safely support them. + +### `spread-angle` + +- **Label:** `Fan Spread` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `12` +- **Description:** Controls the per-step rotation between adjacent cards at full spread; larger values fan the cards wider. +- **Constraints:** `min: 4`, `max: 20`, `step: 1`, `unit: deg` +- **Binding:** `variable` `--fan-spread-step` using template `${value}deg` + +### `pivot-depth` + +- **Label:** `Pivot Depth` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `140` +- **Description:** Moves the shared rotation origin below the deck; deeper pivots create wider, shallower arcs. +- **Constraints:** `min: 100`, `max: 200`, `step: 5`, `unit: %` +- **Binding:** `style` `.deck > .card` `transform-origin` using template `center ${value}%` + +### `scroll-distance` + +- **Label:** `Scroll Distance` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `600` +- **Description:** Sets the runway height that paces how much scrolling drives the full fan. +- **Constraints:** `min: 300`, `max: 900`, `step: 50`, `unit: vh` +- **Binding:** `style` `#scroll-wrapper` `height` using template `${value}vh` + +## Interact Template + +```ts +const RANGE = { + rangeStart: { name: 'contain', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'contain', offset: { value: 55, unit: 'percentage' } }, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'both' as const, +}; + +// Recompute from real card count and spread — do not copy literal angles. +const CARDS = 7; +const SPREAD = 12; // per-step degrees at full fan (bind to --fan-spread-step) +const REST = 0.8; // per-step degrees at rest +const MID = Math.floor(CARDS / 2); + +// Per-card fan effect: rotate from a small rest angle to the full offset angle +// around the shared transform-origin below the deck. +const fanEffect = (index: number) => { + const off = index - MID; + return { + key: `card${index + 1}`, + keyframeEffect: { + name: `fan-${index + 1}`, + keyframes: [ + { transform: `rotate(${off * REST}deg)` }, + { transform: `rotate(${off * SPREAD}deg)` }, + ], + }, + ...RANGE, + }; +}; + +const interaction = { + key: 'scrollSection', + trigger: 'viewProgress', + effects: Array.from({ length: CARDS }, (_, i) => fanEffect(i)), +}; +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle-2.md b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle-2.md new file mode 100644 index 0000000..7cac926 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle-2.md @@ -0,0 +1,337 @@ +# Diagonal Shuffle + +Cards fly in diagonally from alternating corners, rotating and scaling into a loose center stack as the section scrolls. Any text attached to the images is lifted out into a sticky caption that alternates in sync with the stack. + +## Summary + +- **ID:** `diagonal-shuffle` +- **Target shape:** Best for 3–7 similarly sized **image-primary** subjects (photos/thumbnails, optionally with a short caption/number/label) that can be absolutely centered inside one sticky viewport stage, where each can animate independently over a staggered scroll range. **Not for text-content grids** (blog/feature/amenity cards built from heading + paragraph + button) — those are a reject (see the Gate). +- **Description:** Several centered image cards each fly in from an alternating bottom corner (odd from bottom-left, even from bottom-right), un-rotating and scaling up to settle at a slight tilt as the section scrolls past, converging into a loose pinned stack. Any text that belonged to each image is displayed separately as a sticky caption that fades in while its image is the one at center and fades out as the next image arrives. +- **Core motion (non-negotiable):** This is a **CONVERGENCE**. Cards must start off-screen and *gather* onto a single point at the center of a **sticky, pinned stage**. If the cards end up in their normal in-flow layout slots (a spread-out row/grid), the pattern has NOT been reproduced. Sticky pinning + absolute centering is what makes the convergence exist. **A convergence that flies cards in but then lets the stage scroll away — leaving frames mostly blank after the first card — is an equally severe failure: the pattern only counts as reproduced if the stack forms AND remains pinned at center through the full scroll range.** +- **A "Card" in the source is a BUNDLE: image + the text/button beneath or beside it.** The most common misread of this pattern is failing to see that the image and the paragraph/number/button under it are **one repeated unit**. Recognize the whole bundle — then *never* animate the bundle. What flies is ONLY the image extracted from it; the text is handled separately (lifted to a sticky caption) or the section is rejected. Binding a card to the bundle (or to the text-bearing part of it) tilts whole text blocks and buttons into a diagonal mess with no convergence — the single most disqualifying outcome. +- **What flies is ONE clean image node — never a bundle, cell, or caption-bearing node.** The animated card must be a single, text-free visual node (an image-only clone). If any label, caption, number, heading, or button rides along on the converging card, the pattern is NOT reproduced. This holds even when the convergence itself (sticky + centering) works. +- **Attached text becomes a sticky, alternating caption — it does NOT ride the stack and does NOT stay in the scattered grid.** When each image has an associated short caption/label/number, lift that text into a separate sticky caption layer pinned top-left on the stage. Each caption fades in as its image reaches center and fades out as the next image flies in, so exactly one caption reads at a time. This is how the text stays legible and synced without polluting the image stack. +- **THE ONE SANCTIONED BUILD:** There is exactly **one** way to build this pattern: **synthesize a fresh standalone sticky stage layered over the section, clone ONLY each `` into fresh empty wrappers as the cards, and (if images carry text) lift each caption into a synthesized sticky caption layer.** You do not reuse the section's grid/gallery containers as the stage, you do not bind cards to any pre-existing DOM node, and you do not "animate the existing cells in place." Those are the failure modes this pattern keeps falling into, and each is an **automatic build-time reject**. The only decision per section is: run the sanctioned build, or reject the section entirely. + +## The Gate (mandatory — decide BEFORE mapping anything) + +Run this classification on the repeated unit first. It has exactly three outcomes and there is **no fourth "animate the cells" path**. + +1. **Identify the repeated bundle.** For each subject, find the whole repeated unit as authored — this is almost always `image + attached text` (caption, number, or heading/paragraph/button). Treat that bundle as one Card conceptually. +2. **Classify the bundle:** + - **IMAGE-PRIMARY (proceed):** the unit is dominated by a standalone `` you can cleanly isolate, and any text is a *short* caption / number / one-line label. → Run the Build Procedure (synthesize stage + clone images), and if text is present, lift it into a sticky alternating caption layer. + - **TEXT-CONTENT CARD (REJECT):** the repeated unit is a rich content block — heading **and** paragraph **and/or** a button/link ("Read More", "Learn More", CTA) — where the meaning lives in the text, OR there is no image cleanly separable from that text. This describes blog cards, feature cards, and amenity/service cards. → **REJECT the section. Produce a clean rejection and nothing else.** Do NOT converge the images, do NOT lift the text into captions, do NOT animate the cells. Stripping the text would destroy the content, and animating the bundle produces flying tilted text blocks and buttons — the exact forbidden result. + + +## Build Procedure (mandatory — for IMAGE-PRIMARY sections only) + +Apply these steps for every section that passed the Gate. Do not branch on "the images look clean" — always synthesize (this also fixes fragile deep selectors like `.g-image` that happen to work today but break tomorrow). + +1. **Extract the visual subjects.** Find the `` (or equivalent thumbnail) node for each of the 3–7 subjects. This is the only visual thing you extract from the existing DOM. +2. **Extract the attached text (if any).** For each subject, capture the short caption/number/label text that belonged to its bundle. You will re-home this text in a synthesized sticky caption layer — never leave it bound to the source cell, and never let it enter a card wrapper. +3. **Synthesize a fresh stage.** Create a NEW `
            ` layered over (or wrapping) the section and give it the `stickyStage` styles. **Never** assign the `stickyStage` role to an element that already exists in the section — no gallery `…__content` wrapper, no `.g-item` container, no grid/flex layout div, no `#comp-…` root. A reused container drags along its own text/structure and its (or an ancestor's) `overflow`/`transform`/`filter`/`opacity` silently kills `position: sticky`. +4. **Clone only the image into fresh empty wrappers.** For each subject, create a brand-new empty `
            `, clone ONLY the `` into it, and append it to the synthesized stage. The card wrapper owns its own `aspect-ratio`/box sizing. The original bundled cells stay in flow or are hidden — they never enter the stage and never get a card key. +5. **Synthesize the sticky caption layer (if text was extracted).** Create a NEW `
            ` on the stage and put each subject's text into its own `

            `, stacked at the same top-left anchor. Bind each caption to a key so it can alternate opacity (see template). Captions are synthesized nodes, never the source text nodes left in place. +6. **Bind keys only to synthesized wrappers.** `repeatedCard` must resolve to a card node you created; `stickyCaption` must resolve to a caption node you created. If any key resolves to a pre-existing element (a cell, figure, `.g-item`, or a deep descendant like `.g-item:nth-of-type(n) .g-image` / `… img`), that is an **automatic reject of the mapping**. Fix by cloning into fresh wrappers, or reject the section. + +> Structural enforcement: because the card is always a wrapper you created around only an ``, and the caption is always synthesized text, carrying text into the stack becomes impossible and deep/fragile selectors never arise. If you find yourself typing a selector that points into the section's original markup for the stage, a card, or a caption, stop — you have left the sanctioned build. + +## Demo HTML + +```html +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            +

            +

            +

            +

            +
            +
            +
            +``` + +> `.sticky-wrapper` is a freshly synthesized stage; each `.card` holds ONLY the cloned image; `.caption-layer` holds the lifted texts, stacked top-left, alternating opacity. Captions, numbers, titles, and buttons are never inside a flying card. The `.caption-layer` is omitted entirely when the images have no attached text. + +## Selector Contract + +1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns sticky pinning + clipping + perspective, `repeatedCard` owns absolute centering plus the diagonal fly-in transform, and `stickyCaption` owns a pinned top-left text slot with alternating opacity. **These roles cannot be collapsed** — in particular, `stickyStage` is not optional and cannot be replaced by the section's existing in-flow layout container. +2. **`stickyStage` MUST be a synthesized standalone wrapper — reusing an existing container is an automatic reject.** Do not bind it to a gallery/grid/section container (`.comp-…__content`, `.g-item`, a flex/grid layout div, an internal `#comp-…` root), *even if it renders correctly in one preview*. Such wrappers, or an ancestor, frequently carry `overflow: hidden/auto`, `transform`, `filter`, or `opacity < 1` that *silently* kill `position: sticky` and freeze ViewTimeline — the stage scrolls out of view and the converged cards leave the viewport (blank frames). Always create a fresh `
            ` layered over the section. +3. **`repeatedCard` MUST resolve to a node you synthesized in this build — binding it to any pre-existing DOM element is an automatic reject.** The card is a fresh empty wrapper into which you clone ONLY the ``. It must NEVER be a bundle/cell/`.g-item`/figure that also holds a caption, number, title, link, button, or paragraph, and it must NEVER be a deep descendant chain (`.g-item:nth-of-type(3) .g-image`, `.g-item:nth-of-type(n) img`, etc.) — those are fragile even when the node happens to be image-only. If the repeated unit bundles image + text, you extract the image out; you may not point at an inner node inside the cell. +4. **`stickyCaption` (only when images carry text) MUST be a synthesized text node in a sticky layer — never the source text left in place.** Each caption is pinned top-left on the stage and alternates opacity in sync with its card. Captions never sit inside a card wrapper (they would ride the stack) and never stay in the original scattered grid (they would drift with the layout). If a section's text cannot be reduced to a short sticky caption — because it is a rich heading+paragraph+button block — that is a Gate REJECT, not a caption. +5. **There is no "animate in place" option.** For a multi-column flex/grid of cells whose content includes headings, paragraphs, buttons, captions, or numbering, the ONLY allowed responses are (a) if IMAGE-PRIMARY, run the Build Procedure (synthesize a stage, image-only clones, sticky captions) — or (b) REJECT. Mapping those cells to `repeatedCard` and animating them where they sit is forbidden and disqualifying. +6. **Deep descendant card selectors are a rejection-worthy defect, not a warning.** Brittle chains into a gallery's internal DOM are rejected because they (a) break on re-render/re-order, (b) still leave the card nested inside the caption-bearing cell so its text rides along, and (c) target an inner node that has *lost* the card's own `aspect-ratio`/box sizing, so the animated element collapses or distorts. Every card is a stable, synthesized single-node clone wrapper. +7. Repeated cards are absolute children centered on `stickyStage` via `top/left: 50%` + a base `translate(-50%, -50%)`. **Every keyframe transform MUST re-declare that centering translate before adding the fly-in offset.** Omitting it means the cards never gather at center — the #1 motion failure and a direct symptom of skipping the sticky stage. +8. This pattern has no `collection` grid role — cards stack directly on the sticky stage. Do not introduce or reuse a flex/grid wrapper that removes the absolute centering. +9. Keep the fly-in transform on the card roots, not on raw `img` descendants; the `img` fills the card via `object-cover` and must not carry the animation. +10. Use rendered `#comp-...` ids only for locating the source ``/text nodes to clone, never `DESKTOP--...` ids. Do NOT map `stickyStage`, `repeatedCard`, or `stickyCaption` to any `#comp-...` element — those roles are always synthesized. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section that drives the shared `viewProgress` trigger for every card and caption. This is the one role that may map to an existing section element. | +| `stickyStage` | A **freshly synthesized** sticky viewport-sized wrapper that pins during scroll, clips the flying cards, and provides `perspective`. **Mandatory, always synthesized, never a reused gallery/grid/`…__content`/`#comp-…` container**, and it must stay pinned for the entire scroll range — not just at the start. | +| `repeatedCard` | Absolutely centered sibling cards, each a **freshly synthesized** single-node **image-only** clone wrapper — no caption, number, title, or button, and no pre-existing/deep-descendant selector — that fly in from an alternating corner over a staggered range. Binding this role to any pre-existing DOM node is an automatic reject. | +| `stickyCaption` | **Present only for image-primary sections whose images carry text.** A **freshly synthesized** text node in a sticky top-left layer that fades in while its image is centered and fades out as the next image arrives, so one caption reads at a time. Never a card child; never the original text left in the grid. If the text is a rich heading+paragraph+button block, do not create captions — the section is a Gate REJECT. | + +## Adaptation Notes + +1. **Run the Gate before anything else.** Image-primary → build. Text-content card (heading+paragraph+button) or no isolable image → clean REJECT with no animation. The one section type that reproduces easily (clean product images) and image galleries with short captions take the *same* build; text-content grids take *no* build. +2. **Recognize the bundle, then split it.** The repeated unit is almost always `image + attached text`. Never animate the bundle. Clone ONLY the `` into a fresh card wrapper; lift the short caption into the sticky caption layer; hide or leave the original cell in flow. The captions/numbers must never enter the stage as card children and never get a card key. +3. **Handle attached text as a sticky, alternating caption.** Pin the caption layer top-left on the stage. Each caption's opacity ramps 0→1 over its own card's fly-in range and 1→0 as the next card flies in (last caption stays visible to the end). This keeps the text readable and in sync while the images pile up cleanly at center. If there is no attached text, omit the caption layer entirely. +4. **Always synthesize even when the source offers a working selector.** A deep selector like `.g-image` may render correctly today because it happens to be image-only, but it is fragile and leaves the node nested in its caption cell. Clone the image into a fresh wrapper anyway — reliability across sections comes from a uniform synthesized build. +5. **Never reuse the gallery's own containers for the stage.** Their internal wrappers (`…__content`, item containers, `#comp-…` roots) are the single most common place sticky silently dies and the most common source of text artifacts and zero-box collapse. Overlay a fresh `
            ` and place the image-only clones + caption layer into it. +6. **Ancestor safety check (required, on the synthesized stage).** After creating the stage, verify that NONE of its ancestors up to the scroll source carries `overflow: hidden`/`auto`, `transform`, `filter`, or `opacity < 1`. Any one breaks `position: sticky` and freezes ViewTimeline. If an offending ancestor exists and cannot be neutralized, hoist the stage above it (or reject). +7. Alternate the fly-in side by index: odd cards enter from bottom-left (negative X, negative rotate), even from bottom-right (positive X, positive rotate). Preserve the small settle rotation so the final stack stays loose. +8. **Stagger from the real card count so the LAST card settles before scroll end.** Do not hard-code the demo's 5-card offsets. Distribute the staggered windows across a usable range that ends around 90% of `cover`, and derive the step from `N`, so the final card fully arrives while the stage is still pinned (never off-screen at the last frame). See the template's `cardRange`. Captions inherit the same per-card timing. +9. Size the runway from card count: `~90vh` per card plus intro/outro slack (demo `450vh` covers five). +10. Off-screen distances are viewport-relative (`±80vw`, `50vh`); keep them in `vw/vh`. Reduce on wide screens if cards feel too far-flung. +11. Cards start visible (`opacity: 1`) and rely on being off-stage + clipped by `overflow: clip`. Captions are the exception — they use explicit opacity keyframes to alternate. +12. **Verify the convergence — motion, cleanliness, and captions, at BOTH ends AND the middle.** Scrub the full scroll range: + - **Start:** every card off-screen; only the first caption (if any) is beginning to appear. + - **Mid-scroll (critical):** the stage is STILL pinned at center and the accumulating stack is visible — frames must not go blank after the first card. A blank mid-range means the stage un-pinned (clip/transform ancestor per §6, or a reused wrapper that should never have been used). + - **Settle:** every card overlaps at center in a tilted stack, none in a distinct layout slot; the **last** card is fully arrived before scroll end, not still off-screen. + - **Clean stack (mandatory):** the stacked cards show ONLY imagery — no caption text, numbers, titles, or buttons piled in the stack or scattered at the stage bottom. Any text on the flying/stacked cards means a bundle/cell was animated or a deep node targeted — re-run the Build Procedure or reject. + - **Caption sync (when captions exist):** exactly one caption is legible at a time, pinned top-left, switching as each new image reaches center. + If any check fails, fix the offending role or reject. "Cards fly in" alone is NOT sufficient evidence. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `#scroll-section` | The `viewProgress` source for all card and caption effects. May map to an existing section element. | +| `stickyStage` | `stickyStage` | `#scroll-section .sticky-wrapper` | Sticky pin + clip + perspective. Mandatory and **always a synthesized standalone wrapper** — never a reused gallery/grid/`…__content`/`#comp-…` container. Confirm no clip/transform ancestor (Adaptation §6). | +| `card1` | `repeatedCard` | `#card-1` | Minimum repeated fly-in card — a synthesized single-node **image-only** clone wrapper (odd → from left); extend outward for `card4..cardN`. | +| `card2` | `repeatedCard` | `#card-2` | Repeated fly-in card (even → from right). | +| `card3` | `repeatedCard` | `#card-3` | Repeated fly-in card (odd → from left). | +| `card4` | `repeatedCard` | `#card-4` | Repeated fly-in card (even → from right). | +| `card5` | `repeatedCard` | `#card-5` | Repeated fly-in card (odd → from left). | +| `caption1` | `stickyCaption` | `#caption-1` | **Only when images carry text.** Synthesized sticky top-left text, fades in/out in sync with `card1`; extend for `caption4..captionN`. Omit the whole caption row if there is no attached text. | +| `caption2` | `stickyCaption` | `#caption-2` | Sticky caption synced with `card2`. | +| `caption3` | `stickyCaption` | `#caption-3` | Sticky caption synced with `card3`. | +| `caption4` | `stickyCaption` | `#caption-4` | Sticky caption synced with `card4`. | +| `caption5` | `stickyCaption` | `#caption-5` | Sticky caption synced with `card5`. | + +> Repeated card and caption keys keep their trailing index (`card1`/`caption1`, …) so they compact into `card{n}`/`caption{n}` groups; extend the rows for more items, alternating card entry side by parity. **Each card key MUST resolve to a synthesized image-only clone wrapper; each caption key to a synthesized sticky text node — both created in this build.** If any key resolves to a pre-existing cell that carries text/buttons, or to a deep descendant, that is an automatic reject. If the Gate classified the section as a text-content card, produce NO elements — reject the section. + +## Required Styles + +### `scrollSource` — `#scroll-section` + +```css +#scroll-section { + position: relative; + height: 450vh; +} +``` + +Reason: creates enough scroll distance for all staggered fly-in ranges to play out. + +### `stickyStage` — `#scroll-section .sticky-wrapper` + +```css +#scroll-section .sticky-wrapper { + position: sticky; + top: 0; + height: 100vh; + width: 100vw; + overflow: clip; + perspective: 1200px; +} +``` + +Reason: pins the stage so cards have a single fixed anchor to converge onto, clips off-screen cards without breaking ViewTimeline (`clip`, not `hidden`), and adds depth for the tilt. Never omit it; **always create it fresh**. Sticky only holds if no ancestor between this wrapper and `#scroll-section` sets `overflow: hidden/auto`, `transform`, `filter`, or `opacity < 1` (Adaptation §5–6). + +### `repeatedCard` — `#scroll-section .card` + +```css +#scroll-section .card { + position: absolute; + top: 50%; + left: 50%; + width: 90vw; + max-width: 400px; + aspect-ratio: 3 / 4; + border-radius: 1rem; + transform-style: preserve-3d; + will-change: transform, opacity; + overflow: hidden; +} + +#scroll-section .card > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +@media (min-width: 768px) { + #scroll-section .card { + aspect-ratio: 4 / 3; + } +} +``` + +Reason: absolutely centers each card and establishes the base box the keyframe transforms build on; the base `translate(-50%, -50%)` depends on this. If the card is `position: static/relative` (as in-flow grid cells are), the centering translate has nothing to anchor to and the convergence fails. **The card must be a synthesized wrapper that owns its box sizing and contains ONLY the cloned image.** + +### `stickyCaption` — `#scroll-section .caption-layer` / `.caption` + +```css +#scroll-section .caption-layer { + position: absolute; + top: 6vh; + left: 6vw; + max-width: min(90vw, 32rem); + pointer-events: none; + z-index: 2; +} + +#scroll-section .caption { + position: absolute; /* all captions share the same top-left anchor */ + top: 0; + left: 0; + margin: 0; + opacity: 0; /* alternated by the caption effect */ + will-change: opacity; +} +``` + +Reason: pins every caption to a single top-left slot above the stack (`z-index` over the cards) and defaults them hidden; the caption effect ramps opacity so exactly one reads at a time, synced to its card. Omit this block entirely for sections whose images have no attached text. Never bind these styles to the original text cells — the captions are synthesized nodes. + +## Suggested Controls + +Expose the fly-in distance and the entrance scale by default; add scroll length only when the section owns its own runway height. + +### `fly-distance` + +- **Label:** `Fly-In Distance` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `80` +- **Description:** How far off-screen (horizontally) each card starts before shuffling to center. +- **Constraints:** `min: 40`, `max: 100`, `step: 5`, `unit: vw` +- **Binding:** `variable` `--card-fly-distance` using template `${value}vw` + +### `start-scale` + +- **Label:** `Entrance Scale` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `0.7` +- **Description:** The scale of each card at the start of its fly-in, before it grows to full size. +- **Constraints:** `min: 0.5`, `max: 1`, `step: 0.05`, `unit: x` +- **Binding:** `variable` `--card-start-scale` using a direct value + +### `scroll-length` + +- **Label:** `Scroll Length` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `450` +- **Description:** Total scroll runway height; increase for more cards or slower shuffling. +- **Constraints:** `min: 300`, `max: 700`, `step: 25`, `unit: vh` +- **Binding:** `style` `#scroll-section` property `height` using template `${value}vh` + +## Interact Template + +```ts +const EASING = 'ease-out'; + +const CARD_COUNT = 5; // set from the real number of extracted images +const RANGE_START = 5; // percent of 'cover' where the first card begins +const RANGE_END = 90; // percent where the LAST card must be fully settled (< 100 so it lands before scroll end) +const CARD_DURATION = 20; // percent of 'cover' each card takes to fly in + +// Count-aware stagger: step so the last card ENDS at RANGE_END, never off-screen at the final frame. +// step = (RANGE_END - RANGE_START - CARD_DURATION) / (CARD_COUNT - 1) -> a small overlap between neighbours. +const STEP = + CARD_COUNT > 1 ? (RANGE_END - RANGE_START - CARD_DURATION) / (CARD_COUNT - 1) : 0; +const cardStart = (index: number) => RANGE_START + index * STEP; + +const cardRange = (index: number) => ({ + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: cardStart(index) } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: cardStart(index) + CARD_DURATION } }, + easing: EASING, + fill: 'both' as const, +}); + +// Odd cards fly in from bottom-left, even from bottom-right; small settle rotation keeps the stack loose. +// --card-fly-distance (default 80vw) and --card-start-scale (default 0.7) drive the entrance. +// NOTE: the leading `translate(-50%, -50%)` is REQUIRED in BOTH keyframes — it re-declares the +// absolute centering so the card converges onto the stage center. +// GATE (run first): only IMAGE-PRIMARY sections reach this template. Text-content cards (heading + +// paragraph + button) are a clean REJECT — do NOT emit any card/caption effects for them. +// BUILD: `stickyStage` is a FRESHLY SYNTHESIZED wrapper (never a reused gallery/grid/#comp-… container), +// each `card{n}` resolves to a wrapper holding ONLY a cloned , and any attached text lives in the +// synthesized sticky caption layer below — never inside a card. +const flyInEffect = (key: string, index: number, settleRotate: number) => { + const fromLeft = index % 2 === 0; // index 0,2,4 => card1,card3,card5 => left + const dx = fromLeft ? 'calc(-1 * var(--card-fly-distance, 80vw))' : 'var(--card-fly-distance, 80vw)'; + const startRotate = fromLeft ? -45 : 45; + return { + key, + keyframeEffect: { + name: `${key}-fly-in`, + keyframes: [ + { + transform: `translate(-50%, -50%) translate(${dx}, 50vh) rotate(${startRotate}deg) scale(var(--card-start-scale, 0.7))`, + opacity: 1, + }, + { + transform: `translate(-50%, -50%) translate(0, 0) rotate(${settleRotate}deg) scale(1)`, + opacity: 1, + }, + ], + }, + ...cardRange(index), + }; +}; + +// Sticky caption: text lifted out of each image's bundle, pinned top-left, alternating. +// Caption i fades in as card i arrives (its own range) and fades out as card i+1 arrives; +// the last caption holds to scroll end. Emit these ONLY when the images carried short captions. +const captionEffect = (key: string, index: number) => { + const isLast = index === CARD_COUNT - 1; + const start = cardStart(index); + const end = isLast ? 100 : cardStart(index + 1) + CARD_DURATION; + return { + key, + keyframeEffect: { + name: `${key}-caption`, + keyframes: isLast + ? [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 0.35 }, { opacity: 1, offset: 1 }] + : [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 0.3 }, + { opacity: 1, offset: 0.7 }, + { opacity: 0, offset: 1 }, + ], + }, + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: start } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: end } }, + easing: EASING, + fill: 'both' as const, + }; +}; + +// Final settle tilts taper toward 0 on the last card — recompute for a different count. +const SETTLE_ROTATIONS = [-4, 3, -2, 1, 0]; + +const HAS_CAPTIONS = true; // false when the extracted images had no attached text + +const interactions = SETTLE_ROTATIONS.slice(0, CARD_COUNT).map((rotate, index) => ({ + key: 'scrollSection', + trigger: 'viewProgress', + effects: [ + flyInEffect(`card${index + 1}`, index, rotate), + ...(HAS_CAPTIONS ? [captionEffect(`caption${index + 1}`, index)] : []), + ], +})); +``` diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md new file mode 100644 index 0000000..cfe9c5a --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md @@ -0,0 +1,339 @@ +# Diagonal Shuffle + +Cards fly in diagonally from alternating corners, rotating and scaling into a loose center stack as the section scrolls. Any text attached to the images is lifted out into a sticky caption that alternates in sync with the stack. + +## Summary + +- **ID:** `diagonal-shuffle` +- **Target shape:** Best for 3–7 similarly sized **image-primary** subjects (photos/thumbnails, optionally with a short caption/number/label) that can be absolutely centered inside one sticky viewport stage, where each can animate independently over a staggered scroll range. **Not for text-content grids** (blog/feature/amenity cards built from heading + paragraph + button) — those are a reject (see the Gate). +- **Description:** Several centered image cards each fly in from an alternating bottom corner (odd from bottom-left, even from bottom-right), un-rotating and scaling up to settle at a slight tilt as the section scrolls past, converging into a loose pinned stack. Any text that belonged to each image is displayed separately as a sticky caption that fades in while its image is the one at center and fades out as the next image arrives. +- **Core motion (non-negotiable):** This is a **CONVERGENCE**. Cards must start off-screen and *gather* onto a single point at the center of a **sticky, pinned stage**. If the cards end up in their normal in-flow layout slots (a spread-out row/grid), the pattern has NOT been reproduced. Sticky pinning + absolute centering is what makes the convergence exist. **A convergence that flies cards in but then lets the stage scroll away — leaving frames mostly blank after the first card — is an equally severe failure: the pattern only counts as reproduced if the stack forms AND remains pinned at center through the full scroll range.** +- **A "Card" in the source is a BUNDLE: image + the text/button beneath or beside it.** The most common misread of this pattern is failing to see that the image and the paragraph/number/button under it are **one repeated unit**. Recognize the whole bundle — then *never* animate the bundle. What flies is ONLY the image extracted from it; the text is handled separately (lifted to a sticky caption) or the section is rejected. Binding a card to the bundle (or to the text-bearing part of it) tilts whole text blocks and buttons into a diagonal mess with no convergence — the single most disqualifying outcome. +- **What flies is ONE clean image node — never a bundle, cell, or caption-bearing node.** The animated card must be a single, text-free visual node (an image-only clone). If any label, caption, number, heading, or button rides along on the converging card, the pattern is NOT reproduced. This holds even when the convergence itself (sticky + centering) works. +- **Attached text becomes a sticky, alternating caption — it does NOT ride the stack and does NOT stay in the scattered grid.** When each image has an associated short caption/label/number, lift that text into a separate sticky caption layer pinned top-left on the stage. Each caption fades in as its image reaches center and fades out as the next image flies in, so exactly one caption reads at a time. This is how the text stays legible and synced without polluting the image stack. +- **THE ONE SANCTIONED BUILD:** There is exactly **one** way to build this pattern: **synthesize a fresh standalone sticky stage layered over the section, clone ONLY each `` into fresh empty wrappers as the cards, and (if images carry text) lift each caption into a synthesized sticky caption layer.** You do not reuse the section's grid/gallery containers as the stage, you do not bind cards to any pre-existing DOM node, and you do not "animate the existing cells in place." Those are the failure modes this pattern keeps falling into, and each is an **automatic build-time reject**. The only decision per section is: run the sanctioned build, or reject the section entirely. + +## The Gate (mandatory — decide BEFORE mapping anything) + +Run this classification on the repeated unit first. It has exactly three outcomes and there is **no fourth "animate the cells" path**. + +1. **Identify the repeated bundle.** For each subject, find the whole repeated unit as authored — this is almost always `image + attached text` (caption, number, or heading/paragraph/button). Treat that bundle as one Card conceptually. +2. **Classify the bundle:** + - **IMAGE-PRIMARY (proceed):** the unit is dominated by a standalone `` you can cleanly isolate, and any text is a *short* caption / number / one-line label. → Run the Build Procedure (synthesize stage + clone images), and if text is present, lift it into a sticky alternating caption layer. + - **TEXT-CONTENT CARD (REJECT):** the repeated unit is a rich content block — heading **and** paragraph **and/or** a button/link ("Read More", "Learn More", CTA) — where the meaning lives in the text, OR there is no image cleanly separable from that text. This describes blog cards, feature cards, and amenity/service cards. → **REJECT the section. Produce a clean rejection and nothing else.** Do NOT converge the images, do NOT lift the text into captions, do NOT animate the cells. Stripping the text would destroy the content, and animating the bundle produces flying tilted text blocks and buttons — the exact forbidden result. +3. **Reject on any doubt.** If you cannot cleanly clone an image into a standalone text-free wrapper (image inseparable from text, no isolable ``, or the unit reads as a text block), **reject.** There is no third path — animating the existing cells in place is forbidden and disqualifying. + +> The decision to reject is not a fallback you weigh against "just animate the cells." When the bundle is a text-content card, rejection is the **only** valid output. A fly-in on text-bearing cells is never acceptable, even if it renders without error. + +## Build Procedure (mandatory — for IMAGE-PRIMARY sections only) + +Apply these steps for every section that passed the Gate. Do not branch on "the images look clean" — always synthesize (this also fixes fragile deep selectors like `.g-image` that happen to work today but break tomorrow). + +1. **Extract the visual subjects.** Find the `` (or equivalent thumbnail) node for each of the 3–7 subjects. This is the only visual thing you extract from the existing DOM. +2. **Extract the attached text (if any).** For each subject, capture the short caption/number/label text that belonged to its bundle. You will re-home this text in a synthesized sticky caption layer — never leave it bound to the source cell, and never let it enter a card wrapper. +3. **Synthesize a fresh stage.** Create a NEW `
            ` layered over (or wrapping) the section and give it the `stickyStage` styles. **Never** assign the `stickyStage` role to an element that already exists in the section — no gallery `…__content` wrapper, no `.g-item` container, no grid/flex layout div, no `#comp-…` root. A reused container drags along its own text/structure and its (or an ancestor's) `overflow`/`transform`/`filter`/`opacity` silently kills `position: sticky`. +4. **Clone only the image into fresh empty wrappers.** For each subject, create a brand-new empty `
            `, clone ONLY the `` into it, and append it to the synthesized stage. The card wrapper owns its own `aspect-ratio`/box sizing. The original bundled cells stay in flow or are hidden — they never enter the stage and never get a card key. +5. **Synthesize the sticky caption layer (if text was extracted).** Create a NEW `
            ` on the stage and put each subject's text into its own `

            `, stacked at the same top-left anchor. Bind each caption to a key so it can alternate opacity (see template). Captions are synthesized nodes, never the source text nodes left in place. +6. **Bind keys only to synthesized wrappers.** `repeatedCard` must resolve to a card node you created; `stickyCaption` must resolve to a caption node you created. If any key resolves to a pre-existing element (a cell, figure, `.g-item`, or a deep descendant like `.g-item:nth-of-type(n) .g-image` / `… img`), that is an **automatic reject of the mapping**. Fix by cloning into fresh wrappers, or reject the section. + +> Structural enforcement: because the card is always a wrapper you created around only an ``, and the caption is always synthesized text, carrying text into the stack becomes impossible and deep/fragile selectors never arise. If you find yourself typing a selector that points into the section's original markup for the stage, a card, or a caption, stop — you have left the sanctioned build. + +## Demo HTML + +```html +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            +

            +

            +

            +

            +
            +
            +
            +``` + +> `.sticky-wrapper` is a freshly synthesized stage; each `.card` holds ONLY the cloned image; `.caption-layer` holds the lifted texts, stacked top-left, alternating opacity. Captions, numbers, titles, and buttons are never inside a flying card. The `.caption-layer` is omitted entirely when the images have no attached text. + +## Selector Contract + +1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns sticky pinning + clipping + perspective, `repeatedCard` owns absolute centering plus the diagonal fly-in transform, and `stickyCaption` owns a pinned top-left text slot with alternating opacity. **These roles cannot be collapsed** — in particular, `stickyStage` is not optional and cannot be replaced by the section's existing in-flow layout container. +2. **`stickyStage` MUST be a synthesized standalone wrapper — reusing an existing container is an automatic reject.** Do not bind it to a gallery/grid/section container (`.comp-…__content`, `.g-item`, a flex/grid layout div, an internal `#comp-…` root), *even if it renders correctly in one preview*. Such wrappers, or an ancestor, frequently carry `overflow: hidden/auto`, `transform`, `filter`, or `opacity < 1` that *silently* kill `position: sticky` and freeze ViewTimeline — the stage scrolls out of view and the converged cards leave the viewport (blank frames). Always create a fresh `
            ` layered over the section. +3. **`repeatedCard` MUST resolve to a node you synthesized in this build — binding it to any pre-existing DOM element is an automatic reject.** The card is a fresh empty wrapper into which you clone ONLY the ``. It must NEVER be a bundle/cell/`.g-item`/figure that also holds a caption, number, title, link, button, or paragraph, and it must NEVER be a deep descendant chain (`.g-item:nth-of-type(3) .g-image`, `.g-item:nth-of-type(n) img`, etc.) — those are fragile even when the node happens to be image-only. If the repeated unit bundles image + text, you extract the image out; you may not point at an inner node inside the cell. +4. **`stickyCaption` (only when images carry text) MUST be a synthesized text node in a sticky layer — never the source text left in place.** Each caption is pinned top-left on the stage and alternates opacity in sync with its card. Captions never sit inside a card wrapper (they would ride the stack) and never stay in the original scattered grid (they would drift with the layout). If a section's text cannot be reduced to a short sticky caption — because it is a rich heading+paragraph+button block — that is a Gate REJECT, not a caption. +5. **There is no "animate in place" option.** For a multi-column flex/grid of cells whose content includes headings, paragraphs, buttons, captions, or numbering, the ONLY allowed responses are (a) if IMAGE-PRIMARY, run the Build Procedure (synthesize a stage, image-only clones, sticky captions) — or (b) REJECT. Mapping those cells to `repeatedCard` and animating them where they sit is forbidden and disqualifying. +6. **Deep descendant card selectors are a rejection-worthy defect, not a warning.** Brittle chains into a gallery's internal DOM are rejected because they (a) break on re-render/re-order, (b) still leave the card nested inside the caption-bearing cell so its text rides along, and (c) target an inner node that has *lost* the card's own `aspect-ratio`/box sizing, so the animated element collapses or distorts. Every card is a stable, synthesized single-node clone wrapper. +7. Repeated cards are absolute children centered on `stickyStage` via `top/left: 50%` + a base `translate(-50%, -50%)`. **Every keyframe transform MUST re-declare that centering translate before adding the fly-in offset.** Omitting it means the cards never gather at center — the #1 motion failure and a direct symptom of skipping the sticky stage. +8. This pattern has no `collection` grid role — cards stack directly on the sticky stage. Do not introduce or reuse a flex/grid wrapper that removes the absolute centering. +9. Keep the fly-in transform on the card roots, not on raw `img` descendants; the `img` fills the card via `object-cover` and must not carry the animation. +10. Use rendered `#comp-...` ids only for locating the source ``/text nodes to clone, never `DESKTOP--...` ids. Do NOT map `stickyStage`, `repeatedCard`, or `stickyCaption` to any `#comp-...` element — those roles are always synthesized. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section that drives the shared `viewProgress` trigger for every card and caption. This is the one role that may map to an existing section element. | +| `stickyStage` | A **freshly synthesized** sticky viewport-sized wrapper that pins during scroll, clips the flying cards, and provides `perspective`. **Mandatory, always synthesized, never a reused gallery/grid/`…__content`/`#comp-…` container**, and it must stay pinned for the entire scroll range — not just at the start. | +| `repeatedCard` | Absolutely centered sibling cards, each a **freshly synthesized** single-node **image-only** clone wrapper — no caption, number, title, or button, and no pre-existing/deep-descendant selector — that fly in from an alternating corner over a staggered range. Binding this role to any pre-existing DOM node is an automatic reject. | +| `stickyCaption` | **Present only for image-primary sections whose images carry text.** A **freshly synthesized** text node in a sticky top-left layer that fades in while its image is centered and fades out as the next image arrives, so one caption reads at a time. Never a card child; never the original text left in the grid. If the text is a rich heading+paragraph+button block, do not create captions — the section is a Gate REJECT. | + +## Adaptation Notes + +1. **Run the Gate before anything else.** Image-primary → build. Text-content card (heading+paragraph+button) or no isolable image → clean REJECT with no animation. The one section type that reproduces easily (clean product images) and image galleries with short captions take the *same* build; text-content grids take *no* build. +2. **Recognize the bundle, then split it.** The repeated unit is almost always `image + attached text`. Never animate the bundle. Clone ONLY the `` into a fresh card wrapper; lift the short caption into the sticky caption layer; hide or leave the original cell in flow. The captions/numbers must never enter the stage as card children and never get a card key. +3. **Handle attached text as a sticky, alternating caption.** Pin the caption layer top-left on the stage. Each caption's opacity ramps 0→1 over its own card's fly-in range and 1→0 as the next card flies in (last caption stays visible to the end). This keeps the text readable and in sync while the images pile up cleanly at center. If there is no attached text, omit the caption layer entirely. +4. **Always synthesize even when the source offers a working selector.** A deep selector like `.g-image` may render correctly today because it happens to be image-only, but it is fragile and leaves the node nested in its caption cell. Clone the image into a fresh wrapper anyway — reliability across sections comes from a uniform synthesized build. +5. **Never reuse the gallery's own containers for the stage.** Their internal wrappers (`…__content`, item containers, `#comp-…` roots) are the single most common place sticky silently dies and the most common source of text artifacts and zero-box collapse. Overlay a fresh `
            ` and place the image-only clones + caption layer into it. +6. **Ancestor safety check (required, on the synthesized stage).** After creating the stage, verify that NONE of its ancestors up to the scroll source carries `overflow: hidden`/`auto`, `transform`, `filter`, or `opacity < 1`. Any one breaks `position: sticky` and freezes ViewTimeline. If an offending ancestor exists and cannot be neutralized, hoist the stage above it (or reject). +7. Alternate the fly-in side by index: odd cards enter from bottom-left (negative X, negative rotate), even from bottom-right (positive X, positive rotate). Preserve the small settle rotation so the final stack stays loose. +8. **Stagger from the real card count so the LAST card settles before scroll end.** Do not hard-code the demo's 5-card offsets. Distribute the staggered windows across a usable range that ends around 90% of `cover`, and derive the step from `N`, so the final card fully arrives while the stage is still pinned (never off-screen at the last frame). See the template's `cardRange`. Captions inherit the same per-card timing. +9. Size the runway from card count: `~90vh` per card plus intro/outro slack (demo `450vh` covers five). +10. Off-screen distances are viewport-relative (`±80vw`, `50vh`); keep them in `vw/vh`. Reduce on wide screens if cards feel too far-flung. +11. Cards start visible (`opacity: 1`) and rely on being off-stage + clipped by `overflow: clip`. Captions are the exception — they use explicit opacity keyframes to alternate. +12. **Verify the convergence — motion, cleanliness, and captions, at BOTH ends AND the middle.** Scrub the full scroll range: + - **Start:** every card off-screen; only the first caption (if any) is beginning to appear. + - **Mid-scroll (critical):** the stage is STILL pinned at center and the accumulating stack is visible — frames must not go blank after the first card. A blank mid-range means the stage un-pinned (clip/transform ancestor per §6, or a reused wrapper that should never have been used). + - **Settle:** every card overlaps at center in a tilted stack, none in a distinct layout slot; the **last** card is fully arrived before scroll end, not still off-screen. + - **Clean stack (mandatory):** the stacked cards show ONLY imagery — no caption text, numbers, titles, or buttons piled in the stack or scattered at the stage bottom. Any text on the flying/stacked cards means a bundle/cell was animated or a deep node targeted — re-run the Build Procedure or reject. + - **Caption sync (when captions exist):** exactly one caption is legible at a time, pinned top-left, switching as each new image reaches center. + If any check fails, fix the offending role or reject. "Cards fly in" alone is NOT sufficient evidence. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `#scroll-section` | The `viewProgress` source for all card and caption effects. May map to an existing section element. | +| `stickyStage` | `stickyStage` | `#scroll-section .sticky-wrapper` | Sticky pin + clip + perspective. Mandatory and **always a synthesized standalone wrapper** — never a reused gallery/grid/`…__content`/`#comp-…` container. Confirm no clip/transform ancestor (Adaptation §6). | +| `card1` | `repeatedCard` | `#card-1` | Minimum repeated fly-in card — a synthesized single-node **image-only** clone wrapper (odd → from left); extend outward for `card4..cardN`. | +| `card2` | `repeatedCard` | `#card-2` | Repeated fly-in card (even → from right). | +| `card3` | `repeatedCard` | `#card-3` | Repeated fly-in card (odd → from left). | +| `card4` | `repeatedCard` | `#card-4` | Repeated fly-in card (even → from right). | +| `card5` | `repeatedCard` | `#card-5` | Repeated fly-in card (odd → from left). | +| `caption1` | `stickyCaption` | `#caption-1` | **Only when images carry text.** Synthesized sticky top-left text, fades in/out in sync with `card1`; extend for `caption4..captionN`. Omit the whole caption row if there is no attached text. | +| `caption2` | `stickyCaption` | `#caption-2` | Sticky caption synced with `card2`. | +| `caption3` | `stickyCaption` | `#caption-3` | Sticky caption synced with `card3`. | +| `caption4` | `stickyCaption` | `#caption-4` | Sticky caption synced with `card4`. | +| `caption5` | `stickyCaption` | `#caption-5` | Sticky caption synced with `card5`. | + +> Repeated card and caption keys keep their trailing index (`card1`/`caption1`, …) so they compact into `card{n}`/`caption{n}` groups; extend the rows for more items, alternating card entry side by parity. **Each card key MUST resolve to a synthesized image-only clone wrapper; each caption key to a synthesized sticky text node — both created in this build.** If any key resolves to a pre-existing cell that carries text/buttons, or to a deep descendant, that is an automatic reject. If the Gate classified the section as a text-content card, produce NO elements — reject the section. + +## Required Styles + +### `scrollSource` — `#scroll-section` + +```css +#scroll-section { + position: relative; + height: 450vh; +} +``` + +Reason: creates enough scroll distance for all staggered fly-in ranges to play out. + +### `stickyStage` — `#scroll-section .sticky-wrapper` + +```css +#scroll-section .sticky-wrapper { + position: sticky; + top: 0; + height: 100vh; + width: 100vw; + overflow: clip; + perspective: 1200px; +} +``` + +Reason: pins the stage so cards have a single fixed anchor to converge onto, clips off-screen cards without breaking ViewTimeline (`clip`, not `hidden`), and adds depth for the tilt. Never omit it; **always create it fresh**. Sticky only holds if no ancestor between this wrapper and `#scroll-section` sets `overflow: hidden/auto`, `transform`, `filter`, or `opacity < 1` (Adaptation §5–6). + +### `repeatedCard` — `#scroll-section .card` + +```css +#scroll-section .card { + position: absolute; + top: 50%; + left: 50%; + width: 90vw; + max-width: 400px; + aspect-ratio: 3 / 4; + border-radius: 1rem; + transform-style: preserve-3d; + will-change: transform, opacity; + overflow: hidden; +} + +#scroll-section .card > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +@media (min-width: 768px) { + #scroll-section .card { + aspect-ratio: 4 / 3; + } +} +``` + +Reason: absolutely centers each card and establishes the base box the keyframe transforms build on; the base `translate(-50%, -50%)` depends on this. If the card is `position: static/relative` (as in-flow grid cells are), the centering translate has nothing to anchor to and the convergence fails. **The card must be a synthesized wrapper that owns its box sizing and contains ONLY the cloned image.** + +### `stickyCaption` — `#scroll-section .caption-layer` / `.caption` + +```css +#scroll-section .caption-layer { + position: absolute; + top: 6vh; + left: 6vw; + max-width: min(90vw, 32rem); + pointer-events: none; + z-index: 2; +} + +#scroll-section .caption { + position: absolute; /* all captions share the same top-left anchor */ + top: 0; + left: 0; + margin: 0; + opacity: 0; /* alternated by the caption effect */ + will-change: opacity; +} +``` + +Reason: pins every caption to a single top-left slot above the stack (`z-index` over the cards) and defaults them hidden; the caption effect ramps opacity so exactly one reads at a time, synced to its card. Omit this block entirely for sections whose images have no attached text. Never bind these styles to the original text cells — the captions are synthesized nodes. + +## Suggested Controls + +Expose the fly-in distance and the entrance scale by default; add scroll length only when the section owns its own runway height. + +### `fly-distance` + +- **Label:** `Fly-In Distance` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `80` +- **Description:** How far off-screen (horizontally) each card starts before shuffling to center. +- **Constraints:** `min: 40`, `max: 100`, `step: 5`, `unit: vw` +- **Binding:** `variable` `--card-fly-distance` using template `${value}vw` + +### `start-scale` + +- **Label:** `Entrance Scale` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `0.7` +- **Description:** The scale of each card at the start of its fly-in, before it grows to full size. +- **Constraints:** `min: 0.5`, `max: 1`, `step: 0.05`, `unit: x` +- **Binding:** `variable` `--card-start-scale` using a direct value + +### `scroll-length` + +- **Label:** `Scroll Length` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `450` +- **Description:** Total scroll runway height; increase for more cards or slower shuffling. +- **Constraints:** `min: 300`, `max: 700`, `step: 25`, `unit: vh` +- **Binding:** `style` `#scroll-section` property `height` using template `${value}vh` + +## Interact Template + +```ts +const EASING = 'ease-out'; + +const CARD_COUNT = 5; // set from the real number of extracted images +const RANGE_START = 5; // percent of 'cover' where the first card begins +const RANGE_END = 90; // percent where the LAST card must be fully settled (< 100 so it lands before scroll end) +const CARD_DURATION = 20; // percent of 'cover' each card takes to fly in + +// Count-aware stagger: step so the last card ENDS at RANGE_END, never off-screen at the final frame. +// step = (RANGE_END - RANGE_START - CARD_DURATION) / (CARD_COUNT - 1) -> a small overlap between neighbours. +const STEP = + CARD_COUNT > 1 ? (RANGE_END - RANGE_START - CARD_DURATION) / (CARD_COUNT - 1) : 0; +const cardStart = (index: number) => RANGE_START + index * STEP; + +const cardRange = (index: number) => ({ + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: cardStart(index) } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: cardStart(index) + CARD_DURATION } }, + easing: EASING, + fill: 'both' as const, +}); + +// Odd cards fly in from bottom-left, even from bottom-right; small settle rotation keeps the stack loose. +// --card-fly-distance (default 80vw) and --card-start-scale (default 0.7) drive the entrance. +// NOTE: the leading `translate(-50%, -50%)` is REQUIRED in BOTH keyframes — it re-declares the +// absolute centering so the card converges onto the stage center. +// GATE (run first): only IMAGE-PRIMARY sections reach this template. Text-content cards (heading + +// paragraph + button) are a clean REJECT — do NOT emit any card/caption effects for them. +// BUILD: `stickyStage` is a FRESHLY SYNTHESIZED wrapper (never a reused gallery/grid/#comp-… container), +// each `card{n}` resolves to a wrapper holding ONLY a cloned , and any attached text lives in the +// synthesized sticky caption layer below — never inside a card. +const flyInEffect = (key: string, index: number, settleRotate: number) => { + const fromLeft = index % 2 === 0; // index 0,2,4 => card1,card3,card5 => left + const dx = fromLeft ? 'calc(-1 * var(--card-fly-distance, 80vw))' : 'var(--card-fly-distance, 80vw)'; + const startRotate = fromLeft ? -45 : 45; + return { + key, + keyframeEffect: { + name: `${key}-fly-in`, + keyframes: [ + { + transform: `translate(-50%, -50%) translate(${dx}, 50vh) rotate(${startRotate}deg) scale(var(--card-start-scale, 0.7))`, + opacity: 1, + }, + { + transform: `translate(-50%, -50%) translate(0, 0) rotate(${settleRotate}deg) scale(1)`, + opacity: 1, + }, + ], + }, + ...cardRange(index), + }; +}; + +// Sticky caption: text lifted out of each image's bundle, pinned top-left, alternating. +// Caption i fades in as card i arrives (its own range) and fades out as card i+1 arrives; +// the last caption holds to scroll end. Emit these ONLY when the images carried short captions. +const captionEffect = (key: string, index: number) => { + const isLast = index === CARD_COUNT - 1; + const start = cardStart(index); + const end = isLast ? 100 : cardStart(index + 1) + CARD_DURATION; + return { + key, + keyframeEffect: { + name: `${key}-caption`, + keyframes: isLast + ? [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 0.35 }, { opacity: 1, offset: 1 }] + : [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 0.3 }, + { opacity: 1, offset: 0.7 }, + { opacity: 0, offset: 1 }, + ], + }, + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: start } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: end } }, + easing: EASING, + fill: 'both' as const, + }; +}; + +// Final settle tilts taper toward 0 on the last card — recompute for a different count. +const SETTLE_ROTATIONS = [-4, 3, -2, 1, 0]; + +const HAS_CAPTIONS = true; // false when the extracted images had no attached text + +const interactions = SETTLE_ROTATIONS.slice(0, CARD_COUNT).map((rotate, index) => ({ + key: 'scrollSection', + trigger: 'viewProgress', + effects: [ + flyInEffect(`card${index + 1}`, index, rotate), + ...(HAS_CAPTIONS ? [captionEffect(`caption${index + 1}`, index)] : []), + ], +})); +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md.history.json b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md.history.json new file mode 100644 index 0000000..9470a50 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/DiagonalShuffle.md.history.json @@ -0,0 +1,39 @@ +{ + "working": "# Diagonal Shuffle\n\nCards fly in diagonally from alternating corners, rotating and scaling into a loose center stack as the section scrolls.\n\n## Summary\n\n- **ID:** `diagonal-shuffle`\n- **Target shape:** Best for 3–7 similarly sized sibling cards absolutely centered inside one sticky viewport stage, where each card can animate independently over a staggered scroll range.\n- **Description:** Five centered cards each fly in from an alternating bottom corner (odd from bottom-left, even from bottom-right), un-rotating and scaling up to settle at a slight tilt as the section scrolls past.\n\n## Demo HTML\n\n```html\n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n```\n\n## Selector Contract\n\n1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns sticky pinning + clipping + perspective, and `repeatedCard` owns absolute centering plus the diagonal fly-in transform.\n2. Repeated cards are absolute children centered on `stickyStage` via `top/left: 50%` + a base `translate(-50%, -50%)`. Every keyframe transform MUST re-declare that centering translate before adding the fly-in offset, or the cards jump off center.\n3. This pattern has no `collection` grid role — cards stack directly on the sticky stage. Do not introduce a flex/grid wrapper that removes the absolute centering.\n4. Keep the fly-in transform on the card roots, not on raw `img` descendants; the `img` fills the card via `object-cover` and must not carry the animation.\n5. Use rendered `#comp-...` ids for cards, never `DESKTOP--...` ids. In Wix, map `stickyStage` to the internal-container-root `#comp-...` and place the absolutely-centered cards inside it.\n\n## Role Guidance\n\n| Role | Guidance |\n| --- | --- |\n| `scrollSource` | The tall section that drives the shared `viewProgress` trigger for every card. |\n| `stickyStage` | A sticky viewport-sized wrapper that pins during scroll, clips the flying cards, and provides `perspective` for depth. |\n| `repeatedCard` | Absolutely centered sibling cards that each fly in from an alternating corner over a staggered range. |\n\n## Adaptation Notes\n\n1. Alternate the fly-in side by index: odd cards enter from bottom-left (negative X, negative rotate), even cards from bottom-right (positive X, positive rotate). Preserve the small settle rotation so the final stack stays loose, not perfectly aligned.\n2. Stagger each card's range across the scroll source. Demo uses `start = 5 + (n-1)·15`, `end = start + 20` (percent of `cover`), giving a 5% overlap between consecutive cards. Recompute the step from the real card count so the last card finishes before scroll end.\n3. Size the runway from card count: more cards need a longer `scroll-section` height. The demo's `450vh` covers five staggered ranges; scale roughly `~90vh` per card plus intro/outro slack.\n4. Off-screen distances are viewport-relative (`±80vw`, `50vh`); keep them in `vw/vh` so the entrance clears the frame on any width. Reduce the distance if cards feel too far-flung on wide screens.\n5. Cards start visible (`opacity: 1`) and rely on being off-stage + clipped by `overflow: clip`; if you instead fade them, add an opacity keyframe rather than the base `opacity: 0` default.\n6. Reject the pattern if you cannot keep a distinct sticky stage that both pins and clips the absolutely-centered cards.\n\n## Required Elements\n\n| Key | Role | Demo Selector | Purpose |\n| --- | --- | --- | --- |\n| `scrollSection` | `scrollSource` | `#scroll-section` | The `viewProgress` source for all card effects. |\n| `stickyStage` | `stickyStage` | `#scroll-section .sticky-wrapper` | Sticky pin + clip + perspective. Wix: `#comp-...` with `data-testid=\"internal-container-root\"`. |\n| `card1` | `repeatedCard` | `#card-1` | Minimum repeated fly-in card (odd → from left); extend outward for `card4..cardN`. |\n| `card2` | `repeatedCard` | `#card-2` | Repeated fly-in card (even → from right). |\n| `card3` | `repeatedCard` | `#card-3` | Repeated fly-in card (odd → from left). |\n| `card4` | `repeatedCard` | `#card-4` | Repeated fly-in card (even → from right). |\n| `card5` | `repeatedCard` | `#card-5` | Repeated fly-in card (odd → from left). |\n\n> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items, alternating entry side by parity.\n\n## Required Styles\n\n### `scrollSource` — `#scroll-section`\n\n```css\n#scroll-section {\n position: relative;\n height: 450vh;\n}\n```\n\nReason: creates enough scroll distance for all five staggered fly-in ranges to play out.\n\n### `stickyStage` — `#scroll-section .sticky-wrapper`\n\n```css\n#scroll-section .sticky-wrapper {\n position: sticky;\n top: 0;\n height: 100vh;\n width: 100vw;\n overflow: clip;\n perspective: 1200px;\n}\n```\n\nReason: pins the stage to the viewport, clips the off-screen cards without breaking ViewTimeline (`clip`, not `hidden`), and adds depth for the tilt.\n\n### `repeatedCard` — `#scroll-section .card`\n\n```css\n#scroll-section .card {\n position: absolute;\n top: 50%;\n left: 50%;\n width: 90vw;\n max-width: 400px;\n aspect-ratio: 3 / 4;\n border-radius: 1rem;\n transform-style: preserve-3d;\n will-change: transform, opacity;\n overflow: hidden;\n}\n\n@media (min-width: 768px) {\n #scroll-section .card {\n aspect-ratio: 4 / 3;\n }\n}\n```\n\nReason: absolutely centers each card on the stage and establishes the base box the keyframe transforms build on; the base `translate(-50%, -50%)` in the animation depends on this centering.\n\n## Suggested Controls\n\nExpose the fly-in distance and the entrance scale by default; add scroll length only when the section owns its own runway height.\n\n### `fly-distance`\n\n- **Label:** `Fly-In Distance`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `80`\n- **Description:** How far off-screen (horizontally) each card starts before shuffling to center.\n- **Constraints:** `min: 40`, `max: 100`, `step: 5`, `unit: vw`\n- **Binding:** `variable` `--card-fly-distance` using template `${value}vw`\n\n### `start-scale`\n\n- **Label:** `Entrance Scale`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `0.7`\n- **Description:** The scale of each card at the start of its fly-in, before it grows to full size.\n- **Constraints:** `min: 0.5`, `max: 1`, `step: 0.05`, `unit: x`\n- **Binding:** `variable` `--card-start-scale` using a direct value\n\n### `scroll-length`\n\n- **Label:** `Scroll Length`\n- **Group:** `Layout`\n- **Type:** `range`\n- **Default:** `450`\n- **Description:** Total scroll runway height; increase for more cards or slower shuffling.\n- **Constraints:** `min: 300`, `max: 700`, `step: 25`, `unit: vh`\n- **Binding:** `style` `#scroll-section` property `height` using template `${value}vh`\n\n## Interact Template\n\n```ts\nconst EASING = 'ease-out';\n\n// Per-card staggered range: start = 5 + (n-1)*15, end = start + 20 (percent of 'cover').\n// Recompute the step and count from the real number of cards.\nconst cardRange = (index: number) => ({\n rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 5 + index * 15 } },\n rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 25 + index * 15 } },\n easing: EASING,\n fill: 'both' as const,\n});\n\n// Odd cards fly in from bottom-left, even from bottom-right; small settle rotation keeps the stack loose.\n// --card-fly-distance (default 80vw) and --card-start-scale (default 0.7) drive the entrance.\nconst flyInEffect = (key: string, index: number, settleRotate: number) => {\n const fromLeft = index % 2 === 0; // index 0,2,4 => card1,card3,card5 => left\n const dx = fromLeft ? 'calc(-1 * var(--card-fly-distance, 80vw))' : 'var(--card-fly-distance, 80vw)';\n const startRotate = fromLeft ? -45 : 45;\n return {\n key,\n keyframeEffect: {\n name: `${key}-fly-in`,\n keyframes: [\n {\n transform: `translate(-50%, -50%) translate(${dx}, 50vh) rotate(${startRotate}deg) scale(var(--card-start-scale, 0.7))`,\n opacity: 1,\n },\n {\n transform: `translate(-50%, -50%) translate(0, 0) rotate(${settleRotate}deg) scale(1)`,\n opacity: 1,\n },\n ],\n },\n ...cardRange(index),\n };\n};\n\n// Final settle tilts taper toward 0 on the last card — recompute for a different count.\nconst SETTLE_ROTATIONS = [-4, 3, -2, 1, 0];\n\nconst interactions = SETTLE_ROTATIONS.map((rotate, index) => ({\n key: 'scrollSection',\n trigger: 'viewProgress',\n effects: [flyInEffect(`card${index + 1}`, index, rotate)],\n}));\n```", + "rounds": [ + { + "round": 1, + "guideline": "# Diagonal Shuffle\n\nCards fly in diagonally from alternating corners, rotating and scaling into a loose center stack as the section scrolls.\n\n## Summary\n\n- **ID:** `diagonal-shuffle`\n- **Target shape:** Best for 3–7 similarly sized sibling cards absolutely centered inside one sticky viewport stage, where each card can animate independently over a staggered scroll range.\n- **Description:** Five centered cards each fly in from an alternating bottom corner (odd from bottom-left, even from bottom-right), un-rotating and scaling up to settle at a slight tilt as the section scrolls past.\n\n## Demo HTML\n\n```html\n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n```\n\n## Selector Contract\n\n1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns sticky pinning + clipping + perspective, and `repeatedCard` owns absolute centering plus the diagonal fly-in transform.\n2. Repeated cards are absolute children centered on `stickyStage` via `top/left: 50%` + a base `translate(-50%, -50%)`. Every keyframe transform MUST re-declare that centering translate before adding the fly-in offset, or the cards jump off center.\n3. This pattern has no `collection` grid role — cards stack directly on the sticky stage. Do not introduce a flex/grid wrapper that removes the absolute centering.\n4. Keep the fly-in transform on the card roots, not on raw `img` descendants; the `img` fills the card via `object-cover` and must not carry the animation.\n5. Use rendered `#comp-...` ids for cards, never `DESKTOP--...` ids. In Wix, map `stickyStage` to the internal-container-root `#comp-...` and place the absolutely-centered cards inside it.\n\n## Role Guidance\n\n| Role | Guidance |\n| --- | --- |\n| `scrollSource` | The tall section that drives the shared `viewProgress` trigger for every card. |\n| `stickyStage` | A sticky viewport-sized wrapper that pins during scroll, clips the flying cards, and provides `perspective` for depth. |\n| `repeatedCard` | Absolutely centered sibling cards that each fly in from an alternating corner over a staggered range. |\n\n## Adaptation Notes\n\n1. Alternate the fly-in side by index: odd cards enter from bottom-left (negative X, negative rotate), even cards from bottom-right (positive X, positive rotate). Preserve the small settle rotation so the final stack stays loose, not perfectly aligned.\n2. Stagger each card's range across the scroll source. Demo uses `start = 5 + (n-1)·15`, `end = start + 20` (percent of `cover`), giving a 5% overlap between consecutive cards. Recompute the step from the real card count so the last card finishes before scroll end.\n3. Size the runway from card count: more cards need a longer `scroll-section` height. The demo's `450vh` covers five staggered ranges; scale roughly `~90vh` per card plus intro/outro slack.\n4. Off-screen distances are viewport-relative (`±80vw`, `50vh`); keep them in `vw/vh` so the entrance clears the frame on any width. Reduce the distance if cards feel too far-flung on wide screens.\n5. Cards start visible (`opacity: 1`) and rely on being off-stage + clipped by `overflow: clip`; if you instead fade them, add an opacity keyframe rather than the base `opacity: 0` default.\n6. Reject the pattern if you cannot keep a distinct sticky stage that both pins and clips the absolutely-centered cards.\n\n## Required Elements\n\n| Key | Role | Demo Selector | Purpose |\n| --- | --- | --- | --- |\n| `scrollSection` | `scrollSource` | `#scroll-section` | The `viewProgress` source for all card effects. |\n| `stickyStage` | `stickyStage` | `#scroll-section .sticky-wrapper` | Sticky pin + clip + perspective. Wix: `#comp-...` with `data-testid=\"internal-container-root\"`. |\n| `card1` | `repeatedCard` | `#card-1` | Minimum repeated fly-in card (odd → from left); extend outward for `card4..cardN`. |\n| `card2` | `repeatedCard` | `#card-2` | Repeated fly-in card (even → from right). |\n| `card3` | `repeatedCard` | `#card-3` | Repeated fly-in card (odd → from left). |\n| `card4` | `repeatedCard` | `#card-4` | Repeated fly-in card (even → from right). |\n| `card5` | `repeatedCard` | `#card-5` | Repeated fly-in card (odd → from left). |\n\n> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items, alternating entry side by parity.\n\n## Required Styles\n\n### `scrollSource` — `#scroll-section`\n\n```css\n#scroll-section {\n position: relative;\n height: 450vh;\n}\n```\n\nReason: creates enough scroll distance for all five staggered fly-in ranges to play out.\n\n### `stickyStage` — `#scroll-section .sticky-wrapper`\n\n```css\n#scroll-section .sticky-wrapper {\n position: sticky;\n top: 0;\n height: 100vh;\n width: 100vw;\n overflow: clip;\n perspective: 1200px;\n}\n```\n\nReason: pins the stage to the viewport, clips the off-screen cards without breaking ViewTimeline (`clip`, not `hidden`), and adds depth for the tilt.\n\n### `repeatedCard` — `#scroll-section .card`\n\n```css\n#scroll-section .card {\n position: absolute;\n top: 50%;\n left: 50%;\n width: 90vw;\n max-width: 400px;\n aspect-ratio: 3 / 4;\n border-radius: 1rem;\n transform-style: preserve-3d;\n will-change: transform, opacity;\n overflow: hidden;\n}\n\n@media (min-width: 768px) {\n #scroll-section .card {\n aspect-ratio: 4 / 3;\n }\n}\n```\n\nReason: absolutely centers each card on the stage and establishes the base box the keyframe transforms build on; the base `translate(-50%, -50%)` in the animation depends on this centering.\n\n## Suggested Controls\n\nExpose the fly-in distance and the entrance scale by default; add scroll length only when the section owns its own runway height.\n\n### `fly-distance`\n\n- **Label:** `Fly-In Distance`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `80`\n- **Description:** How far off-screen (horizontally) each card starts before shuffling to center.\n- **Constraints:** `min: 40`, `max: 100`, `step: 5`, `unit: vw`\n- **Binding:** `variable` `--card-fly-distance` using template `${value}vw`\n\n### `start-scale`\n\n- **Label:** `Entrance Scale`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `0.7`\n- **Description:** The scale of each card at the start of its fly-in, before it grows to full size.\n- **Constraints:** `min: 0.5`, `max: 1`, `step: 0.05`, `unit: x`\n- **Binding:** `variable` `--card-start-scale` using a direct value\n\n### `scroll-length`\n\n- **Label:** `Scroll Length`\n- **Group:** `Layout`\n- **Type:** `range`\n- **Default:** `450`\n- **Description:** Total scroll runway height; increase for more cards or slower shuffling.\n- **Constraints:** `min: 300`, `max: 700`, `step: 25`, `unit: vh`\n- **Binding:** `style` `#scroll-section` property `height` using template `${value}vh`\n\n## Interact Template\n\n```ts\nconst EASING = 'ease-out';\n\n// Per-card staggered range: start = 5 + (n-1)*15, end = start + 20 (percent of 'cover').\n// Recompute the step and count from the real number of cards.\nconst cardRange = (index: number) => ({\n rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 5 + index * 15 } },\n rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 25 + index * 15 } },\n easing: EASING,\n fill: 'both' as const,\n});\n\n// Odd cards fly in from bottom-left, even from bottom-right; small settle rotation keeps the stack loose.\n// --card-fly-distance (default 80vw) and --card-start-scale (default 0.7) drive the entrance.\nconst flyInEffect = (key: string, index: number, settleRotate: number) => {\n const fromLeft = index % 2 === 0; // index 0,2,4 => card1,card3,card5 => left\n const dx = fromLeft ? 'calc(-1 * var(--card-fly-distance, 80vw))' : 'var(--card-fly-distance, 80vw)';\n const startRotate = fromLeft ? -45 : 45;\n return {\n key,\n keyframeEffect: {\n name: `${key}-fly-in`,\n keyframes: [\n {\n transform: `translate(-50%, -50%) translate(${dx}, 50vh) rotate(${startRotate}deg) scale(var(--card-start-scale, 0.7))`,\n opacity: 1,\n },\n {\n transform: `translate(-50%, -50%) translate(0, 0) rotate(${settleRotate}deg) scale(1)`,\n opacity: 1,\n },\n ],\n },\n ...cardRange(index),\n };\n};\n\n// Final settle tilts taper toward 0 on the last card — recompute for a different count.\nconst SETTLE_ROTATIONS = [-4, 3, -2, 1, 0];\n\nconst interactions = SETTLE_ROTATIONS.map((rotate, index) => ({\n key: 'scrollSection',\n trigger: 'viewProgress',\n effects: [flyInEffect(`card${index + 1}`, index, rotate)],\n}));\n```", + "sections": [ + { + "id": "portfolio", + "config": "{\n \"$schema\": \"interact-experience/1.0\",\n \"id\": \"diagonal-shuffle-gallery\",\n \"name\": \"Diagonal Shuffle Gallery\",\n \"description\": \"The four gallery cards fly in diagonally from alternating bottom corners, un-rotating and scaling up into place over a staggered scroll range across the section.\",\n \"elements\": {\n \"scrollSection\": {\n \"selector\": \".comp-mqryiq7r\",\n \"styles\": {\n \"--card-fly-distance\": \"80vw\",\n \"--card-start-scale\": \"0.7\",\n \"--card-fly-rise\": \"50vh\",\n \"--card-fly-rotate\": \"45deg\"\n }\n },\n \"card1\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(1)\"\n },\n \"card2\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(2)\"\n },\n \"card3\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(3)\"\n },\n \"card4\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(4)\"\n }\n },\n \"styles\": [\n {\n \"selector\": \".comp-mqryiq7r__content\",\n \"properties\": {\n \"overflow\": \"clip\"\n }\n },\n {\n \"selector\": \".comp-mqryiqa5\",\n \"properties\": {\n \"perspective\": \"1200px\",\n \"overflow\": \"clip\"\n }\n },\n {\n \"selector\": \".comp-mqryiqa5 .g-item\",\n \"properties\": {\n \"transform-style\": \"preserve-3d\",\n \"will-change\": \"transform, opacity\"\n }\n }\n ],\n \"interact\": {\n \"effects\": {},\n \"interactions\": [\n {\n \"key\": \"scrollSection\",\n \"trigger\": \"viewProgress\",\n \"effects\": [\n {\n \"key\": \"card1\",\n \"easing\": \"ease-out\",\n \"fill\": \"both\",\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 5 } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 25 } },\n \"keyframeEffect\": {\n \"name\": \"card1-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(calc(-1 * var(--card-fly-distance, 80vw)), var(--card-fly-rise, 50vh)) rotate(calc(-1 * var(--card-fly-rotate, 45deg))) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": \"1\"\n },\n {\n \"transform\": \"translate(0, 0) rotate(-4deg) scale(1)\",\n \"opacity\": \"1\"\n }\n ]\n }\n },\n {\n \"key\": \"card2\",\n \"easing\": \"ease-out\",\n \"fill\": \"both\",\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 20 } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 40 } },\n \"keyframeEffect\": {\n \"name\": \"card2-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(var(--card-fly-distance, 80vw), var(--card-fly-rise, 50vh)) rotate(var(--card-fly-rotate, 45deg)) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": \"1\"\n },\n {\n \"transform\": \"translate(0, 0) rotate(3deg) scale(1)\",\n \"opacity\": \"1\"\n }\n ]\n }\n },\n {\n \"key\": \"card3\",\n \"easing\": \"ease-out\",\n \"fill\": \"both\",\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 35 } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 55 } },\n \"keyframeEffect\": {\n \"name\": \"card3-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(calc(-1 * var(--card-fly-distance, 80vw)), var(--card-fly-rise, 50vh)) rotate(calc(-1 * var(--card-fly-rotate, 45deg))) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": \"1\"\n },\n {\n \"transform\": \"translate(0, 0) rotate(-2deg) scale(1)\",\n \"opacity\": \"1\"\n }\n ]\n }\n },\n {\n \"key\": \"card4\",\n \"easing\": \"ease-out\",\n \"fill\": \"both\",\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 50 } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"unit\": \"percentage\", \"value\": 70 } },\n \"keyframeEffect\": {\n \"name\": \"card4-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(var(--card-fly-distance, 80vw), var(--card-fly-rise, 50vh)) rotate(var(--card-fly-rotate, 45deg)) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": \"1\"\n },\n {\n \"transform\": \"translate(0, 0) rotate(1deg) scale(1)\",\n \"opacity\": \"1\"\n }\n ]\n }\n }\n ]\n }\n ]\n },\n \"controls\": [\n {\n \"id\": \"fly-distance\",\n \"label\": \"Fly-In Distance\",\n \"description\": \"How far off-screen horizontally each card starts before shuffling into place.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 80,\n \"constraints\": { \"min\": 40, \"max\": 100, \"step\": 5, \"unit\": \"vw\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-fly-distance\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vw\" }\n }\n ]\n },\n {\n \"id\": \"start-scale\",\n \"label\": \"Entrance Scale\",\n \"description\": \"Scale of each card at the start of its fly-in, before it grows to full size.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 0.7,\n \"constraints\": { \"min\": 0.5, \"max\": 1, \"step\": 0.05, \"unit\": \"x\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-start-scale\",\n \"transform\": { \"type\": \"direct\" }\n }\n ]\n },\n {\n \"id\": \"fly-rise\",\n \"label\": \"Rise Distance\",\n \"description\": \"How far below its resting position each card starts before rising into place.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 50,\n \"constraints\": { \"min\": 0, \"max\": 100, \"step\": 5, \"unit\": \"vh\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-fly-rise\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vh\" }\n }\n ]\n },\n {\n \"id\": \"fly-rotate\",\n \"label\": \"Entrance Rotation\",\n \"description\": \"Starting rotation of each card before it un-rotates toward its loose settle tilt.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 45,\n \"constraints\": { \"min\": 0, \"max\": 90, \"step\": 5, \"unit\": \"deg\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-fly-rotate\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}deg\" }\n }\n ]\n }\n ]\n}", + "html": "
            \n
            \n
            \n

            Portfolio

            \n

            Our work

            \n
            \n
            \"\"
            01
            \n
            \"\"
            02
            \n
            \"\"
            03
            \n
            \"\"
            04
            \n
            \n

            This is the space to introduce your Projects section. Take this opportunity to give visitors a brief overview of the types of projects they'll find featured in the showcase below. Consider adding an image or video to spark their interest.

            \n
            \n
            ", + "css": ".container {\n position: relative;\n width: 100%;\n height: 100%;\n}\n\n.backgroundLayer {\n position: absolute;\n inset: 0;\n overflow: clip;\n}\n\n.backgroundLayer .background {\n position: absolute;\n inset: 0;\n background-size: cover;\n background-position: center;\n}\n\n.content {\n position: relative;\n}\n\n.presetWrapper {\n display: contents;\n}\n\n.image3,\n.imageLayer {\n width: 100%;\n height: 100%;\n}\n\n.imageLayer {\n overflow: hidden;\n}\n\n.imageLayer > img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.logo-wrapper .linkLayer {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.logo-wrapper img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.line-wrapper {\n display: flex;\n align-items: center;\n}\n\n.line-wrapper > .line {\n width: 100%;\n border-top: 1px solid currentColor;\n}\n\n.menu .navbar {\n display: flex;\n}\n\n.ph-box {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n background: #ededed;\n border: 1px dashed #c4c4c4;\n font: 500 13px/1.3 system-ui, -apple-system, sans-serif;\n color: #8a8a8a;\n letter-spacing: 0.04em;\n}\n\n.imageLayer > .ph-box,\n.logo-wrapper > .ph-box {\n width: 100%;\n height: 100%;\n}\n\n.comp-mqryiq7r {\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n}\n\n.comp-mqryiq7r__bg {\n border-bottom-style: solid;\n border-bottom-width: 0px;\n border-bottom-color: transparent;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqryiq7r__content {\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqryiq8r3 {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-top: 68px;\n margin-bottom: 4px;\n width: 23.6%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 400 18px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq8r3 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqryiq9r {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-bottom: 73.836px;\n width: 29.4%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 700 22px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq9r :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqryiqa5 {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-bottom: 86.297px;\n width: 92.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n border-bottom-left-radius: 0px;\n border-left-color: #000000;\n padding-left: 0px;\n padding-top: 0px;\n border-left-width: 0px;\n padding-bottom: 0px;\n border-right-style: solid;\n border-right-color: #000000;\n border-bottom-width: 0px;\n border-bottom-right-radius: 0px;\n background-color: transparent;\n padding-right: 0px;\n border-top-style: solid;\n border-left-style: solid;\n border-top-right-radius: 0px;\n border-right-width: 0px;\n border-bottom-style: solid;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-top-color: #000000;\n border-top-width: 0px;\n border-bottom-color: #000000;\n border-top-left-radius: 0px;\n}\n\n.comp-mqryiqa5 {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n column-gap: 11px;\n row-gap: 11px;\n}\n\n.comp-mqryiqa5 .g-item {\n display: flex;\n flex-direction: column;\n border-bottom-width: 0px;\n border-left-color: #000000;\n border-top-style: solid;\n border-left-style: solid;\n padding-top: 0px;\n border-left-width: 0px;\n border-top-left-radius: 0px;\n border-top-right-radius: 0px;\n border-bottom-color: #000000;\n border-top-width: 0px;\n border-top-color: #000000;\n border-bottom-style: solid;\n padding-bottom: 0px;\n padding-left: 0px;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-right-style: solid;\n padding-right: 0px;\n border-right-width: 0px;\n border-right-color: #000000;\n background-color: transparent;\n border-bottom-left-radius: 0px;\n border-bottom-right-radius: 0px;\n}\n\n.comp-mqryiqa5 .g-image {\n box-sizing: border-box;\n padding-left: 0px;\n background-color: transparent;\n border-top-left-radius: 0px;\n border-top-color: #000000;\n border-right-style: solid;\n border-left-style: solid;\n border-left-color: #000000;\n border-bottom-width: 0px;\n border-bottom-style: solid;\n padding-right: 0px;\n border-right-width: 0px;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-top-width: 0px;\n border-left-width: 0px;\n border-top-style: solid;\n padding-top: 0px;\n border-bottom-left-radius: 0px;\n padding-bottom: 0px;\n border-bottom-right-radius: 0px;\n border-right-color: #000000;\n border-bottom-color: #000000;\n border-top-right-radius: 0px;\n}\n\n.comp-mqryiqa5 .g-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n}\n\n.comp-mqryiqa5 .g-title {\n text-decoration-line: none;\n background-color: transparent;\n text-transform: none;\n text-shadow: none;\n padding-right: 20px;\n color: #ffffff;\n text-align: center;\n padding-top: 12px;\n font: normal normal 700 36px/1.2em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.2em;\n padding-bottom: 12px;\n font-size: 24px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiqa5 .g-counter {\n text-decoration-line: none;\n background-color: #ffffff;\n text-transform: none;\n text-shadow: 1px 0px transparent, -1px 0px transparent, 0px 1px transparent, 0px -1px transparent;\n padding-right: 20px;\n color: #ffffff;\n text-align: center;\n padding-top: 12px;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.4em;\n padding-bottom: 12px;\n font-size: 15px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiqa5 .g-desc {\n text-decoration-line: none;\n background-color: rgba(255, 255, 255, 0);\n text-transform: none;\n text-shadow: 1px 0px transparent, -1px 0px transparent, 0px 1px transparent, 0px -1px transparent;\n padding-right: 20px;\n color: rgba(255, 255, 255, 1);\n text-align: center;\n padding-top: 0px;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.4em;\n padding-bottom: 12px;\n font-size: 15px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiq9y5 {\n margin-left: 36.96%;\n margin-right: 0%;\n margin-top: 4.438px;\n margin-bottom: 59.805px;\n width: 45.6%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq9y5 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}" + } + ], + "score": 5, + "notes": "it got the ranges wrong and finishes the scroll prematurel; which results in the final image not scrolling into screen and shown" + }, + { + "round": 2, + "guideline": "# Diagonal Shuffle\n\nCards fly in diagonally from alternating corners, rotating and scaling into a loose center stack as the section scrolls.\n\n## Summary\n\n- **ID:** `diagonal-shuffle`\n- **Target shape:** Best for 3–7 similarly sized sibling cards absolutely centered inside one sticky viewport stage, where each card can animate independently over a staggered scroll range.\n- **Description:** Five centered cards each fly in from an alternating bottom corner (odd from bottom-left, even from bottom-right), un-rotating and scaling up to settle at a slight tilt as the section scrolls past.\n\n## Demo HTML\n\n```html\n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n```\n\n## Selector Contract\n\n1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns sticky pinning + clipping + perspective, and `repeatedCard` owns absolute centering plus the diagonal fly-in transform.\n2. Repeated cards are absolute children centered on `stickyStage` via `top/left: 50%` + a base `translate(-50%, -50%)`. Every keyframe transform MUST re-declare that centering translate before adding the fly-in offset, or the cards jump off center.\n3. This pattern has no `collection` grid role — cards stack directly on the sticky stage. Do not introduce a flex/grid wrapper that removes the absolute centering.\n4. Keep the fly-in transform on the card roots, not on raw `img` descendants; the `img` fills the card via `object-cover` and must not carry the animation.\n5. Use rendered `#comp-...` ids for cards, never `DESKTOP--...` ids. In Wix, map `stickyStage` to the internal-container-root `#comp-...` and place the absolutely-centered cards inside it.\n\n## Role Guidance\n\n| Role | Guidance |\n| --- | --- |\n| `scrollSource` | The tall section that drives the shared `viewProgress` trigger for every card. |\n| `stickyStage` | A sticky viewport-sized wrapper that pins during scroll, clips the flying cards, and provides `perspective` for depth. |\n| `repeatedCard` | Absolutely centered sibling cards that each fly in from an alternating corner over a staggered range. |\n\n## Adaptation Notes\n\n1. Alternate the fly-in side by index: odd cards enter from bottom-left (negative X, negative rotate), even cards from bottom-right (positive X, positive rotate). Preserve the small settle rotation so the final stack stays loose, not perfectly aligned.\n2. Stagger each card's range across the scroll source. Demo uses `start = 5 + (n-1)·15`, `end = start + 20` (percent of `cover`), giving a 5% overlap between consecutive cards. Recompute the step from the real card count so the last card finishes before scroll end.\n3. Size the runway from card count: more cards need a longer `scroll-section` height. The demo's `450vh` covers five staggered ranges; scale roughly `~90vh` per card plus intro/outro slack.\n4. Off-screen distances are viewport-relative (`±80vw`, `50vh`); keep them in `vw/vh` so the entrance clears the frame on any width. Reduce the distance if cards feel too far-flung on wide screens.\n5. Cards start visible (`opacity: 1`) and rely on being off-stage + clipped by `overflow: clip`; if you instead fade them, add an opacity keyframe rather than the base `opacity: 0` default.\n6. Reject the pattern if you cannot keep a distinct sticky stage that both pins and clips the absolutely-centered cards.\n\n## Required Elements\n\n| Key | Role | Demo Selector | Purpose |\n| --- | --- | --- | --- |\n| `scrollSection` | `scrollSource` | `#scroll-section` | The `viewProgress` source for all card effects. |\n| `stickyStage` | `stickyStage` | `#scroll-section .sticky-wrapper` | Sticky pin + clip + perspective. Wix: `#comp-...` with `data-testid=\"internal-container-root\"`. |\n| `card1` | `repeatedCard` | `#card-1` | Minimum repeated fly-in card (odd → from left); extend outward for `card4..cardN`. |\n| `card2` | `repeatedCard` | `#card-2` | Repeated fly-in card (even → from right). |\n| `card3` | `repeatedCard` | `#card-3` | Repeated fly-in card (odd → from left). |\n| `card4` | `repeatedCard` | `#card-4` | Repeated fly-in card (even → from right). |\n| `card5` | `repeatedCard` | `#card-5` | Repeated fly-in card (odd → from left). |\n\n> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items, alternating entry side by parity.\n\n## Required Styles\n\n### `scrollSource` — `#scroll-section`\n\n```css\n#scroll-section {\n position: relative;\n height: 450vh;\n}\n```\n\nReason: creates enough scroll distance for all five staggered fly-in ranges to play out.\n\n### `stickyStage` — `#scroll-section .sticky-wrapper`\n\n```css\n#scroll-section .sticky-wrapper {\n position: sticky;\n top: 0;\n height: 100vh;\n width: 100vw;\n overflow: clip;\n perspective: 1200px;\n}\n```\n\nReason: pins the stage to the viewport, clips the off-screen cards without breaking ViewTimeline (`clip`, not `hidden`), and adds depth for the tilt.\n\n### `repeatedCard` — `#scroll-section .card`\n\n```css\n#scroll-section .card {\n position: absolute;\n top: 50%;\n left: 50%;\n width: 90vw;\n max-width: 400px;\n aspect-ratio: 3 / 4;\n border-radius: 1rem;\n transform-style: preserve-3d;\n will-change: transform, opacity;\n overflow: hidden;\n}\n\n@media (min-width: 768px) {\n #scroll-section .card {\n aspect-ratio: 4 / 3;\n }\n}\n```\n\nReason: absolutely centers each card on the stage and establishes the base box the keyframe transforms build on; the base `translate(-50%, -50%)` in the animation depends on this centering.\n\n## Suggested Controls\n\nExpose the fly-in distance and the entrance scale by default; add scroll length only when the section owns its own runway height.\n\n### `fly-distance`\n\n- **Label:** `Fly-In Distance`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `80`\n- **Description:** How far off-screen (horizontally) each card starts before shuffling to center.\n- **Constraints:** `min: 40`, `max: 100`, `step: 5`, `unit: vw`\n- **Binding:** `variable` `--card-fly-distance` using template `${value}vw`\n\n### `start-scale`\n\n- **Label:** `Entrance Scale`\n- **Group:** `Motion`\n- **Type:** `range`\n- **Default:** `0.7`\n- **Description:** The scale of each card at the start of its fly-in, before it grows to full size.\n- **Constraints:** `min: 0.5`, `max: 1`, `step: 0.05`, `unit: x`\n- **Binding:** `variable` `--card-start-scale` using a direct value\n\n### `scroll-length`\n\n- **Label:** `Scroll Length`\n- **Group:** `Layout`\n- **Type:** `range`\n- **Default:** `450`\n- **Description:** Total scroll runway height; increase for more cards or slower shuffling.\n- **Constraints:** `min: 300`, `max: 700`, `step: 25`, `unit: vh`\n- **Binding:** `style` `#scroll-section` property `height` using template `${value}vh`\n\n## Interact Template\n\n```ts\nconst EASING = 'ease-out';\n\n// Per-card staggered range: start = 5 + (n-1)*15, end = start + 20 (percent of 'cover').\n// Recompute the step and count from the real number of cards.\nconst cardRange = (index: number) => ({\n rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 5 + index * 15 } },\n rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 25 + index * 15 } },\n easing: EASING,\n fill: 'both' as const,\n});\n\n// Odd cards fly in from bottom-left, even from bottom-right; small settle rotation keeps the stack loose.\n// --card-fly-distance (default 80vw) and --card-start-scale (default 0.7) drive the entrance.\nconst flyInEffect = (key: string, index: number, settleRotate: number) => {\n const fromLeft = index % 2 === 0; // index 0,2,4 => card1,card3,card5 => left\n const dx = fromLeft ? 'calc(-1 * var(--card-fly-distance, 80vw))' : 'var(--card-fly-distance, 80vw)';\n const startRotate = fromLeft ? -45 : 45;\n return {\n key,\n keyframeEffect: {\n name: `${key}-fly-in`,\n keyframes: [\n {\n transform: `translate(-50%, -50%) translate(${dx}, 50vh) rotate(${startRotate}deg) scale(var(--card-start-scale, 0.7))`,\n opacity: 1,\n },\n {\n transform: `translate(-50%, -50%) translate(0, 0) rotate(${settleRotate}deg) scale(1)`,\n opacity: 1,\n },\n ],\n },\n ...cardRange(index),\n };\n};\n\n// Final settle tilts taper toward 0 on the last card — recompute for a different count.\nconst SETTLE_ROTATIONS = [-4, 3, -2, 1, 0];\n\nconst interactions = SETTLE_ROTATIONS.map((rotate, index) => ({\n key: 'scrollSection',\n trigger: 'viewProgress',\n effects: [flyInEffect(`card${index + 1}`, index, rotate)],\n}));\n```", + "sections": [ + { + "id": "portfolio", + "config": "{\n \"$schema\": \"interact-experience/1.0\",\n \"id\": \"diagonal-shuffle-comp-mqryiq7r\",\n \"name\": \"Diagonal Shuffle\",\n \"description\": \"The four gallery cards fly in diagonally from alternating bottom corners, un-rotating and scaling up to settle in a loose centered stack on a sticky stage as the section scrolls.\",\n \"elements\": {\n \"scrollSection\": {\n \"selector\": \".comp-mqryiq7r\"\n },\n \"stickyStage\": {\n \"selector\": \".comp-mqryiqa5\"\n },\n \"card1\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(1)\"\n },\n \"card2\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(2)\"\n },\n \"card3\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(3)\"\n },\n \"card4\": {\n \"selector\": \".comp-mqryiqa5 .g-item:nth-child(4)\"\n }\n },\n \"styles\": [\n {\n \"selector\": \".comp-mqryiq7r\",\n \"properties\": {\n \"position\": \"relative\",\n \"height\": \"400vh\",\n \"max-height\": \"none\",\n \"--card-fly-distance\": \"80vw\",\n \"--card-start-scale\": \"0.7\"\n }\n },\n {\n \"selector\": \".comp-mqryiq7r__content\",\n \"properties\": {\n \"grid-template-rows\": \"min-content min-content 1fr\"\n }\n },\n {\n \"selector\": \".comp-mqryiqa5\",\n \"properties\": {\n \"display\": \"block\",\n \"position\": \"sticky\",\n \"top\": \"0\",\n \"height\": \"100vh\",\n \"width\": \"100%\",\n \"margin-left\": \"0\",\n \"margin-bottom\": \"0\",\n \"overflow\": \"clip\",\n \"perspective\": \"1200px\"\n }\n },\n {\n \"selector\": \".comp-mqryiqa5 .g-item\",\n \"properties\": {\n \"position\": \"absolute\",\n \"top\": \"50%\",\n \"left\": \"50%\",\n \"width\": \"90vw\",\n \"max-width\": \"400px\",\n \"transform\": \"translate(-50%, -50%)\",\n \"transform-style\": \"preserve-3d\",\n \"will-change\": \"transform\"\n }\n },\n {\n \"selector\": \".comp-mqryiqa5 .g-item .ph-box\",\n \"properties\": {\n \"width\": \"100%\"\n }\n }\n ],\n \"interact\": {\n \"effects\": {},\n \"conditions\": {\n \"motion-ok\": {\n \"type\": \"media\",\n \"predicate\": \"(prefers-reduced-motion: no-preference)\"\n }\n },\n \"interactions\": [\n {\n \"id\": \"cards-diagonal-shuffle\",\n \"key\": \"scrollSection\",\n \"trigger\": \"viewProgress\",\n \"conditions\": [\"motion-ok\"],\n \"effects\": [\n {\n \"key\": \"card1\",\n \"keyframeEffect\": {\n \"name\": \"card1-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(calc(-1 * var(--card-fly-distance, 80vw)), 50vh) rotate(-45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0px, 0px) rotate(-4deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 5, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 25, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card2\",\n \"keyframeEffect\": {\n \"name\": \"card2-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(var(--card-fly-distance, 80vw), 50vh) rotate(45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0px, 0px) rotate(3deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 25, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 45, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card3\",\n \"keyframeEffect\": {\n \"name\": \"card3-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(calc(-1 * var(--card-fly-distance, 80vw)), 50vh) rotate(-45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0px, 0px) rotate(-2deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 45, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 65, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card4\",\n \"keyframeEffect\": {\n \"name\": \"card4-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(var(--card-fly-distance, 80vw), 50vh) rotate(45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0px, 0px) rotate(0deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 65, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 85, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n }\n ]\n }\n ]\n },\n \"controls\": [\n {\n \"id\": \"fly-distance\",\n \"label\": \"Fly-In Distance\",\n \"description\": \"How far off-screen (horizontally) each card starts before shuffling to center.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 80,\n \"constraints\": { \"min\": 40, \"max\": 100, \"step\": 5, \"unit\": \"vw\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-fly-distance\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vw\" }\n }\n ]\n },\n {\n \"id\": \"start-scale\",\n \"label\": \"Entrance Scale\",\n \"description\": \"The scale of each card at the start of its fly-in, before it grows to full size.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 0.7,\n \"constraints\": { \"min\": 0.5, \"max\": 1, \"step\": 0.05, \"unit\": \"x\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-start-scale\",\n \"transform\": { \"type\": \"direct\" }\n }\n ]\n },\n {\n \"id\": \"scroll-length\",\n \"label\": \"Scroll Length\",\n \"description\": \"Total scroll runway height; increase for slower shuffling.\",\n \"group\": \"Layout\",\n \"type\": \"range\",\n \"defaultValue\": 400,\n \"constraints\": { \"min\": 300, \"max\": 700, \"step\": 25, \"unit\": \"vh\" },\n \"bindings\": [\n {\n \"target\": \"style\",\n \"targetId\": \".comp-mqryiq7r\",\n \"property\": \"height\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vh\" }\n }\n ]\n },\n {\n \"id\": \"card-width\",\n \"label\": \"Card Width\",\n \"description\": \"Maximum width of each stacked card on the sticky stage.\",\n \"group\": \"Layout\",\n \"type\": \"range\",\n \"defaultValue\": 400,\n \"constraints\": { \"min\": 260, \"max\": 640, \"step\": 20, \"unit\": \"px\" },\n \"bindings\": [\n {\n \"target\": \"style\",\n \"targetId\": \".comp-mqryiqa5 .g-item\",\n \"property\": \"max-width\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}px\" }\n }\n ]\n }\n ],\n \"disableWhen\": [\n {\n \"mediaQuery\": \"(prefers-reduced-motion: reduce)\",\n \"label\": \"Reduced motion\"\n }\n ],\n \"meta\": {\n \"category\": \"scroll\",\n \"tags\": [\"diagonal-shuffle\", \"viewProgress\", \"sticky\", \"cards\", \"stack\"]\n }\n}", + "html": "
            \n
            \n
            \n

            Portfolio

            \n

            Our work

            \n
            \n
            \"\"
            01
            \n
            \"\"
            02
            \n
            \"\"
            03
            \n
            \"\"
            04
            \n
            \n

            This is the space to introduce your Projects section. Take this opportunity to give visitors a brief overview of the types of projects they'll find featured in the showcase below. Consider adding an image or video to spark their interest.

            \n
            \n
            ", + "css": ".container {\n position: relative;\n width: 100%;\n height: 100%;\n}\n\n.backgroundLayer {\n position: absolute;\n inset: 0;\n overflow: clip;\n}\n\n.backgroundLayer .background {\n position: absolute;\n inset: 0;\n background-size: cover;\n background-position: center;\n}\n\n.content {\n position: relative;\n}\n\n.presetWrapper {\n display: contents;\n}\n\n.image3,\n.imageLayer {\n width: 100%;\n height: 100%;\n}\n\n.imageLayer {\n overflow: hidden;\n}\n\n.imageLayer > img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.logo-wrapper .linkLayer {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.logo-wrapper img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.line-wrapper {\n display: flex;\n align-items: center;\n}\n\n.line-wrapper > .line {\n width: 100%;\n border-top: 1px solid currentColor;\n}\n\n.menu .navbar {\n display: flex;\n}\n\n.ph-box {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n background: #ededed;\n border: 1px dashed #c4c4c4;\n font: 500 13px/1.3 system-ui, -apple-system, sans-serif;\n color: #8a8a8a;\n letter-spacing: 0.04em;\n}\n\n.imageLayer > .ph-box,\n.logo-wrapper > .ph-box {\n width: 100%;\n height: 100%;\n}\n\n.comp-mqryiq7r {\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n}\n\n.comp-mqryiq7r__bg {\n border-bottom-style: solid;\n border-bottom-width: 0px;\n border-bottom-color: transparent;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqryiq7r__content {\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqryiq8r3 {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-top: 68px;\n margin-bottom: 4px;\n width: 23.6%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 400 18px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq8r3 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqryiq9r {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-bottom: 73.836px;\n width: 29.4%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 700 22px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq9r :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqryiqa5 {\n margin-left: 3.91%;\n margin-right: 0%;\n margin-bottom: 86.297px;\n width: 92.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n border-bottom-left-radius: 0px;\n border-left-color: #000000;\n padding-left: 0px;\n padding-top: 0px;\n border-left-width: 0px;\n padding-bottom: 0px;\n border-right-style: solid;\n border-right-color: #000000;\n border-bottom-width: 0px;\n border-bottom-right-radius: 0px;\n background-color: transparent;\n padding-right: 0px;\n border-top-style: solid;\n border-left-style: solid;\n border-top-right-radius: 0px;\n border-right-width: 0px;\n border-bottom-style: solid;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-top-color: #000000;\n border-top-width: 0px;\n border-bottom-color: #000000;\n border-top-left-radius: 0px;\n}\n\n.comp-mqryiqa5 {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n column-gap: 11px;\n row-gap: 11px;\n}\n\n.comp-mqryiqa5 .g-item {\n display: flex;\n flex-direction: column;\n border-bottom-width: 0px;\n border-left-color: #000000;\n border-top-style: solid;\n border-left-style: solid;\n padding-top: 0px;\n border-left-width: 0px;\n border-top-left-radius: 0px;\n border-top-right-radius: 0px;\n border-bottom-color: #000000;\n border-top-width: 0px;\n border-top-color: #000000;\n border-bottom-style: solid;\n padding-bottom: 0px;\n padding-left: 0px;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-right-style: solid;\n padding-right: 0px;\n border-right-width: 0px;\n border-right-color: #000000;\n background-color: transparent;\n border-bottom-left-radius: 0px;\n border-bottom-right-radius: 0px;\n}\n\n.comp-mqryiqa5 .g-image {\n box-sizing: border-box;\n padding-left: 0px;\n background-color: transparent;\n border-top-left-radius: 0px;\n border-top-color: #000000;\n border-right-style: solid;\n border-left-style: solid;\n border-left-color: #000000;\n border-bottom-width: 0px;\n border-bottom-style: solid;\n padding-right: 0px;\n border-right-width: 0px;\n box-shadow: 0px 0px 30px 1px rgba(0, 0, 0, 0);\n border-top-width: 0px;\n border-left-width: 0px;\n border-top-style: solid;\n padding-top: 0px;\n border-bottom-left-radius: 0px;\n padding-bottom: 0px;\n border-bottom-right-radius: 0px;\n border-right-color: #000000;\n border-bottom-color: #000000;\n border-top-right-radius: 0px;\n}\n\n.comp-mqryiqa5 .g-row {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n}\n\n.comp-mqryiqa5 .g-title {\n text-decoration-line: none;\n background-color: transparent;\n text-transform: none;\n text-shadow: none;\n padding-right: 20px;\n color: #ffffff;\n text-align: center;\n padding-top: 12px;\n font: normal normal 700 36px/1.2em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.2em;\n padding-bottom: 12px;\n font-size: 24px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiqa5 .g-counter {\n text-decoration-line: none;\n background-color: #ffffff;\n text-transform: none;\n text-shadow: 1px 0px transparent, -1px 0px transparent, 0px 1px transparent, 0px -1px transparent;\n padding-right: 20px;\n color: #ffffff;\n text-align: center;\n padding-top: 12px;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.4em;\n padding-bottom: 12px;\n font-size: 15px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiqa5 .g-desc {\n text-decoration-line: none;\n background-color: rgba(255, 255, 255, 0);\n text-transform: none;\n text-shadow: 1px 0px transparent, -1px 0px transparent, 0px 1px transparent, 0px -1px transparent;\n padding-right: 20px;\n color: rgba(255, 255, 255, 1);\n text-align: center;\n padding-top: 0px;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n line-height: 1.4em;\n padding-bottom: 12px;\n font-size: 15px;\n padding-left: 20px;\n display: none;\n}\n\n.comp-mqryiq9y5 {\n margin-left: 36.96%;\n margin-right: 0%;\n margin-top: 4.438px;\n margin-bottom: 59.805px;\n width: 45.6%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 16px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqryiq9y5 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}" + }, + { + "id": "grid-cards 2", + "config": "{\n \"$schema\": \"interact-experience/1.0\",\n \"id\": \"diagonal-shuffle-comp-mqs0pkfm\",\n \"name\": \"Diagonal Shuffle\",\n \"description\": \"Six content cards fly in diagonally from alternating bottom corners, un-rotating and scaling up to settle into a loose center stack on a sticky stage as the section scrolls.\",\n \"elements\": {\n \"scrollSection\": {\n \"selector\": \".comp-mqs0pkfm\"\n },\n \"stickyStage\": {\n \"selector\": \".comp-mqs0pkfm__content\"\n },\n \"card1\": {\n \"selector\": \".comp-mqs0pkfu\"\n },\n \"card2\": {\n \"selector\": \".comp-mqs0pkgz\"\n },\n \"card3\": {\n \"selector\": \".comp-mqs0pkj02\"\n },\n \"card4\": {\n \"selector\": \".comp-mqs0pkkb1\"\n },\n \"card5\": {\n \"selector\": \".comp-mqs0pkld2\"\n },\n \"card6\": {\n \"selector\": \".comp-mqs0pkme4\"\n }\n },\n \"styles\": [\n {\n \"selector\": \".comp-mqs0pkfm\",\n \"properties\": {\n \"position\": \"relative\",\n \"height\": \"550vh\",\n \"max-height\": \"none\"\n }\n },\n {\n \"selector\": \".comp-mqs0pkfm__content\",\n \"properties\": {\n \"display\": \"block\",\n \"position\": \"sticky\",\n \"top\": \"0\",\n \"height\": \"100vh\",\n \"width\": \"100%\",\n \"overflow\": \"clip\",\n \"perspective\": \"1200px\"\n }\n },\n {\n \"selector\": \".comp-mqs0pkfu, .comp-mqs0pkgz, .comp-mqs0pkj02, .comp-mqs0pkkb1, .comp-mqs0pkld2, .comp-mqs0pkme4\",\n \"properties\": {\n \"position\": \"absolute\",\n \"top\": \"50%\",\n \"left\": \"50%\",\n \"margin\": \"0\",\n \"width\": \"90vw\",\n \"max-width\": \"400px\",\n \"height\": \"auto\",\n \"min-height\": \"260px\",\n \"transform\": \"translate(-50%, -50%)\",\n \"transform-style\": \"preserve-3d\",\n \"will-change\": \"transform\",\n \"border-radius\": \"1rem\",\n \"overflow\": \"clip\",\n \"box-shadow\": \"0 24px 48px rgba(0, 0, 0, 0.25)\"\n }\n },\n {\n \"selector\": \".comp-mqs0pkhv1, .comp-mqs0pkig2, .comp-mqs0pkjs4, .comp-mqs0pkl41, .comp-mqs0pkm53, .comp-mqs0pkn62, .comp-mqs0pkng2\",\n \"properties\": {\n \"display\": \"none\"\n }\n },\n {\n \"selector\": \".comp-mqs0pkfu, .comp-mqs0pkgz, .comp-mqs0pkj02, .comp-mqs0pkkb1, .comp-mqs0pkld2, .comp-mqs0pkme4\",\n \"mediaQuery\": \"(max-width: 767px)\",\n \"properties\": {\n \"max-width\": \"340px\"\n }\n }\n ],\n \"interact\": {\n \"effects\": {},\n \"conditions\": {\n \"reduced-motion-ok\": {\n \"type\": \"media\",\n \"predicate\": \"(prefers-reduced-motion: no-preference)\"\n }\n },\n \"interactions\": [\n {\n \"id\": \"diagonal-shuffle-scroll\",\n \"key\": \"scrollSection\",\n \"trigger\": \"viewProgress\",\n \"conditions\": [\"reduced-motion-ok\"],\n \"effects\": [\n {\n \"key\": \"card1\",\n \"keyframeEffect\": {\n \"name\": \"card1-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(calc(-1 * var(--card-fly-distance, 80vw)), 50vh) rotate(-45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(-4deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 5, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 23, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card2\",\n \"keyframeEffect\": {\n \"name\": \"card2-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(var(--card-fly-distance, 80vw), 50vh) rotate(45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(3deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 18, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 36, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card3\",\n \"keyframeEffect\": {\n \"name\": \"card3-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(calc(-1 * var(--card-fly-distance, 80vw)), 50vh) rotate(-45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(-2deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 31, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 49, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card4\",\n \"keyframeEffect\": {\n \"name\": \"card4-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(var(--card-fly-distance, 80vw), 50vh) rotate(45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(2deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 44, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 62, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card5\",\n \"keyframeEffect\": {\n \"name\": \"card5-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(calc(-1 * var(--card-fly-distance, 80vw)), 50vh) rotate(-45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(-1deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 57, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 75, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n },\n {\n \"key\": \"card6\",\n \"keyframeEffect\": {\n \"name\": \"card6-fly-in\",\n \"keyframes\": [\n {\n \"transform\": \"translate(-50%, -50%) translate(var(--card-fly-distance, 80vw), 50vh) rotate(45deg) scale(var(--card-start-scale, 0.7))\",\n \"opacity\": 1\n },\n {\n \"transform\": \"translate(-50%, -50%) translate(0, 0) rotate(0deg) scale(1)\",\n \"opacity\": 1\n }\n ]\n },\n \"rangeStart\": { \"name\": \"cover\", \"offset\": { \"value\": 70, \"unit\": \"percentage\" } },\n \"rangeEnd\": { \"name\": \"cover\", \"offset\": { \"value\": 88, \"unit\": \"percentage\" } },\n \"easing\": \"ease-out\",\n \"fill\": \"both\"\n }\n ]\n }\n ]\n },\n \"controls\": [\n {\n \"id\": \"fly-distance\",\n \"label\": \"Fly-In Distance\",\n \"description\": \"How far off-screen (horizontally) each card starts before shuffling to center.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 80,\n \"constraints\": { \"min\": 40, \"max\": 100, \"step\": 5, \"unit\": \"vw\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-fly-distance\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vw\" }\n }\n ]\n },\n {\n \"id\": \"start-scale\",\n \"label\": \"Entrance Scale\",\n \"description\": \"The scale of each card at the start of its fly-in, before it grows to full size.\",\n \"group\": \"Motion\",\n \"type\": \"range\",\n \"defaultValue\": 0.7,\n \"constraints\": { \"min\": 0.5, \"max\": 1, \"step\": 0.05, \"unit\": \"x\" },\n \"bindings\": [\n {\n \"target\": \"variable\",\n \"targetId\": \"--card-start-scale\",\n \"transform\": { \"type\": \"direct\" }\n }\n ]\n },\n {\n \"id\": \"scroll-length\",\n \"label\": \"Scroll Length\",\n \"description\": \"Total scroll runway height; increase for slower shuffling.\",\n \"group\": \"Layout\",\n \"type\": \"range\",\n \"defaultValue\": 550,\n \"constraints\": { \"min\": 350, \"max\": 800, \"step\": 25, \"unit\": \"vh\" },\n \"bindings\": [\n {\n \"target\": \"style\",\n \"targetId\": \".comp-mqs0pkfm\",\n \"property\": \"height\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}vh\" }\n }\n ]\n },\n {\n \"id\": \"card-width\",\n \"label\": \"Card Width\",\n \"description\": \"Maximum width of each stacked card.\",\n \"group\": \"Layout\",\n \"type\": \"range\",\n \"defaultValue\": 400,\n \"constraints\": { \"min\": 300, \"max\": 640, \"step\": 20, \"unit\": \"px\" },\n \"bindings\": [\n {\n \"target\": \"style\",\n \"targetId\": \".comp-mqs0pkfu, .comp-mqs0pkgz, .comp-mqs0pkj02, .comp-mqs0pkkb1, .comp-mqs0pkld2, .comp-mqs0pkme4\",\n \"property\": \"max-width\",\n \"transform\": { \"type\": \"template\", \"template\": \"${value}px\" }\n }\n ]\n }\n ],\n \"disableWhen\": [\n {\n \"mediaQuery\": \"(prefers-reduced-motion: reduce)\",\n \"label\": \"Reduced motion\"\n }\n ],\n \"meta\": {\n \"category\": \"scroll\",\n \"tags\": [\"diagonal-shuffle\", \"viewProgress\", \"sticky\", \"cards\", \"stack\"]\n }\n}", + "html": "
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 6

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 5

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 4

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 3

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 2

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenity 1

            \n

            This is the space to highlight the additional services and conveniences you provide to enhance the client or visitor experience.

            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n
            \n

            Amenities

            \n
            \n
            \n
            \n
            ", + "css": ".container {\n position: relative;\n width: 100%;\n height: 100%;\n}\n\n.backgroundLayer {\n position: absolute;\n inset: 0;\n overflow: clip;\n}\n\n.backgroundLayer .background {\n position: absolute;\n inset: 0;\n background-size: cover;\n background-position: center;\n}\n\n.content {\n position: relative;\n}\n\n.presetWrapper {\n display: contents;\n}\n\n.image3,\n.imageLayer {\n width: 100%;\n height: 100%;\n}\n\n.imageLayer {\n overflow: hidden;\n}\n\n.imageLayer > img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.logo-wrapper .linkLayer {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.logo-wrapper img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.line-wrapper {\n display: flex;\n align-items: center;\n}\n\n.line-wrapper > .line {\n width: 100%;\n border-top: 1px solid currentColor;\n}\n\n.menu .navbar {\n display: flex;\n}\n\n.ph-box {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n background: #ededed;\n border: 1px dashed #c4c4c4;\n font: 500 13px/1.3 system-ui, -apple-system, sans-serif;\n color: #8a8a8a;\n letter-spacing: 0.04em;\n}\n\n.imageLayer > .ph-box,\n.logo-wrapper > .ph-box {\n width: 100%;\n height: 100%;\n}\n\n.comp-mqs0pkfm {\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n}\n\n.comp-mqs0pkfm__bg {\n border-bottom-style: solid;\n border-bottom-width: 0px;\n border-bottom-color: transparent;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #000000;\n}\n\n.comp-mqs0pkfm__content {\n display: grid;\n grid-template-columns: 1fr 1fr;\n grid-template-rows: minmax(164.969px,auto) minmax(276.969px,auto) minmax(210.969px,auto) minmax(278.969px,auto) minmax(208.969px,auto) minmax(283.984px,auto) minmax(207.969px,auto);\n}\n\n.comp-mqs0pkfu {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 7;\n grid-row-end: 8;\n place-self: stretch;\n}\n\n.comp-mqs0pkfu__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkfu__content {\n padding-bottom: 8.18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pkg43 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-top: 17.891px;\n margin-bottom: 33.836px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkg43 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkgc {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-bottom: 30.461px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkgc :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkgi5 {\n margin-left: 3.28%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkgz {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 7;\n grid-row-end: 8;\n place-self: stretch;\n}\n\n.comp-mqs0pkgz__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkgz__content {\n padding-bottom: 8.102px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pkh6 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-top: 17.891px;\n margin-bottom: 27.734px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkh6 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkhc4 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-bottom: 36.641px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkhc4 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkhj5 {\n margin-left: 3.13%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkhv1 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 6;\n grid-row-end: 7;\n place-self: stretch;\n}\n\n.comp-mqs0pkhv1__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkhv1__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pki14 {\n margin-left: 3.28%;\n margin-right: 0%;\n margin-top: -50.891px;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n}\n\n.comp-mqs0pkig2 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 6;\n grid-row-end: 7;\n place-self: stretch;\n}\n\n.comp-mqs0pkig2__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkig2__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pkim4 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-top: -51.953px;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n}\n\n.comp-mqs0pkj02 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 5;\n grid-row-end: 6;\n place-self: stretch;\n}\n\n.comp-mqs0pkj02__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkj02__content {\n padding-bottom: 9.203px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pkj7 {\n margin-left: 3.28%;\n margin-right: 0%;\n margin-top: 18.828px;\n margin-bottom: 31.094px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkj7 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkjc5 {\n margin-left: 2.97%;\n margin-right: 0%;\n margin-bottom: 32.242px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkjc5 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkjh5 {\n margin-left: 2.97%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkjs4 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 4;\n grid-row-end: 5;\n place-self: stretch;\n}\n\n.comp-mqs0pkjs4__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkjs4__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pkjz4 {\n margin-left: 3.59%;\n margin-right: 0%;\n margin-top: -53.016px;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n}\n\n.comp-mqs0pkkb1 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 5;\n grid-row-end: 6;\n place-self: stretch;\n}\n\n.comp-mqs0pkkb1__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkkb1__content {\n padding-bottom: 8.196px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pkkh4 {\n margin-left: 3.75%;\n margin-right: 0%;\n margin-top: 18.828px;\n margin-bottom: 37.469px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkkh4 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkkm5 {\n margin-left: 3.75%;\n margin-right: 0%;\n margin-bottom: 26.875px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkkm5 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkkr5 {\n margin-left: 3.13%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkl41 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 4;\n grid-row-end: 5;\n place-self: stretch;\n}\n\n.comp-mqs0pkl41__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkl41__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pkld2 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: stretch;\n}\n\n.comp-mqs0pkld2__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkld2__content {\n padding-bottom: 10.336px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pklj3 {\n margin-left: 3.59%;\n margin-right: 0%;\n margin-top: 19.766px;\n margin-bottom: 26.656px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pklj3 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pklo5 {\n margin-left: 3.59%;\n margin-right: 0%;\n margin-bottom: 36.609px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pklo5 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pklt5 {\n margin-left: 3.59%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkm53 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 2;\n grid-column-end: 3;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: stretch;\n}\n\n.comp-mqs0pkm53__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkm53__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pkme4 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: stretch;\n}\n\n.comp-mqs0pkme4__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkme4__content {\n padding-bottom: 10.227px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: min-content min-content 1fr;\n}\n\n.comp-mqs0pkmk4 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-top: 19.766px;\n margin-bottom: 19.906px;\n width: 47.2%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 18px/1.3em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkmk4 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkmp4 {\n margin-left: 3.13%;\n margin-right: 0%;\n margin-bottom: 43.469px;\n width: 71.8%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: start;\n color: #000000;\n font: normal normal 400 14px/1.5em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pkmp4 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}\n\n.comp-mqs0pkmu5 {\n margin-left: 3.13%;\n margin-right: 0%;\n width: 25%;\n height: auto;\n min-height: 32.438px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 3;\n grid-row-end: 4;\n place-self: start;\n}\n\n.comp-mqs0pkn62 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 2;\n grid-row-end: 3;\n place-self: stretch;\n}\n\n.comp-mqs0pkn62__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 1px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: #000000;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkn62__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pkng2 {\n margin: 0%;\n width: auto;\n height: auto;\n min-height: 0px;\n max-height: 99999px;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 3;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: stretch;\n}\n\n.comp-mqs0pkng2__bg {\n border-bottom-style: solid;\n border-bottom-width: 1px;\n border-bottom-color: #000000;\n border-start-end-radius: 0px;\n box-shadow: none;\n border-inline-end-width: 0px;\n border-top-width: 0px;\n border-inline-start-color: transparent;\n border-start-start-radius: 0px;\n border-inline-start-width: 0px;\n border-top-style: solid;\n border-end-start-radius: 0px;\n border-inline-start-style: solid;\n border-inline-end-style: solid;\n border-inline-end-color: transparent;\n border-top-color: transparent;\n border-end-end-radius: 0px;\n background-color: #ffffff;\n}\n\n.comp-mqs0pkng2__content {\n padding-bottom: 18px;\n display: grid;\n grid-template-columns: repeat(1, minmax(0, 1fr));\n grid-template-rows: 1fr;\n}\n\n.comp-mqs0pknj2 {\n margin-left: 1.56%;\n margin-right: 0%;\n margin-top: 82.438px;\n width: 33.1%;\n min-width: 0px;\n max-width: 99999px;\n grid-column-start: 1;\n grid-column-end: 2;\n grid-row-start: 1;\n grid-row-end: 2;\n place-self: start;\n color: #000000;\n font: normal normal 700 28px/1.2em 'Madefor', sans-serif;\n letter-spacing: 0em;\n mix-blend-mode: normal;\n}\n\n.comp-mqs0pknj2 :where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure) {\n margin: 0;\n font: inherit;\n color: inherit;\n letter-spacing: inherit;\n}" + } + ], + "score": 5, + "notes": "the portfolio example didn't do sticky; and it broke the layout; I only see two images out of 4; I think in a situation like that I would've wanted the final position of the animation to be the same as the original layout (so the cards scroll in diagonally to fit in their original positions)\n\ngrid-cards 2 actually did the animation perfectly; but the agent thought that the cards are the text and buttons; but what I wanted was for the images to be the main thing (and maybe the text could've been sticky to the left and change when each image changes)" + } + ] +} \ No newline at end of file diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalAndVerticalScroll.md b/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalAndVerticalScroll.md new file mode 100644 index 0000000..c59afe4 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalAndVerticalScroll.md @@ -0,0 +1,201 @@ + +# Horizontal And Vertical Scroll + +Cards enter vertically into a sticky viewport frame, then the row pans horizontally. + +## Summary + +- **ID:** `horizontal-and-vertical-scroll` +- **Target shape:** Best for 3 or more sibling cards/images that can share one horizontal row inside a sticky viewport-height frame. +- **Description:** A sticky carousel sequence where cards rise into a clipped frame, then the full row pans sideways through the viewport. + +## Demo HTML + +```html +
            +
            +
            +
            1
            +
            2
            +
            3
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSection` owns runway, `stickyFrame` owns sticky/clipping, `horizontalTrack` owns horizontal translateX, and `repeatedCard` owns vertical entry. +2. `scrollSection`, `stickyFrame`, and `horizontalTrack` must stay distinct. In Wix, `stickyFrame` is usually the internal-container-root and `horizontalTrack` is its `[data-testid="internal-container-content"]` child. +3. Cards are not sticky. Only the shared `stickyFrame` pins the scene, and only `horizontalTrack` pans sideways. +4. Use viewport units only for the runway and sticky frame. If cards use percentage heights, `horizontalTrack` must establish the composition height. +5. Compute horizontal pan from real overflow width. Three cards are the minimum useful pattern. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section whose `viewProgress` drives both the vertical entrances and horizontal pan. | +| `stickyFrame` | The shared sticky viewport frame that centers and clips the carousel while the section scrolls. | +| `horizontalTrack` | The flex row that contains repeated cards and receives the horizontal translateX effect. | +| `repeatedCard` | Cards/images in the horizontal row; each receives an individual vertical entrance effect. | + +## Adaptation Notes + +1. The source section does not need to already be a carousel; repeated siblings can be reorganized into a horizontal row with CSS. +2. Preserve the section root outer layout and keep card size relative to the sticky frame instead of converting cards to viewport-height blocks. +3. When cards use percentage heights, set `height: 100%` on `horizontalTrack` so those percentages resolve against a real stage height. +4. Cards should enter with stage-relative `translateY(...)` on the card roots, and the horizontal pan should start only after the row is already visible. +5. If the row does not overflow the frame, reduce or skip the horizontal pan instead of forcing a meaningless translateX. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `.scroll-section` | The `viewProgress` source for the combined vertical-entry and horizontal-pan sequence. | +| `stickyFrame` | `stickyFrame` | `.sticky-frame` | The shared sticky frame that pins the row and clips cards while they enter and pan. | +| `horizontalTrack` | `horizontalTrack` | `#horizontal-track` | The moving row of cards; this element receives the horizontal translateX effect. | +| `card1` | `repeatedCard` | `.scroll-section #card-1` | Minimum repeated row card; extend for `card4..cardN`. | +| `card2` | `repeatedCard` | `.scroll-section #card-2` | Repeated row card. | +| `card3` | `repeatedCard` | `.scroll-section #card-3` | Repeated row card. | + +> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items. + +## Required Styles + +### `scrollSource` — `.scroll-section` + +```css +.scroll-section { + position: relative; + min-height: 700vh; +} +``` + +Reason: creates enough scroll distance for staged vertical entrances followed by horizontal pan. + +### `stickyFrame` — `.sticky-frame` + +```css +.sticky-frame { + position: sticky; + top: 12.5vh; + height: 75vh; + width: 100%; + overflow: clip; +} +``` + +Reason: pins and clips the visible carousel frame; top should center the frame based on card height. + +### `horizontalTrack` — `#horizontal-track` + +```css +#horizontal-track { + display: flex; + flex-direction: row; + align-items: center; + height: 100%; + width: max-content; + gap: 4px; + will-change: transform; +} +``` + +Reason: creates the row whose width exceeds the viewport and establishes a real composition height for percentage-sized cards. + +### `repeatedCard` — `#horizontal-track > .card` + +```css +#horizontal-track > .card { + flex: 0 0 auto; + width: auto; + height: 75%; + aspect-ratio: 4 / 5; + transform: translateY(140%); + will-change: transform; + overflow: clip; +} +``` + +Reason: keep card size relative to the sticky frame instead of using viewport-height cards. Preserve the source aspect ratio (or measured width), size the card inside the frame, and start it just below that frame with a stage-relative translateY. + +### `repeatedCard` — `.card` + +```css +.card { + margin: 0; +} +``` + +Reason: prevents default or inherited spacing from corrupting row width calculations. + +## Suggested Controls + +Expose the sideways pan distance and the row spacing; add more only when the adapted experience introduces new stable knobs. + +### `pan-distance` + +- **Label:** `Pan Distance` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `55` +- **Description:** How far the row pans sideways through the frame (magnitude of the negative translateX). +- **Constraints:** `min: 20`, `max: 80`, `step: 5`, `unit: %` +- **Binding:** `variable` `--hv-pan-distance` using template `${value}%` + +### `card-gap` + +- **Label:** `Card Gap` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `4` +- **Description:** Spacing between cards in the horizontal row. +- **Constraints:** `min: 0`, `max: 40`, `step: 2`, `unit: px` +- **Binding:** `variable` `--hv-card-gap` using template `${value}px` + +## Interact Template + +```ts +const RANGE = { + easing: 'linear', + fill: 'both' as const, +}; +const ENTRY_RANGE_ENDS = [40, 50, 60] as const; + +const verticalEntryEffect = (key: string, end: number) => ({ + key, + keyframeEffect: { + name: `${key}-vertical-entry`, + keyframes: [ + { transform: 'translateY(125%)' }, + { transform: 'translateY(0)' }, + ], + }, + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 10 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: end } }, + ...RANGE, +}); + +const horizontalTrackEffect = (endTranslate: string) => ({ + key: 'horizontalTrack', + keyframeEffect: { + name: 'horizontal-track-scroll', + keyframes: [{ transform: 'translateX(0)' }, { transform: endTranslate }], + }, + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 50 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 90 } }, + ...RANGE, +}); + +const interaction = { + key: 'scrollSection', + trigger: 'viewProgress', + effects: [ + horizontalTrackEffect('translateX(-55%)'), + ...ENTRY_RANGE_ENDS.map((end, index) => + verticalEntryEffect(`card${index + 1}`, end), + ), + ], +}; +``` diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalLanes.md b/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalLanes.md new file mode 100644 index 0000000..906be80 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/HorizontalLanes.md @@ -0,0 +1,213 @@ +# Horizontal Lanes + +Multiple rows of items scroll sideways forever at different speeds and directions. + +## Summary + +- **ID:** `horizontal-lanes` +- **Target shape:** Best for 2–5 stacked rows of similarly sized items (image strips, logo walls, card marquees) that should drift horizontally on a loop while in view. +- **Description:** Each lane clips an over-wide track holding two identical copies of its items; the track loops between `translateX(0)` and `translateX(-50%)` continuously, so items scroll past seamlessly. Odd lanes drift one way, even lanes the other, each at its own speed. + +## Demo HTML + +```html + +``` + +## Selector Contract + +1. Role ownership is strict: each `marqueeLane` owns the `viewEnter` source plus the clipping; each `marqueeTrack` owns the slide transform; `trackHalf` owns the duplicated-set structure; `laneItem` owns item sizing. +2. The track MUST contain exactly **two** identical content sets (`trackHalf` × 2, same items in the same order). The `translateX(-50%)` loop assumes the track is exactly two sets wide — one set shows a gap, three or more breaks the 50% math. +3. The `viewEnter` source and the animated target must be **different** elements: source is the lane (`lane{n}`), target is the track (`track{n}`). The raw demo animates the track as its own source with `type: 'state'`; per `@wix/interact` that risks re-trigger/never-firing, so map the source to the lane instead. +4. The track must be `width: max-content` inside an `overflow: hidden` lane, so it can exceed the lane and be clipped. Use rendered ids/classes, not invented ones. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `marqueeLane` | A fixed-height row that clips its track and acts as the `viewEnter` source so the loop only runs while on screen. | +| `marqueeTrack` | The over-wide flex row that actually moves; `width: max-content`, animated on `transform`. The effect target. | +| `trackHalf` | One of the two identical content sets inside the track; the duplication is what makes the `-50%` loop seamless. | +| `laneItem` | A repeated item carried by the track; keeps its own width and never shrinks. | + +## Adaptation Notes + +1. Render each lane's items **twice**, in order, as two `trackHalf` children — the loop math (`translateX(0) ↔ translateX(-50%)`) depends on the track being exactly two sets wide. +2. Direction alternates by lane parity: odd lanes `[-50% → 0]` (drift right), even lanes `[0 → -50%]` (drift left). Set each track's CSS initial `transform` to match its first keyframe so there's no jump before the loop starts. +3. Speed is `trackWidth / duration`. The illustrative 40–55s are tuned to the demo's set width; when item count or size changes, scale each lane's `duration` proportionally to keep a constant pixels-per-second, and keep durations slightly different per lane for a natural multi-speed feel. +4. `@wix/interact` runs in JSON, which has no `Infinity` — serialize the endless loop as `iterations: 0` (treated as infinite). The TS template below writes `Infinity` only for readability. +5. Keep `viewEnter` with `type: 'state'` (plays while the lane is visible, pauses off-screen) — do not switch to `once`, which would stop the marquee after the first entry. To add lanes, extend `lane4..laneN` + `track4..trackN` as pairs; hiding the lower lanes under a mobile breakpoint is optional. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `lane1` | `marqueeLane` | `#lane-1` (`.gallery-row`) | `viewEnter` source + clip for track 1; extend outward for `lane4..laneN`. | +| `lane2` | `marqueeLane` | `#lane-2` (`.gallery-row`) | `viewEnter` source + clip for track 2. | +| `lane3` | `marqueeLane` | `#lane-3` (`.gallery-row`) | `viewEnter` source + clip for track 3. | +| `track1` | `marqueeTrack` | `#wrapper-1` | Moving flex track (two duplicated sets); marquee target for lane 1. | +| `track2` | `marqueeTrack` | `#wrapper-2` | Marquee target for lane 2. | +| `track3` | `marqueeTrack` | `#wrapper-3` | Marquee target for lane 3. | + +> Repeated keys keep their trailing index and are paired: `lane{n}` is the source for `track{n}`. Extend the rows together as matched `lane4`+`track4` … `laneN`+`trackN` pairs. + +## Required Styles + +### `marqueeLane` — `.gallery-row` + +```css +.gallery-row { + height: var(--row-height, 240px); + position: relative; + overflow: hidden; +} +``` + +Reason: a fixed-height lane that clips the wider moving track so only one lane's worth of items shows at a time. + +### `marqueeTrack` — `.animation-wrapper` + +```css +.animation-wrapper { + display: flex; + flex-direction: row; + height: 100%; + width: max-content; + will-change: transform; +} +``` + +Reason: a single horizontal row sized to its full content so it can slide left/right and be clipped by the lane; `will-change` hints compositing for the perpetual transform. + +### `trackHalf` — `.animation-wrapper > div` + +```css +.animation-wrapper > div { + display: flex; + flex-direction: row; + height: 100%; +} +``` + +Reason: the two identical sets sit side-by-side so a `-50%` shift equals exactly one set width — the moment the first set scrolls out, the second is in the same place, making the loop seamless. + +### `laneItem` — `.image-container` + +```css +.image-container { + position: relative; + height: 100%; + flex-shrink: 0; +} +``` + +Reason: items keep their natural width and never compress, so the track's total width (and therefore the loop distance) stays stable. + +### `laneItem` — `.gallery-image` + +```css +.gallery-image { + height: 100%; + width: auto; + object-fit: cover; + box-sizing: border-box; + padding: var(--img-padding, 15px); + border-radius: var(--img-border-radius, 24px); + display: block; +} +``` + +Reason: images size to the lane height and keep their aspect ratio, which sets each item's width (hence the track width); the padding creates the visible gap between items. + +## Suggested Controls + +Expose the loop speed and the lane height as the core knobs; item padding is a secondary spacing knob. + +### `speed` + +- **Label:** `Scroll Speed` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `45` +- **Description:** How long one full loop takes; lower is faster. Applied as each lane's effect duration (vary slightly per lane). +- **Constraints:** `min: 15`, `max: 90`, `step: 1`, `unit: s` +- **Suggested variable:** `--marquee-duration` + +### `row-height` + +- **Label:** `Lane Height` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `240` +- **Description:** Height of each lane, which also scales the items (they size to lane height). +- **Constraints:** `min: 120`, `max: 420`, `step: 10`, `unit: px` +- **Suggested variable:** `--row-height` + +### `item-padding` + +- **Label:** `Item Gap` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `15` +- **Description:** Padding around each item — the visible gap between items in a lane. +- **Constraints:** `min: 0`, `max: 40`, `step: 1`, `unit: px` +- **Suggested variable:** `--img-padding` + +## Interact Template + +```ts +// viewEnter `state` plays the marquee while the lane is on screen and pauses it +// when the lane scrolls away (cheap off-screen). Source = lane, target = track, +// so source and target are different elements (see Selector Contract #3). + +// Two seamless directions. The track holds TWO identical sets, so a -50% shift +// equals exactly one set width. +const moveRight = [{ transform: 'translateX(-50%)' }, { transform: 'translateX(0)' }]; +const moveLeft = [{ transform: 'translateX(0)' }, { transform: 'translateX(-50%)' }]; + +// Illustrative per-lane durations (ms). Recompute from real track width to hold +// a constant pixels/second; keep them slightly different per lane. +const LANE_DURATIONS = [40000, 50000, 45000]; +``` + +```ts +// One marquee effect per lane. Direction alternates by index parity. +// NOTE: in serialized JSON use `iterations: 0` (treated as infinite) — JSON has +// no `Infinity`. +const marqueeEffect = (trackKey: string, index: number) => ({ + key: trackKey, + keyframeEffect: { + name: `${trackKey}-marquee`, + keyframes: index % 2 === 0 ? moveRight : moveLeft, + }, + duration: LANE_DURATIONS[index] ?? 45000, + easing: 'linear', + iterations: Infinity, // serialize as 0 +}); + +const laneKeys = ['lane1', 'lane2', 'lane3'] as const; +const trackKeys = ['track1', 'track2', 'track3'] as const; + +// Each lane is its own viewEnter(state) source driving only its own track. +const interactions = laneKeys.map((laneKey, i) => ({ + key: laneKey, + trigger: 'viewEnter', + params: { type: 'state' }, + effects: [marqueeEffect(trackKeys[i], i)], +})); +``` diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/Scroll_3D_Animation.md b/Ani-Mate Prompts/Gallery-and-Carousel/Scroll_3D_Animation.md new file mode 100644 index 0000000..d2ba4d2 --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/Scroll_3D_Animation.md @@ -0,0 +1,251 @@ +# Scroll 3D Animation + +Split-screen copy with a rotating 3D panel stack that subtly fans out on scroll. + +## Summary + +- **ID:** `scroll-3d-animation` +- **Target shape:** Best for one tall scroll section with fixed intro copy on one side and `4-8` overlapped image/card panels centered in a separate 3D stage. +- **Description:** A fixed panel stage starts turned away in 3D, rotates toward the viewer over the first half of the scroll, and keeps a stack of depth-layered panels centered while each panel drifts slightly sideways according to its index. + +## Demo HTML + +```html +
            +
            +

            Title 01

            +

            Scroll-driven 3D animation with horizontal subtle movement.

            +
            + +
            +
            Panel 1
            +
            Panel 2
            +
            Panel 3
            +
            Panel 4
            +
            Panel 5
            +
            Panel 6
            +
            Panel 7
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSection` owns the tall runway, `copyBlock` owns the fixed text column, `panelStage` owns the shared 3D rotation, and each `repeatedPanel` owns its own size, depth, and subtle horizontal drift. +2. Keep `copyBlock` and `panelStage` as separate fixed siblings inside the same scroll section. Do not wrap the text into the rotating 3D stage. +3. Only `panelStage` receives the `rotateY(...)` reveal. Individual panels keep their own centered transform plus per-item scale and horizontal drift. +4. Depth belongs on the repeated panel roots via `translateZ(...)` or the CSS `translate` longhand. Do not fake the stack by offsetting margins or rotating the whole section. +5. On narrow screens, collapse to a static stacked column and remove the motion. The source example disables the animation at `max-width: 1280px`. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall section whose `viewProgress` drives the wrapper reveal and all per-panel drift effects. | +| `copyBlock` | Fixed left-side copy that stays readable and does not rotate with the 3D stage. | +| `panelStage` | Fixed right-side perspective container that stays centered and owns the shared `rotateY` reveal. | +| `repeatedPanel` | Overlapped image/card panels centered in the stage; each keeps its own size, scale, and z-depth. | + +## Adaptation Notes + +1. Preserve the split layout when the section already has one text column and one visual column. This pattern depends on the text remaining still while the panel stack rotates independently. +2. Recompute panel size, scale, and z-depth from the real item count instead of copying the demo numbers literally. The source ramps panel width from roughly `45vw` to `65vw`, height from `30vw` to `42vw`, and scale from `0.75` to `1.15`. +3. Keep all panels anchored to the same center point with `left: 50%` plus `translateX(-50%)`; vary only depth and small horizontal drift. +4. If the source section uses cards instead of pure images, animate the card root and keep media filling that root with `width/height: 100%` and `object-fit: cover`. +5. If there is no real split layout, this pattern can still work with centered copy above the stage, but the fixed-copy/sidebar feel is part of the original example and should be preserved when possible. +6. For reduced motion or small screens, fall back to a normal vertical list of panels and skip the interaction entirely rather than forcing a broken 3D layout. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `.intro` | The `viewProgress` source for the whole sequence. | +| `copyBlock` | `copyBlock` | `.text-block` | Fixed text column that stays outside the animated 3D stage. | +| `panelStage` | `panelStage` | `.panel-wrapper` | Shared perspective container that rotates from `-180deg` to `0deg`. | +| `panel1` | `repeatedPanel` | `.panel-wrapper #panel-0` | Repeated centered panel; extend as `panel4..panelN`. | +| `panel2` | `repeatedPanel` | `.panel-wrapper #panel-1` | Repeated centered panel. | +| `panel3` | `repeatedPanel` | `.panel-wrapper #panel-2` | Repeated centered panel. | +| `panel4` | `repeatedPanel` | `.panel-wrapper #panel-3` | Repeated centered panel. | + +> Repeated panel keys should keep their trailing index (`panel1`, `panel2`, …) even if the DOM ids start at `panel-0`; extend the pattern through `panelN` for more items. + +## Required Styles + +### `scrollSource` — `.intro` + +```css +.intro { + position: relative; + min-height: 300vh; + padding: 20px; +} +``` + +Reason: creates the full scroll runway for the wrapper reveal and the scrubbed panel drift. + +### `copyBlock` — `.text-block` + +```css +.text-block { + position: fixed; + top: 50%; + left: 3%; + z-index: 10; + width: 18%; + min-width: 200px; + transform: translateY(-50%); +} +``` + +Reason: keeps the copy readable and stationary while the 3D panel stage animates beside it. + +### `panelStage` — `.panel-wrapper` + +```css +.panel-wrapper { + position: fixed; + top: 50%; + left: calc(3% + 18% + 1%); + width: calc(100% - (3% + 18% + 4%)); + display: flex; + justify-content: center; + align-items: center; + perspective: var(--panel-perspective, 2000px); + transform: translateY(-50%); + transform-style: preserve-3d; +} +``` + +Reason: creates the shared fixed 3D viewport and supplies the exact transform baseline the wrapper reveal animates on top of. + +### `repeatedPanel` — `.panel-wrapper > .panel` + +```css +.panel-wrapper > .panel { + position: absolute; + left: 50%; + width: var(--panel-width, 45vw); + height: var(--panel-height, 30vw); + transform: translateX(-50%) scale(var(--panel-scale, 1)); + translate: 0 0 var(--panel-z, 0px); + transform-origin: center center; + transform-style: preserve-3d; + will-change: transform, translate; +} +``` + +Reason: centers every repeated panel on the same anchor while allowing per-panel size, scale, and z-depth to vary independently. + +### `repeatedPanel` — `.panel-wrapper > .panel > img` + +```css +.panel-wrapper > .panel > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +``` + +Reason: makes image panels fill the animated card root without introducing inner layout drift. + +## Suggested Controls + +Expose the stack spacing and the per-panel horizontal spread first; they are the most stable knobs in the source pattern. + +### `panel-gap` + +- **Label:** `Panel Gap` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `120` +- **Description:** Distance in pixels between successive panels on the z-axis. +- **Constraints:** `min: 40`, `max: 220`, `step: 10`, `unit: px` +- **Binding:** `variable` `--panel-gap` using a direct value + +### `panel-drift` + +- **Label:** `Panel Drift` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `20` +- **Description:** Horizontal drift per panel index by the end of the scroll. +- **Constraints:** `min: 0`, `max: 60`, `step: 2`, `unit: px` +- **Binding:** `variable` `--panel-drift` using template `${value}px` + +### `stage-perspective` + +- **Label:** `Perspective` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `2000` +- **Description:** Depth strength for the shared 3D stage. +- **Constraints:** `min: 800`, `max: 3000`, `step: 100`, `unit: px` +- **Binding:** `variable` `--panel-perspective` using template `${value}px` + +## Interact Template + +```ts +const FULL_RANGE = { + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 0 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 100 } }, + easing: 'linear', + fill: 'both' as const, +}; + +const configurePanelLayout = (panel: HTMLElement, index: number, count: number) => { + const progress = count > 1 ? index / (count - 1) : 1; + panel.style.setProperty('--panel-width', `${45 + progress * 20}vw`); + panel.style.setProperty('--panel-height', `${30 + progress * 12}vw`); + panel.style.setProperty('--panel-scale', `${0.75 + progress * 0.4}`); + panel.style.setProperty('--panel-z', `calc(var(--panel-gap) * ${-index} * 1px)`); +}; + +const wrapperReveal = { + key: 'panelStage', + keyframeEffect: { + name: 'panel-stage-rotate-in', + keyframes: [ + { transform: 'translateY(-50%) rotateY(-180deg)' }, + { transform: 'translateY(-50%) rotateY(0deg)' }, + ], + }, + rangeStart: { name: 'cover', offset: { unit: 'percentage', value: 0 } }, + rangeEnd: { name: 'cover', offset: { unit: 'percentage', value: 50 } }, + easing: 'ease-out', + fill: 'both' as const, +}; + +const panelDriftEffect = (key: string, index: number, count: number) => { + const progress = count > 1 ? index / (count - 1) : 1; + const scale = 0.75 + progress * 0.4; + const xEnd = (index - (count - 1) / 2) * 20; + + return { + key, + keyframeEffect: { + name: `${key}-drift`, + keyframes: [ + { transform: `translateX(-50%) translateX(0px) scale(${scale})` }, + { transform: `translateX(-50%) translateX(${xEnd}px) scale(${scale})` }, + ], + }, + ...FULL_RANGE, + }; +}; + +const interaction = { + key: 'scrollSection', + trigger: 'viewProgress', + effects: [ + wrapperReveal, + ...Array.from({ length: panelCount }, (_, index) => + panelDriftEffect(`panel${index + 1}`, index, panelCount), + ), + ], +}; +``` + +## Source + +Derived from [`Scroll_3D_Animation.html`](https://github.com/wix-incubator/interact-examples/blob/main/Gallery-and-Carousel/Scroll_3D_Animation.html). diff --git a/Ani-Mate Prompts/Gallery-and-Carousel/WheelCarousel.md b/Ani-Mate Prompts/Gallery-and-Carousel/WheelCarousel.md new file mode 100644 index 0000000..de2360d --- /dev/null +++ b/Ani-Mate Prompts/Gallery-and-Carousel/WheelCarousel.md @@ -0,0 +1,243 @@ +# Top-Arc Wheel Carousel + +Image cards ride a slowly spinning wheel while staying upright, with only the top arc revealed. + +## Summary + +- **ID:** `wheel-carousel` +- **Target shape:** Best for a gallery of 8–12 similarly sized square images that can share one circular stage inside a clipping frame; suits hero or promo sections where only the top arc of the wheel is visible above copy. +- **Description:** A dozen image cards are positioned around a circle on a wheel that rotates continuously; each card counter-rotates to stay upright, the frame clips everything but the top arc, and hovering a card zooms its image. + +## Demo HTML + +```html +
            +
            +
            +
            +
            + +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `viewportFrame` owns clipping and edge masks, `wheelStage` owns the continuous rotation and the radial layout origin, `repeatedCard` owns radial placement plus the counter-rotation, and `cardImage` owns the hover zoom. +2. `viewportFrame` and `wheelStage` must be different selectors. The frame never rotates; only the wheel rotates. Rotating the frame would drag the clip mask and fade edges with it. +3. The card counter-rotation effect must use the same duration, easing, and iterations as the wheel spin with the opposite direction (`-360deg` vs `360deg`). Any mismatch makes the cards visibly tumble instead of staying upright. +4. Keep radial placement and counter-rotation on the card roots (`#card-n`), not on the raw `img` descendants. The hover zoom is the only effect that targets the inner image. +5. Each card and each inner image must be wrapped in its own `interact-element`, because cards are counter-rotation targets and images are independent hover targets. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `viewportFrame` | The clipping section that reveals only the top arc and applies the edge/bottom fade masks; never animated. | +| `wheelStage` | The circular turntable that holds all cards and rotates continuously via a `viewEnter` loop. | +| `repeatedCard` | Repeated card roots placed at even angular intervals around the wheel; each counter-rotates to stay upright. | +| `cardImage` | The image inside each card; the sole target of the hover zoom effect. | + +## Adaptation Notes + +1. Place cards with a radial formula, not literal demo offsets: for card index `i` of `N` cards, `angle = i * (360 / N)` degrees, `x = r * cos(angle)`, `y = r * sin(angle)`, then `margin-left: calc((x − cs/2) * 1vmin)` and `margin-top: calc((y − cs/2) * 1vmin)`, where `--r` is the radius and `--cs` the card size. The demo hard-codes 12 cards at 30° with precomputed cosines — recompute these when `N` changes. +2. Keep radius and card size expressed through the `--r` / `--cs` custom properties in `vmin` so the wheel scales with the viewport and the responsive breakpoints keep working. +3. Reveal the top arc by giving the wheel a positive `margin-top` and clipping with the frame height; the bottom fade layer hides the lower half. Adjust `margin-top` and frame height together when you change the radius. +4. Assign z-index by arc depth (front/lower cards higher) so overlapping cards stack believably; derive it from vertical position rather than copying the demo's exact numbers. +5. The rotation is a continuous `viewEnter` loop (`iterations: Infinity`), not a scroll-driven effect — there is no runway height to size and no ViewTimeline to protect. +6. If the section cannot host a distinct non-rotating frame and a rotating wheel, reject the pattern; collapsing them breaks the clip mask. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `viewportFrame` | `viewportFrame` | `.arc-viewport` | Clipping frame that reveals the top arc; `position: relative`, fixed height, `overflow: hidden`. | +| `wheelStage` | `wheelStage` | `#wheel` | Rotating turntable; the `viewEnter` trigger source and radial layout origin. Must differ from `viewportFrame`. | +| `card1` | `repeatedCard` | `#card-1` | Minimum repeated radial card; extend outward for `card4..cardN`. | +| `card2` | `repeatedCard` | `#card-2` | Repeated radial card. | +| `card3` | `repeatedCard` | `#card-3` | Repeated radial card. | +| `cardImg1` | `cardImage` | `#card-1-img` | Hover-zoom image inside `card1`; extend for `cardImg4..cardImgN`. | +| `cardImg2` | `cardImage` | `#card-2-img` | Hover-zoom image inside `card2`. | +| `cardImg3` | `cardImage` | `#card-3-img` | Hover-zoom image inside `card3`. | + +> Repeated keys must keep their trailing index (`card1`, `card2`, … and `cardImg1`, `cardImg2`, …) so they compact into the `card{n}` and `cardImg{n}` groups. Extend both rows to match the real card count (`card4..cardN`, `cardImg4..cardImgN`); the demo uses 12 of each. + +## Required Styles + +### `viewportFrame` — `.arc-viewport` + +```css +.arc-viewport { + position: relative; + width: 100%; + height: 68vh; + overflow: hidden; + display: flex; + justify-content: center; + align-items: flex-start; +} +``` + +Reason: fixes the visible window and clips the wheel so only the top arc shows above the copy; centers the wheel horizontally and anchors it to the top. + +### `wheelStage` — `#wheel` + +```css +#wheel { + position: relative; + width: calc(var(--r) * 2vmin + var(--cs) * 1vmin); + height: calc(var(--r) * 2vmin + var(--cs) * 1vmin); + transform-origin: center center; + margin-top: 10vh; + flex-shrink: 0; +} +``` + +Reason: sizes the turntable to the circle diameter plus one card, centers its rotation, and pushes it down so the clip frame exposes the upper arc. + +### `repeatedCard` — `.card` + +```css +.card { + position: absolute; + width: calc(var(--cs) * 1vmin); + height: calc(var(--cs) * 1vmin); + left: 50%; + top: 50%; + border-radius: var(--cr); + overflow: hidden; + transform-origin: center center; +} +``` + +Reason: anchors every card to the wheel center so the radial `margin` offsets place them on the circle, and centers `transform-origin` so the counter-rotation keeps each card upright. + +### `repeatedCard` — `#card-n` (per-card radial placement) + +```css +#card-1 { + margin-left: calc((var(--r) * 1 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0 - var(--cs) / 2) * 1vmin); + z-index: 100; +} +``` + +Reason: positions each card at its angle on the circle (`cos`/`sin` of `i * 360 / N`) offset by half the card size; recompute the multipliers and z-index per card when the count or radius changes. + +### `cardImage` — `.card img` + +```css +.card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +``` + +Reason: fills the card frame so the hover scale zooms a cropped image cleanly with no letterboxing. + +## Suggested Controls + +Expose the wheel geometry and its rotation speed; these are the stable knobs that reshape the pattern without breaking the counter-rotation contract. + +### `radius` + +- **Label:** `Wheel Radius` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `65` +- **Description:** Controls how large the circle is; larger values push cards farther from the center. +- **Constraints:** `min: 20`, `max: 80`, `step: 1`, `unit: vmin` +- **Binding:** `variable` `--r` using a direct value + +### `card-size` + +- **Label:** `Card Size` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `20` +- **Description:** Controls the width and height of each image card. +- **Constraints:** `min: 8`, `max: 30`, `step: 1`, `unit: vmin` +- **Binding:** `variable` `--cs` using a direct value + +### `spin-duration` + +- **Label:** `Spin Speed` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `30000` +- **Description:** Controls how long one full wheel revolution takes; the card counter-rotation duration must match. +- **Constraints:** `min: 8000`, `max: 60000`, `step: 1000`, `unit: ms` +- **Binding:** `effect` `wheel-spin` property `duration` using a direct value, and `effect` `card-counter` property `duration` using the same value + +## Interact Template + +```ts +// How many cards ride the wheel — recompute radial CSS placement when this changes. +const CARD_COUNT = 12; +const SPIN_DURATION = 30000; // ms per revolution; wheel and counter must match. + +// Continuous clockwise spin on the wheel stage. +const wheelSpin = { + keyframeEffect: { + name: 'wheel-spin-kf', + keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], + }, + duration: SPIN_DURATION, + iterations: Infinity, + easing: 'linear', +}; + +// Counter-rotation so cards stay upright — opposite direction, identical timing. +const cardCounter = { + keyframeEffect: { + name: 'card-counter-kf', + keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-360deg)' }], + }, + duration: SPIN_DURATION, + iterations: Infinity, + easing: 'linear', +}; + +// Hover zoom for the image inside a card. +const imgHover = { + keyframeEffect: { + name: 'img-hover-kf', + keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)' }], + }, + duration: 250, + easing: 'ease-out', + fill: 'both' as const, +}; + +const cardKeys = Array.from({ length: CARD_COUNT }, (_, i) => `card${i + 1}`); + +const config = { + effects: { + 'wheel-spin': wheelSpin, + 'card-counter': cardCounter, + 'img-hover': imgHover, + }, + interactions: [ + // Start the loop when the wheel enters view: spin the stage, counter-rotate every card. + { + key: 'wheelStage', + trigger: 'viewEnter', + effects: [ + { key: 'wheelStage', effectId: 'wheel-spin' }, + ...cardKeys.map((key) => ({ key, effectId: 'card-counter' })), + ], + }, + // One hover interaction per card, zooming its own image. + ...cardKeys.map((key, i) => ({ + key, + trigger: 'hover', + effects: [ + { key: `cardImg${i + 1}`, effectId: 'img-hover', triggerType: 'alternate' }, + ], + })), + ], +}; +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Image_Background/column-squeeze-reveal.md b/Ani-Mate Prompts/Image_Background/column-squeeze-reveal.md new file mode 100644 index 0000000..88e9006 --- /dev/null +++ b/Ani-Mate Prompts/Image_Background/column-squeeze-reveal.md @@ -0,0 +1,222 @@ +# Column Squeeze Reveal + +A left text column squeezes narrow while its headline shrinks and the background image zooms, all pinned on scroll. + +## Summary + +- **ID:** `column-squeeze-reveal` +- **Target shape:** Best for a full-bleed section with a vertical text/label column overlaid on a single background image, where the column can pin to the viewport and reveal more of the image as it narrows. +- **Description:** A sticky stage holds a narrow left column over a background image; as the section scrolls, the column's inner panel squeezes from wide to narrow, the headline scales down, and the background image zooms in. + +## Demo HTML + +```html +
            +
            +
            +
            +
            +
            +
            +
            +
            +

            Static intro copy…

            +
            +
            + Built + Space +
            +
            +
            +
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSource` owns the tall runway, `stickyStage` owns the pin and clip, `squeezePanel` owns the width animation, `headline` owns the text scale, and `backgroundLayer` owns the zoom. +2. `scrollSource` and `stickyStage` must be different selectors; the runway (`300vh`) sits on the outer section and the pin (`sticky`, `100vh`) sits on the inner stage. +3. The width animation targets the panel's own inner wrapper (`.left-inner`), not the positioned `left-col` overlay — animating the overlay's own width would move its absolute anchoring, not reveal the image. +4. The zoom targets the background media element (`.bg-image`), never the sticky stage or an ancestor of the `viewProgress` targets; scaling an ancestor freezes ViewTimeline sampling. +5. Keep the background and column as distinct stacked layers (`z-index` ordered); collapsing them into one element breaks the reveal. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall outer section whose height creates the `viewProgress` scroll distance. | +| `stickyStage` | A sticky viewport-height frame that pins the composition and clips overflow during scroll. | +| `squeezePanel` | The inner wrapper of the overlaid text column whose width animates from wide to narrow. | +| `headline` | The oversized display text inside the column that scales down as the column narrows. | +| `backgroundLayer` | The background image element behind the column that zooms in over the same range. | + +## Adaptation Notes + +1. Put scroll distance on `scrollSource` (`~300vh`) and pinning on `stickyStage` (`100vh`); never merge them onto one element. +2. Squeeze the column by animating `width` on `squeezePanel` (e.g. `22vw → 9vw`); recompute both endpoints from the real column width so the ending panel still fits its content legibly. +3. Scale the headline on `headline` with `transform: scale()`; pick the end scale so the text fits the narrowed column (demo uses `1 → 0.41`) rather than copying the literal factor. +4. Zoom the background with `transform: scale()` on `backgroundLayer` only (demo `1 → 1.4`); keep `overflow: clip` on the stage and column so the zoom and squeeze stay masked. +5. All three effects share one range on the single `scrollSource` trigger — keep their `rangeStart`/`rangeEnd` identical so squeeze, scale, and zoom stay synchronized. +6. Reject the pattern if you cannot keep a distinct sticky stage, a clip-masked column panel, and a separate zoomable background layer. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollDriver` | `scrollSource` | `.scroll-driver` | The `viewProgress` source; tall runway for the whole pattern. | +| `stickyStage` | `stickyStage` | `.sticky-stage` | Sticky pin and clip frame; not itself animated. | +| `squeezePanel` | `squeezePanel` | `.left-inner` | Inner column wrapper whose `width` animates wide → narrow. | +| `headline` | `headline` | `.hero-text-inner` | Display text wrapper that scales down over the range. | +| `bgImage` | `backgroundLayer` | `.bg-image` | Background media element that zooms in over the range. | + +## Required Styles + +### `scrollSource` — `.scroll-driver` + +```css +.scroll-driver { + height: 300vh; +} +``` + +Reason: creates enough scroll distance for the squeeze, scale, and zoom to play out fully. + +### `stickyStage` — `.sticky-stage` + +```css +.sticky-stage { + position: sticky; + top: 1.5rem; + width: calc(100vw - 3rem); + height: calc(100vh - 3rem); + overflow: hidden; +} +``` + +Reason: pins the composition to the viewport and clips the zooming image and squeezing column while the section scrolls. + +### `squeezePanel` — `.left-inner` + +```css +.left-inner { + width: 22vw; + height: 100%; + position: relative; + overflow: clip; + background: #0a0a0a; +} +``` + +Reason: establishes the starting column width and clips its contents so the headline is masked as the panel narrows, progressively revealing the background. + +### `backgroundLayer` — `.bg-image` + +```css +.bg-image { + width: 100%; + height: 100%; + background-size: cover; + background-position: center top; + transform-origin: center center; +} +``` + +Reason: fills the stage so a `scale()` zoom stays covered, and centers the transform origin so the zoom reads as a push-in rather than a drift. + +### `headline` — `.hero-text-inner` + +```css +.hero-text-inner { + position: relative; + width: 100%; + height: 100%; + transform-origin: left bottom; +} +``` + +Reason: anchors the scale to the bottom-left so the shrinking headline stays pinned to the column corner instead of floating toward center. + +## Suggested Controls + +Expose the squeeze range and the background zoom as the primary knobs; add the headline end scale when the composition needs fine tuning. + +### `squeeze-end` + +- **Label:** `Column End Width` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `9` +- **Description:** Controls how narrow the text column becomes at the end of the scroll, and thus how much of the background image is revealed. +- **Constraints:** `min: 4`, `max: 18`, `step: 1`, `unit: vw` +- **Binding:** `variable` `--column-end-width` using template `${value}vw` + +### `image-zoom` + +- **Label:** `Image Zoom` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `1.4` +- **Description:** Controls the ending scale of the background image as the section scrolls past. +- **Constraints:** `min: 1`, `max: 1.8`, `step: 0.05`, `unit: x` +- **Binding:** `variable` `--bg-end-scale` using a direct value + +### `text-scale` + +- **Label:** `Headline End Scale` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `0.41` +- **Description:** Controls how small the headline shrinks so it stays inside the narrowed column. +- **Constraints:** `min: 0.25`, `max: 0.8`, `step: 0.01`, `unit: x` +- **Binding:** `variable` `--headline-end-scale` using a direct value + +## Interact Template + +```ts +// Shared scroll range for all three effects — keep identical so they stay synchronized. +const RANGE = { + rangeStart: { name: 'entry', offset: { value: 100, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 0, unit: 'percentage' } }, + fill: 'both' as const, + easing: 'ease-in-out', +}; + +// Recompute endpoints from the real column width, headline size, and desired reveal. +const squeezeEffect = { + key: 'squeezePanel', + selector: '.left-inner', + keyframeEffect: { + name: 'squeeze-column', + keyframes: [{ width: '22vw' }, { width: '9vw' }], + }, + ...RANGE, +}; + +const headlineScaleEffect = { + key: 'headline', + selector: '.hero-text-inner', + keyframeEffect: { + name: 'scale-headline', + keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(0.41)' }], + }, + ...RANGE, +}; + +const zoomEffect = { + key: 'bgImage', + selector: '.bg-image', + keyframeEffect: { + name: 'zoom-image', + keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }], + }, + ...RANGE, +}; + +const interaction = { + key: 'scrollDriver', + trigger: 'viewProgress', + effects: [squeezeEffect, headlineScaleEffect, zoomEffect], +}; +``` \ No newline at end of file diff --git a/Ani-Mate Prompts/Image_Background/lumina-orbit-scroll.md b/Ani-Mate Prompts/Image_Background/lumina-orbit-scroll.md new file mode 100644 index 0000000..73d02bd --- /dev/null +++ b/Ani-Mate Prompts/Image_Background/lumina-orbit-scroll.md @@ -0,0 +1,74 @@ + { + "id": "lumina-orbit-scroll", + "name": "Lumina Orbit Scroll", + "description": "A single large image stays pinned, then shrinks into a tilted luminous card and fades away through scroll.", + "targetShape": "Best for one dominant hero image or media surface, optionally with centered overlay copy above it.", + "selectorContract": [ + "Role ownership is strict: scrollSection owns runway, stickyFrame owns sticky/clipping, and primaryImage owns the transform/filter animation.", + "Animate only the dominant image/media root. Do not bind the transform to stickyFrame or to a wrapper that also contains copy.", + "In Wix, stickyFrame is usually the internal-container-root and primaryImage should be the concrete image comp or stable image-only wrapper inside it.", + "Use viewport units only for the runway and sticky frame. Recompute runway height from the desired pacing instead of copying the demo number." + ], + "roleGuidance": { + "scrollSource": "Tall section whose viewProgress drives the image orbit.", + "stickyFrame": "Pinned viewport-sized frame that centers and clips the large image.", + "primaryImage": "The single large image or image-only media wrapper that receives the transform/filter sequence." + }, + "adaptationNotes": [ + "Keep the source image sizing model unless the image needs explicit stage fill; `width/height: 100%` with `object-fit: cover` is the normal baseline.", + "If the section has overlay copy, leave it static or animate it separately with its own selector.", + "Use the image root or image-only wrapper as primaryImage so text does not shrink and tilt with the image.", + "If the image should not disappear fully, stop the last keyframe earlier instead of copying the demo fade-out literally." + ], + "requiredElements": [ + { "key": "scrollSection", "role": "scrollSource", "demoSelector": ".sticky-track" }, + { "key": "stickyFrame", "role": "stickyFrame", "demoSelector": "#sticky-frame" }, + { "key": "primaryImage", "role": "primaryImage", "demoSelector": "#hero-image" } + ], + "requiredStyles": [ + { + "targetRole": "scrollSource", + "declarations": { "position": "relative", "minHeight": "500vh" } + }, + { + "targetRole": "stickyFrame", + "declarations": { + "position": "sticky", + "top": "0", + "height": "100vh", + "display": "flex", + "alignItems": "center", + "justifyContent": "center", + "overflow": "clip" + } + }, + { + "targetRole": "primaryImage", + "declarations": { + "display": "block", + "width": "100vw", + "height": "100vh", + "objectFit": "cover", + "willChange": "transform, filter, opacity, border-radius" + } + } + ], + "interactionRecipe": { + "trigger": "viewProgress", + "target": "scrollSection", + "effects": [ + { + "key": "primaryImage", + "kind": "imageOrbitAway", + "rangeStart": "contain 0%", + "rangeEnd": "contain 100%", + "keyframeSummary": [ + "start: slightly enlarged full-stage image", + "mid: shrink into tilted luminous card", + "end: tiny desaturated faded image" + ], + "notes": "Scale, 3D tilt, border radius, filter, and opacity all evolve together." + } + ] + } + } \ No newline at end of file diff --git a/Ani-Mate Prompts/Image_Background/manifest-expand-scroll.md b/Ani-Mate Prompts/Image_Background/manifest-expand-scroll.md new file mode 100644 index 0000000..fe8de2c --- /dev/null +++ b/Ani-Mate Prompts/Image_Background/manifest-expand-scroll.md @@ -0,0 +1,181 @@ +# Manifest Expand Scroll + +Single-image block expands from a corner card into a full-frame hero through scroll. + +## Summary + +- **ID:** `manifest-expand-scroll` +- **Name:** `Manifest Expand Scroll` +- **Description:** A single large image begins as a cropped anchored block and expands through a sticky frame until it nearly fills the viewport, while the image zoom settles back to full scale. +- **Best for:** one dominant image or media block that can grow from a smaller anchored composition into a near full-bleed hero. + +## Demo HTML + +```html +
            +
            +
            +
            + +
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: `scrollSection` owns runway, `stickyFrame` owns sticky/clipping, `imageBox` owns expansion geometry, and `primaryImage` owns zoom. +2. `stickyFrame`, `imageBox`, and `primaryImage` must stay distinct selectors. `imageBox` is the absolute block inside the frame; `primaryImage` is the actual media surface inside it. +3. Use viewport units only for the outer runway and sticky frame. Animate `imageBox` with inset/top/right/bottom/left values inside the frame, not by resizing the section. +4. Prefer a concrete media selector over a broad `img` descendant. Recompute the start/end inset values from the real composition. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | Tall section whose `viewProgress` drives the expansion of the image block. | +| `stickyFrame` | Pinned viewport-sized frame that clips the expanding image composition. | +| `imageBox` | Absolute positioned image block that expands from a smaller anchored crop toward full-frame. | +| `primaryImage` | The image/media surface inside `imageBox` that zooms from 1.25 back to 1 as the box expands. | + +## Adaptation Notes + +1. Treat this as a single-image hero pattern, not a gallery pattern. +2. Adapt the start box to the real editorial crop; the demo corner and margin values are illustrative. +3. Usually keep the end box slightly inset from the viewport unless the section truly wants full bleed. +4. Keep width/height 100% and object-fit cover on `primaryImage` so the zoom reads as camera motion, not layout resize. +5. If the image has no dimensions after adaptation, `primaryImage` was probably mapped too deep; move it to the actual media root or stable media wrapper. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollSection` | `scrollSource` | `.sticky-track` | The `viewProgress` source for the expanding single-image composition. | +| `stickyFrame` | `stickyFrame` | `#sticky-frame` | The pinned/clipped viewport frame around the expanding image. | +| `imageBox` | `imageBox` | `#image-box` | The expanding image block whose absolute geometry changes through scroll. | +| `primaryImage` | `primaryImage` | `#hero-image` | The image/media surface inside `imageBox` that settles from an enlarged crop to its final scale. | + +## Required Styles + +### `scrollSource` + +Selector: `.sticky-track` + +```css +.sticky-track { + position: relative; + min-height: 400vh; +} +``` + +Reason: creates the runway needed for the full anchored-block-to-hero expansion. + +### `stickyFrame` + +Selector: `#sticky-frame` + +```css +#sticky-frame { + position: sticky; + top: 0; + height: 100vh; + width: 100vw; + overflow: clip; +} +``` + +Reason: pins and clips the expanding image composition inside the viewport. + +### `imageBox` + +Selector: `#image-box` + +```css +#image-box { + position: absolute; + top: calc(60% - 24px); + right: calc(75% - 24px); + bottom: 24px; + left: 24px; + overflow: clip; + will-change: top, right, bottom, left; +} +``` + +Reason: defines the anchored starting crop that expands across the sticky frame. Recompute these inset values from the actual section composition. + +### `primaryImage` + +Selector: `#hero-image` + +```css +#hero-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + will-change: transform; +} +``` + +Reason: keeps the image/media surface itself filling the expanding block while the internal zoom settles back to `scale(1)`. Apply these dimensions on the `primaryImage` selector, not only on a descendant `img` tag. + +## Interact Template + +### Range + +```ts +const RANGE = { + rangeStart: { name: 'contain', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'contain', offset: { value: 100, unit: 'percentage' } }, + fill: 'both' as const, +}; +``` + +### Image Box Expand Effect + +```ts +const imageBoxExpandEffect = { + key: 'imageBox', + keyframeEffect: { + name: 'manifest-expand-container', + keyframes: [ + { top: 'calc(60% - 24px)', right: 'calc(75% - 24px)', offset: 0 }, + { top: 'calc(60% - 24px)', right: '24px', offset: 0.5 }, + { top: '24px', right: '24px', offset: 1 }, + ], + }, + ...RANGE, +}; +``` + +### Primary Image Zoom Effect + +```ts +const primaryImageZoomEffect = { + key: 'primaryImage', + keyframeEffect: { + name: 'manifest-expand-image-zoom-out', + keyframes: [ + { transform: 'scale(1.25)', offset: 0 }, + { transform: 'scale(1)', offset: 1 }, + ], + }, + ...RANGE, +}; +``` + +### Interaction + +```ts +{ + key: 'scrollSection', + trigger: 'viewProgress', + effects: [imageBoxExpandEffect, primaryImageZoomEffect], +} +``` + +## Source + +This Markdown file was derived from [example.ts](/Users/marinebr/dev/responsive-editor-packages/packages/editor-package-ani-mate/src/examples/ManifestExpandScroll/example.ts). diff --git a/Ani-Mate Prompts/Typographic_interactions/cards-peel-off-scroll.md b/Ani-Mate Prompts/Typographic_interactions/cards-peel-off-scroll.md new file mode 100644 index 0000000..1c1b509 --- /dev/null +++ b/Ani-Mate Prompts/Typographic_interactions/cards-peel-off-scroll.md @@ -0,0 +1,222 @@ +# Cards Peel Off Scroll + +Stacked text cards pin in place and peel away one by one as you scroll. + +## Summary + +- **ID:** `cards-peel-off-scroll` +- **Target shape:** Best for 3–6 full-screen, similarly sized "story" cards that should stack and reveal sequentially — each card needs its own tall scroll runway, not a single shared sticky stage. +- **Description:** A series of centered cards rest at slight alternating tilts; as the page scrolls each top card rotates a little further and fades out, peeling away to reveal the next card pinned beneath it. The last card tilts in and stays. + +## Demo HTML + +```html +
            +

            The Journey

            +

            From idea to completion

            +
            + +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict and **per card**: each `scrollSource` (card section) owns its own runway + stacking offset (min-height, negative margin, z-index), each `stickyFrame` (card wrap) owns the sticky pin, and each `repeatedCard` (card root) owns the resting tilt + peel transform. +2. Cards do **not** share one sticky stage. Every card has its own section + sticky wrap; sections overlap via negative `margin-top`. Collapsing them into a single sticky container turns this into a fan/spread pattern, not a peel-off. +3. `z-index` must **descend** from the first card to the last (first on top). Equal or ascending z-index breaks the reveal order — the top card must peel away to expose the one beneath. +4. The peel transform belongs on the card root (the `data-interact-key` element), never on the icon, heading, or text descendants. +5. Do not clip the sticky frame — cards rotate beyond their own box as they peel. Keep `overflow` visible on the frame; only the page/body may use `overflow-x: hidden`. Use rendered `#comp-...` ids in Wix, never `DESKTOP--...` ids. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollSource` | The tall card section that drives one card's `viewProgress` exit; owns the stacking offset (min-height = runway, negative margin pulling it over the previous section, descending z-index). | +| `stickyFrame` | A `position: sticky`, `100dvh` wrapper that pins a single card centered in the viewport while its section scrolls past. | +| `repeatedCard` | The card root that rests at a slight tilt and rotates further while fading to transparent as it peels off — or, for the final card, tilts in on enter and stays. | + +## Adaptation Notes + +1. Each card is a self-contained stack: `scrollSource` section (runway) → `stickyFrame` wrap (pin) → `repeatedCard` card. Repeat the unit per card; do not merge them into one shared stage. +2. Stacking-offset formula for card *n* (1-based): `min-height` is the peel runway (≈ `(2 + n) × 100dvh`; longer = slower peel), `margin-top` of every card after the first = `-(previous section's min-height)` so it begins overlapping where the previous card pinned, and `z-index = count − n + 1` (first card highest). +3. Resting tilt alternates sign with a small magnitude (≈ ±2.5–5°). On peel, the card rotates a further ~6° **in the same direction** while `opacity` goes `1 → 0` across the `exit` range. +4. The **last** card uses `viewEnter` (tilt in once) instead of a `viewProgress` exit — it is the final layer and must not peel away. +5. Cards are viewport-relative (≈`57.6dvh` wide, `5 / 4` aspect). Keep each card inside its sticky frame; on narrow widths clamp width to `min(57.6dvh, calc(100vw - gutter))`. +6. The hero is an optional fixed entrance accent (fade + rise on `viewEnter`); include or drop it independently of the card stack. +7. When item count changes, extend `card4..cardN` by repeating the section/frame/card unit and continuing the min-height / margin / z-index formulas. The last card is always the enter-only one. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `card1` | `repeatedCard` | `.card-section.first .card` | Top card; rests at a slight tilt and peels off (rotate + fade) over its exit range. | +| `card2` | `repeatedCard` | `.card-section.second .card` | Peels off to reveal `card3`. | +| `card3` | `repeatedCard` | `.card-section.third .card` | Peels off to reveal `card4`. | +| `card4` | `repeatedCard` | `.card-section.fourth .card` | Final layer; tilts in on `viewEnter` and stays (does not peel). | + +> Repeated card keys must keep their trailing index (`card1`, `card2`, …) so they compact into the `card{n}` group; extend the row as `card4..cardN` for more items, keeping the last key as the enter-only card. + +## Required Styles + +### `scrollSource` — `.card-section` + +```css +.card-section { + position: relative; +} +.card-section.first { min-height: 280dvh; z-index: 4; } +.card-section.second { min-height: 440dvh; margin-top: -280dvh; z-index: 3; } +.card-section.third { min-height: 600dvh; margin-top: -440dvh; z-index: 2; } +.card-section.fourth { min-height: 700dvh; margin-top: -600dvh; z-index: 1; } +``` + +Reason: each section supplies its card's scroll runway; the negative `margin-top` overlaps it onto the previous section so the next card pins beneath the current one, and descending `z-index` keeps earlier cards on top so they peel away first. Recompute heights/margins/z-index from real card count — these literals are for four cards. + +### `stickyFrame` — `.card-wrap` + +```css +.card-wrap { + position: sticky; + top: 0; + height: 100dvh; + display: grid; + place-items: start center; + padding: max(10rem, 27dvh) 2rem 2rem; +} +``` + +Reason: pins one card centered near the top of the viewport while its section scrolls; no `overflow` clip so the card can rotate past its box during the peel. + +### `repeatedCard` — `.card` + +```css +.card { + --tilt: 0deg; + width: var(--card-width, 57.6dvh); + aspect-ratio: 5 / 4; + transform: rotate(var(--tilt)); + transform-origin: center center; + will-change: transform, opacity; +} +.card-section.first .card { --tilt: -4deg; } +.card-section.second .card { --tilt: 5deg; } +.card-section.third .card { --tilt: -3.5deg; } +.card-section.fourth .card { --tilt: 2.5deg; } +``` + +Reason: sets each card's resting tilt and viewport-relative size; the peel keyframes start from this resting `rotate(var(--tilt))` and carry it further while fading. `will-change` keeps the rotate/opacity animation smooth. + +## Suggested Controls + +Expose the pattern's core feel knobs — resting tilt, peel intensity, and card size. The adapting agent wires each variable into the styles and keyframes it emits. + +### `card-tilt` + +- **Label:** `Card Tilt` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `4` +- **Description:** Base magnitude of each card's resting tilt; the sign alternates per card. +- **Constraints:** `min: 0`, `max: 10`, `step: 0.5`, `unit: deg` +- **Binding:** `variable` `--card-tilt` using template `${value}deg` + +### `peel-rotation` + +- **Label:** `Peel Rotation` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `6` +- **Description:** Extra degrees a card rotates while it fades out and peels away. +- **Constraints:** `min: 2`, `max: 20`, `step: 1`, `unit: deg` +- **Binding:** `variable` `--card-peel-rotation` using template `${value}deg` + +### `card-width` + +- **Label:** `Card Width` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `57.6` +- **Description:** Card width relative to viewport height; cards keep a 5 / 4 aspect ratio. +- **Constraints:** `min: 30`, `max: 80`, `step: 1`, `unit: dvh` +- **Binding:** `variable` `--card-width` using template `${value}dvh` + +## Interact Template + +```ts +// Each top card peels across its EXIT range; progress is the card leaving the viewport. +const exitRange = { + rangeStart: { name: 'exit', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 100, unit: 'percentage' } }, + easing: 'ease-in', + fill: 'both' as const, +}; + +// Resting tilt per card — alternating, small. Recompute for the real card count. +const TILTS = [-4, 5, -3.5, 2.5]; +const PEEL_EXTRA = 6; // extra degrees rotated while fading out (see peel-rotation control) + +// Top cards: rotate further in the same direction + fade to 0 as they leave. +const peelEffect = (key: string, tilt: number) => ({ + key, + trigger: 'viewProgress', + conditions: ['full-motion'], + effects: [{ + keyframeEffect: { + name: `${key}-peel`, + keyframes: [ + { transform: `rotate(${tilt}deg)`, opacity: 1 }, + { transform: `rotate(${tilt + Math.sign(tilt) * PEEL_EXTRA}deg)`, opacity: 0 }, + ], + }, + ...exitRange, + }], +}); + +// Final card: tilts in once on enter and stays (does not peel). +const enterTiltEffect = (key: string, tilt: number) => ({ + key, + trigger: 'viewEnter', + params: { type: 'once' }, + conditions: ['full-motion'], + effects: [{ + keyframeEffect: { + name: `${key}-enter`, + keyframes: [{ transform: 'rotate(0deg)' }, { transform: `rotate(${tilt}deg)` }], + }, + duration: 600, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'forwards', + }], +}); + +const interactions = [ + // every card except the last peels off… + ...TILTS.slice(0, -1).map((tilt, i) => peelEffect(`card${i + 1}`, tilt)), + // …the last card tilts in and stays. + enterTiltEffect(`card${TILTS.length}`, TILTS[TILTS.length - 1]), +]; + +// Gate motion on user preference. +const conditions = { + 'full-motion': { type: 'media', predicate: '(prefers-reduced-motion: no-preference)' }, +}; +``` diff --git a/Ani-Mate Prompts/Typographic_interactions/text-cards-slide-in.md b/Ani-Mate Prompts/Typographic_interactions/text-cards-slide-in.md new file mode 100644 index 0000000..5c34ab6 --- /dev/null +++ b/Ani-Mate Prompts/Typographic_interactions/text-cards-slide-in.md @@ -0,0 +1,261 @@ +# Text Cards Slide In + +Stacked cards slide into a fixed center stage on scroll, alternating from left and right. + +## Summary + +- **ID:** `text-cards-slide-in` +- **Target shape:** Best for 3–6 similarly sized content cards that should reveal one-at-a-time over a fixed backdrop, each driven by its own full-viewport scroll step. +- **Description:** A fixed, centered stage holds a stack of cards; as the page scrolls, each card slides in from alternating sides (odd from the left, even from the right) with a 3D perspective swing and lands centered on top of the previous one. + +## Demo HTML + +```html + +
            +

            The Journey

            +

            From idea to completion

            +
            +
            + + +
            +
            +
            +
            +
            +
            + + +
            +
            +
            +
            +
            +
            +
            +``` + +## Selector Contract + +1. Role ownership is strict: each `scrollTrigger` owns one card's scroll runway and `viewProgress` source, `cardStage` owns the fixed centered pinning, and `repeatedCard` owns the slide transform plus stacking `z-index`. +2. The pairing is one-to-one: `trigger{n}` drives `card{n}`. Do not collapse all cards onto a single trigger — each card needs its own scroll step or they all animate at once. +3. `cardStage` must be a different element from the scroll triggers. The stage is a fixed overlay; the triggers live in normal document flow because they are what create the scroll distance. +4. Cards stack with increasing `z-index` so each new card lands on top. Keep that ordering when adding cards. +5. The animated node is the card wrapper (`data-interact-key="card-{n}"`), not the inner content element. Start it hidden and let the effect reveal it. + +## Role Guidance + +| Role | Guidance | +| --- | --- | +| `scrollTrigger` | A full-viewport (`100vh`) section in normal flow; its `viewProgress` drives exactly one card. One per card. | +| `cardStage` | A fixed, full-viewport, centered overlay that pins every card in the same spot. `pointer-events: none` so it never blocks scrolling. | +| `repeatedCard` | Absolutely positioned card wrappers, all overlapping at the stage center, stacked by `z-index`, that slide in from off-screen. | +| `staticBackdrop` | Optional fixed hero behind the cards that fades in once on first view. Decorative, not required for the slide mechanic. | + +## Adaptation Notes + +1. Scroll distance is one `100vh` step per card plus one trailing spacer section. `N` cards → `N` trigger sections + 1 spacer. +2. The off-screen start distance (`120vw` in the demo) must exceed half the viewport so cards fully clear the stage before sliding in; reduce it if the stage is narrow or the runway feels too long. +3. `cardStage` is `position: fixed`, not `sticky` — it floats above the scroll canvas via `z-index` and uses `pointer-events: none` so the page still scrolls through it. +4. Alternate `enter-from-left` / `enter-from-right` by card index parity (odd → left, even → right). For a calmer look, pick one direction for every card. +5. Always provide a reduced-motion fallback: swap the slide for a plain opacity `fade-center` under a `(prefers-reduced-motion: reduce)` condition. +6. Cards start hidden and use `fill: both` so they persist after entering and accumulate centered. Do not reset them at range end. +7. To change card count, extend `card4..cardN` and `trigger4..triggerN` together as matched pairs, continuing the z-index increase and the left/right alternation. + +## Required Elements + +| Key | Role | Demo Selector | Purpose | +| --- | --- | --- | --- | +| `scrollCanvas` | container | `.scroll-canvas` | Holds the per-card scroll steps; `z-index: 0` so it sits below the fixed stage. | +| `trigger1` | `scrollTrigger` | `.scroll-canvas > interact-element:nth-child(1)` | `viewProgress` source for `card1`; extend outward for `trigger4..triggerN`. | +| `trigger2` | `scrollTrigger` | `.scroll-canvas > interact-element:nth-child(2)` | `viewProgress` source for `card2`. | +| `trigger3` | `scrollTrigger` | `.scroll-canvas > interact-element:nth-child(3)` | `viewProgress` source for `card3`. | +| `cardStage` | `cardStage` | `.card-stage` | Fixed, centered overlay pinning the cards; `pointer-events: none`. | +| `card1` | `repeatedCard` | `[data-interact-key="card-1"]` | Minimum slide-in card (odd → from left); extend for `card4..cardN`. | +| `card2` | `repeatedCard` | `[data-interact-key="card-2"]` | Slide-in card (even → from right). | +| `card3` | `repeatedCard` | `[data-interact-key="card-3"]` | Slide-in card (odd → from left). | +| `heroTitle` | `staticBackdrop` | `[data-interact-key="hero-title"]` | Optional: backdrop title that fades up once on first view. | +| `heroSubtitle` | `staticBackdrop` | `[data-interact-key="hero-subtitle"]` | Optional: backdrop subtitle, fades up shortly after the title. | + +> Repeated keys must keep their trailing index (`card1`/`trigger1`, `card2`/`trigger2`, …) so they compact into the `card{n}` / `trigger{n}` groups; extend the rows as matched `card4`+`trigger4` … `cardN`+`triggerN` pairs. + +## Required Styles + +### `scrollTrigger` — `.scroll-section` + +```css +.scroll-section { + height: 100vh; +} +``` + +Reason: gives each card one full viewport of scroll so its `viewProgress` (entry 0%→100%) plays out across a single screen. + +### `cardStage` — `.card-stage` + +```css +.card-stage { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; +} +``` + +Reason: pins every card to the same centered spot above the scroll canvas while letting scroll events pass straight through. + +### `repeatedCard` — `.card-wrapper` + +```css +.card-wrapper { + position: absolute; + width: var(--card-stage-width, 630px); + aspect-ratio: 4 / 3.2; + opacity: 0; + transform-origin: center center; + will-change: transform, opacity; +} + +.card-wrapper:nth-child(1) { z-index: 1; } +.card-wrapper:nth-child(2) { z-index: 2; } +.card-wrapper:nth-child(3) { z-index: 3; } +.card-wrapper:nth-child(4) { z-index: 4; } +``` + +Reason: overlaps all cards at the stage center, hides them until their effect reveals them, and stacks them so each new card lands on top of the last. + +## Suggested Controls + +Expose the slide travel and the perspective swing as the core feel knobs; card width is a secondary layout knob. + +### `slide-distance` + +- **Label:** `Slide Distance` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `120` +- **Description:** How far off-screen each card starts before sliding to the center stage. +- **Constraints:** `min: 60`, `max: 160`, `step: 10`, `unit: vw` +- **Suggested variable:** `--card-slide-distance` + +### `tilt` + +- **Label:** `Swing Tilt` +- **Group:** `Motion` +- **Type:** `range` +- **Default:** `14` +- **Description:** The 3D Y-axis rotation applied as a card swings in; `0` gives a flat horizontal slide. +- **Constraints:** `min: 0`, `max: 30`, `step: 1`, `unit: deg` +- **Suggested variable:** `--card-tilt` + +### `card-width` + +- **Label:** `Card Width` +- **Group:** `Layout` +- **Type:** `range` +- **Default:** `630` +- **Description:** Width of the cards on the centered stage; the height follows from the `4 / 3.2` aspect ratio. +- **Constraints:** `min: 360`, `max: 800`, `step: 10`, `unit: px` +- **Suggested variable:** `--card-stage-width` + +## Interact Template + +```ts +// Each card's viewProgress runs across the entry of its own scroll step. +const entryRange = { + rangeStart: { name: 'entry', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'entry', offset: { value: 100, unit: 'percentage' } }, + easing: 'ease-out', + fill: 'both' as const, +}; + +// Illustrative travel/tilt — wire these to --card-slide-distance / --card-tilt. +const SLIDE = '120vw'; +const TILT = '14deg'; +``` + +```ts +const conditions = { + 'full-motion': { type: 'media', predicate: '(prefers-reduced-motion: no-preference)' }, + 'reduced-motion': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, +}; + +const effects = { + 'enter-from-left': { + keyframeEffect: { + name: 'enter-left', + keyframes: [ + { opacity: -0.6, transform: `perspective(800px) translateX(-${SLIDE}) rotateX(-6deg) rotateY(${TILT})` }, + { opacity: 1, transform: 'perspective(800px) translateX(0) rotateX(0) rotateY(0)' }, + ], + }, + ...entryRange, + }, + 'enter-from-right': { + keyframeEffect: { + name: 'enter-right', + keyframes: [ + { opacity: -0.6, transform: `perspective(800px) translateX(${SLIDE}) rotateX(-6deg) rotateY(-${TILT})` }, + { opacity: 1, transform: 'perspective(800px) translateX(0) rotateX(0) rotateY(0)' }, + ], + }, + ...entryRange, + }, + // Reduced-motion fallback: no travel, just a fade. + 'fade-center': { + keyframeEffect: { name: 'fade-center', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + ...entryRange, + }, +}; +``` + +```ts +// One interaction per card: trigger{n} drives card{n}, direction by index parity. +const cardKeys = ['card1', 'card2', 'card3'] as const; + +const cardInteractions = cardKeys.map((cardKey, i) => ({ + key: `trigger${i + 1}`, + trigger: 'viewProgress', + effects: [ + { key: cardKey, effectId: i % 2 === 0 ? 'enter-from-left' : 'enter-from-right', conditions: ['full-motion'] }, + { key: cardKey, effectId: 'fade-center', conditions: ['reduced-motion'] }, + ], +})); + +// Optional backdrop: hero fades up once when it first enters the viewport. +const heroInteractions = [ + { + key: 'heroTitle', + trigger: 'viewEnter', + params: { type: 'once' }, + effects: [{ + keyframeEffect: { name: 'hero-title-fade', keyframes: [ + { opacity: 0, transform: 'translateY(16px)' }, + { opacity: 1, transform: 'translateY(0)' }, + ] }, + duration: 800, easing: 'ease-out', fill: 'forwards', + }], + }, + { + key: 'heroSubtitle', + trigger: 'viewEnter', + params: { type: 'once' }, + effects: [{ + keyframeEffect: { name: 'hero-sub-fade', keyframes: [ + { opacity: 0, transform: 'translateY(16px)' }, + { opacity: 1, transform: 'translateY(0)' }, + ] }, + duration: 800, delay: 400, easing: 'ease-out', fill: 'forwards', + }], + }, +]; + +const interactions = [...heroInteractions, ...cardInteractions]; +``` diff --git a/Gallery-and-Carousel/3DSmallCarousel.html b/Gallery-and-Carousel/3DSmallCarousel.html index 6347273..93b4cb3 100644 --- a/Gallery-and-Carousel/3DSmallCarousel.html +++ b/Gallery-and-Carousel/3DSmallCarousel.html @@ -264,7 +264,7 @@ - + \ No newline at end of file diff --git a/Gallery-and-Carousel/AccordionScrollVertical.html b/Gallery-and-Carousel/AccordionScrollVertical.html index 617b170..00520c7 100644 --- a/Gallery-and-Carousel/AccordionScrollVertical.html +++ b/Gallery-and-Carousel/AccordionScrollVertical.html @@ -161,7 +161,7 @@

            Ocean Cliffs

            - + \ No newline at end of file diff --git a/Gallery-and-Carousel/BlurFocus_Gallery.html b/Gallery-and-Carousel/BlurFocus_Gallery.html index 8ca0ed1..723ab1c 100644 --- a/Gallery-and-Carousel/BlurFocus_Gallery.html +++ b/Gallery-and-Carousel/BlurFocus_Gallery.html @@ -5,7 +5,6 @@ Wild Nature Gallery Hover Blur with @wix/interact - @@ -177,12 +177,12 @@

            The Collection

            - +
            - +
            Alpine peaks
            @@ -190,9 +190,9 @@

            The Collection

            Alpine Peaks

            -
            + - +
            Tropical shore
            @@ -200,9 +200,9 @@

            Alpine Peaks

            Tropical Shore

            -
            + - +
            Northern lights
            @@ -210,9 +210,9 @@

            Tropical Shore

            Northern Lights

            -
            + - +
            Cherry blossoms
            @@ -220,9 +220,9 @@

            Northern Lights

            Cherry Blossoms

            -
            + - +
            Sand dunes
            @@ -230,9 +230,9 @@

            Cherry Blossoms

            Sand Dunes

            -
            + - +
            Waterfall
            @@ -240,9 +240,9 @@

            Sand Dunes

            Misty Waterfall

            -
            + - +
            City skyline
            @@ -250,19 +250,19 @@

            Misty Waterfall

            City Lights

            -
            +
            -
            +

            — fin —

            + - +
            -
            +
            - + \ No newline at end of file diff --git a/Gallery-and-Carousel/DiagonalShuffle.html b/Gallery-and-Carousel/DiagonalShuffle.html index ccf804c..1fbbdcf 100644 --- a/Gallery-and-Carousel/DiagonalShuffle.html +++ b/Gallery-and-Carousel/DiagonalShuffle.html @@ -8,7 +8,7 @@ - + - - + .arc-viewport { + position: relative; + width: 100%; + height: 68vh; + overflow: hidden; + display: flex; + justify-content: center; + align-items: flex-start; + } -
            - -
            + .arc-viewport::before, + .arc-viewport::after { + content: ""; + position: absolute; + top: 0; + width: 14%; + height: 100%; + z-index: 10; + pointer-events: none; + } + .arc-viewport::before { + left: 0; + background: linear-gradient(to right, #0a0a0f, transparent); + } + .arc-viewport::after { + right: 0; + background: linear-gradient(to left, #0a0a0f, transparent); + } - -
            - - carousel image 1 - -
            -
            + .fade-bottom { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 45%; + background: linear-gradient(to top, #0a0a0f 8%, transparent); + z-index: 10; + pointer-events: none; + } - -
            - - carousel image 2 - -
            -
            + .wheel { + position: relative; + width: calc(var(--r) * 2vmin + var(--cs) * 1vmin); + height: calc(var(--r) * 2vmin + var(--cs) * 1vmin); + transform-origin: center center; + margin-top: 10vh; + flex-shrink: 0; + } - -
            - - carousel image 3 - -
            -
            + interact-element { + display: contents; + } - -
            - - carousel image 4 - -
            -
            + .card { + position: absolute; + width: calc(var(--cs) * 1vmin); + height: calc(var(--cs) * 1vmin); + left: 50%; + top: 50%; + border-radius: var(--cr); + overflow: hidden; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.4), + 0 12px 40px rgba(0, 0, 0, 0.25); + transform-origin: center center; + } - -
            - - carousel image 5 - -
            -
            + .card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } - -
            - - carousel image 6 - -
            -
            + /* 12 cards at 30° intervals — cos/sin precomputed */ + #card-1 { + margin-left: calc((var(--r) * 1 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0 - var(--cs) / 2) * 1vmin); + z-index: 100; + } + #card-2 { + margin-left: calc((var(--r) * 0.866 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0.5 - var(--cs) / 2) * 1vmin); + z-index: 150; + } + #card-3 { + margin-left: calc((var(--r) * 0.5 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0.866 - var(--cs) / 2) * 1vmin); + z-index: 187; + } + #card-4 { + margin-left: calc((var(--r) * 0 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 1 - var(--cs) / 2) * 1vmin); + z-index: 200; + } + #card-5 { + margin-left: calc((var(--r) * -0.5 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0.866 - var(--cs) / 2) * 1vmin); + z-index: 187; + } + #card-6 { + margin-left: calc((var(--r) * -0.866 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0.5 - var(--cs) / 2) * 1vmin); + z-index: 150; + } + #card-7 { + margin-left: calc((var(--r) * -1 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * 0 - var(--cs) / 2) * 1vmin); + z-index: 100; + } + #card-8 { + margin-left: calc((var(--r) * -0.866 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * -0.5 - var(--cs) / 2) * 1vmin); + z-index: 50; + } + #card-9 { + margin-left: calc((var(--r) * -0.5 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * -0.866 - var(--cs) / 2) * 1vmin); + z-index: 13; + } + #card-10 { + margin-left: calc((var(--r) * 0 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * -1 - var(--cs) / 2) * 1vmin); + z-index: 0; + } + #card-11 { + margin-left: calc((var(--r) * 0.5 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * -0.866 - var(--cs) / 2) * 1vmin); + z-index: 13; + } + #card-12 { + margin-left: calc((var(--r) * 0.866 - var(--cs) / 2) * 1vmin); + margin-top: calc((var(--r) * -0.5 - var(--cs) / 2) * 1vmin); + z-index: 50; + } - -
            - - carousel image 7 - -
            -
            + .copy { + text-align: center; + padding: 0 1.5rem 5rem; + position: relative; + z-index: 20; + margin-top: -10vh; + } - -
            - - carousel image 8 - -
            -
            + .headline { + font-size: clamp(26px, 4.5vw, 56px); + font-weight: 300; + letter-spacing: -0.01em; + line-height: 1.15; + } - -
            - - carousel image 9 - -
            -
            + .sub { + opacity: 0.55; + margin-top: 12px; + font-size: clamp(13px, 1.6vw, 17px); + font-weight: 400; + } - -
            - - carousel image 10 - -
            -
            + .cta { + display: inline-block; + margin-top: 24px; + padding: 14px 26px; + background: #34d399; + color: #042; + border-radius: 12px; + font-weight: 600; + font-size: 15px; + text-decoration: none; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + } + .cta:hover { + transform: translateY(-1px); + box-shadow: 0 8px 24px rgba(52, 211, 153, 0.3); + } + .cta:active { + transform: translateY(1px); + } - -
            - - carousel image 11 - -
            -
            + @media (max-width: 768px) { + :root { + --r: 22; + --cs: 12; + } + .arc-viewport { + height: 58vh; + } + .wheel { + margin-top: 8vh; + } + .copy { + margin-top: -8vh; + } + } - -
            - - carousel image 12 - + @media (max-width: 480px) { + :root { + --r: 18; + --cs: 10; + } + .arc-viewport { + height: 50vh; + } + .wheel { + margin-top: 6vh; + } + .copy { + margin-top: -5vh; + } + } + + + +
            + +
            + +
            + + carousel image 1 + +
            +
            + + +
            + + carousel image 2 + +
            +
            + + +
            + + carousel image 3 + +
            +
            + + +
            + + carousel image 4 + +
            +
            + + +
            + + carousel image 5 + +
            +
            + + +
            + + carousel image 6 + +
            +
            + + +
            + + carousel image 7 + +
            +
            + + +
            + + carousel image 8 + +
            +
            + + +
            + + carousel image 9 + +
            +
            + + +
            + + carousel image 10 + +
            +
            + + +
            + + carousel image 11 + +
            +
            + + +
            + + carousel image 12 + +
            +
            - - -
            -
            -
            -
            - -
            -
            25% Off All
            Top Rated Headphones
            -
            Explore Limited Time Offers
            - Get Started -
            - - - + interactions: [ + { + key: "#wheel", + trigger: "viewEnter", + effects: [ + { + key: "#wheel", + effectId: "wheel-spin", + }, + { + key: "#card-1", + effectId: "card-counter", + }, + { + key: "#card-2", + effectId: "card-counter", + }, + { + key: "#card-3", + effectId: "card-counter", + }, + { + key: "#card-4", + effectId: "card-counter", + }, + { + key: "#card-5", + effectId: "card-counter", + }, + { + key: "#card-6", + effectId: "card-counter", + }, + { + key: "#card-7", + effectId: "card-counter", + }, + { + key: "#card-8", + effectId: "card-counter", + }, + { + key: "#card-9", + effectId: "card-counter", + }, + { + key: "#card-10", + effectId: "card-counter", + }, + { + key: "#card-11", + effectId: "card-counter", + }, + { + key: "#card-12", + effectId: "card-counter", + }, + ], + }, + { + key: "#card-1", + trigger: "hover", + effects: [ + { + key: "#card-1-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-2", + trigger: "hover", + effects: [ + { + key: "#card-2-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-3", + trigger: "hover", + effects: [ + { + key: "#card-3-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-4", + trigger: "hover", + effects: [ + { + key: "#card-4-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-5", + trigger: "hover", + effects: [ + { + key: "#card-5-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-6", + trigger: "hover", + effects: [ + { + key: "#card-6-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-7", + trigger: "hover", + effects: [ + { + key: "#card-7-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-8", + trigger: "hover", + effects: [ + { + key: "#card-8-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-9", + trigger: "hover", + effects: [ + { + key: "#card-9-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-10", + trigger: "hover", + effects: [ + { + key: "#card-10-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-11", + trigger: "hover", + effects: [ + { + key: "#card-11-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + { + key: "#card-12", + trigger: "hover", + effects: [ + { + key: "#card-12-img", + effectId: "img-hover", + triggerType: "alternate", + }, + ], + }, + ], + }; + + Interact.create(config); + + diff --git a/Gallery-and-Carousel/WindowScroll.html b/Gallery-and-Carousel/WindowScroll.html index e480b08..1ecb005 100644 --- a/Gallery-and-Carousel/WindowScroll.html +++ b/Gallery-and-Carousel/WindowScroll.html @@ -111,9 +111,9 @@ } /* * We must wrap each element we reference in the config - * in a wix-interact-element. + * in a interact-element. */ - wix-interact-element { + interact-element { /* These wrappers need to respect the layout of their children */ display: contents; } @@ -129,54 +129,54 @@

            Scroll down to begin...

            - +
            - +
            Panel One
            -
            + - +
            Panel Two
            -
            + - +
            Panel Three
            -
            + - +
            Panel Four
            -
            + - +
            Panel Five
            -
            + - +
            Panel Six
            -
            +
            -
            +
            @@ -187,12 +187,12 @@

            You've reached the end.

            Import @wix/interact as a module. The configuration script MUST also be type="module". --> - + - + \ No newline at end of file diff --git a/Image_Background/BG_Image_ShapeMask_Gallery.html b/Image_Background/BG_Image_ShapeMask_Gallery.html index 296f1ae..d1ffcea 100644 --- a/Image_Background/BG_Image_ShapeMask_Gallery.html +++ b/Image_Background/BG_Image_ShapeMask_Gallery.html @@ -208,7 +208,7 @@

            About Us

            - + \ No newline at end of file diff --git a/Image_Background/Diagonal_Slideshow.html b/Image_Background/Diagonal_Slideshow.html index 1807460..bd5b604 100644 --- a/Image_Background/Diagonal_Slideshow.html +++ b/Image_Background/Diagonal_Slideshow.html @@ -264,7 +264,7 @@ // ─── Load @wix/interact ─── let Interact; try { - const mod = await import('https://esm.sh/@wix/interact/web'); + const mod = await import('https://esm.sh/@wix/interact@2.5.1/web'); Interact = mod.Interact; } catch (e) { console.warn('Wix Interact failed to load:', e); @@ -647,8 +647,8 @@ trigger: 'viewProgress', effects: [{ key: `title-${i}`, - rangeStart: { name: 'exit', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'exit', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'exit', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 100, unit: 'percentage' } }, fill: 'forwards', keyframeEffect: { name: `titleDiag${i}`, diff --git a/Image_Background/Kinetic 155 Horizon.html b/Image_Background/Kinetic 155 Horizon.html index f7a2da3..822984d 100644 --- a/Image_Background/Kinetic 155 Horizon.html +++ b/Image_Background/Kinetic 155 Horizon.html @@ -93,7 +93,7 @@

            - + \ No newline at end of file diff --git a/Image_Background/left-panel-slide-out-reveal.html b/Image_Background/left-panel-slide-out-reveal.html index ddf46aa..1ccffcd 100644 --- a/Image_Background/left-panel-slide-out-reveal.html +++ b/Image_Background/left-panel-slide-out-reveal.html @@ -278,7 +278,7 @@

            Built
            Different.

            - + \ No newline at end of file diff --git a/Image_Background/manifest-expand-scroll_02.html b/Image_Background/manifest-expand-scroll_02.html index 27573d0..75eae9d 100644 --- a/Image_Background/manifest-expand-scroll_02.html +++ b/Image_Background/manifest-expand-scroll_02.html @@ -165,7 +165,7 @@

            MANIFEST®

            - + \ No newline at end of file diff --git a/Image_Background/rift-slit-reveal-02.html b/Image_Background/rift-slit-reveal-02.html index fc2322d..9d15b55 100644 --- a/Image_Background/rift-slit-reveal-02.html +++ b/Image_Background/rift-slit-reveal-02.html @@ -161,15 +161,15 @@

            RI - import { Interact } from 'https://esm.sh/@wix/interact/web'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions: [ { key: 'subtitle', trigger: 'viewEnter', - params: { type: 'once' }, effects: [{ + triggerType: 'once', keyframeEffect: { name: 'sub-in', keyframes: [ @@ -186,8 +186,8 @@

            RIRIRI - import { Interact } from 'https://esm.sh/@wix/interact/web'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; Interact.create({ interactions: [ { key: 'title', trigger: 'viewEnter', - params: { type: 'once' }, effects: [ { selector: '.letter:nth-child(1)', @@ -181,6 +180,7 @@

            RIRIRIRIRIRIRIShaping
            Space & Light

            - + \ No newline at end of file diff --git a/Image_Background/sticky-perspective-shrink.html b/Image_Background/sticky-perspective-shrink.html index f6726bb..9513d6e 100644 --- a/Image_Background/sticky-perspective-shrink.html +++ b/Image_Background/sticky-perspective-shrink.html @@ -105,7 +105,7 @@

            Structure

            - + \ No newline at end of file diff --git a/Typographic_interactions/Editorial Text Reveal.html b/Typographic_interactions/Editorial Text Reveal.html index b834fd8..4846d26 100644 --- a/Typographic_interactions/Editorial Text Reveal.html +++ b/Typographic_interactions/Editorial Text Reveal.html @@ -270,7 +270,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.93.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const config = { interactions: [ @@ -283,8 +283,8 @@

            - + \ No newline at end of file diff --git a/Typographic_interactions/IconText Pro gallery.html b/Typographic_interactions/IconText Pro gallery.html index e6ec917..4746755 100644 --- a/Typographic_interactions/IconText Pro gallery.html +++ b/Typographic_interactions/IconText Pro gallery.html @@ -290,7 +290,7 @@

            Steel Grids

            - + \ No newline at end of file diff --git a/Typographic_interactions/Ripple_Hover.html b/Typographic_interactions/Ripple_Hover.html index 50e9d08..7f40353 100644 --- a/Typographic_interactions/Ripple_Hover.html +++ b/Typographic_interactions/Ripple_Hover.html @@ -172,7 +172,7 @@

            LIQUIDITY

            - + \ No newline at end of file diff --git a/Typographic_interactions/RiseOfTheDead.html b/Typographic_interactions/RiseOfTheDead.html index b8b0abb..f10c8da 100644 --- a/Typographic_interactions/RiseOfTheDead.html +++ b/Typographic_interactions/RiseOfTheDead.html @@ -99,7 +99,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; // 1. Setup Content const word1 = "RISING"; @@ -170,7 +170,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const isMobile = window.innerWidth < 768; const revealWidth = isMobile ? '25px' : '125px'; @@ -246,8 +246,8 @@ const createEffect = (key, startOffset, endOffset) => ({ key, fill: 'both', - rangeStart: { name: 'cover', offset: { value: startOffset, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: endOffset, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: startOffset, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: endOffset, unit: 'percentage' } }, keyframeEffect: { name: `reveal-${key}`, keyframes: leftRevealKeyframes diff --git a/Typographic_interactions/Scroll_Paragraph_Fade.html b/Typographic_interactions/Scroll_Paragraph_Fade.html index b0b8974..7f83004 100644 --- a/Typographic_interactions/Scroll_Paragraph_Fade.html +++ b/Typographic_interactions/Scroll_Paragraph_Fade.html @@ -75,7 +75,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; // 1. Text Preparation const eyebrowContent = "The Philosophy"; @@ -166,8 +166,8 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; // 1. Text Preparation const eyebrowContent = "The Philosophy"; @@ -166,8 +166,8 @@

            - + \ No newline at end of file diff --git a/Typographic_interactions/Tech_ Glitch.html b/Typographic_interactions/Tech_ Glitch.html index 1ceb790..009a89f 100644 --- a/Typographic_interactions/Tech_ Glitch.html +++ b/Typographic_interactions/Tech_ Glitch.html @@ -96,7 +96,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -219,9 +219,10 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const config = { effects: { @@ -178,8 +178,8 @@ key: 'mask-L', fill: 'both', composite: 'replace', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'close-L', keyframes: [ @@ -193,8 +193,8 @@ key: 'mask-R', fill: 'both', composite: 'replace', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'close-R', keyframes: [ @@ -208,8 +208,8 @@ key: 'mask-T', fill: 'both', composite: 'replace', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'close-T', keyframes: [ @@ -223,8 +223,8 @@ key: 'mask-B', fill: 'both', composite: 'replace', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'close-B', keyframes: [ @@ -237,8 +237,8 @@ 'fade-text-1': { key: 'primary-text', fill: 'both', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'out-1', keyframes: [ @@ -252,8 +252,8 @@ 'fade-text-2': { key: 'secondary-text', fill: 'both', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'in-2', keyframes: [ @@ -267,8 +267,8 @@ 'center-dot-reveal': { key: 'center-dot', fill: 'both', - rangeStart: { name: 'cover', offset: { value: 0, type: 'percentage' } }, - rangeEnd: { name: 'cover', offset: { value: 100, type: 'percentage' } }, + rangeStart: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'cover', offset: { value: 100, unit: 'percentage' } }, keyframeEffect: { name: 'dot-in', keyframes: [ diff --git a/Typographic_interactions/Vshape_Headline.html b/Typographic_interactions/Vshape_Headline.html index 4730026..7b57ce9 100644 --- a/Typographic_interactions/Vshape_Headline.html +++ b/Typographic_interactions/Vshape_Headline.html @@ -84,7 +84,7 @@

            - import { Interact } from 'https://esm.sh/@wix/interact@1.86.0'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const text = "INTERACT"; const container = document.getElementById('title-container'); @@ -121,10 +121,10 @@

            - import { Interact } from 'https://esm.sh/@wix/interact'; + import { Interact } from 'https://esm.sh/@wix/interact@2.5.1/web'; const exitRange = { rangeStart: { name: 'exit', offset: { value: 0, unit: 'percentage' } }, @@ -300,7 +300,6 @@

            Complete

            { key: 'hero-title', trigger: 'viewEnter', - params: { type: 'once' }, effects: [{ keyframeEffect: { name: 'hero-title-fade', @@ -309,6 +308,7 @@

            Complete

            { opacity: 1, transform: 'translateY(0)' }, ], }, + triggerType: 'once', duration: 1200, ...heroEnter, }], @@ -316,7 +316,6 @@

            Complete

            { key: 'hero-subtitle', trigger: 'viewEnter', - params: { type: 'once' }, effects: [{ keyframeEffect: { name: 'hero-sub-fade', @@ -325,6 +324,7 @@

            Complete

            { opacity: 1, transform: 'translateY(0)' }, ], }, + triggerType: 'once', duration: 1000, delay: 300, ...heroEnter, @@ -392,7 +392,6 @@

            Complete

            { key: 'card-4', trigger: 'viewEnter', - params: { type: 'once' }, conditions: ['full-motion'], effects: [{ keyframeEffect: { @@ -402,6 +401,7 @@

            Complete

            { transform: 'rotate(2.5deg)' }, ], }, + triggerType: 'once', duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards', @@ -412,4 +412,4 @@

            Complete

            - + \ No newline at end of file diff --git a/Typographic_interactions/stacked-text-cards-scroll.html b/Typographic_interactions/stacked-text-cards-scroll.html index 9dffaa2..77a8fe9 100644 --- a/Typographic_interactions/stacked-text-cards-scroll.html +++ b/Typographic_interactions/stacked-text-cards-scroll.html @@ -270,7 +270,7 @@

            Complete

            - + \ No newline at end of file diff --git a/Typographic_interactions/text-cards-slide-in.html b/Typographic_interactions/text-cards-slide-in.html index fdba63d..3665545 100644 --- a/Typographic_interactions/text-cards-slide-in.html +++ b/Typographic_interactions/text-cards-slide-in.html @@ -290,7 +290,7 @@

            Complete

            - + \ No newline at end of file diff --git a/Typographic_interactions/text-fade-3d-perspective.html b/Typographic_interactions/text-fade-3d-perspective.html index 067e273..9b46c35 100644 --- a/Typographic_interactions/text-fade-3d-perspective.html +++ b/Typographic_interactions/text-fade-3d-perspective.html @@ -204,7 +204,7 @@

            Finish

            - + \ No newline at end of file diff --git a/interact-UI-elements/dropdown.html b/interact-UI-elements/dropdown.html index 7eb1223..20e5a94 100644 --- a/interact-UI-elements/dropdown.html +++ b/interact-UI-elements/dropdown.html @@ -239,16 +239,16 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/label.html b/interact-UI-elements/label.html index 795b153..409a883 100644 --- a/interact-UI-elements/label.html +++ b/interact-UI-elements/label.html @@ -123,7 +123,7 @@ - + \ No newline at end of file diff --git a/interact-UI-elements/lock-toggle.html b/interact-UI-elements/lock-toggle.html index 40fad0e..0b174b9 100644 --- a/interact-UI-elements/lock-toggle.html +++ b/interact-UI-elements/lock-toggle.html @@ -141,7 +141,7 @@ - + \ No newline at end of file diff --git a/interact-UI-elements/on-off-toggle.html b/interact-UI-elements/on-off-toggle.html index bd40deb..cc5d57f 100644 --- a/interact-UI-elements/on-off-toggle.html +++ b/interact-UI-elements/on-off-toggle.html @@ -189,11 +189,12 @@ - + \ No newline at end of file diff --git a/interact-UI-elements/password-input.html b/interact-UI-elements/password-input.html index 0ef3db4..358190e 100644 --- a/interact-UI-elements/password-input.html +++ b/interact-UI-elements/password-input.html @@ -224,7 +224,7 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/radio-buttons.html b/interact-UI-elements/radio-buttons.html index 8a0a73f..269335e 100644 --- a/interact-UI-elements/radio-buttons.html +++ b/interact-UI-elements/radio-buttons.html @@ -176,7 +176,7 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/search-input-light.html b/interact-UI-elements/search-input-light.html index 0c06e09..e6cd699 100644 --- a/interact-UI-elements/search-input-light.html +++ b/interact-UI-elements/search-input-light.html @@ -221,7 +221,7 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/search-input.html b/interact-UI-elements/search-input.html index 5de1385..d1fb9fb 100644 --- a/interact-UI-elements/search-input.html +++ b/interact-UI-elements/search-input.html @@ -221,7 +221,7 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/smiley-nav-light.html b/interact-UI-elements/smiley-nav-light.html index cf877a7..42e4be0 100644 --- a/interact-UI-elements/smiley-nav-light.html +++ b/interact-UI-elements/smiley-nav-light.html @@ -310,7 +310,7 @@
            - + \ No newline at end of file diff --git a/interact-UI-elements/smiley-nav.html b/interact-UI-elements/smiley-nav.html index b40d961..8a07d0b 100644 --- a/interact-UI-elements/smiley-nav.html +++ b/interact-UI-elements/smiley-nav.html @@ -310,7 +310,7 @@
            - + \ No newline at end of file diff --git a/text_Image/BG_Color_Invert.html b/text_Image/BG_Color_Invert.html index ceaed55..5b6ae4a 100644 --- a/text_Image/BG_Color_Invert.html +++ b/text_Image/BG_Color_Invert.html @@ -208,7 +208,7 @@ - - + \ No newline at end of file diff --git a/text_Image/shape-mask-parallax.html b/text_Image/shape-mask-parallax.html index daa8fe6..7b9137a 100644 --- a/text_Image/shape-mask-parallax.html +++ b/text_Image/shape-mask-parallax.html @@ -208,7 +208,7 @@

            The Person
            Behind It All