diff --git a/.DS_Store b/.DS_Store index a7d5ef4..e5c8492 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e908c4e --- /dev/null +++ b/.gitignore @@ -0,0 +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/ 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

+
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`. diff --git a/docs/superpowers/plans/2026-07-06-prompt-refinement-loop.md b/docs/superpowers/plans/2026-07-06-prompt-refinement-loop.md new file mode 100644 index 0000000..2a11a4a --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-prompt-refinement-loop.md @@ -0,0 +1,989 @@ +# Prompt Refinement Loop 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:** Add a generate→review→refine loop to the validator: run a prompt guideline against several playground sections, render the results, score+note them, and have an agent iteratively refine the *general* guideline until finalized. + +**Architecture:** The validator backend calls the interact-xp playground's `/api/generate` (localhost:5173) per section to get an Experience config, then the validator UI renders each config in an iframe using a **bundled copy** of `@wix/interact-experience-renderer`. An agent rewrites the guideline from holistic score+notes; full round history is kept beside the prompt. + +**Tech Stack:** Node 18+ ESM, Express, `esbuild` (new devDep, bundles the read-only interact-xp source into the validator), the local `claude` CLI (existing `runAgent`), Node's `node:test`. + +## Global Constraints + +- **interact-xp is READ-ONLY.** Never create/edit/delete/move any path under `PLAYGROUND_REPO`; never run a command with it as cwd; never build/install/checkout there. Allowed: read files, import its already-built `dist`, esbuild reading its source while writing output **into `validator/`**, and HTTP to the dev server the user runs. All generated artifacts live under `validator/vendor/`. +- `PLAYGROUND_REPO` default `~/Documents/Dev/Wix/interact-xp`; `PLAYGROUND_URL` default `http://localhost:5173`. Both overridable via env. +- Guideline → `userPromptExample`; fixed instruction → `userPrompt`. `/api/generate` body: `{ user_input, system_rules }`. Response: `{ config, sessionId }` (config is Experience JSON, not HTML). +- Prompt/history paths are scoped to `Ani-Mate Prompts/` (constant `PROMPTS_DIR = 'Ani-Mate Prompts'`); reuse the path-safety guards in `lib/prompts.js`. +- All new code under `validator/`; ESM; `"type": "module"`; tests run with `node --test`. +- SSE endpoints opt in via `Accept: text/event-stream` (mirror `/api/fix`): emit `event: start|result|log|done|error`. +- Do NOT modify `explorer.html` or `analysis/`. + +--- + +### Task 1: Vendor build — bundle the renderer + emit the schema (SPIKE / GATE) + +**Files:** +- Modify: `validator/package.json` (add `esbuild` devDep + `build:vendor` script) +- Create: `validator/scripts/build-vendor.mjs` +- Create (generated, committed): `validator/vendor/render-runtime.js`, `validator/vendor/experience.schema.json` +- Create: `validator/vendor/.gitignore` (none — we DO commit these) + +**Interfaces:** +- Produces: `validator/vendor/render-runtime.js` — a browser ESM bundle exporting `createExperience` (and whatever `@wix/interact-experience-renderer` exports). `validator/vendor/experience.schema.json` — the `EXPERIENCE_SCHEMA` JSON. + +> This task is the feasibility GATE. If esbuild cannot bundle the renderer or emit the schema after reasonable effort, STOP and escalate to the human (fallback per spec: browser automation). Do not hack around it silently. + +- [ ] **Step 1: Add esbuild to the validator (NOT to interact-xp)** + +Run: `cd /Users/hassank/interact-examples/interact-examples/validator && npm install --save-dev esbuild` +Expected: esbuild added under `validator/node_modules`; `validator/package.json` devDependencies has `esbuild`. + +- [ ] **Step 2: Write the vendor build script** + +```js +// 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; + +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"' }, + 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, 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'); +``` + +- [ ] **Step 3: Add the npm script** + +In `validator/package.json` `"scripts"`, add: `"build:vendor": "node scripts/build-vendor.mjs"`. + +- [ ] **Step 4: Run the vendor build (GATE)** + +Run: `cd validator && npm run build:vendor` +Expected: prints `✓ render-runtime.js`, `✓ experience.schema.json`, `vendor build complete`; both files exist under `validator/vendor/`. `experience.schema.json` is a JSON object with a top-level `$schema`/`properties` (a JSON Schema). `render-runtime.js` contains `createExperience`. +If esbuild errors on unresolved imports or TS: attempt fixes limited to esbuild options (e.g. `tsconfig`, `mainFields`, `conditions: ['module','import','default']`, `loader`). If it still fails → STOP, report the exact error, escalate. + +- [ ] **Step 5: Manually verify the render runtime applies a config** + +Create a throwaway HTML file `validator/vendor/_smoke.html`: + +```html + +
    hi
    + + +``` + +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)`. diff --git a/docs/superpowers/plans/2026-07-12-auto-refinement.md b/docs/superpowers/plans/2026-07-12-auto-refinement.md new file mode 100644 index 0000000..47c6c9e --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-auto-refinement.md @@ -0,0 +1,1640 @@ +# Autonomous Prompt Refinement (Refinery) 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:** Autonomous refinement jobs: select prompts + sections → the server iterates generate → capture (Playwright frames+GIF) → vision judge → refine until green (score ≥8) / amber (plateau/cap), two jobs in parallel; the user approves the final guideline from live rendered previews. + +**Architecture:** Node-orchestrated pipeline (spec Approach A). `lib/refinery.js` owns the loop as deterministic code with all four steps injectable; job state persists to `validator/runs//job.json` after every step; every model call is a fresh one-shot `claude -p` subprocess so parallel jobs are isolated by construction. The UI is a stateless window onto `job.json`. + +**Tech Stack:** Node 18+ ESM, Express, `playwright` (chromium already cached on this machine), `gifenc` + `fast-png` (GIF assembly), the local `claude` CLI via `lib/agent.js`, `node:test`. + +**Spec:** `docs/superpowers/specs/2026-07-09-auto-refinement-design.md` + +## Global Constraints + +- **interact-xp is READ-ONLY** (`PLAYGROUND_REPO`): only read files, import built `dist`, HTTP the dev server. Never write/build/install/checkout there. +- Stop rule: green when judge score **≥ 8**; hard cap **5 iterations**; plateau = **two consecutive iterations without a new best score** → amber. Cap reached → green if last score ≥8 else amber. +- Concurrency: **2 jobs**; others queue. Statuses: `queued → running → green|amber|failed`, then `approved` or `idle` (reject). Amber reasons: `plateau`, `cap`, `interrupted`, `judge-error`, `capture-error`, `generate-error`. +- Judge rubric: **pattern fidelity + integrity**; content differences never penalized; strict JSON out `{score, notes, sections:[{id, issues:[]}]}`; one retry on parse failure. +- Every model call honors the topbar model override (`lib/agent-state.js`) and is a fresh subprocess. +- Job artifacts live in `validator/runs/` (gitignored, served at `/runs`). The repo root is served read-only at `/repo`. Prompt paths validated via `lib/prompts.js` guards. +- Approve is the ONLY operation that writes a `.md` (via `writePromptRaw`). +- All new code under `validator/`; ESM; tests run with `node --test`. SSE endpoints opt in via `Accept: text/event-stream`. +- Plan deviation from spec (intentional): no `runs/index.json` — `listJobs` scans `runs/*/job.json` (trivial at this scale, no consistency risk). + +--- + +### Task 1: Jobs store (`lib/jobs-store.js`) + +**Files:** +- Create: `validator/lib/jobs-store.js` +- Create: `validator/runs/.gitignore` (content: `*\n!.gitignore\n`) +- Test: `validator/test/jobs-store.test.js` + +**Interfaces:** +- Produces (all used by Tasks 6–8): + - `examplePathFor(promptRel) -> string` — `'G/Card.md' → 'G/Card.html'` (inverse of `promptRelPath`). + - `createJob(runsDir, { promptPath, examplePath, sections, stop? }) -> Promise` — persists and returns `{ id, promptPath, examplePath, sections, status:'queued', amberReason:null, userNotes:null, stop:{threshold:8,maxIters:5,plateau:2}, iterations:[], createdAt, updatedAt }`. + - `saveJob(runsDir, job) -> Promise` (stamps `updatedAt`), `getJob(runsDir, id) -> Promise`, `listJobs(runsDir) -> Promise` (newest first). + - `jobDir(runsDir, id) -> string` (validates id shape `^j[a-z0-9]+$`, throws otherwise — path safety). + - `markInterrupted(runsDir) -> Promise` — every `running`/`queued` job → `status:'amber', amberReason:'interrupted'`; returns count. + - `finalGuideline(job) -> string|null` — the `guideline` of the best-scoring iteration (ties → latest); iterations without a judge score are ignored; `null` if none. + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/jobs-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 { examplePathFor, createJob, saveJob, getJob, listJobs, jobDir, markInterrupted, finalGuideline } from '../lib/jobs-store.js'; + +const dir = () => mkdtemp(join(tmpdir(), 'iv-runs-')); + +test('examplePathFor inverts promptRelPath', () => { + assert.equal(examplePathFor('G/Card.md'), 'G/Card.html'); + assert.equal(examplePathFor('Deep/Nested/x.md'), 'Deep/Nested/x.html'); +}); + +test('createJob persists a well-formed queued job; getJob round-trips', async () => { + const runs = await dir(); + const job = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['cards'] }); + assert.match(job.id, /^j[a-z0-9]+$/); + assert.equal(job.status, 'queued'); + assert.deepEqual(job.stop, { threshold: 8, maxIters: 5, plateau: 2 }); + assert.deepEqual(job.iterations, []); + const back = await getJob(runs, job.id); + assert.deepEqual(back, job); + assert.equal(await getJob(runs, 'jnope'), null); +}); + +test('listJobs scans job dirs, newest first', async () => { + const runs = await dir(); + const a = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + await new Promise((r) => setTimeout(r, 5)); + const b = await createJob(runs, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + const all = await listJobs(runs); + assert.deepEqual(all.map((j) => j.id), [b.id, a.id]); +}); + +test('jobDir rejects malformed ids (path safety)', async () => { + const runs = await dir(); + assert.throws(() => jobDir(runs, '../escape')); + assert.throws(() => jobDir(runs, 'j/../x')); +}); + +test('markInterrupted flips running/queued to amber(interrupted)', async () => { + const runs = await dir(); + const j1 = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + j1.status = 'running'; await saveJob(runs, j1); + const j2 = await createJob(runs, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + const j3 = await createJob(runs, { promptPath: 'G/C.md', examplePath: 'G/C.html', sections: ['s'] }); + j3.status = 'green'; await saveJob(runs, j3); + const n = await markInterrupted(runs); + assert.equal(n, 2); + assert.equal((await getJob(runs, j1.id)).status, 'amber'); + assert.equal((await getJob(runs, j1.id)).amberReason, 'interrupted'); + assert.equal((await getJob(runs, j2.id)).status, 'amber'); + assert.equal((await getJob(runs, j3.id)).status, 'green'); +}); + +test('finalGuideline picks the best-scoring iteration, latest on tie', () => { + const job = { iterations: [ + { iter: 1, guideline: 'G1', judge: { score: 5 } }, + { iter: 2, guideline: 'G2', judge: { score: 7 } }, + { iter: 3, guideline: 'G3', judge: { score: 7 } }, + { iter: 4, guideline: 'G4', judge: { error: 'boom' } }, + ] }; + assert.equal(finalGuideline(job), 'G3'); + assert.equal(finalGuideline({ iterations: [] }), null); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd validator && node --test test/jobs-store.test.js` +Expected: FAIL — `Cannot find module '../lib/jobs-store.js'`. + +- [ ] **Step 3: Implement** + +```js +// validator/lib/jobs-store.js +import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises'; +import { join, resolve, sep } from 'node:path'; + +// 'G/Card.md' -> 'G/Card.html' (inverse of prompts.js promptRelPath). +export function examplePathFor(promptRel) { + return promptRel.replace(/\.md$/i, '.html'); +} + +export function jobDir(runsDir, id) { + if (!/^j[a-z0-9]+$/.test(id)) throw new Error(`bad job id: ${id}`); + const abs = resolve(runsDir, id); + const base = resolve(runsDir); + if (!abs.startsWith(base + sep)) throw new Error('job path escapes runs dir'); + return abs; +} + +const newId = () => `j${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + +export async function saveJob(runsDir, job) { + job.updatedAt = new Date().toISOString(); + const dir = jobDir(runsDir, job.id); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'job.json'), JSON.stringify(job, null, 2), 'utf8'); +} + +export async function createJob(runsDir, { promptPath, examplePath, sections, stop }) { + const job = { + id: newId(), promptPath, examplePath, sections, + status: 'queued', amberReason: null, userNotes: null, + stop: { threshold: 8, maxIters: 5, plateau: 2, ...(stop || {}) }, + iterations: [], + createdAt: new Date().toISOString(), updatedAt: null, + }; + await saveJob(runsDir, job); + return job; +} + +export async function getJob(runsDir, id) { + try { return JSON.parse(await readFile(join(jobDir(runsDir, id), 'job.json'), 'utf8')); } + catch { return null; } +} + +export async function listJobs(runsDir) { + let entries; + try { entries = await readdir(runsDir, { withFileTypes: true }); } + catch { return []; } + const out = []; + for (const e of entries) { + if (!e.isDirectory() || !/^j[a-z0-9]+$/.test(e.name)) continue; + const job = await getJob(runsDir, e.name); + if (job) out.push(job); + } + return out.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')); +} + +// Boot recovery: execution died with the server; the records survive. +export async function markInterrupted(runsDir) { + let n = 0; + for (const job of await listJobs(runsDir)) { + if (job.status === 'running' || job.status === 'queued') { + job.status = 'amber'; job.amberReason = 'interrupted'; + await saveJob(runsDir, job); n++; + } + } + return n; +} + +// The guideline the user approves: best judge score, latest wins ties. +export function finalGuideline(job) { + let best = null; + for (const it of job.iterations || []) { + const s = it.judge && typeof it.judge.score === 'number' ? it.judge.score : null; + if (s === null) continue; + if (!best || s >= best.score) best = { score: s, guideline: it.guideline }; + } + return best ? best.guideline : null; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd validator && node --test test/jobs-store.test.js` — Expected: PASS (6 tests). + +- [ ] **Step 5: Create `validator/runs/.gitignore`** with exactly: + +``` +* +!.gitignore +``` + +- [ ] **Step 6: Full suite + commit** + +Run: `cd validator && node --test` — Expected: all pass. + +```bash +git add validator/lib/jobs-store.js validator/test/jobs-store.test.js validator/runs/.gitignore +git commit -m "feat(validator): refinery jobs store (persisted job records, boot recovery)" +``` + +--- + +### Task 2: Stop rule + history block + trigger extraction (pure core of `lib/refinery.js`) + +**Files:** +- Create: `validator/lib/refinery.js` (pure functions only; Task 6 adds the engine to this file) +- Test: `validator/test/refinery-core.test.js` + +**Interfaces:** +- Produces: + - `decide({ iterations, stop }) -> { action:'stop', status:'green'|'amber', reason:null|'plateau'|'cap' } | { action:'continue' }` — iterations carry `judge.score`. + - `historyBlock(iterations) -> string` — compact `iter N → S/10: first-line-of-notes` lines (notes truncated to 200 chars), `''` when empty. + - `extractTriggers(source) -> string[]` — unique `trigger: 'x'` values from example HTML source. + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/refinery-core.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { decide, historyBlock, extractTriggers } from '../lib/refinery.js'; + +const stop = { threshold: 8, maxIters: 5, plateau: 2 }; +const iters = (...scores) => scores.map((s, i) => ({ iter: i + 1, judge: { score: s }, guideline: `G${i + 1}` })); + +test('decide: green as soon as the threshold is met', () => { + assert.deepEqual(decide({ iterations: iters(8), stop }), { action: 'stop', status: 'green', reason: null }); + assert.deepEqual(decide({ iterations: iters(5, 9), stop }), { action: 'stop', status: 'green', reason: null }); +}); + +test('decide: continue while below threshold and improving', () => { + assert.deepEqual(decide({ iterations: iters(5), stop }), { action: 'continue' }); + assert.deepEqual(decide({ iterations: iters(5, 6), stop }), { action: 'continue' }); + assert.deepEqual(decide({ iterations: iters(5, 4, 6), stop }), { action: 'continue' }); // one dip then a new best +}); + +test('decide: plateau = two consecutive iterations without a new best', () => { + assert.deepEqual(decide({ iterations: iters(5, 5, 5), stop }), { action: 'stop', status: 'amber', reason: 'plateau' }); + assert.deepEqual(decide({ iterations: iters(5, 6, 6, 5), stop }), { action: 'stop', status: 'amber', reason: 'plateau' }); + assert.deepEqual(decide({ iterations: iters(5, 5), stop }), { action: 'continue' }); // only ONE non-improving iter so far +}); + +test('decide: cap → amber below threshold (green case is caught by the threshold rule)', () => { + assert.deepEqual(decide({ iterations: iters(5, 6, 7, 6, 7), stop }), { action: 'stop', status: 'amber', reason: 'cap' }); +}); + +test('decide: judge errors (no score) count as non-improving', () => { + const its = [ { iter: 1, judge: { score: 5 } }, { iter: 2, judge: { error: 'x' } }, { iter: 3, judge: { error: 'y' } } ]; + assert.deepEqual(decide({ iterations: its, stop }), { action: 'stop', status: 'amber', reason: 'plateau' }); +}); + +test('historyBlock renders compact one-liners and truncates', () => { + const its = [ + { iter: 1, judge: { score: 5, notes: 'ranges finish prematurely\nsecond line ignored' } }, + { iter: 2, judge: { score: 6, notes: 'x'.repeat(300) } }, + { iter: 3, judge: { error: 'parse' } }, + ]; + const block = historyBlock(its); + assert.match(block, /iter 1 → 5\/10: ranges finish prematurely/); + assert.match(block, new RegExp(`iter 2 → 6/10: x{200}(?!x)`)); + assert.match(block, /iter 3 → judge failed/); + assert.equal(historyBlock([]), ''); +}); + +test('extractTriggers finds unique trigger types', () => { + const src = `trigger: 'viewProgress' ... trigger: "hover" ... trigger: 'viewProgress'`; + assert.deepEqual(extractTriggers(src), ['viewProgress', 'hover']); + assert.deepEqual(extractTriggers('no triggers here'), []); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd validator && node --test test/refinery-core.test.js` → FAIL (module not found). + +- [ ] **Step 3: Implement (pure part of `validator/lib/refinery.js`)** + +```js +// validator/lib/refinery.js — autonomous refinement engine. +// This file starts with the pure core (decide/historyBlock/extractTriggers); +// the job runner + queue are added by a later task. + +const scoreOf = (it) => (it.judge && typeof it.judge.score === 'number' ? it.judge.score : null); + +// Stop rule: green at threshold; amber on plateau (two consecutive iterations +// without a NEW BEST score — errors count as non-improving); amber at the cap. +export function decide({ iterations, stop }) { + const last = scoreOf(iterations[iterations.length - 1]); + if (last !== null && last >= stop.threshold) return { action: 'stop', status: 'green', reason: null }; + + let best = -Infinity, sinceBest = 0; + for (const it of iterations) { + const s = scoreOf(it); + if (s !== null && s > best) { best = s; sinceBest = 0; } + else sinceBest++; + } + if (sinceBest >= stop.plateau) return { action: 'stop', status: 'amber', reason: 'plateau' }; + if (iterations.length >= stop.maxIters) return { action: 'stop', status: 'amber', reason: 'cap' }; + return { action: 'continue' }; +} + +// Compact cross-iteration memory for the refiner (explicit, never a session). +export function historyBlock(iterations) { + return (iterations || []).map((it) => { + const s = scoreOf(it); + if (s === null) return `iter ${it.iter} → judge failed`; + const note = String(it.judge.notes || '').split('\n')[0].slice(0, 200); + return `iter ${it.iter} → ${s}/10: ${note}`; + }).join('\n'); +} + +// Trigger types used by the original example (told to the judge: scroll sweeps +// can't show hover/click states, so it must not penalize them). +export function extractTriggers(source) { + const out = []; + for (const m of String(source).matchAll(/trigger:\s*['"](\w+)['"]/g)) { + if (!out.includes(m[1])) out.push(m[1]); + } + return out; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** — `cd validator && node --test test/refinery-core.test.js` → PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/refinery.js validator/test/refinery-core.test.js +git commit -m "feat(validator): refinery stop rule, history block, trigger extraction" +``` + +--- + +### Task 3: Capture (`lib/capture.js`, Playwright + GIF) + +**Files:** +- Modify: `validator/package.json` (deps) +- Create: `validator/lib/capture.js` +- Test: `validator/test/capture.test.js` + +**Interfaces:** +- Produces: + - `scrollPositions(scrollHeight, viewportHeight, frames) -> number[]` (pure). + - `captureSweep(url, outDir, { frames=8, viewport={width:1280,height:800}, settleMs=150, browser }) -> Promise<{ frames: string[], gif: string }>` — writes `frame-0.png … frame-N.png` + `anim.gif` into `outDir`. `browser` (a Playwright Browser) is injectable/reusable; when omitted, launches and closes its own chromium. + - `makeGif(pngBuffers, gifPath, { delayMs=500 }) -> Promise`. + +- [ ] **Step 1: Install deps** + +Run: `cd validator && npm install playwright gifenc fast-png` +Expected: added to `dependencies`. (Chromium is already in `~/Library/Caches/ms-playwright`; if `captureSweep` later errors with "Executable doesn't exist", run `npx playwright install chromium` once.) + +- [ ] **Step 2: Write the failing tests** + +```js +// validator/test/capture.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { scrollPositions, captureSweep } from '../lib/capture.js'; + +test('scrollPositions spreads evenly from 0 to maxScroll', () => { + assert.deepEqual(scrollPositions(4800, 800, 5), [0, 1000, 2000, 3000, 4000]); + assert.deepEqual(scrollPositions(800, 800, 8), [0]); // nothing to scroll + assert.deepEqual(scrollPositions(1000, 800, 2), [0, 200]); + assert.deepEqual(scrollPositions(500, 800, 3), [0]); // shorter than viewport +}); + +// Real-browser smoke: skipped when Playwright/chromium is unavailable. +test('captureSweep captures frames + gif from a static page', { timeout: 60000 }, async (t) => { + let chromium; + try { ({ chromium } = await import('playwright')); await (await chromium.launch()).close(); } + catch { t.skip('playwright/chromium unavailable'); return; } + const dir = await mkdtemp(join(tmpdir(), 'iv-cap-')); + const page = join(dir, 'page.html'); + await writeFile(page, ` +
    `); + const out = join(dir, 'out'); + const res = await captureSweep(`file://${page}`, out, { frames: 3, settleMs: 20 }); + assert.equal(res.frames.length, 3); + for (const f of res.frames) await access(f); + await access(res.gif); +}); +``` + +- [ ] **Step 3: Run to verify failure** — `cd validator && node --test test/capture.test.js` → FAIL (module not found). + +- [ ] **Step 4: Implement** + +```js +// validator/lib/capture.js — headless scroll-sweep capture: PNG frames + a GIF. +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { GIFEncoder, quantize, applyPalette } from 'gifenc'; +import { decode } from 'fast-png'; + +// Even scroll stops from top to bottom (viewport-relative). Pure. +export function scrollPositions(scrollHeight, viewportHeight, frames) { + const max = Math.max(0, scrollHeight - viewportHeight); + if (max === 0) return [0]; + const n = Math.max(2, frames); + return Array.from({ length: n }, (_, i) => Math.round((max * i) / (n - 1))); +} + +// PNG buffers -> animated GIF (256-color quantized). +export async function makeGif(pngBuffers, gifPath, { delayMs = 500 } = {}) { + const gif = GIFEncoder(); + for (const buf of pngBuffers) { + const { data, width, height, channels } = decode(buf); + let rgba = data; + if (channels === 3) { // expand RGB -> RGBA + rgba = new Uint8Array(width * height * 4); + for (let i = 0, j = 0; i < data.length; i += 3, j += 4) { + rgba[j] = data[i]; rgba[j + 1] = data[i + 1]; rgba[j + 2] = data[i + 2]; rgba[j + 3] = 255; + } + } + const palette = quantize(rgba, 256); + gif.writeFrame(applyPalette(rgba, palette), width, height, { palette, delay: delayMs }); + } + gif.finish(); + await writeFile(gifPath, gif.bytes()); +} + +export async function captureSweep(url, outDir, + { frames = 8, viewport = { width: 1280, height: 800 }, settleMs = 150, browser } = {}) { + await mkdir(outDir, { recursive: true }); + const { chromium } = await import('playwright'); + const own = !browser; + const b = browser || await chromium.launch(); + try { + const page = await b.newPage({ viewport }); + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(() => {}); // animations may keep requests alive + await page.waitForTimeout(400); // initial settle (fonts, first paint) + const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight); + const stops = scrollPositions(scrollHeight, viewport.height, frames); + const paths = [], buffers = []; + for (let i = 0; i < stops.length; i++) { + await page.evaluate((y) => window.scrollTo(0, y), stops[i]); + await page.waitForTimeout(settleMs); + const buf = await page.screenshot({ type: 'png' }); + const p = join(outDir, `frame-${i}.png`); + await writeFile(p, buf); + paths.push(p); buffers.push(buf); + } + await page.close(); + const gif = join(outDir, 'anim.gif'); + await makeGif(buffers, gif); + return { frames: paths, gif }; + } finally { + if (own) await b.close(); + } +} +``` + +- [ ] **Step 5: Run tests** — `cd validator && node --test test/capture.test.js` → PASS (2 tests; the smoke may take ~5s). + +- [ ] **Step 6: Commit** + +```bash +git add validator/package.json validator/package-lock.json validator/lib/capture.js validator/test/capture.test.js +git commit -m "feat(validator): playwright scroll-sweep capture (frames + gif)" +``` + +--- + +### Task 4: Judge (`lib/judge.js`) + agent Read-tool support + +**Files:** +- Modify: `validator/lib/agent.js:38-40` (extend runAgent opts) +- Create: `validator/lib/judge.js` +- Test: `validator/test/judge.test.js` + +**Interfaces:** +- Consumes: `runAgent(system, user, { model, onDelta, allowedTools, addDirs })` (extended here). +- Produces: + - `buildJudgePrompt({ guideline, exampleSource, exampleTriggers, originalFrames, sections }) -> { system, user }` — `sections: [{ id, frames, gif?, config?, error? }]`. + - `parseJudgeOutput(text) -> { score, notes, sections }` — fence-stripped strict JSON; throws with a descriptive message on invalid shape. + - `judgeIteration(inputs, { runAgent, addDir, onDelta, model }) -> Promise` — one retry on parse failure (appends the parse error to the user prompt). + +- [ ] **Step 1: Extend `runAgent` (in `validator/lib/agent.js`)** + +Replace the args block: + +```js + const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', + '--verbose', '--system-prompt-file', sysFile, '--exclude-dynamic-system-prompt-sections']; + const effModel = model || getAgentState().model; // explicit > UI override > CLI default + if (effModel) args.push('--model', effModel); +``` + +with (signature becomes `runAgent(system, user, { model, onDelta, allowedTools, addDirs } = {})`): + +```js + const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages', + '--verbose', '--system-prompt-file', sysFile, '--exclude-dynamic-system-prompt-sections']; + if (allowedTools?.length) args.push('--allowedTools', allowedTools.join(',')); + for (const d of addDirs || []) args.push('--add-dir', d); + const effModel = model || getAgentState().model; // explicit > UI override > CLI default + if (effModel) args.push('--model', effModel); +``` + +(Destructure `allowedTools, addDirs` in the function signature. Existing callers pass neither — unchanged behavior.) + +- [ ] **Step 2: Write the failing tests** + +```js +// validator/test/judge.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildJudgePrompt, parseJudgeOutput, judgeIteration } from '../lib/judge.js'; + +const inputs = { + guideline: '# Card Fan', + exampleSource: 'ORIGINAL CODE', + exampleTriggers: ['viewProgress'], + originalFrames: ['/runs/j1/original/frame-0.png'], + sections: [ + { id: 'cards', frames: ['/runs/j1/iter-1/cards/frame-0.png'], config: '{"c":1}' }, + { id: 'hero', error: 'generate failed: 502' }, + ], +}; + +test('buildJudgePrompt embeds rubric, frames, code, triggers, and per-section errors', () => { + const { system, user } = buildJudgePrompt(inputs); + assert.match(system, /pattern fidelity/i); + assert.match(system, /integrity/i); + assert.match(system, /content differences .* not .*penali/is); + assert.match(system, /ONLY .*JSON/is); + assert.match(user, /# Card Fan/); + assert.match(user, /ORIGINAL CODE/); + assert.match(user, /viewProgress/); + assert.match(user, /frame-0\.png/); + assert.match(user, /generate failed: 502/); +}); + +test('parseJudgeOutput handles clean and fenced JSON, rejects bad shapes', () => { + const good = '{"score": 7, "notes": "n", "sections": [{"id":"cards","issues":[]}]}'; + assert.equal(parseJudgeOutput(good).score, 7); + assert.equal(parseJudgeOutput('```json\n' + good + '\n```').score, 7); + assert.throws(() => parseJudgeOutput('not json'), /parse/i); + assert.throws(() => parseJudgeOutput('{"score": "high"}'), /score/i); + assert.throws(() => parseJudgeOutput('{"score": 11, "notes":""}'), /score/i); +}); + +test('judgeIteration retries once on parse failure with the error appended', async () => { + const calls = []; + const runAgent = async (sys, user) => { + calls.push(user); + return calls.length === 1 ? 'garbage' : '{"score": 6, "notes": "better", "sections": []}'; + }; + const out = await judgeIteration(inputs, { runAgent, addDir: '/runs/j1' }); + assert.equal(out.score, 6); + assert.equal(calls.length, 2); + assert.match(calls[1], /previous reply was not valid/i); +}); + +test('judgeIteration surfaces a final failure after the retry', async () => { + const runAgent = async () => 'still garbage'; + await assert.rejects(() => judgeIteration(inputs, { runAgent, addDir: '/x' }), /parse/i); +}); +``` + +- [ ] **Step 3: Run to verify failure** — `cd validator && node --test test/judge.test.js` → FAIL (module not found). + +- [ ] **Step 4: Implement** + +```js +// validator/lib/judge.js — one fresh vision call per iteration: reads the +// original example's frames + each generated section's frames and returns a +// strict-JSON verdict. Isolation: every call is a new claude subprocess. +import { runAgent as realRunAgent } from './agent.js'; + +const SYSTEM = `You are a strict animation reviewer for @wix/interact guidelines. You compare an ORIGINAL animation example against GENERATED results produced by applying a prose guideline to different website sections. + +Grade on exactly two axes: +1. PATTERN FIDELITY — do the generated animations express the SAME MOTION PATTERN as the original (direction, stagger, easing feel, scroll-range pacing — e.g. the animation must span the full scroll range, not finish prematurely), adapted sensibly to each section's own elements? The RIGHT elements must move (e.g. images/cards as the visual subject — not stray text or buttons). +2. INTEGRITY — is each section's layout intact? Nothing clipped, missing, overlapping, or invisible; every element that existed still renders. + +The sections have DIFFERENT content and layout than the original BY DESIGN. Content differences are expected and must NOT be penalized. You are shown scroll-sweep screenshots; triggers other than scroll (hover, click) cannot appear in them — do not penalize what frames cannot show. + +Read the screenshot files you are given (Read tool) before scoring. Score 1-10 where 8 means "ship it". + +OUTPUT CONTRACT: reply with ONLY a JSON object, no fence, no prose: +{"score": <1-10>, "notes": "", "sections": [{"id": "
    ", "issues": ["", ...]}]}`; + +export function buildJudgePrompt({ guideline, exampleSource, exampleTriggers, originalFrames, sections }) { + const secBlocks = sections.map((s) => s.error + ? `### Section "${s.id}"\nGENERATION FAILED: ${s.error} (score integrity accordingly)` + : `### Section "${s.id}"\nFrames (read these):\n${s.frames.map((f) => `- ${f}`).join('\n')}\nGenerated config:\n${s.config}` + ).join('\n\n'); + const user = `## The guideline under test +${guideline} + +## Original example +Triggers used: ${exampleTriggers.join(', ') || 'unknown'} +Source code: +\`\`\`html +${exampleSource} +\`\`\` +Frames of the original (read these): +${originalFrames.map((f) => `- ${f}`).join('\n')} + +## Generated results +${secBlocks} + +Read all frame files, then reply with the JSON verdict only.`; + return { system: SYSTEM, user }; +} + +export function parseJudgeOutput(text) { + let t = String(text).trim(); + const m = t.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i); + if (m) t = m[1].trim(); + const start = t.indexOf('{'), end = t.lastIndexOf('}'); + if (start === -1 || end === -1) throw new Error('judge output: could not parse JSON (no object found)'); + let obj; + try { obj = JSON.parse(t.slice(start, end + 1)); } + catch (e) { throw new Error(`judge output: could not parse JSON (${e.message})`); } + if (typeof obj.score !== 'number' || obj.score < 1 || obj.score > 10) + throw new Error('judge output: score must be a number 1-10'); + return { score: obj.score, notes: String(obj.notes || ''), sections: Array.isArray(obj.sections) ? obj.sections : [] }; +} + +export async function judgeIteration(inputs, { runAgent = realRunAgent, addDir, onDelta, model } = {}) { + const { system, user } = buildJudgePrompt(inputs); + const opts = { model, onDelta, allowedTools: ['Read'], addDirs: addDir ? [addDir] : [] }; + try { + return parseJudgeOutput(await runAgent(system, user, opts)); + } catch (err) { + const retryUser = `${user}\n\nYour previous reply was not valid: ${err.message}. Reply with ONLY the JSON object.`; + return parseJudgeOutput(await runAgent(system, retryUser, opts)); + } +} +``` + +- [ ] **Step 5: Run tests** — `cd validator && node --test test/judge.test.js` → PASS (4 tests). Then the full suite (agent.js changed): `node --test` → all pass. + +- [ ] **Step 6: Commit** + +```bash +git add validator/lib/agent.js validator/lib/judge.js validator/test/judge.test.js +git commit -m "feat(validator): vision judge (pattern fidelity + integrity) with Read-tool agent calls" +``` + +--- + +### Task 5: Refine memory (`lib/refine.js` extension) + +**Files:** +- Modify: `validator/lib/refine.js` +- Test: `validator/test/refine.test.js` (append) + +**Interfaces:** +- Produces: `buildRefinePrompt({ guideline, score, notes, history?, userNotes? })` and `refineGuideline({ guideline, score, notes, history?, userNotes?, onDelta, model, runAgent })` — backward compatible (existing callers pass neither). + - `history`: a preformatted string (from `historyBlock`) — rendered under "Previous iterations". + - `userNotes`: human guidance rendered under "Additional reviewer guidance (from the human)". + +- [ ] **Step 1: Append failing tests to `validator/test/refine.test.js`** + +```js +test('buildRefinePrompt includes history and user notes when given, with no-regression instruction', () => { + const { system, user } = buildRefinePrompt({ guideline: '# G', score: 6, notes: 'n', + history: 'iter 1 → 5/10: ranges premature', userNotes: 'make images the subject' }); + assert.match(system, /without regressing/i); + assert.match(user, /Previous iterations:/); + assert.match(user, /iter 1 → 5\/10: ranges premature/); + assert.match(user, /guidance \(from the human\)/i); + assert.match(user, /make images the subject/); +}); + +test('buildRefinePrompt omits history/userNotes sections when absent (backward compatible)', () => { + const { user } = buildRefinePrompt({ guideline: '# G', score: 6, notes: 'n' }); + assert.doesNotMatch(user, /Previous iterations:/); + assert.doesNotMatch(user, /guidance \(from the human\)/i); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd validator && node --test test/refine.test.js` → the two new tests FAIL. + +- [ ] **Step 3: Implement** + +In `validator/lib/refine.js`: append to the `SYSTEM` constant (after the RULES list item about pattern level): + +``` +- When previous-iteration history is provided, address the CURRENT feedback without regressing what earlier iterations already fixed. +``` + +Replace `buildRefinePrompt` with: + +```js +export function buildRefinePrompt({ guideline, score, notes, history, userNotes }) { + const historyPart = history ? `\nPrevious iterations:\n${history}\n` : ''; + const humanPart = userNotes ? `\nAdditional reviewer guidance (from the human):\n${userNotes}\n` : ''; + const user = `Reviewer score: ${score}/10 + +Reviewer notes (holistic, not specific to one output): +${notes || '(none)'} +${historyPart}${humanPart} +Current guideline to improve: +${guideline}`; + return { system: SYSTEM, user }; +} +``` + +And thread the params through `refineGuideline`: + +```js +export async function refineGuideline({ guideline, score, notes, history, userNotes, onDelta, model, runAgent = realRunAgent }) { + const { system, user } = buildRefinePrompt({ guideline, score, notes, history, userNotes }); + return stripFence(await runAgent(system, user, { model, onDelta })); +} +``` + +- [ ] **Step 4: Run tests** — `cd validator && node --test test/refine.test.js` → PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/refine.js validator/test/refine.test.js +git commit -m "feat(validator): refine with explicit iteration history + human notes" +``` + +--- + +### Task 6: Engine (queue + job runner in `lib/refinery.js`) + +**Files:** +- Modify: `validator/lib/refinery.js` (append the engine to the pure core) +- Test: `validator/test/refinery-engine.test.js` + +**Interfaces:** +- Consumes: `jobs-store.js` (all), `decide`/`historyBlock`/`extractTriggers` (same file), and injected step impls. +- Produces (used by Task 7): + - `createRefinery({ runsDir, rootDir, deps }) -> refinery` where `deps = { generateImpl, captureImpl, judgeImpl, refineImpl, listSectionsImpl, readPromptImpl, readExampleImpl }` (every step injectable; real defaults wired in Task 7). + - `refinery.launch({ promptPaths, sections }) -> Promise<{ jobs }>` — validates each prompt + example exists; per-prompt failures return `{ promptPath, error }` entries instead of jobs. + - `refinery.stop(id)` (flag: finish current iteration then amber `interrupted`), `refinery.relaunch(id, { userNotes }) -> Promise` (amber/idle → re-queued, resumes from the last iteration's refined guideline). + - `refinery.events(id) -> EventEmitter` emitting `('event', { type, ...data })` with types `step|log|iteration|status|end`. + - `refinery.concurrency = 2` (exposed for tests). + +- [ ] **Step 1: Write the failing tests** + +```js +// validator/test/refinery-engine.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 { createRefinery } from '../lib/refinery.js'; +import { getJob } from '../lib/jobs-store.js'; + +// A fully-faked refinery: generate/capture/judge/refine are instant fakes. +// judgeScript: array of scores the judge returns in order (per judge call). +async function rig({ judgeScript, sections = ['cards'], refinePrefix = 'refined-' }) { + const runsDir = await mkdtemp(join(tmpdir(), 'iv-eng-')); + const rootDir = await mkdtemp(join(tmpdir(), 'iv-root-')); + await mkdir(join(rootDir, 'G'), { recursive: true }); + await writeFile(join(rootDir, 'G', 'A.html'), `trigger: 'viewProgress'`); + await mkdir(join(rootDir, 'Ani-Mate Prompts', 'G'), { recursive: true }); + await writeFile(join(rootDir, 'Ani-Mate Prompts', 'G', 'A.md'), '# guideline v0'); + let judgeCalls = 0; + const calls = { generate: 0, capture: 0, refine: [] }; + const refinery = createRefinery({ runsDir, rootDir, deps: { + listSectionsImpl: async () => sections.map((id) => ({ id, html: `<${id}>`, css: '' })), + generateImpl: async () => { calls.generate++; return { config: '{"c":1}' }; }, + captureImpl: async (_url, outDir) => { calls.capture++; await mkdir(outDir, { recursive: true }); + return { frames: [join(outDir, 'frame-0.png')], gif: join(outDir, 'anim.gif') }; }, + judgeImpl: async () => ({ score: judgeScript[judgeCalls++], notes: `notes-${judgeCalls}`, sections: [] }), + refineImpl: async ({ guideline, history, userNotes }) => { calls.refine.push({ history, userNotes }); + return `${refinePrefix}${calls.refine.length}`; }, + } }); + return { refinery, runsDir, rootDir, calls }; +} + +const wait = (refinery, id) => new Promise((resolve) => { + refinery.events(id).on('event', (e) => { if (e.type === 'end') resolve(); }); +}); + +test('green path: stops at threshold, records iterations, no refine after the last', async () => { + const { refinery, runsDir, calls } = await rig({ judgeScript: [5, 8] }); + const { jobs } = await refinery.launch({ promptPaths: ['G/A.md'], sections: ['cards'] }); + await wait(refinery, jobs[0].id); + const job = await getJob(runsDir, jobs[0].id); + assert.equal(job.status, 'green'); + assert.equal(job.iterations.length, 2); + assert.equal(job.iterations[0].guideline, '# guideline v0'); + assert.equal(job.iterations[0].refined, 'refined-1'); + assert.equal(job.iterations[1].guideline, 'refined-1'); // next iter runs with the refined guideline + assert.equal(job.iterations[1].refined, null); // stopped — no refine wasted + assert.equal(calls.refine.length, 1); + assert.match(calls.refine[0].history || '', /iter 1 → 5\/10/); +}); + +test('plateau path: two non-improving iterations → amber(plateau)', async () => { + const { refinery, runsDir } = await rig({ judgeScript: [5, 5, 5] }); + const { jobs } = await refinery.launch({ promptPaths: ['G/A.md'], sections: ['cards'] }); + await wait(refinery, jobs[0].id); + const job = await getJob(runsDir, jobs[0].id); + assert.equal(job.status, 'amber'); + assert.equal(job.amberReason, 'plateau'); + assert.equal(job.iterations.length, 3); +}); + +test('launch validates: missing example file → per-prompt error, no job', async () => { + const { refinery } = await rig({ judgeScript: [8] }); + const res = await refinery.launch({ promptPaths: ['G/Missing.md'], sections: ['cards'] }); + assert.equal(res.jobs.length, 0); + assert.equal(res.errors.length, 1); + assert.match(res.errors[0].error, /example|prompt/i); +}); + +test('relaunch resumes from the last refined guideline and consumes userNotes', async () => { + const { refinery, runsDir, calls } = await rig({ judgeScript: [5, 5, 5, 8] }); + const { jobs } = await refinery.launch({ promptPaths: ['G/A.md'], sections: ['cards'] }); + await wait(refinery, jobs[0].id); // → amber(plateau) after 3 iters + await refinery.relaunch(jobs[0].id, { userNotes: 'focus the images' }); + await wait(refinery, jobs[0].id); + const job = await getJob(runsDir, jobs[0].id); + assert.equal(job.status, 'green'); // 4th judge call returns 8 + assert.equal(job.iterations.length, 4); + assert.equal(job.iterations[3].guideline, 'refined-3'); // resumed from last refined + const withNotes = calls.refine.find((c) => c.userNotes === 'focus the images'); + assert.ok(withNotes, 'userNotes must reach the refine step'); + assert.equal(job.userNotes, null, 'userNotes consumed after use'); +}); + +test('queue: only 2 jobs run concurrently', async () => { + const runsDir = await mkdtemp(join(tmpdir(), 'iv-q-')); + const rootDir = await mkdtemp(join(tmpdir(), 'iv-qroot-')); + await mkdir(join(rootDir, 'Ani-Mate Prompts', 'G'), { recursive: true }); + await mkdir(join(rootDir, 'G'), { recursive: true }); + for (const n of ['A', 'B', 'C']) { + await writeFile(join(rootDir, 'G', `${n}.html`), ''); + await writeFile(join(rootDir, 'Ani-Mate Prompts', 'G', `${n}.md`), '# g'); + } + let running = 0, peak = 0; + const refinery = createRefinery({ runsDir, rootDir, deps: { + listSectionsImpl: async () => [{ id: 's', html: '', css: '' }], + generateImpl: async () => { running++; peak = Math.max(peak, running); + await new Promise((r) => setTimeout(r, 30)); running--; return { config: '{}' }; }, + captureImpl: async (_u, outDir) => { await mkdir(outDir, { recursive: true }); + return { frames: [], gif: join(outDir, 'anim.gif') }; }, + judgeImpl: async () => ({ score: 9, notes: '', sections: [] }), + refineImpl: async () => 'r', + } }); + const { jobs } = await refinery.launch({ promptPaths: ['G/A.md', 'G/B.md', 'G/C.md'], sections: ['s'] }); + await Promise.all(jobs.map((j) => wait(refinery, j.id))); + assert.equal(peak, 2, `expected concurrency 2, saw ${peak}`); + for (const j of jobs) assert.equal((await getJob(runsDir, j.id)).status, 'green'); +}); + +test('all sections failing generate → amber(generate-error)', async () => { + const { refinery, runsDir } = await rig({ judgeScript: [] }); + refinery.deps.generateImpl = async () => { throw new Error('502'); }; + const { jobs } = await refinery.launch({ promptPaths: ['G/A.md'], sections: ['cards'] }); + await wait(refinery, jobs[0].id); + const job = await getJob(runsDir, jobs[0].id); + assert.equal(job.status, 'amber'); + assert.equal(job.amberReason, 'generate-error'); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `cd validator && node --test test/refinery-engine.test.js` → FAIL (`createRefinery` not exported). + +- [ ] **Step 3: Implement (append to `validator/lib/refinery.js`)** + +```js +// ── Engine: queue (concurrency 2) + per-job iteration loop ───────────────── +import { EventEmitter } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { createJob, saveJob, getJob, jobDir, finalGuideline, examplePathFor } from './jobs-store.js'; +import { readPrompt } from './prompts.js'; + +export function createRefinery({ runsDir, rootDir, port = process.env.PORT || 4500, deps = {} }) { + const emitters = new Map(); // jobId -> EventEmitter + const stopFlags = new Set(); // jobIds asked to stop after the current iteration + const queue = []; // jobIds waiting + let active = 0; + const CONCURRENCY = 2; + + const events = (id) => { if (!emitters.has(id)) emitters.set(id, new EventEmitter()); return emitters.get(id); }; + const emit = (id, type, data = {}) => events(id).emit('event', { type, ...data }); + + const readExample = deps.readExampleImpl || ((rel) => readFile(resolve(rootDir, rel), 'utf8')); + + async function runIteration(job, guideline, sectionMetas) { + const iter = job.iterations.length + 1; + const iterRec = { iter, guideline, sections: [], judge: null, refined: null }; + + // 1) generate (bounded by section count; per-section isolation) + emit(job.id, 'step', { iter, step: 'generate' }); + await Promise.all(sectionMetas.map(async (s) => { + try { + const { config } = await deps.generateImpl({ html: s.promptHtml || s.html, css: s.css, guideline }); + iterRec.sections.push({ id: s.id, config, html: s.html, css: s.css, frames: [], gif: null, error: null }); + } catch (err) { + iterRec.sections.push({ id: s.id, config: null, html: s.html, css: s.css, frames: [], gif: null, error: String(err.message || err) }); + } + })); + iterRec.sections.sort((a, b) => a.id.localeCompare(b.id)); + job.iterations.push(iterRec); + await saveJob(runsDir, job); + if (iterRec.sections.every((s) => s.error)) return { failed: 'generate-error' }; + + // 2) capture each generated section (served by our own /render route) + emit(job.id, 'step', { iter, step: 'capture' }); + for (const s of iterRec.sections) { + if (s.error) continue; + const outDir = join(jobDir(runsDir, job.id), `iter-${iter}`, s.id); + try { + const { frames, gif } = await deps.captureImpl( + `http://localhost:${port}/render/${job.id}/${iter}/${encodeURIComponent(s.id)}`, outDir); + s.frames = frames; s.gif = gif; + } catch (err) { + try { // one retry per spec + const { frames, gif } = await deps.captureImpl( + `http://localhost:${port}/render/${job.id}/${iter}/${encodeURIComponent(s.id)}`, outDir); + s.frames = frames; s.gif = gif; + } catch (err2) { return { failed: 'capture-error', detail: String(err2.message || err2) }; } + } + } + await saveJob(runsDir, job); + + // 3) judge (fresh subprocess; retry lives inside judgeIteration) + emit(job.id, 'step', { iter, step: 'judge' }); + try { + iterRec.judge = await deps.judgeImpl({ + guideline, + exampleSource: job._exampleSource, + exampleTriggers: job._exampleTriggers, + originalFrames: job._originalFrames, + sections: iterRec.sections.map((s) => ({ id: s.id, frames: s.frames, config: s.config, error: s.error })), + }, { addDir: jobDir(runsDir, job.id), onDelta: (t, kind) => emit(job.id, 'log', { text: t, kind }) }); + } catch (err) { + iterRec.judge = { error: String(err.message || err) }; + await saveJob(runsDir, job); + return { failed: 'judge-error' }; + } + await saveJob(runsDir, job); + emit(job.id, 'iteration', { iter, score: iterRec.judge.score }); + + // 4) decide + const verdict = decide({ iterations: job.iterations, stop: job.stop }); + if (verdict.action === 'stop') return { verdict }; + if (stopFlags.has(job.id)) { stopFlags.delete(job.id); return { verdict: { action: 'stop', status: 'amber', reason: 'interrupted' } }; } + + // 5) refine (explicit curated memory; userNotes consumed once) + emit(job.id, 'step', { iter, step: 'refine' }); + const refined = await deps.refineImpl({ + guideline, score: iterRec.judge.score, notes: iterRec.judge.notes, + history: historyBlock(job.iterations.slice(0, -1)), + userNotes: job.userNotes, + onDelta: (t, kind) => emit(job.id, 'log', { text: t, kind }), + }); + if (job.userNotes) { job.userNotes = null; } + iterRec.refined = refined; + await saveJob(runsDir, job); + return { next: refined }; + } + + async function runJob(id) { + const job = await getJob(runsDir, id); + if (!job) return; + job.status = 'running'; + await saveJob(runsDir, job); + emit(id, 'status', { status: 'running' }); + try { + // Per-job cached context: example source/triggers + original capture (once). + job._exampleSource = await readExample(job.examplePath); + job._exampleTriggers = extractTriggers(job._exampleSource); + const origDir = join(jobDir(runsDir, id), 'original'); + // Reuse a previous run's original capture (persisted as job.originalFrames). + job._originalFrames = job.originalFrames?.length ? job.originalFrames : null; + if (!job._originalFrames) { + emit(id, 'step', { iter: 0, step: 'capture-original' }); + const { frames } = await deps.captureImpl( + `http://localhost:${port}/repo/${job.examplePath.split('/').map(encodeURIComponent).join('/')}`, origDir); + job._originalFrames = frames; + } + const all = await deps.listSectionsImpl(); + const chosen = all.filter((s) => job.sections.includes(s.id)); + if (!chosen.length) throw new Error('none of the selected sections exist'); + + // Resume: continue from the last refined guideline, else the prompt's .md. + let guideline = job.iterations.length + ? (job.iterations[job.iterations.length - 1].refined ?? finalGuideline(job) ?? await readPrompt(rootDir, job.promptPath)) + : await readPrompt(rootDir, job.promptPath); + if (guideline === null) throw new Error('prompt .md not found'); + + for (;;) { + const res = await runIteration(job, guideline, chosen); + if (res.failed) { job.status = 'amber'; job.amberReason = res.failed; break; } + if (res.verdict) { job.status = res.verdict.status; job.amberReason = res.verdict.reason || null; break; } + guideline = res.next; + } + } catch (err) { + job.status = 'failed'; + job.error = String(err.message || err); + } + // Strip the per-run cache before persisting (frames of the original ARE persisted). + const { _exampleSource, _exampleTriggers, ...rest } = job; + rest.originalFrames = job._originalFrames || rest.originalFrames || []; + delete rest._originalFrames; + await saveJob(runsDir, rest); + emit(id, 'status', { status: rest.status, reason: rest.amberReason }); + emit(id, 'end'); + } + + function pump() { + while (active < CONCURRENCY && queue.length) { + const id = queue.shift(); + active++; + runJob(id).finally(() => { active--; pump(); }); + } + } + + return { + deps, events, concurrency: CONCURRENCY, + async launch({ promptPaths, sections }) { + const jobs = [], errors = []; + for (const promptPath of promptPaths) { + try { + const examplePath = examplePathFor(promptPath); + if (await readPrompt(rootDir, promptPath) === null) throw new Error('prompt .md not found'); + await readExample(examplePath); // throws if the example file is missing + const job = await createJob(runsDir, { promptPath, examplePath, sections }); + jobs.push(job); queue.push(job.id); + } catch (err) { + errors.push({ promptPath, error: String(err.message || err) }); + } + } + pump(); + return { jobs, errors }; + }, + stop(id) { stopFlags.add(id); }, + async relaunch(id, { userNotes } = {}) { + const job = await getJob(runsDir, id); + if (!job) throw new Error('no such job'); + if (job.status === 'running' || job.status === 'queued') throw new Error('job is already active'); + job.status = 'queued'; job.amberReason = null; + if (userNotes) job.userNotes = userNotes; + await saveJob(runsDir, job); + queue.push(id); pump(); + return job; + }, + }; +} +``` + + +- [ ] **Step 4: Run tests** — `cd validator && node --test test/refinery-engine.test.js` → PASS (6 tests). Full suite: `node --test` → all pass. + +- [ ] **Step 5: Commit** + +```bash +git add validator/lib/refinery.js validator/test/refinery-engine.test.js +git commit -m "feat(validator): refinery engine (queue of 2, resumable iteration loop)" +``` + +--- + +### Task 7: Server wiring (endpoints, statics, render route, boot recovery) + +**Files:** +- Modify: `validator/server.js` +- Test: `validator/test/server.test.js` (append) + +**Interfaces:** +- Consumes: `createRefinery` (Task 6), `jobs-store.js`, real step impls: `generate` (playground.js), `captureSweep` (capture.js), `judgeIteration` (judge.js), `refineGuideline` (refine.js), `listSections` (playground.js), `buildRenderDoc` (public/render-frame.js — Node-importable, verified). +- Produces endpoints (spec table): `POST /api/refinery/launch`, `GET /api/refinery/jobs`, `GET /api/refinery/job`, `POST /api/refinery/stop`, `POST /api/refinery/relaunch`, `POST /api/refinery/approve`, `POST /api/refinery/reject`, `GET /api/refinery/diff?id=`, `GET /api/refinery/events?id=` (SSE), `GET /render/:jobId/:iter/:sectionId`, statics `/runs` + `/repo`. + +- [ ] **Step 1: Add imports + wiring in `server.js`** + +Imports (with the others): + +```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 { captureSweep } from './lib/capture.js'; +import { judgeIteration } from './lib/judge.js'; +import { generate as playgroundGenerate } from './lib/playground.js'; +import { buildRenderDoc } from './public/render-frame.js'; +``` + +(`refineGuideline`, `listSections`, `readPrompt`, `writePromptRaw`, `computeDiff` are already imported or exported from modules already in the import list — add `writePromptRaw` to the prompts.js import.) + +Inside `createApp(rootDir)`, after the `/vendor` mount: + +```js + const RUNS_DIR = join(__dirname, 'runs'); + 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: { + listSectionsImpl: listSections, + generateImpl: playgroundGenerate, + captureImpl: captureSweep, + judgeImpl: judgeIteration, + refineImpl: refineGuideline, + } }); + // Boot recovery: execution died with the previous process; records survive. + markInterrupted(RUNS_DIR).catch(() => {}); +``` + +Endpoints (before `return app;`): + +```js + app.post('/api/refinery/launch', async (req, res) => { + const { promptPaths, sections } = req.body; + if (!Array.isArray(promptPaths) || !promptPaths.length) return bad(res, 'promptPaths required'); + if (!Array.isArray(sections) || !sections.length) return bad(res, 'sections required'); + if (!(await pingStatus({}))) return bad(res, 'playground not reachable at :5173 — start it first'); + try { res.json(await refinery.launch({ promptPaths: promptPaths.map(String), sections: sections.map(String) })); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.get('/api/refinery/jobs', async (req, res) => { + let jobs = await listRefineryJobs(RUNS_DIR); + if (req.query.promptPath) jobs = jobs.filter((j) => j.promptPath === String(req.query.promptPath)); + // The list view needs status, not full iteration payloads. + res.json({ jobs: jobs.map(({ id, promptPath, status, amberReason, createdAt, updatedAt, iterations }) => + ({ id, promptPath, status, amberReason, createdAt, updatedAt, + iters: iterations.length, scores: iterations.map((it) => it.judge?.score ?? null) })) }); + }); + + app.get('/api/refinery/job', async (req, res) => { + const job = await getRefineryJob(RUNS_DIR, String(req.query.id || '')); + if (!job) return res.status(404).json({ error: 'no such job' }); + res.json(job); + }); + + app.post('/api/refinery/stop', (req, res) => { refinery.stop(String(req.body.id || '')); res.json({ ok: true }); }); + + app.post('/api/refinery/relaunch', async (req, res) => { + try { res.json(await refinery.relaunch(String(req.body.id || ''), { userNotes: req.body.userNotes })); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/refinery/approve', 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' }); + const guideline = finalGuideline(job); + if (!guideline) return bad(res, 'job has no scored iteration to approve'); + await writePromptRaw(root, job.promptPath, guideline); + job.status = 'approved'; + await saveRefineryJob(RUNS_DIR, job); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/refinery/reject', 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'); + job.status = 'idle'; job.amberReason = null; + await saveRefineryJob(RUNS_DIR, job); + 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 || '')); + if (!job) return res.status(404).json({ error: 'no such job' }); + 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) }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.get('/api/refinery/events', (req, res) => { + const id = String(req.query.id || ''); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (e) => res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`); + const em = refinery.events(id); + em.on('event', send); + req.on('close', () => em.off('event', send)); + }); + + // Rendered doc for a stored iteration section — used by Playwright capture + // AND by the UI's live previews (same pixels for both). + app.get('/render/:jobId/:iter/:sectionId', async (req, res) => { + try { + const job = await getRefineryJob(RUNS_DIR, req.params.jobId); + if (!job) return res.status(404).send('no such job'); + const it = job.iterations.find((x) => x.iter === Number(req.params.iter)); + const sec = it?.sections.find((s) => s.id === req.params.sectionId); + if (!sec || !sec.config) return res.status(404).send('no such render'); + res.type('html').send(buildRenderDoc({ html: sec.html, css: sec.css, config: sec.config })); + } catch (err) { res.status(400).send(String(err.message || err)); } + }); +``` + + +- [ ] **Step 2: Append integration tests to `validator/test/server.test.js`** + +```js +test('refinery endpoints: job listing, approve writes the md, reject returns to idle', 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 { base, server } = await start(root); + // Seed a finished job directly in the store (validator/runs is the app's runsDir). + 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: '# THE WINNER', judge: { score: 9, notes: '' }, sections: [], refined: null }]; + await saveJob(runsDir, job); + try { + const list = await (await fetch(`${base}/api/refinery/jobs?promptPath=${encodeURIComponent('G/A.md')}`)).json(); + const mine = list.jobs.find((j) => j.id === job.id); + assert.ok(mine); assert.deepEqual(mine.scores, [9]); + const full = await (await fetch(`${base}/api/refinery/job?id=${job.id}`)).json(); + assert.equal(full.iterations[0].guideline, '# THE WINNER'); + const d = await (await fetch(`${base}/api/refinery/diff?id=${job.id}`)).json(); + assert.equal(d.changed, true); + const ap = await fetch(`${base}/api/refinery/approve`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: job.id }) }); + assert.equal(ap.status, 200); + assert.equal(await readPrompt(root, 'G/A.md'), '# THE WINNER'); + const rj = await fetch(`${base}/api/refinery/reject`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: job.id }) }); + assert.equal(rj.status, 200); + assert.equal((await (await fetch(`${base}/api/refinery/job?id=${job.id}`)).json()).status, 'idle'); + } 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'); + const { base, server } = await start(root); + 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.iterations = [{ iter: 1, guideline: 'g', judge: null, refined: null, + sections: [{ id: 's', config: '{"x":1}', html: '
    S
    ', css: '.sec{color:red}', frames: [], gif: null, error: null }] }]; + await saveJob(runsDir, job); + try { + const html = await (await fetch(`${base}/render/${job.id}/1/s`)).text(); + assert.match(html, /
    S<\/div>/); + assert.match(html, /createExperience/); + assert.equal((await fetch(`${base}/render/${job.id}/9/s`)).status, 404); + assert.equal((await fetch(`${base}/render/jnope/1/s`)).status, 404); + } 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/launch validates input and playground reachability', async () => { + const { base, server } = await start(await repo()); + const noPaths = await fetch(`${base}/api/refinery/launch`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPaths: [], sections: ['s'] }) }); + assert.equal(noPaths.status, 400); + server.close(); +}); +``` + +- [ ] **Step 3: Run** — `cd validator && node --test test/server.test.js` → PASS. Full suite: `node --test` → all pass. + +- [ ] **Step 4: Commit** + +```bash +git add validator/server.js validator/test/server.test.js +git commit -m "feat(validator): refinery endpoints, /runs + /repo statics, /render route, boot recovery" +``` + +--- + +### Task 8: Refinery UI (status dots, launch sheet, queue widget, job view, approve) + +**Files:** +- Modify: `validator/public/index.html` (replace loop markup with job view + launch sheet + queue widget) +- Modify: `validator/public/app.js` (remove manual-loop code; add refinery flows) +- Modify: `validator/public/styles.css` +- Manual smoke (needs playground + server running) + +**Interfaces:** +- Consumes: every Task 7 endpoint; `buildRenderDoc` (client-side, for live previews from the stored `{config, html, css}`); existing helpers `$`, `api`, `esc`, `streamSSE`, `appendLog`, activity modal, expand modal, diff modal. +- UI principle (spec): stateless window onto `job.json` — every render re-reads server state; navigation cannot lose anything. + +- [ ] **Step 1: Remove the manual loop UI** + +In `index.html`: delete the `#loopView` inner markup (`.loop-top`, `#loopGrid`, `#loopFeedback`) and the `#loopBtn`/`#roundsRail` buttons in the fix panel. Keep the `#loopView` container div (renamed usage) — replace with: + +```html + +``` + +In the fix panel (where `#loopBtn` was): + +```html + +
    +``` + +Add the launch sheet next to the other modals: + +```html + +``` + +In `app.js`: delete `openLoop`, `renderLoopHead`, `loopStatus`, `renderSectionChips`, `cellRender`, `renderGrid`, `setLoopBusy`, `loopGenerate`, `loopRefine`, `loopRefreshRounds`, `renderRounds`, `setFeedbackLocked`, `viewRound`, `backToCurrent`, `openPromptDiff`, and every event handler referencing `loopSections/loopGrid/scoreRange/regenBtn/refineBtn/loopActivityBtn/roundsRail/loopBtn/loopHead`. Keep: expand modal, diff modal, activity modal, `streamSSE`, `appendLog`. Keep `state.loop` removed; add `state.refinery = { jobsByPrompt: {}, sections: [], selected: new Set(), currentJob: null, viewIter: null, es: null }`. + +- [ ] **Step 2: Prompt-tree dots + selection (app.js)** + +Add a poller + dot renderer. The prompts tree row template (in the existing `renderTree` prompt branch) gains a checkbox and a dot: + +```js +// status dot for a prompt: latest job wins. +function promptDot(path) { + const jobs = state.refinery.jobsByPrompt[path] || []; + if (!jobs.length) return ''; + const j = jobs[0]; + const cls = { queued: 'q', running: 'run', green: 'ok', amber: 'warn', failed: 'fail', approved: 'done', idle: '' }[j.status] || ''; + const label = j.status === 'running' ? `running · iter ${j.iters}` : j.status; + return cls ? `` : ''; +} + +async function refreshJobs() { + try { + const { jobs } = await api('/api/refinery/jobs'); + const by = {}; + for (const j of jobs) (by[j.promptPath] = by[j.promptPath] || []).push(j); + state.refinery.jobsByPrompt = by; + renderQueueWidget(jobs); + if (state.view === 'prompts') renderTree(); + // live-follow the open job + const cur = state.refinery.currentJob; + if (cur && !$('loopView').hidden) { + const fresh = jobs.find((j) => j.id === cur.id); + if (fresh && (fresh.status !== cur.status || fresh.iters !== cur.iters)) openJobView(cur.id, { keepIter: true }); + } + } catch { /* server briefly down */ } +} +setInterval(refreshJobs, 4000); +refreshJobs(); +``` + +In the prompt row template add `` before the name and `${promptDot(f.path)}` after it; wire in the tree click handler (prompts branch): + +```js + if (e.target.classList.contains('jcb')) { + const p = e.target.dataset.jp; + if (state.refinery.selected.has(p)) state.refinery.selected.delete(p); else state.refinery.selected.add(p); + $('refineCount').textContent = state.refinery.selected.size; + $('refineSelBtn').hidden = state.view !== 'prompts' || !state.refinery.selected.size; + return; + } +``` + +And in `renderPromptView()` set `$('refineSelBtn').hidden = !(state.view === 'prompts' && state.refinery.selected.size);` (replacing the old `loopBtn` visibility line) — and instead of hiding `#loopView` unconditionally, show the job view when the selected prompt has jobs: at the end of `renderPromptView`, `if (state.currentPrompt && (state.refinery.jobsByPrompt[state.currentPrompt] || []).length) { openJobView((state.refinery.jobsByPrompt[state.currentPrompt])[0].id); }`. + +- [ ] **Step 3: Launch sheet (app.js)** + +```js +async function openLaunch() { + const { sections } = await api('/api/playground/sections'); + state.refinery.sections = []; + $('launchPrompts').innerHTML = [...state.refinery.selected].map((p) => `
    ${esc(p)}
    `).join(''); + $('launchSections').innerHTML = sections.map((s) => + `${esc(s.id)}`).join(''); + $('launchErr').textContent = ''; + $('launchModal').hidden = false; +} +$('refineSelBtn').onclick = openLaunch; +$('launchClose').onclick = () => { $('launchModal').hidden = true; }; +$('launchSections').addEventListener('click', (e) => { + const chip = e.target.closest('.chip'); if (!chip) return; + const id = chip.dataset.ls; + const i = state.refinery.sections.indexOf(id); + if (i >= 0) state.refinery.sections.splice(i, 1); + else if (state.refinery.sections.length < 4) state.refinery.sections.push(id); + chip.classList.toggle('on', state.refinery.sections.includes(id)); +}); +$('launchGo').onclick = async () => { + if (!state.refinery.sections.length) { $('launchErr').textContent = 'Pick at least one section.'; return; } + const res = await api('/api/refinery/launch', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ promptPaths: [...state.refinery.selected], sections: state.refinery.sections }) }); + if (res.error) { $('launchErr').textContent = res.error; return; } + if (res.errors?.length) $('launchErr').textContent = res.errors.map((e) => `${e.promptPath}: ${e.error}`).join(' · '); + else $('launchModal').hidden = true; + state.refinery.selected.clear(); + $('refineSelBtn').hidden = true; + refreshJobs(); +}; +``` + +- [ ] **Step 4: Queue widget (app.js)** + +```js +function renderQueueWidget(jobs) { + const act = jobs.filter((j) => j.status === 'running' || j.status === 'queued'); + $('queueWidget').innerHTML = !act.length ? '' : + '
    Refinery queue
    ' + act.map((j) => + `
    + ${j.status === 'running' ? '' : ''} + ${esc(j.promptPath.split('/').pop())} + ${j.status === 'running' ? `iter ${Math.max(1, j.iters)}` : 'queued'}
    `).join(''); +} +$('queueWidget').addEventListener('click', (e) => { + const row = e.target.closest('.qw-row'); if (!row) return; + state.currentPrompt = row.dataset.jp; + renderTree(); render(); + openJobView(row.dataset.job); +}); +``` + +- [ ] **Step 5: Job view (app.js)** — the heart of it. Everything renders from a fresh `GET /api/refinery/job`. + +```js +const STATUS_BADGE = { + queued: ['Queued', ''], running: ['Running', ''], green: ['Green — review & approve', 'ok'], + amber: ['Amber — needs attention', 'history'], failed: ['Failed', 'history'], + approved: ['Approved ✓', 'ok'], idle: ['Idle', ''], +}; + +async function openJobView(jobId, { keepIter } = {}) { + const job = await api(`/api/refinery/job?id=${encodeURIComponent(jobId)}`); + if (job.error) return; + state.refinery.currentJob = job; + if (!keepIter) state.refinery.viewIter = null; + $('markdown').hidden = true; $('code').hidden = true; $('preview').hidden = true; $('diff').hidden = true; + $('placeholder').hidden = true; $('loopView').hidden = false; + renderJobView(job); + subscribeJob(job); +} + +function renderJobView(job) { + const [label, cls] = STATUS_BADGE[job.status] || [job.status, '']; + const scores = job.iterations.map((it) => it.judge?.score ?? '×'); + const viewIter = state.refinery.viewIter ?? job.iterations.length; + // header: badge, score trail, iteration chips, actions + $('jobHead').innerHTML = ` + ${esc(label)}${job.amberReason ? ` · ${esc(job.amberReason)}` : ''} + ${esc(job.promptPath)} · iter ${job.iterations.length}/${job.stop.maxIters} + ${scores.length ? '· scores ' + scores.join(' → ') : ''} + ${job.iterations.map((it) => + ``).join('')} + + ${job.status === 'running' ? '' : ''} + `; + // body: the viewed iteration + const it = job.iterations.find((x) => x.iter === viewIter); + 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 + const done = ['green', 'amber', 'failed', 'idle'].includes(job.status); + $('jobBar').hidden = !done; + if (done) { + $('jobBar').innerHTML = ` + +
    + ${job.status !== 'failed' && job.iterations.length ? '' : ''} + + ${job.iterations.length ? '' : ''} + ${job.status !== 'idle' && job.status !== 'failed' ? '' : ''} +
    `; + } +} + +// Live previews use src=/render/... (session-cookie-free, CORS-safe: same origin, +// sandboxed like the old grid). Expanded view reuses the same URL. +$('jobBody').addEventListener('click', (e) => { + const x = e.target.closest('[data-xj]'); if (!x) return; + $('expandTitle').textContent = `${x.dataset.xs} · iteration ${x.dataset.xi}`; + $('expandFrame').removeAttribute('srcdoc'); + $('expandFrame').src = `/render/${x.dataset.xj}/${x.dataset.xi}/${encodeURIComponent(x.dataset.xs)}`; + $('expandModal').hidden = false; +}); + +$('jobHead').addEventListener('click', async (e) => { + const chip = e.target.closest('.iter-chip'); + if (chip) { state.refinery.viewIter = Number(chip.dataset.iter); renderJobView(state.refinery.currentJob); return; } + if (e.target.id === 'jobStopBtn') { + await api('/api/refinery/stop', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: state.refinery.currentJob.id }) }); + document.getElementById('loopStatus').textContent = 'Will stop after the current iteration.'; + } + if (e.target.id === 'jobActivityBtn') openActivity(); +}); + +$('jobBar').addEventListener('click', async (e) => { + const job = state.refinery.currentJob; if (!job) return; + const post = (path, body) => api(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + if (e.target.id === 'jobApprove') { + const r = await post('/api/refinery/approve', { id: job.id }); + document.getElementById('loopStatus').textContent = r.error || 'Approved — guideline written to the .md.'; + refreshJobs(); openJobView(job.id); + } else if (e.target.id === 'jobRelaunch') { + const notes = document.getElementById('jobNotes')?.value || ''; + const r = await post('/api/refinery/relaunch', { id: job.id, userNotes: notes || undefined }); + document.getElementById('loopStatus').textContent = r.error || 'Relaunched.'; + refreshJobs(); openJobView(job.id); + } else if (e.target.id === 'jobReject') { + const r = await post('/api/refinery/reject', { id: job.id }); + document.getElementById('loopStatus').textContent = r.error || 'Rejected — history kept.'; + 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(''); + $('diffModal').hidden = false; + } +}); + +// SSE: stream logs into the activity modal; refresh the view on step/status. +function subscribeJob(job) { + state.refinery.es?.close(); + if (job.status !== 'running' && job.status !== 'queued') { state.refinery.es = null; return; } + const es = new EventSource(`/api/refinery/events?id=${encodeURIComponent(job.id)}`); + state.refinery.es = es; + 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; }); +} +``` + +Note: `subscribeJob` uses native `EventSource` (GET SSE) — simpler than `streamSSE` here and reconnects free. + +- [ ] **Step 6: Styles (append to styles.css)** + +```css +/* ── 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; } +``` + +- [ ] **Step 7: Suite green + syntax check** + +Run: `cd validator && node --check public/app.js && node --test` — Expected: syntax OK, all tests pass. + +- [ ] **Step 8: Manual smoke (needs playground on :5173 + `npm start`, user-driven)** + +1. Prompts tab → check 1–2 prompts → **Refine selected** → pick 2 sections → **Start**. +2. Watch: tree dots pulse; queue widget shows both; job view streams step + judge/refiner reasoning in the Activity modal; iteration chips appear as iterations complete with the score trail updating. +3. When green/amber: click iteration chips to compare; expand a preview; **Δ Prompt diff**; **Approve** → confirm the `.md` changed (Prompts → Raw); or add notes → **Relaunch**. +4. Restart the server mid-run → job shows amber `interrupted` → **Relaunch** resumes from the last iteration. + +- [ ] **Step 9: Commit** + +```bash +git add validator/public/index.html validator/public/app.js validator/public/styles.css +git commit -m "feat(validator): refinery UI — status dots, launch sheet, queue widget, job view, approve flow" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** jobs store + boot recovery (T1) ✔; stop rule/plateau/history/triggers (T2) ✔; Playwright capture frames+GIF + v1 hover limitation (T3, judge told triggers in T4 prompt) ✔; vision judge with Read tool, rubric, strict JSON + one retry (T4) ✔; refine memory + userNotes (T5) ✔; queue of 2, per-step persistence, resume-from-last-iteration, stop-after-iteration, per-section generate isolation, amber reasons (T6) ✔; all spec endpoints + `/runs` `/repo` statics + `/render` + SSE + approve-writes-md (T7) ✔; UI dots/launch/queue/job-view/live-preview approve + stateless-window principle + retired manual loop (T8) ✔; playground-down check at launch (T7) ✔. +- **Intentional deviations:** no `runs/index.json` (scan instead — noted in Global Constraints); `GET /api/refinery/events` is GET-based SSE via native EventSource (spec's Accept-header rule applies to POST SSE endpoints; a GET event stream is the standard EventSource contract). +- **Type consistency check:** job record shape identical across T1/T6/T7/T8 (`iterations[].{iter,guideline,sections[].{id,config,html,css,frames,gif,error},judge{score,notes,sections}|{error},refined}`); `decide`/`historyBlock` signatures match T2↔T6; `captureImpl(url, outDir) -> {frames,gif}` matches T3↔T6; `judgeImpl(inputs, {addDir,onDelta}) -> {score,notes,sections}` matches T4↔T6; `refineImpl({guideline,score,notes,history,userNotes,onDelta}) -> string` matches T5↔T6; `finalGuideline` used by approve+diff (T1↔T7). +- **Known risk for implementers:** Task 6's engine captures via `http://localhost:/render/...` — the server must be running for REAL jobs (T7 wires the port), but engine TESTS never hit HTTP (captureImpl faked). The self-start port default (4500) matches `createRefinery`'s default. 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). diff --git a/docs/superpowers/specs/2026-07-06-prompt-refinement-loop-design.md b/docs/superpowers/specs/2026-07-06-prompt-refinement-loop-design.md new file mode 100644 index 0000000..988e0c6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-prompt-refinement-loop-design.md @@ -0,0 +1,207 @@ +# Prompt Refinement Loop — Design Spec + +**Date:** 2026-07-06 +**Status:** Approved (brainstorm), pending implementation plan +**Author:** Hassan Kettany + Claude Code +**Depends on:** the existing Interact Validator (`validator/`) and the convert-to-prompt feature (`Ani-Mate Prompts/`). + +## Problem + +The validator can now turn an animation example into a reusable prose **guideline** +(`Ani-Mate Prompts/.md`, produced by the convert-interact-demo-example skill). +But a guideline is only good if it **generalizes** — it must produce quality results when +applied to many different real sections, not just the demo it came from. There is no way to +test a guideline against real sections and iteratively improve it. + +## Goal + +Close the loop: **select a prompt → run it in the interact-xp playground against several +sections → review the rendered results → score + note them → an agent refines the *general* +guideline (not overfit to any one output) → run again → repeat until satisfied → finalize.** + +The feedback is holistic and pattern-level (it must not overfit to a single generated result), +because the prompt is meant to work across many sections. + +## Investigation findings (interact-xp `playground` branch) + +Verified read-only against `~/Documents/Dev/Wix/interact-xp`: + +- The playground runs on the **Vite dev server at `localhost:5173`**; `POST /api/generate` + is dev-server-only middleware (`apps/playground/vite-plugin-local-agent.ts`) that shells out + to the local `claude` CLI (reuses `claude login`, no API key). +- **Request body (fresh):** `{ user_input, system_rules, provider?, model?, effort? }`. + These two strings are assembled client-side by `buildGenerate()` from + `@wix/interact-experience-prompt` (built `dist` exists), called with + `{ html, css, userPrompt, userPromptExample, schema: EXPERIENCE_SCHEMA }`. + - A reusable **guideline** maps to `userPromptExample`; the actual instruction is `userPrompt`. + - The **target section** is supplied as the `html`/`css` fields — there is no separate target id. + Applying one guideline to different sections = re-issuing the call with different section markup. +- **Response:** `{ config, sessionId }` where `config` is a JSON string of an + `@wix/interact-experience` **Experience** (NOT HTML). +- **Config → pixels:** rendered only client-side via `createExperience` from + `@wix/interact-experience-renderer`, which injects ` + +
    ${html || ''}
    ${animate} +`; +} diff --git a/validator/public/styles.css b/validator/public/styles.css new file mode 100644 index 0000000..1863ce5 --- /dev/null +++ b/validator/public/styles.css @@ -0,0 +1,372 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +:root { + --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; +} + +*, *::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; + 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); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} + +.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); +} + +/* ── 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 read as a floating panel in the central column (clear of the + left/right panels and the top tab groups). */ +#code, #diff { + 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; } +#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; } + +/* 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; } +.seg { display: inline-flex; gap: 2px; padding: 4px; } +#agentBar { align-items: center; gap: 7px; padding: 4px 8px 4px 5px; } +.agent-select { font-size: 11.5px; color: var(--text); background: var(--fill-1); border: 1px solid var(--hair); + border-radius: 7px; padding: 4px 6px; cursor: pointer; } +.agent-select:hover { background: var(--fill-2); } +.ctx-chip { font-family: var(--mono); font-size: 10.5px; color: var(--text-2); white-space: nowrap; cursor: default; } +.ctx-chip.warn { color: #fdba74; } +#ctxReset { font-size: 13px; padding: 1px 5px; } +.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: 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(--fill-3); } +.btn:active { transform: scale(0.97); } +.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); } + +/* 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 { + 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; +} +.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; } +.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); } +.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; } + +/* 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: 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; } +#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; } +.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; } +@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); } +/* "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; } +#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; } +/* 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; } +::-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: 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; } +/* Header (round indicator) + section picker dock to the top of the scroll area */ +.loop-top { position: sticky; top: 0; z-index: 3; 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-head { display: flex; align-items: center; gap: 10px; padding: 11px 16px 0; } +.round-badge { font-size: 11px; font-weight: 600; letter-spacing: .02em; padding: 3px 11px; border-radius: 980px; + background: var(--accent-soft); color: #dbeafe; border: 1px solid var(--accent); white-space: nowrap; } +.round-badge.history { background: rgba(251,146,60,0.16); color: #fdba74; border-color: rgba(251,146,60,0.55); } +.loop-sub { font-size: 11.5px; color: var(--text-3); } +.loop-status { margin-left: auto; font-size: 11.5px; color: var(--text-2); text-align: right; } +.btn-mini { padding: 5px 11px; font-size: 11.5px; flex: none; } +.loop-sections { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 16px 12px; } +.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); } +/* 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 .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; } +/* 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-feedback input:disabled, .loop-feedback textarea:disabled { opacity: .45; cursor: default; } +.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; border: 1px solid transparent; } +.round-row:hover { background: var(--fill-2); } +.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, #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-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; } +.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; } diff --git a/validator/runs/.gitignore b/validator/runs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/validator/runs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore 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/server.js b/validator/server.js new file mode 100644 index 0000000..847580f --- /dev/null +++ b/validator/server.js @@ -0,0 +1,452 @@ +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, listDrafts } from './lib/drafts.js'; +import { runFix } from './lib/fix.js'; +import { runConvert } from './lib/convert.js'; +import { listPrompts, readPrompt, writePromptRaw } 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, roundRefined } from './lib/loop-store.js'; +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, 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'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function createApp(rootDir, { port } = {}) { + const root = resolve(rootDir); + const app = express(); + app.use(express.json({ limit: '5mb' })); + app.use(express.static(join(__dirname, 'public'))); + app.use('/vendor', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); }, express.static(join(__dirname, 'vendor'))); + + const RUNS_DIR = join(__dirname, 'runs'); + 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, port, deps: { + listSectionsImpl: listSections, + generateImpl: generate, + captureImpl: captureSweep, + judgeImpl: judgeIteration, + refineImpl: refineGuideline, + } }); + // Boot recovery: execution died with the previous process; records survive. + markInterrupted(RUNS_DIR).catch(() => {}); + + 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/drafts', async (_req, res) => { + try { + res.json({ paths: await listDrafts(root) }); + } 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) => { + 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), + onLog: (path, text, kind) => send('log', { path, text, kind }) }); + 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) }); } + }); + + 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) => { + 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) => { + 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 }); + }); + + // ── 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) }); } + }); + + 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(); + // 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) => { + try { res.json(await readLoop(root, String(req.query.promptPath))); } + catch (err) { bad(res, String(err.message || err)); } + }); + + // Diff the original .md guideline against the current working version + // (default) or against the guideline a specific round PRODUCED (?round=K). + app.get('/api/loop/diff', async (req, res) => { + try { + const promptPath = String(req.query.promptPath); + const [original, loop] = await Promise.all([readPrompt(root, promptPath), readLoop(root, promptPath)]); + if (original === null) return res.status(404).json({ error: 'no prompt' }); + let target = loop.working ?? ''; + if (req.query.round) { + const refined = roundRefined(loop, Number(req.query.round)); + if (refined === null) return bad(res, `no round ${req.query.round}`); + target = refined; + } + res.json({ changed: original !== target, parts: computeDiff(original, target) }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + // ── Agent runtime (model override + token accounting) ───────── + app.get('/api/agent/status', (_req, res) => { res.json(getAgentState()); }); + app.post('/api/agent/model', (req, res) => { setModelOverride(req.body.model); res.json(getAgentState()); }); + app.post('/api/agent/reset', (_req, res) => { resetTotals(); res.json(getAgentState()); }); + + 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'); + // 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 { + // 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) }); + } + })); + }; + + // 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, configs } = req.body; + if (!promptPath) return bad(res, 'promptPath required'); + 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 }); + 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) => { + 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)); } + }); + + app.post('/api/refinery/launch', async (req, res) => { + const { promptPaths, sections } = req.body; + if (!Array.isArray(promptPaths) || !promptPaths.length) return bad(res, 'promptPaths required'); + if (!Array.isArray(sections) || !sections.length) return bad(res, 'sections required'); + if (!(await pingStatus({}))) return bad(res, 'playground not reachable at :5173 — start it first'); + try { res.json(await refinery.launch({ promptPaths: promptPaths.map(String), sections: sections.map(String) })); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.get('/api/refinery/jobs', async (req, res) => { + let jobs = await listRefineryJobs(RUNS_DIR); + if (req.query.promptPath) jobs = jobs.filter((j) => j.promptPath === String(req.query.promptPath)); + // The list view needs status, not full iteration payloads. + res.json({ jobs: jobs.map(({ id, promptPath, status, amberReason, createdAt, updatedAt, iterations }) => + ({ id, promptPath, status, amberReason, createdAt, updatedAt, + iters: iterations.length, scores: iterations.map((it) => it.judge?.score ?? null) })) }); + }); + + app.get('/api/refinery/job', async (req, res) => { + const job = await getRefineryJob(RUNS_DIR, String(req.query.id || '')); + if (!job) return res.status(404).json({ error: 'no such job' }); + res.json(job); + }); + + app.post('/api/refinery/stop', (req, res) => { refinery.stop(String(req.body.id || '')); res.json({ ok: true }); }); + + app.post('/api/refinery/relaunch', async (req, res) => { + try { res.json(await refinery.relaunch(String(req.body.id || ''), { userNotes: req.body.userNotes })); } + catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/refinery/approve', 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'); + const guideline = finalGuideline(job); + if (!guideline) return bad(res, 'job has no scored iteration to approve'); + await writePromptRaw(root, job.promptPath, guideline); + job.status = 'approved'; + await saveRefineryJob(RUNS_DIR, job); + res.json({ ok: true }); + } catch (err) { bad(res, String(err.message || err)); } + }); + + app.post('/api/refinery/reject', 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'); + job.status = 'idle'; job.amberReason = null; + await saveRefineryJob(RUNS_DIR, job); + res.json({ ok: true }); + } 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 || '')); + if (!job) return res.status(404).json({ error: 'no such job' }); + const original = await readPrompt(root, job.promptPath); + const final = finalGuideline(job); + if (original === null || final === null) return bad(res, 'nothing to diff'); + // 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)); } + }); + + app.get('/api/refinery/events', (req, res) => { + const id = String(req.query.id || ''); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (e) => res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`); + const em = refinery.events(id); + em.on('event', send); + req.on('close', () => em.off('event', send)); + }); + + // Rendered doc for a stored iteration section — used by Playwright capture + // AND by the UI's live previews (same pixels for both). + app.get('/render/:jobId/:iter/:sectionId', async (req, res) => { + try { + const job = await getRefineryJob(RUNS_DIR, req.params.jobId); + if (!job) return res.status(404).send('no such job'); + const it = job.iterations.find((x) => x.iter === Number(req.params.iter)); + const sec = it?.sections.find((s) => s.id === req.params.sectionId); + if (!sec || !sec.config) return res.status(404).send('no such render'); + res.type('html').send(buildRenderDoc({ html: sec.html, css: sec.css, config: sec.config })); + } catch (err) { res.status(400).send(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, { port }).listen(port, () => { + console.log(`Interact Validator on http://localhost:${port} (root: ${root})`); + }); +} diff --git a/validator/test/agent.test.js b/validator/test/agent.test.js new file mode 100644 index 0000000..e1624f5 --- /dev/null +++ b/validator/test/agent.test.js @@ -0,0 +1,36 @@ +// 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'); +}); +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'); +}); +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'); +}); diff --git a/validator/test/capture.test.js b/validator/test/capture.test.js new file mode 100644 index 0000000..7f4d3d8 --- /dev/null +++ b/validator/test/capture.test.js @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { scrollPositions, captureSweep } from '../lib/capture.js'; + +test('scrollPositions spreads evenly from 0 to maxScroll', () => { + assert.deepEqual(scrollPositions(4800, 800, 5), [0, 1000, 2000, 3000, 4000]); + assert.deepEqual(scrollPositions(800, 800, 8), [0]); // nothing to scroll + assert.deepEqual(scrollPositions(1000, 800, 2), [0, 200]); + assert.deepEqual(scrollPositions(500, 800, 3), [0]); // shorter than viewport +}); + +// Real-browser smoke: skipped when Playwright/chromium is unavailable. +test('captureSweep captures frames + gif from a static page', { timeout: 60000 }, async (t) => { + let chromium; + try { ({ chromium } = await import('playwright')); await (await chromium.launch()).close(); } + catch { t.skip('playwright/chromium unavailable'); return; } + const dir = await mkdtemp(join(tmpdir(), 'iv-cap-')); + const page = join(dir, 'page.html'); + await writeFile(page, ` +
    `); + const out = join(dir, 'out'); + const res = await captureSweep(`file://${page}`, out, { frames: 3, settleMs: 20 }); + assert.equal(res.frames.length, 3); + for (const f of res.frames) await access(f); + await access(res.gif); +}); diff --git a/validator/test/codemod.test.js b/validator/test/codemod.test.js new file mode 100644 index 0000000..26cf311 --- /dev/null +++ b/validator/test/codemod.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyCodemods } from '../lib/codemod.js'; + +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\.5\.1\/web/); + assert.doesNotMatch(output, /@1\.79\.0/); + assert.equal(applied.length, 1); +}); + +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\.5\.1\/web'/); +}); + +test('updateVersion normalizes a versionless /web subpath', () => { + const { output } = applyCodemods("from 'https://esm.sh/@wix/interact/web'", ['updateVersion']); + assert.match(output, /@wix\/interact@2\.5\.1\/web/); +}); + +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'"); +}); + +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 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'/); + 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/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/detect.test.js b/validator/test/detect.test.js new file mode 100644 index 0000000..6ebce08 --- /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.5.1'); + 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.5.1/web'; + 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.5.1/web'; + 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.5.1/web'; + 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'); +}); diff --git a/validator/test/drafts.test.js b/validator/test/drafts.test.js new file mode 100644 index 0000000..e64a853 --- /dev/null +++ b/validator/test/drafts.test.js @@ -0,0 +1,65 @@ +// 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, listDrafts } 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('listDrafts returns root-relative posix paths for every draft on disk', async () => { + const root = await repo(); + assert.deepEqual(await listDrafts(root), []); // no .drafts dir yet + await writeDraft(root, 'Gallery-and-Carousel/A.html', 'FIXED\n'); + await writeDraft(root, 'Image_Background/B.html', 'FIXED\n'); + assert.deepEqual(await listDrafts(root), ['Gallery-and-Carousel/A.html', 'Image_Background/B.html']); + await discardDraft(root, 'Gallery-and-Carousel/A.html'); + assert.deepEqual(await listDrafts(root), ['Image_Background/B.html']); +}); + +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'); +}); 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'); +}); diff --git a/validator/test/fix.test.js b/validator/test/fix.test.js new file mode 100644 index 0000000..48dc6e9 --- /dev/null +++ b/validator/test/fix.test.js @@ -0,0 +1,139 @@ +// 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'; +// a clean, latest-version, no-customEffect snippet +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' }] }] });`; + +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('updateVersion is done by codemod (no agent) and pins the version', async () => { + const r = await root(); + 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: 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'); + const draft = await readDraft(r, 'A.html'); + 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.5.1/web'; + 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: src, optionIds: ['migrateSyntax'], customPrompt: '', specText: SPEC, + runAgent: async () => { agentCalled = true; return 'UNUSED'; }, + }); + assert.equal(agentCalled, false); + assert.equal(res.via, 'script'); + assert.doesNotMatch(await readDraft(r, 'B.html'), /wix-interact-element/); +}); + +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.5.1/web'; + Interact.create({ interactions:[{ key:'a', trigger:'hover', params:{ method:'toggle' }, + effects:[{ customEffect:()=>{} }] }] });`; + let agentCalled = false; + const res = await fixFile(r, 'C.html', { + 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, '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(); + 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', { + source: 'OLD', optionIds: ['convertCustomEffect'], customPrompt: '', specText: SPEC, + runAgent: async () => draftWithCustomEffect, + }); + assert.equal(res.status, 'needsReview'); +}); + +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 () => 'UNUSED', concurrency: 2 }); + assert.equal(results.length, 2); +}); diff --git a/validator/test/jobs-store.test.js b/validator/test/jobs-store.test.js new file mode 100644 index 0000000..f4087b9 --- /dev/null +++ b/validator/test/jobs-store.test.js @@ -0,0 +1,77 @@ +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 { examplePathFor, createJob, saveJob, getJob, listJobs, jobDir, deleteJob, markInterrupted, finalGuideline } from '../lib/jobs-store.js'; + +const dir = () => mkdtemp(join(tmpdir(), 'iv-runs-')); + +test('examplePathFor inverts promptRelPath', () => { + assert.equal(examplePathFor('G/Card.md'), 'G/Card.html'); + assert.equal(examplePathFor('Deep/Nested/x.md'), 'Deep/Nested/x.html'); +}); + +test('createJob persists a well-formed queued job; getJob round-trips', async () => { + const runs = await dir(); + const job = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['cards'] }); + assert.match(job.id, /^j[a-z0-9]+$/); + assert.equal(job.status, 'queued'); + assert.deepEqual(job.stop, { threshold: 8, maxIters: 5, plateau: 2 }); + assert.deepEqual(job.iterations, []); + const back = await getJob(runs, job.id); + assert.deepEqual(back, job); + assert.equal(await getJob(runs, 'jnope'), null); +}); + +test('listJobs scans job dirs, newest first', async () => { + const runs = await dir(); + const a = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + await new Promise((r) => setTimeout(r, 5)); + const b = await createJob(runs, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + const all = await listJobs(runs); + assert.deepEqual(all.map((j) => j.id), [b.id, a.id]); +}); + +test('jobDir rejects malformed ids (path safety)', async () => { + const runs = await dir(); + assert.throws(() => jobDir(runs, '../escape')); + assert.throws(() => jobDir(runs, 'j/../x')); +}); + +test('markInterrupted flips running/queued to amber(interrupted)', async () => { + const runs = await dir(); + const j1 = await createJob(runs, { promptPath: 'G/A.md', examplePath: 'G/A.html', sections: ['s'] }); + j1.status = 'running'; await saveJob(runs, j1); + const j2 = await createJob(runs, { promptPath: 'G/B.md', examplePath: 'G/B.html', sections: ['s'] }); + const j3 = await createJob(runs, { promptPath: 'G/C.md', examplePath: 'G/C.html', sections: ['s'] }); + j3.status = 'green'; await saveJob(runs, j3); + const n = await markInterrupted(runs); + assert.equal(n, 2); + assert.equal((await getJob(runs, j1.id)).status, 'amber'); + assert.equal((await getJob(runs, j1.id)).amberReason, 'interrupted'); + assert.equal((await getJob(runs, j2.id)).status, 'amber'); + assert.equal((await getJob(runs, j3.id)).status, 'green'); +}); + +test('finalGuideline picks the best-scoring iteration, latest on tie', () => { + const job = { iterations: [ + { iter: 1, guideline: 'G1', judge: { score: 5 } }, + { iter: 2, guideline: 'G2', judge: { score: 7 } }, + { iter: 3, guideline: 'G3', judge: { score: 7 } }, + { iter: 4, guideline: 'G4', judge: { error: 'boom' } }, + ] }; + 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/judge.test.js b/validator/test/judge.test.js new file mode 100644 index 0000000..5871f6c --- /dev/null +++ b/validator/test/judge.test.js @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildJudgePrompt, parseJudgeOutput, judgeIteration } from '../lib/judge.js'; + +const inputs = { + guideline: '# Card Fan', + exampleSource: 'ORIGINAL CODE', + exampleTriggers: ['viewProgress'], + originalFrames: ['/runs/j1/original/frame-0.png'], + sections: [ + { id: 'cards', frames: ['/runs/j1/iter-1/cards/frame-0.png'], config: '{"c":1}' }, + { id: 'hero', error: 'generate failed: 502' }, + ], +}; + +test('buildJudgePrompt embeds rubric, frames, code, triggers, and per-section errors', () => { + const { system, user } = buildJudgePrompt(inputs); + assert.match(system, /pattern fidelity/i); + assert.match(system, /integrity/i); + assert.match(system, /content differences .* not .*penali/is); + assert.match(system, /ONLY .*JSON/is); + assert.match(user, /# Card Fan/); + assert.match(user, /ORIGINAL CODE/); + assert.match(user, /viewProgress/); + assert.match(user, /frame-0\.png/); + assert.match(user, /generate failed: 502/); +}); + +test('parseJudgeOutput handles clean and fenced JSON, rejects bad shapes', () => { + const good = '{"score": 7, "notes": "n", "sections": [{"id":"cards","issues":[]}]}'; + assert.equal(parseJudgeOutput(good).score, 7); + assert.equal(parseJudgeOutput('```json\n' + good + '\n```').score, 7); + assert.throws(() => parseJudgeOutput('not json'), /parse/i); + assert.throws(() => parseJudgeOutput('{"score": "high"}'), /score/i); + assert.throws(() => parseJudgeOutput('{"score": 11, "notes":""}'), /score/i); +}); + +test('judgeIteration retries once on parse failure with the error appended', async () => { + const calls = []; + const runAgent = async (sys, user) => { + calls.push(user); + return calls.length === 1 ? 'garbage' : '{"score": 6, "notes": "better", "sections": []}'; + }; + const out = await judgeIteration(inputs, { runAgent, addDir: '/runs/j1' }); + assert.equal(out.score, 6); + assert.equal(calls.length, 2); + assert.match(calls[1], /previous reply was not valid/i); +}); + +test('judgeIteration surfaces a final failure after the retry', async () => { + const runAgent = async () => 'still garbage'; + await assert.rejects(() => judgeIteration(inputs, { runAgent, addDir: '/x' }), /parse/i); +}); diff --git a/validator/test/loop-store.test.js b/validator/test/loop-store.test.js new file mode 100644 index 0000000..3047db5 --- /dev/null +++ b/validator/test/loop-store.test.js @@ -0,0 +1,68 @@ +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, roundRefined } 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'); +}); + +test('rounds keep their refined output; rollback cannot destroy it', 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' }); + await rollback(root, 'G/Card.md', 1); // working → '# V0 guideline' + const loop = await readLoop(root, 'G/Card.md'); + assert.equal(roundRefined(loop, 1), '# V1'); // survives the rollback + assert.equal(roundRefined(loop, 2), '# V2'); + assert.equal(roundRefined(loop, 9), null); // unknown round +}); + +test('roundRefined falls back for legacy histories without a refined field', () => { + const loop = { working: '# V2', rounds: [ + { round: 1, guideline: '# V0' }, // legacy: no refined + { round: 2, guideline: '# V1' }, + ] }; + assert.equal(roundRefined(loop, 1), '# V1'); // next round's input + assert.equal(roundRefined(loop, 2), '# V2'); // last round → working +}); 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 "}' }); + 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(' 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(); +}); + +test('GET /api/drafts lists drafts on disk so the UI can hydrate after a refresh', async () => { + const root = await repo(); + const { base, server } = await start(root); + const { writeDraft } = await import('../lib/drafts.js'); + const empty = await (await fetch(`${base}/api/drafts`)).json(); + assert.deepEqual(empty.paths, []); + await writeDraft(root, 'G/A.html', 'FIXED'); + const list = await (await fetch(`${base}/api/drafts`)).json(); + assert.deepEqual(list.paths, ['G/A.html']); + 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(); +}); + +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(); +}); + +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(); +}); + +test('GET /api/loop/diff diffs the original md against working (and a given round)', async () => { + const root = await repo(); + const { writePrompt } = await import('../lib/prompts.js'); + const { recordRound } = await import('../lib/loop-store.js'); + await writePrompt(root, 'G/A.html', 'line one\n'); // → G/A.md + await recordRound(root, 'G/A.md', { guideline: 'line one\n', sections: [], score: 6, notes: '', newWorking: 'line two\n' }); + const { base, server } = await start(root); + // vs working: original "line one" → working "line two" + const cur = await (await fetch(`${base}/api/loop/diff?promptPath=${encodeURIComponent('G/A.md')}`)).json(); + assert.equal(cur.changed, true); + assert.ok(cur.parts.some((p) => p.removed && p.value.includes('line one'))); + assert.ok(cur.parts.some((p) => p.added && p.value.includes('line two'))); + // vs round 1's REFINED output ("line two") → changed, same as working here + const r1 = await (await fetch(`${base}/api/loop/diff?promptPath=${encodeURIComponent('G/A.md')}&round=1`)).json(); + assert.equal(r1.changed, true); + assert.ok(r1.parts.some((p) => p.added && p.value.includes('line two'))); + // unknown round → 400; missing prompt → 404 + assert.equal((await fetch(`${base}/api/loop/diff?promptPath=${encodeURIComponent('G/A.md')}&round=9`)).status, 400); + assert.equal((await fetch(`${base}/api/loop/diff?promptPath=${encodeURIComponent('G/nope.md')}`)).status, 404); + 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(); +}); + +test('agent status/model/reset endpoints manage the override and totals', async () => { + const { base, server } = await start(await repo()); + let s = await (await fetch(`${base}/api/agent/status`)).json(); + assert.equal(s.model, null); + assert.equal(typeof s.window, 'number'); + s = await (await fetch(`${base}/api/agent/model`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: 'opus' }) })).json(); + assert.equal(s.model, 'opus'); + s = await (await fetch(`${base}/api/agent/model`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model: '' }) })).json(); + assert.equal(s.model, null); // empty clears the override + s = await (await fetch(`${base}/api/agent/reset`, { method: 'POST' })).json(); + assert.deepEqual(s.totals, { calls: 0, input: 0, output: 0 }); + 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(); +}); + +test('refinery endpoints: job listing, approve writes the md, reject returns to idle', 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 { base, server } = await start(root); + // Seed a finished job directly in the store (validator/runs is the app's runsDir). + 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: '# THE WINNER', judge: { score: 9, notes: '' }, sections: [], refined: null }]; + await saveJob(runsDir, job); + try { + const list = await (await fetch(`${base}/api/refinery/jobs?promptPath=${encodeURIComponent('G/A.md')}`)).json(); + const mine = list.jobs.find((j) => j.id === job.id); + assert.ok(mine); assert.deepEqual(mine.scores, [9]); + const full = await (await fetch(`${base}/api/refinery/job?id=${job.id}`)).json(); + assert.equal(full.iterations[0].guideline, '# THE WINNER'); + const d = await (await fetch(`${base}/api/refinery/diff?id=${job.id}`)).json(); + assert.equal(d.changed, true); + const ap = await fetch(`${base}/api/refinery/approve`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: job.id }) }); + assert.equal(ap.status, 200); + assert.equal(await readPrompt(root, 'G/A.md'), '# THE WINNER'); + const rj = await fetch(`${base}/api/refinery/reject`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: job.id }) }); + assert.equal(rj.status, 200); + assert.equal((await (await fetch(`${base}/api/refinery/job?id=${job.id}`)).json()).status, 'idle'); + } 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/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 /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'); + 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'); + const { base, server } = await start(root); + 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.iterations = [{ iter: 1, guideline: 'g', judge: null, refined: null, + sections: [{ id: 's', config: '{"x":1}', html: '
        S
        ', css: '.sec{color:red}', frames: [], gif: null, error: null }] }]; + await saveJob(runsDir, job); + try { + const html = await (await fetch(`${base}/render/${job.id}/1/s`)).text(); + assert.match(html, /
        S<\/div>/); + assert.match(html, /createExperience/); + assert.equal((await fetch(`${base}/render/${job.id}/9/s`)).status, 404); + assert.equal((await fetch(`${base}/render/jnope/1/s`)).status, 404); + } 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/launch validates input and playground reachability', async () => { + const { base, server } = await start(await repo()); + const noPaths = await fetch(`${base}/api/refinery/launch`, { method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promptPaths: [], sections: ['s'] }) }); + assert.equal(noPaths.status, 400); + server.close(); +}); 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 +};