diff --git a/.cursor/plans/generate-llms-spec.md b/.cursor/plans/generate-llms-spec.md
new file mode 100644
index 00000000..ed172238
--- /dev/null
+++ b/.cursor/plans/generate-llms-spec.md
@@ -0,0 +1,178 @@
+# generate-llms.mjs -- Script Specification
+
+Stage 1 of the [llms.txt web presence plan](llms-txt_web_presence_c57a751b.plan.md).
+
+## Purpose
+
+A deterministic Node.js ESM script that reads `packages/interact/rules/*.md` and produces two output files:
+
+1. **`llms.txt`** -- a lightweight table-of-contents following the [llmstxt.org spec](https://llmstxt.org)
+2. **`llms-full.txt`** -- all rule files concatenated into a single document
+
+Both files are generated (never hand-edited). The script runs at deploy time in CI and locally via `yarn generate:llms`.
+
+## File
+
+`scripts/generate-llms.mjs` -- plain ESM, no dependencies beyond `node:fs` and `node:path`.
+
+## Inputs
+
+- **Rules directory**: `packages/interact/rules/` (all `.md` files)
+- **Package metadata**: `packages/interact/package.json` (reads `version` and `description`)
+
+## Outputs
+
+The script writes three files (all paths relative to repo root):
+
+| Output | Purpose |
+| ---------------------------- | ------------------------------------------------------------------ |
+| `llms.txt` | Deployed to site root; also the file that ships in the npm package |
+| `llms-full.txt` | Deployed to site root only |
+| `packages/interact/llms.txt` | Copy for npm package inclusion (identical to root `llms.txt`) |
+
+### llms.txt format
+
+Must conform to the llmstxt.org spec: exactly one H1, blockquote immediately after, body text, then H2 sections with link lists.
+
+```
+# @wix/interact
+
+> {description from package.json}
+
+- Install: `npm install @wix/interact @wix/motion-presets`
+- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`)
+- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove
+- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks
+- Configs are JSON-serializable -- designed for LLM generation
+
+## Docs
+
+- [Full Reference]({BASE_URL}/rules/full-lean.md): {extracted description} ({N} lines)
+- [Integration Guide]({BASE_URL}/rules/integration.md): {extracted description} ({N} lines)
+
+## Optional
+
+- [{title}]({BASE_URL}/rules/{file}): {extracted description} ({N} lines)
+ ... one entry per trigger file, alphabetically ...
+- [All rules in one file]({BASE_URL}/llms-full.txt): Complete concatenation ({total} lines)
+```
+
+Where:
+
+- `BASE_URL` = `https://wix.github.io/interact`
+- `{extracted description}` = the text on the line immediately following the `# ...` heading in each file (the first non-empty line after the H1). Trim to first sentence if longer than 120 chars.
+- `{N}` = line count of that file
+- Body text (the bullet list between blockquote and `## Docs`) is **static/hardcoded** -- it describes the library's capabilities and does not change when rules files change.
+
+### llms-full.txt format
+
+```
+# @wix/interact v{version} -- AI Rules Reference
+# {BASE_URL}/llms.txt
+# {file_count} files, {total_lines} lines
+
+--- full-lean.md ---
+{full content of full-lean.md}
+
+--- integration.md ---
+{full content of integration.md}
+
+--- click.md ---
+...
+```
+
+Header lines use `#` comment style (not markdown headings -- this is a concatenated document, not a spec-compliant llms.txt). Each file is separated by `--- {filename} ---` on its own line, followed by a blank line, then the file content verbatim. No trailing separator after the last file.
+
+## File Ordering
+
+Explicit priority list, then alphabetical fallback for unknown files:
+
+1. `full-lean.md` (always first -- comprehensive reference)
+2. `integration.md` (setup and framework patterns)
+3. All remaining `.md` files, sorted alphabetically by filename
+
+This ordering is optimized for truncation: an agent reading only the first ~1000 lines of `llms-full.txt` still gets the two most important files.
+
+New files added to the rules directory in the future are automatically discovered and appended alphabetically after the priority files.
+
+## Determinism
+
+The script must produce **byte-identical output** given the same input files and package.json version. This means:
+
+- No timestamps, dates, or random values in output
+- File discovery uses sorted directory listing
+- Line counts are computed, not hardcoded
+
+## Error Handling
+
+- If `packages/interact/rules/` does not exist or contains zero `.md` files: exit with code 1 and a clear error message.
+- If `packages/interact/package.json` is missing or has no `version` field: exit with code 1.
+- If a rules `.md` file has no `# ` heading on its first line: use the filename (without extension) as the title. Log a warning to stderr.
+
+## Test Spec
+
+Tests live in `scripts/generate-llms.spec.mjs` and run with the repo's vitest setup.
+
+### Strategy
+
+Test the script's **core logic as imported functions** -- not by spawning a child process. The script should export its key functions so tests can call them directly with controlled inputs (temporary directories with fixture files). The script's CLI entry point (the top-level code that reads real paths and writes real files) remains a thin wrapper around these functions and does not need its own test.
+
+### Exported functions
+
+The script should export these for testability:
+
+- `generateLlmsTxt(files, metadata)` -- returns the `llms.txt` string
+ - `files`: array of `{ name, content, lineCount }` objects, already in final order
+ - `metadata`: `{ version, description, baseUrl }`
+- `generateLlmsFullTxt(files, metadata)` -- returns the `llms-full.txt` string
+ - Same signature
+- `orderFiles(fileNames)` -- returns sorted array applying the priority + alphabetical rule
+- `extractDescription(content)` -- returns the description line from a markdown file
+
+### Test cases
+
+**orderFiles**:
+
+- Current 7 files: returns `['full-lean.md', 'integration.md', 'click.md', 'hover.md', 'pointermove.md', 'viewenter.md', 'viewprogress.md']`
+- With unknown file `zebra.md`: appended after all known trigger files
+- With unknown files `aaa.md` and `zzz.md`: both appended alphabetically after known files
+- Empty array: returns empty array
+- Only unknown files: returns them sorted alphabetically
+
+**extractDescription**:
+
+- Standard file (`# Title\n\nDescription line here`): returns `"Description line here"`
+- File with blank lines between heading and description: skips blanks, returns first non-empty line
+- File with no content after heading: returns empty string
+- File with no `# ` heading: returns empty string
+- Long description (>120 chars): truncated to first sentence (first `.` followed by space or EOL)
+
+**generateLlmsTxt**:
+
+- With the current 7 files: output starts with `# @wix/interact`, has `> ` blockquote, has `## Docs` with 2 entries, has `## Optional` with 6 entries (5 triggers + llms-full.txt link)
+- All URLs are absolute HTTPS
+- Line counts in parentheses match input
+- Body text (static bullets) is present between blockquote and `## Docs`
+- Exactly one H1 in the entire output
+- No trailing whitespace on any line
+
+**generateLlmsFullTxt**:
+
+- Header contains version and file count
+- Each file preceded by `--- {filename} ---` separator
+- Content of each file appears verbatim (byte-equal to input)
+- Files appear in the correct order
+- No separator after the last file's content
+- Total line count in header matches actual line count of body
+
+**Determinism**:
+
+- Calling `generateLlmsTxt` twice with same input produces identical output
+- Calling `generateLlmsFullTxt` twice with same input produces identical output
+
+### Not tested (avoid rabbit holes)
+
+- Filesystem I/O (reading real files, writing output) -- tested manually during the verify step
+- CLI exit codes -- trivial wrapper, not worth mocking `process.exit`
+- Network accessibility of generated URLs -- verified manually post-deploy
+- Content correctness of the static body text -- it's a string literal, testing it would just duplicate it
diff --git a/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md b/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md
new file mode 100644
index 00000000..a3e98315
--- /dev/null
+++ b/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md
@@ -0,0 +1,204 @@
+---
+name: llms-txt web presence
+overview: Add llms.txt and llms-full.txt to the docs site for AI agent discoverability, with deterministic generation scripts that stay in sync as rules evolve.
+todos:
+ - id: gen-script
+ content: Create `scripts/generate-llms.mjs` -- reads rules dir, produces both `llms.txt` and `llms-full.txt` deterministically
+ status: completed
+ - id: workflow
+ content: Update `.github/workflows/interactdocs.yml` -- run generation script and copy outputs to `_site/`
+ status: pending
+ - id: npm-ship
+ content: Add `llms.txt` to `packages/interact/package.json` files array, add `.gitignore` entries for generated files
+ status: pending
+ - id: canonical-url
+ content: Add canonical URL HTML comment to top of `full-lean.md`
+ status: pending
+ - id: root-script
+ content: Add `generate:llms` script to root `package.json`
+ status: pending
+ - id: verify
+ content: Run generation script locally and verify both outputs are correct
+ status: pending
+isProject: false
+---
+
+# llms.txt + Web Presence (Revised)
+
+## Research Context
+
+Surveyed the llms.txt ecosystem as of May 2026 to inform this plan:
+
+- **Spec**: The [llmstxt.org](https://llmstxt.org) original spec (Jeremy Howard, Sep 2024) remains the de facto standard for OSS libraries. The extended v1.7.0 (ai-visibility.org.uk) adds business-oriented sections (`## Contact`, `identity.json`) that are not relevant here. We follow the original spec.
+- **Comparable libraries with llms.txt**:
+ - [GSAP](https://gsap.com/llms.txt) -- pure link index (511 lines), flat H2 sections by API category, relative URLs to `/docs/v3/*.md`
+ - [PixiJS](https://pixijs.com/llms.txt) -- cleanest spec compliance: H1 + blockquote + H2 sections with `.md` links. Offers **three tiers**: `llms.txt` (nav index), `llms-medium.txt` (guides without API), `llms-full.txt` (complete). Also ships `pixijs-skills` npm package for agent install.
+ - [GSAP Vault](https://gsapvault.com/llms.txt) -- richer body text with inline descriptions, technique tags, framework integration snippets. Links to `/llms-full.txt` under `## For More Details`.
+ - [Popmotion](https://context7.com/popmotion/popmotion/llms.txt) -- hosted on Context7, full inline docs with code examples (not link-based).
+ - [Anime.js v4](https://github.com/juliangarnier/anime/issues/1105) -- community-contributed `.instructions.md`, not yet merged.
+ - [Motion.dev](https://motion.dev/docs/ai-kit) -- no llms.txt; uses MCP server + paid AI Kit ($399).
+- **Spec key rules**: (1) exactly one H1; (2) blockquote immediately after H1; (3) body text before H2s is allowed; (4) `## Optional` is a reserved section name meaning "can be skipped for shorter context"; (5) link format is `- [Name](url): Description`; (6) all URLs should be absolute HTTPS; (7) no marketing hyperbole.
+- **Registry**: [llms-txt-hub](https://github.com/thedaviddias/llms-txt-hub) (650+ entries) accepts submissions via web form at llmstxthub.com or PR with `.mdx` file in `packages/content/data/websites/`.
+
+**Design decisions informed by research**:
+
+- Two tiers (not three): our total corpus is ~2115 lines / ~65KB. PixiJS needs three tiers because their full docs are 29,000+ lines. For us, `full-lean.md` (700 lines) already serves as the "medium" read -- an agent can fetch just that one file. Adding a separate `llms-medium.txt` would duplicate it without value.
+- `## Optional` section: per the spec, this signals "skip these for shorter context." Trigger-specific files go here since `full-lean.md` already covers all triggers at a summary level. An agent only needs the trigger files for deep dives.
+- Absolute URLs throughout (not relative): GSAP uses relative paths, but those only resolve when fetched from their domain. Absolute URLs work everywhere -- in npm, in GitHub raw views, in agent tool output.
+- Body text includes install command and key capabilities (like GSAP Vault), not just a bare blockquote (like GSAP).
+
+---
+
+## Current State
+
+The rules directory has **7 files / 2115 lines**:
+
+- `full-lean.md` (700 lines) -- comprehensive reference: config structure, triggers, effects, sequences, conditions, CSS generation
+- `integration.md` (334 lines) -- framework entry points (vanilla/React/Web Components), registerEffects, config schema
+- `click.md` (189 lines), `hover.md` (191 lines), `pointermove.md` (279 lines), `viewenter.md` (226 lines), `viewprogress.md` (196 lines) -- trigger-specific deep dives
+
+The deployment workflow ([`.github/workflows/interactdocs.yml`](.github/workflows/interactdocs.yml)) copies rules to `_site/rules/` at line 90. The site serves them as raw markdown at `https://wix.github.io/interact/rules/*.md`. GitHub Pages serves `.txt` files as `text/plain` and `.md` files as `application/octet-stream` -- both are fine for agent consumption (no HTML wrapping).
+
+---
+
+## Deliverables
+
+### 1. Generation script: `scripts/generate-llms.mjs`
+
+A single ESM script that produces **both** files deterministically from the rules directory. This is the core of the "continuous development" strategy -- when rules change, re-running the script updates both outputs.
+
+**Design:**
+
+- Reads `packages/interact/rules/` directory
+- Produces `llms.txt` (table of contents) and `llms-full.txt` (concatenated content)
+- File discovery is **dynamic** (reads directory listing), but ordering is explicit via a priority list with a fallback for unknown files (alphabetical). New files get included automatically but appended at the end.
+- The script reads the package version from [`packages/interact/package.json`](packages/interact/package.json) and embeds it in both outputs.
+- The script extracts the first heading line (`# ...`) from each file to generate link descriptions automatically (no hardcoded descriptions to drift).
+- Plain ESM, no dependencies beyond `node:fs` and `node:path`.
+
+**File ordering** (optimized for truncation -- essentials first):
+
+1. `full-lean.md` -- the comprehensive reference, always first
+2. `integration.md` -- setup and framework patterns
+3. Trigger files alphabetically: `click.md`, `hover.md`, `pointermove.md`, `viewenter.md`, `viewprogress.md`
+4. Any new `.md` files added later -- appended alphabetically
+
+**llms.txt output** follows the [llmstxt.org spec](https://llmstxt.org):
+
+```markdown
+# @wix/interact
+
+> Declarative, configuration-driven interaction library -- binds animations to triggers via JSON config. Web-native, AI-ready, framework-agnostic.
+
+- Install: `npm install @wix/interact @wix/motion-presets`
+- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`)
+- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove
+- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks
+- Configs are JSON-serializable -- designed for LLM generation
+
+## Docs
+
+- [Full Reference](https://wix.github.io/interact/rules/full-lean.md): Complete rules -- config structure, all triggers, effects, sequences, conditions, CSS generation, element resolution (700 lines)
+- [Integration Guide](https://wix.github.io/interact/rules/integration.md): Entry points, registerEffects, config schema, FOUC prevention, static API (334 lines)
+
+## Optional
+
+- [Click Trigger](https://wix.github.io/interact/rules/click.md): Click and keyboard-activate interactions (189 lines)
+- [Hover Trigger](https://wix.github.io/interact/rules/hover.md): Hover and keyboard-focus interactions (191 lines)
+- [PointerMove Trigger](https://wix.github.io/interact/rules/pointermove.md): Pointer-driven real-time animations with hitArea and centeredToTarget (279 lines)
+- [ViewEnter Trigger](https://wix.github.io/interact/rules/viewenter.md): Viewport entrance animations via IntersectionObserver (226 lines)
+- [ViewProgress Trigger](https://wix.github.io/interact/rules/viewprogress.md): Scroll-driven animations via ViewTimeline (196 lines)
+- [All rules in one file](https://wix.github.io/interact/llms-full.txt): Complete concatenation for single-fetch consumption (~2115 lines)
+```
+
+Key structural decisions vs. previous plan:
+
+- **`## Docs` instead of `## Reference`**: aligns with PixiJS and the llmstxt.org example (FastHTML uses `## Docs`).
+- **Trigger files under `## Optional`**: per the spec, this section signals "can be skipped for shorter context." An agent reading only `## Docs` gets `full-lean.md` + `integration.md` (1034 lines) -- sufficient for most tasks. Trigger files are supplementary deep dives.
+- **`llms-full.txt` also under `## Optional`**: it's an alternative consumption path, not required.
+- **Body text as a bullet list**: concise capabilities that help an agent decide relevance before following any links. Mirrors GSAP Vault's approach but without marketing language.
+
+**llms-full.txt output**: all files concatenated with `--- filename ---` separators, preceded by a header block:
+
+```
+# @wix/interact v2.2.2 -- AI Rules Reference
+# https://wix.github.io/interact/llms.txt
+# 7 files, ~2115 lines
+```
+
+### 2. Update deployment workflow
+
+Edit [`.github/workflows/interactdocs.yml`](.github/workflows/interactdocs.yml) -- add after the rules copy (line 90):
+
+```yaml
+# Generate llms.txt files for AI agent discoverability
+node scripts/generate-llms.mjs
+cp llms.txt _site/llms.txt
+cp llms-full.txt _site/llms-full.txt
+```
+
+This runs generation at deploy time so files are always in sync with the rules that ship. No need to commit generated files.
+
+Also update the directory structure comment (lines 67-71) to document the new paths:
+
+```yaml
+# /llms.txt -> AI agent discovery index (llmstxt.org standard)
+# /llms-full.txt -> All rules concatenated for single-fetch consumption
+```
+
+### 3. Ship llms.txt in npm package
+
+Edit [`packages/interact/package.json`](packages/interact/package.json) `"files"` array:
+
+```json
+"files": ["dist", "rules", "docs", "llms.txt"]
+```
+
+The generation script should write `llms.txt` directly into `packages/interact/` (in addition to repo root) so it's available for both the website deploy and the npm publish. Add `packages/interact/llms.txt` to `.gitignore` since it's generated.
+
+`llms-full.txt` does NOT ship in the npm package -- agents with local filesystem access can read `rules/` directly. Keeps the package lean.
+
+### 4. Add canonical URL to full-lean.md
+
+Add a single-line HTML comment at the top of [`packages/interact/rules/full-lean.md`](packages/interact/rules/full-lean.md):
+
+```markdown
+
+
+# @wix/interact -- Rules
+```
+
+Only `full-lean.md` gets this -- it's the entry-point file every agent reads first. Adding it to all 7 files would waste tokens in every agent context.
+
+### 5. Root package.json script
+
+Add to [`package.json`](package.json) scripts:
+
+```json
+"generate:llms": "node scripts/generate-llms.mjs"
+```
+
+### 6. Submit to registries (manual, post-deploy)
+
+**llms-txt-hub** (primary): After deployment, submit via the web form at [llmstxthub.com](https://llmstxthub.com) (log in with GitHub). The submission requires:
+
+- Website URL: `https://wix.github.io/interact/`
+- llms.txt URL: `https://wix.github.io/interact/llms.txt`
+- llms-full.txt URL: `https://wix.github.io/interact/llms-full.txt`
+- Category: Developer Tools
+
+Alternatively, open a PR on [thedaviddias/llms-txt-hub](https://github.com/thedaviddias/llms-txt-hub) adding an `.mdx` file at `packages/content/data/websites/wix-interact-llms-txt.mdx`. Do NOT edit `data/websites.json` (auto-generated).
+
+**Context7** (secondary): Context7 indexes library docs for LLMs (e.g., [Popmotion on Context7](https://context7.com/popmotion/popmotion/llms.txt)). Consider submitting `@wix/interact` after the llms.txt is live. This is lower priority since Context7 often auto-discovers npm packages.
+
+---
+
+## Files Touched
+
+- **NEW**: `scripts/generate-llms.mjs` (~100 lines)
+- **EDIT**: `.github/workflows/interactdocs.yml` (add generation + copy steps)
+- **EDIT**: `packages/interact/package.json` (add `llms.txt` to files array)
+- **EDIT**: `packages/interact/rules/full-lean.md` (add canonical URL comment)
+- **EDIT**: `package.json` (add `generate:llms` script)
+- **EDIT**: `.gitignore` (add generated file entries)
+- **GENERATED** (not committed): `llms.txt`, `llms-full.txt`, `packages/interact/llms.txt`
diff --git a/.github/workflows/preview-llms.yml b/.github/workflows/preview-llms.yml
new file mode 100644
index 00000000..d86000ef
--- /dev/null
+++ b/.github/workflows/preview-llms.yml
@@ -0,0 +1,47 @@
+name: Preview llms.txt
+
+on:
+ pull_request:
+ paths:
+ - 'packages/interact/rules/**'
+ - 'scripts/generate-llms.mjs'
+ - 'packages/interact/package.json'
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ preview:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 24
+
+ - name: Generate llms.txt files
+ run: node scripts/generate-llms.mjs
+
+ - name: Upload generated files
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: llms-txt-preview
+ path: |
+ llms.txt
+ llms-full.txt
+
+ - name: Post or update PR comment
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ RUN_ID: ${{ github.run_id }}
+ run: ./scripts/post-llms-preview.sh
diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs
new file mode 100644
index 00000000..dc9a15b5
--- /dev/null
+++ b/scripts/generate-llms.mjs
@@ -0,0 +1,206 @@
+import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const BASE_URL = 'https://wix.github.io/interact';
+const KNOWN_ORDER = [
+ 'full-lean.md',
+ 'integration.md',
+ 'click.md',
+ 'hover.md',
+ 'pointermove.md',
+ 'viewenter.md',
+ 'viewprogress.md',
+];
+const DOCS_LINK_TITLES = new Map([
+ ['full-lean.md', 'Full Reference'],
+ ['integration.md', 'Integration Guide'],
+]);
+
+const STATIC_BODY = [
+ '- Install: `npm install @wix/interact @wix/motion-presets`',
+ '- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`)',
+ '- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove',
+ '- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks',
+ '- Configs are JSON-serializable -- designed for LLM generation',
+].join('\n');
+
+/**
+ * Sorts file names: priority files first (in PRIORITY order),
+ * then remaining files alphabetically.
+ */
+export function orderFiles(fileNames) {
+ const known = KNOWN_ORDER.filter((name) => fileNames.includes(name));
+ const unknown = fileNames.filter((name) => !KNOWN_ORDER.includes(name)).sort();
+ return [...known, ...unknown];
+}
+
+/**
+ * Extracts the first non-empty line after the H1 heading.
+ * If the line exceeds 120 chars, truncates to the first sentence
+ * (first `.` followed by whitespace or end-of-string).
+ */
+export function extractDescription(content) {
+ const lines = content.split('\n');
+ let pastHeading = false;
+
+ for (const line of lines) {
+ if (!pastHeading) {
+ if (line.startsWith('# ')) {
+ pastHeading = true;
+ }
+ continue;
+ }
+
+ const trimmed = line.trim();
+ if (trimmed === '') continue;
+
+ if (trimmed.length <= 120) return trimmed;
+
+ const match = trimmed.match(/^(.*?\.)(?:\s|$)/);
+ if (match) return match[1];
+
+ return trimmed;
+ }
+
+ return '';
+}
+
+/**
+ * Generates the llms.txt table-of-contents string (llmstxt.org spec).
+ * @param {Array<{name: string, content: string, lineCount: number}>} files - ordered file list
+ * @param {{version: string, description: string, baseUrl: string}} metadata
+ */
+export function generateLlmsTxt(files, metadata) {
+ const { description, baseUrl } = metadata;
+ const lines = [];
+
+ lines.push('# @wix/interact');
+ lines.push('');
+ lines.push(`> ${description}`);
+ lines.push('');
+ lines.push(STATIC_BODY);
+ lines.push('');
+ lines.push('## Docs');
+ lines.push('');
+
+ const docsFiles = files.filter((f) => DOCS_LINK_TITLES.has(f.name));
+ for (const file of docsFiles) {
+ const title = DOCS_LINK_TITLES.get(file.name);
+ const desc = extractDescription(file.content);
+ lines.push(`- [${title}](${baseUrl}/rules/${file.name}): ${desc} (${file.lineCount} lines)`);
+ }
+
+ lines.push('');
+ lines.push('## Optional');
+ lines.push('');
+
+ const optionalFiles = files.filter((f) => !DOCS_LINK_TITLES.has(f.name));
+ for (const file of optionalFiles) {
+ const headingMatch = file.content.match(/^# (.+)$/m);
+ const title = headingMatch ? headingMatch[1].trim() : file.name.replace(/\.md$/, '');
+ const desc = extractDescription(file.content);
+ lines.push(`- [${title}](${baseUrl}/rules/${file.name}): ${desc} (${file.lineCount} lines)`);
+ }
+
+ const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0);
+ lines.push(
+ `- [All rules in one file](${baseUrl}/llms-full.txt): Complete concatenation (${totalLines} lines)`,
+ );
+ lines.push('');
+
+ return lines.join('\n');
+}
+
+/**
+ * Generates the llms-full.txt concatenated document string.
+ * @param {Array<{name: string, content: string, lineCount: number}>} files - ordered file list
+ * @param {{version: string, description: string, baseUrl: string}} metadata
+ */
+export function generateLlmsFullTxt(files, metadata) {
+ const { version, baseUrl } = metadata;
+ const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0);
+
+ let output = `# @wix/interact v${version} -- AI Rules Reference\n`;
+ output += `# ${baseUrl}/llms.txt\n`;
+ output += `# ${files.length} files, ${totalLines} lines\n`;
+
+ for (const file of files) {
+ output += `\n--- ${file.name} ---\n\n`;
+ output += file.content;
+ }
+
+ return output;
+}
+
+// --------------- CLI entry point ---------------
+
+const RULES_DIR = 'packages/interact/rules';
+const PKG_PATH = 'packages/interact/package.json';
+
+function countLines(content) {
+ const parts = content.split('\n');
+ return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length;
+}
+
+function main() {
+ let pkg;
+ try {
+ pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
+ } catch {
+ console.error(`Error: cannot read ${PKG_PATH}`);
+ process.exit(1);
+ }
+
+ if (!pkg.version) {
+ console.error(`Error: no "version" field in ${PKG_PATH}`);
+ process.exit(1);
+ }
+
+ let fileNames;
+ try {
+ fileNames = readdirSync(RULES_DIR).filter((f) => f.endsWith('.md'));
+ } catch {
+ console.error(`Error: cannot read rules directory ${RULES_DIR}`);
+ process.exit(1);
+ }
+
+ if (fileNames.length === 0) {
+ console.error(`Error: no .md files found in ${RULES_DIR}`);
+ process.exit(1);
+ }
+
+ const ordered = orderFiles(fileNames);
+
+ const files = ordered.map((name) => {
+ const content = readFileSync(join(RULES_DIR, name), 'utf-8');
+ const lineCount = countLines(content);
+
+ if (!content.startsWith('# ')) {
+ console.warn(`Warning: ${name} has no H1 heading on first line`);
+ }
+
+ return { name, content, lineCount };
+ });
+
+ const metadata = {
+ version: pkg.version,
+ description: pkg.description,
+ baseUrl: BASE_URL,
+ };
+
+ const llmsTxt = generateLlmsTxt(files, metadata);
+ const llmsFullTxt = generateLlmsFullTxt(files, metadata);
+
+ writeFileSync('llms.txt', llmsTxt);
+ writeFileSync('llms-full.txt', llmsFullTxt);
+ writeFileSync(join('packages/interact', 'llms.txt'), llmsTxt);
+
+ console.log(`Generated llms.txt (${countLines(llmsTxt)} lines)`);
+ console.log(`Generated llms-full.txt (${countLines(llmsFullTxt)} lines)`);
+ console.log('Copied llms.txt to packages/interact/llms.txt');
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/scripts/generate-llms.spec.mjs b/scripts/generate-llms.spec.mjs
new file mode 100644
index 00000000..788d2ec3
--- /dev/null
+++ b/scripts/generate-llms.spec.mjs
@@ -0,0 +1,304 @@
+import { describe, it, expect } from 'vitest';
+import {
+ orderFiles,
+ extractDescription,
+ generateLlmsTxt,
+ generateLlmsFullTxt,
+} from './generate-llms.mjs';
+
+const BASE_URL = 'https://wix.github.io/interact';
+
+const METADATA = {
+ version: '1.0.0',
+ description: 'Test description for the library.',
+ baseUrl: BASE_URL,
+};
+
+function makeFile(name, heading, descriptionLine, extraLines = 3) {
+ const lines = [`# ${heading}`, '', descriptionLine];
+ for (let i = 0; i < extraLines; i++) {
+ lines.push(`Line ${i + 1} of ${name}.`);
+ }
+ lines.push('');
+ const content = lines.join('\n');
+ const lineCount = lines.length - 1; // trailing empty string from final \n
+ return { name, content, lineCount };
+}
+
+const CURRENT_FILENAMES = [
+ 'full-lean.md',
+ 'integration.md',
+ 'click.md',
+ 'hover.md',
+ 'pointermove.md',
+ 'viewenter.md',
+ 'viewprogress.md',
+];
+
+function makeCurrentFiles() {
+ return [
+ makeFile('full-lean.md', '@wix/interact — Rules', 'Complete reference.', 5),
+ makeFile(
+ 'integration.md',
+ '@wix/interact Integration Rules',
+ 'Setup and framework patterns.',
+ 4,
+ ),
+ makeFile(
+ 'click.md',
+ 'Click Trigger Rules for @wix/interact',
+ 'Click-triggered interactions.',
+ 2,
+ ),
+ makeFile(
+ 'hover.md',
+ 'Hover Trigger Rules for @wix/interact',
+ 'Hover-triggered interactions.',
+ 2,
+ ),
+ makeFile(
+ 'pointermove.md',
+ 'PointerMove Trigger Rules for @wix/interact',
+ 'Pointer-driven real-time animations.',
+ 3,
+ ),
+ makeFile(
+ 'viewenter.md',
+ 'ViewEnter Trigger Rules for @wix/interact',
+ 'Viewport entrance animations.',
+ 3,
+ ),
+ makeFile(
+ 'viewprogress.md',
+ 'ViewProgress Trigger Rules for @wix/interact',
+ 'Scroll-driven animations.',
+ 3,
+ ),
+ ];
+}
+
+// ─── orderFiles ──────────────────────────────────────────────
+
+describe('orderFiles', () => {
+ it('orders the current 7 files correctly', () => {
+ const shuffled = [
+ 'viewprogress.md',
+ 'click.md',
+ 'integration.md',
+ 'hover.md',
+ 'full-lean.md',
+ 'pointermove.md',
+ 'viewenter.md',
+ ];
+ expect(orderFiles(shuffled)).toEqual([
+ 'full-lean.md',
+ 'integration.md',
+ 'click.md',
+ 'hover.md',
+ 'pointermove.md',
+ 'viewenter.md',
+ 'viewprogress.md',
+ ]);
+ });
+
+ it('appends unknown file after all known trigger files', () => {
+ const input = [...CURRENT_FILENAMES, 'zebra.md'];
+ const result = orderFiles(input);
+ expect(result.indexOf('zebra.md')).toBe(result.length - 1);
+ });
+
+ it('appends multiple unknown files alphabetically after known files', () => {
+ const input = [...CURRENT_FILENAMES, 'zzz.md', 'aaa.md'];
+ const result = orderFiles(input);
+ const unknownStart = CURRENT_FILENAMES.length;
+ expect(result.slice(unknownStart)).toEqual(['aaa.md', 'zzz.md']);
+ });
+
+ it('returns empty array for empty input', () => {
+ expect(orderFiles([])).toEqual([]);
+ });
+
+ it('sorts only-unknown files alphabetically', () => {
+ expect(orderFiles(['gamma.md', 'alpha.md', 'beta.md'])).toEqual([
+ 'alpha.md',
+ 'beta.md',
+ 'gamma.md',
+ ]);
+ });
+});
+
+// ─── extractDescription ──────────────────────────────────────
+
+describe('extractDescription', () => {
+ it('extracts description from a standard file', () => {
+ const content = '# Title\n\nDescription line here\n';
+ expect(extractDescription(content)).toBe('Description line here');
+ });
+
+ it('skips blank lines between heading and description', () => {
+ const content = '# Title\n\n\n\nDescription after blanks\n';
+ expect(extractDescription(content)).toBe('Description after blanks');
+ });
+
+ it('returns empty string when no content after heading', () => {
+ expect(extractDescription('# Title\n')).toBe('');
+ expect(extractDescription('# Title\n\n\n')).toBe('');
+ });
+
+ it('returns empty string when no heading exists', () => {
+ expect(extractDescription('No heading here\nJust text\n')).toBe('');
+ });
+
+ it('truncates long description to first sentence', () => {
+ const long =
+ '# Title\n\nFirst sentence here. ' +
+ 'Second sentence that pushes the total well beyond one hundred and twenty characters easily enough to trigger truncation.\n';
+ expect(extractDescription(long)).toBe('First sentence here.');
+ });
+
+ it('handles period inside backticks followed by sentence break', () => {
+ const content =
+ '# Title\n\n' +
+ 'These rules help generate pointer-driven interactions using `@wix/interact`. ' +
+ 'PointerMove triggers create real-time animations that respond to mouse movement over elements or the entire viewport.\n';
+ expect(extractDescription(content)).toBe(
+ 'These rules help generate pointer-driven interactions using `@wix/interact`.',
+ );
+ });
+
+ it('returns the full line when under 120 chars', () => {
+ const content = '# Title\n\nShort description.\n';
+ expect(extractDescription(content)).toBe('Short description.');
+ });
+
+ it('returns full single-sentence line even if over 120 chars', () => {
+ const sentence =
+ 'This is one extremely long sentence without any period-space breaks that goes on and on far past one hundred and twenty characters total';
+ const content = `# Title\n\n${sentence}\n`;
+ expect(extractDescription(content)).toBe(sentence);
+ });
+});
+
+// ─── generateLlmsTxt ────────────────────────────────────────
+
+describe('generateLlmsTxt', () => {
+ const files = makeCurrentFiles();
+ const output = generateLlmsTxt(files, METADATA);
+ const outputLines = output.split('\n');
+
+ it('starts with exactly one H1', () => {
+ const h1Lines = outputLines.filter((l) => l.startsWith('# '));
+ expect(h1Lines).toHaveLength(1);
+ expect(outputLines[0]).toBe('# @wix/interact');
+ });
+
+ it('has a blockquote with the package description', () => {
+ expect(output).toContain(`> ${METADATA.description}`);
+ });
+
+ it('includes static body text between blockquote and ## Docs', () => {
+ const blockquoteIdx = outputLines.indexOf(`> ${METADATA.description}`);
+ const docsIdx = outputLines.indexOf('## Docs');
+ expect(blockquoteIdx).toBeGreaterThan(-1);
+ expect(docsIdx).toBeGreaterThan(blockquoteIdx);
+
+ const between = outputLines.slice(blockquoteIdx + 1, docsIdx).join('\n');
+ expect(between).toContain('npm install @wix/interact');
+ expect(between).toContain('Five trigger types');
+ });
+
+ it('has ## Docs with 2 entries', () => {
+ const docsIdx = outputLines.indexOf('## Docs');
+ const optIdx = outputLines.indexOf('## Optional');
+ const docsLinks = outputLines.slice(docsIdx, optIdx).filter((l) => l.startsWith('- ['));
+ expect(docsLinks).toHaveLength(2);
+ });
+
+ it('has ## Optional with 6 entries (5 triggers + llms-full.txt)', () => {
+ const optIdx = outputLines.indexOf('## Optional');
+ const optLinks = outputLines.slice(optIdx).filter((l) => l.startsWith('- ['));
+ expect(optLinks).toHaveLength(6);
+ });
+
+ it('uses absolute HTTPS URLs for all links', () => {
+ const links = outputLines.filter((l) => l.startsWith('- ['));
+ for (const link of links) {
+ expect(link).toMatch(/\(https:\/\//);
+ }
+ });
+
+ it('includes correct line counts in parentheses', () => {
+ for (const file of files) {
+ expect(output).toContain(`(${file.lineCount} lines)`);
+ }
+ });
+
+ it('has no trailing whitespace on any line', () => {
+ for (const line of outputLines) {
+ expect(line).toBe(line.trimEnd());
+ }
+ });
+});
+
+// ─── generateLlmsFullTxt ────────────────────────────────────
+
+describe('generateLlmsFullTxt', () => {
+ const files = makeCurrentFiles();
+ const output = generateLlmsFullTxt(files, METADATA);
+
+ it('header contains version and file count', () => {
+ expect(output).toContain(`v${METADATA.version}`);
+ expect(output).toContain(`${files.length} files`);
+ });
+
+ it('each file is preceded by a separator', () => {
+ for (const file of files) {
+ expect(output).toContain(`--- ${file.name} ---`);
+ }
+ });
+
+ it('includes each file content verbatim', () => {
+ for (const file of files) {
+ expect(output).toContain(file.content);
+ }
+ });
+
+ it('files appear in the correct order', () => {
+ let lastIdx = -1;
+ for (const file of files) {
+ const idx = output.indexOf(`--- ${file.name} ---`);
+ expect(idx).toBeGreaterThan(lastIdx);
+ lastIdx = idx;
+ }
+ });
+
+ it('has no separator after the last file content', () => {
+ const lastFile = files[files.length - 1];
+ const lastSepIdx = output.lastIndexOf('---');
+ const lastContentIdx = output.lastIndexOf(lastFile.content);
+ expect(lastContentIdx).toBeGreaterThan(lastSepIdx);
+ });
+
+ it('total line count in header matches sum of file line counts', () => {
+ const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0);
+ expect(output).toContain(`${totalLines} lines`);
+ });
+});
+
+// ─── determinism ────────────────────────────────────────────
+
+describe('determinism', () => {
+ const files = makeCurrentFiles();
+
+ it('generateLlmsTxt produces identical output on repeated calls', () => {
+ const a = generateLlmsTxt(files, METADATA);
+ const b = generateLlmsTxt(files, METADATA);
+ expect(a).toBe(b);
+ });
+
+ it('generateLlmsFullTxt produces identical output on repeated calls', () => {
+ const a = generateLlmsFullTxt(files, METADATA);
+ const b = generateLlmsFullTxt(files, METADATA);
+ expect(a).toBe(b);
+ });
+});
diff --git a/scripts/post-llms-preview.sh b/scripts/post-llms-preview.sh
new file mode 100755
index 00000000..e0d63740
--- /dev/null
+++ b/scripts/post-llms-preview.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Posts or updates a PR comment with the generated llms.txt preview.
+# Called by the preview-llms workflow after generate-llms.mjs has run.
+#
+# Required env vars (set by the workflow):
+# GH_TOKEN - GitHub token for API access
+# PR_NUMBER - Pull request number
+# REPO - owner/repo string
+# RUN_ID - Workflow run ID (for artifact link)
+
+: "${GH_TOKEN:?GH_TOKEN is required}"
+: "${PR_NUMBER:?PR_NUMBER is required}"
+: "${REPO:?REPO is required}"
+: "${RUN_ID:?RUN_ID is required}"
+
+MARKER=''
+COMMENT_FILE=$(mktemp)
+trap 'rm -f "$COMMENT_FILE"' EXIT
+
+{
+ echo "$MARKER"
+ echo '### Generated `llms.txt` preview'
+ echo ''
+ echo ''
+ echo 'llms.txt
'
+ echo ''
+ echo '```markdown'
+ cat llms.txt
+ echo '```'
+ echo ''
+ echo ' '
+ echo ''
+ echo ''
+ echo 'llms-full.txt header
'
+ echo ''
+ echo '```'
+ head -3 llms-full.txt
+ echo '```'
+ echo ''
+ echo ' '
+ echo ''
+ echo "> [Download full files](https://github.com/${REPO}/actions/runs/${RUN_ID}) from workflow artifacts, or run \`node scripts/generate-llms.mjs\` locally."
+} > "$COMMENT_FILE"
+
+COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
+ --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
+ | head -1)
+
+if [ -n "$COMMENT_ID" ]; then
+ jq -Rs '{body: .}' "$COMMENT_FILE" | \
+ gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" -X PATCH --input -
+else
+ gh pr comment "$PR_NUMBER" --body-file "$COMMENT_FILE"
+fi