diff --git a/.changeset/phase-2-presentation-layer.md b/.changeset/phase-2-presentation-layer.md
new file mode 100644
index 0000000..22519e5
--- /dev/null
+++ b/.changeset/phase-2-presentation-layer.md
@@ -0,0 +1,107 @@
+---
+"@prosdevlab/experience-sdk": minor
+"@prosdevlab/experience-sdk-plugins": minor
+---
+
+**Phase 2: Presentation Layer - Modal & Inline Plugins**
+
+This release introduces two powerful new rendering plugins with built-in form support, completing the presentation layer for the Experience SDK.
+
+## ๐ New Features
+
+### Modal Plugin
+- **Rich Content Modals**: Display announcements, promotions, and interactive content
+- **Built-in Forms**: Email capture, surveys, and feedback forms with validation
+- **Size Variants**: Small, medium, large, and extra-large sizes
+- **Hero Images**: Full-width images for visual impact
+- **Responsive Design**: Mobile fullscreen mode for optimal UX
+- **Keyboard Navigation**: Focus trap, Escape key, Tab navigation
+- **Animations**: Smooth fade-in/fade-out transitions
+- **Form States**: Success, error, and loading states
+- **API Methods**: `show()`, `remove()`, `isShowing()`, `showFormState()`, `resetForm()`, `getFormData()`
+
+### Inline Plugin
+- **DOM Insertion**: Embed content anywhere in your page
+- **5 Insertion Methods**: `replace`, `append`, `prepend`, `before`, `after`
+- **CSS Selector Targeting**: Use any valid selector to target elements
+- **Dismissal with Persistence**: Users can dismiss and it persists in localStorage
+- **No Layout Disruption**: Seamlessly integrates with existing page structure
+- **API Methods**: `show()`, `remove()`, `isShowing()`
+
+### Forms (Built into Modal)
+- **Field Types**: text, email, url, tel, number, textarea, select, checkbox, radio
+- **Validation**: Required, email, URL, pattern, custom validation, min/max length
+- **Real-time Feedback**: Validates on blur, shows errors inline
+- **Submission Handling**: Emits `experiences:modal:form:submit` event
+- **Success/Error States**: Built-in UI for post-submission states
+- **Pure Functions**: Validation and rendering logic easily extractable
+
+## ๐จ Theming & Customization
+
+### CSS Variables
+All plugins now support CSS variable theming:
+- **Modal**: `--xp-modal-*` variables for backdrop, dialog, title, content, buttons
+- **Forms**: `--xp-form-*` variables for inputs, labels, errors, submit button
+- **Banner**: `--xp-banner-*` variables (refactored from inline styles)
+- **Inline**: `--xp-inline-*` variables for custom styling
+
+See the [Theming Guide](https://prosdevlab.github.io/experience-sdk/guides/theming) for full reference.
+
+## ๐ง API Improvements
+
+### Runtime
+- **Auto-registration**: Modal and inline plugins are automatically registered
+- **Plugin API Access**: Expose plugin APIs via singleton (`experiences.modal.show()`)
+- **Trigger Event Handling**: Explicit listeners for each trigger type (exit intent, scroll depth, time delay)
+
+### Display Conditions
+Seamless integration with existing display condition plugins:
+- **Exit Intent + Modal**: Capture emails before users leave
+- **Scroll Depth + Inline**: Progressive feature discovery
+- **Time Delay + Modal**: Time-sensitive promotions
+- **Page Visits + Banner**: Returning user messages
+
+## ๐ฆ Bundle Size
+- **Core SDK**: 13.4 KB gzipped (under 15 KB target โ
)
+- **All Plugins**: ~26 KB gzipped total (smaller than competitors like Pathfora at ~47 KB)
+- **Excellent Compression**: CSS-in-JS with CSS variables maintains small footprint
+
+## ๐งช Testing
+- **432 tests passing** (unit, integration, browser tests)
+- **Modal Plugin**: 56 tests for core functionality, forms, keyboard nav, accessibility
+- **Inline Plugin**: 24 tests for DOM insertion, dismissal, persistence
+- **Form Validation**: 35 tests for all field types and edge cases
+- **Integration Tests**: 10 tests for plugin interactions
+- **Exit Intent**: 21 tests with timing and sensitivity validation
+
+## ๐ Documentation
+- **Modal Plugin Reference**: Complete API docs with examples
+- **Inline Plugin Reference**: Full insertion method documentation
+- **Theming Guide**: CSS variable reference with examples
+- **Use Case Examples**: 4 complete implementation guides in playground
+
+## ๐ Playground Enhancements
+- **Layout Gallery Hub**: Visual directory for banner, modal, and inline layouts
+- **Navigation System**: Breadcrumbs and sub-navigation tabs
+- **Use Case Examples**:
+ - Exit Intent Email Capture (exit intent + modal forms)
+ - Feature Discovery Journey (scroll depth + inline + modal)
+ - Time-Delayed Promotions (time delay + hero image modal)
+ - Promotions & Announcements (banner examples)
+- **Interactive Demos**: Live examples with SDK integration
+
+## โ ๏ธ Breaking Changes
+None. This is a **minor** release with backward compatibility.
+
+## ๐ Next Steps (Phase 3+)
+- Browser tests for form focus management
+- Composable form plugin (separate from modal)
+- Additional layout plugins (tooltip, slideout, sticky bar)
+- Multi-instance support with `instanceId` tracking
+
+---
+
+**Migration Guide**: No migration needed. Simply upgrade and start using the new plugins!
+
+**Full Changelog**: See [Phase 2 Spec](https://github.com/prosdevlab/experience-sdk/blob/main/specs/phase-2-presentation-layer/spec.md)
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f640021..3564846 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,21 @@ jobs:
- name: Install dependencies
run: pnpm install
+ - name: Get Playwright version
+ id: playwright-version
+ run: echo "version=$(node -p "require('./package.json').devDependencies.playwright")" >> $GITHUB_OUTPUT
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: npx playwright install --with-deps chromium
+
- name: Lint
run: pnpm lint
@@ -38,5 +53,8 @@ jobs:
- name: Type check
run: pnpm typecheck
- - name: Test
- run: pnpm test
\ No newline at end of file
+ - name: Test (Unit)
+ run: pnpm test
+
+ - name: Test (Browser)
+ run: pnpm test:browser
\ No newline at end of file
diff --git a/README.md b/README.md
index 298c28b..77597b4 100644
--- a/README.md
+++ b/README.md
@@ -4,61 +4,96 @@
**A lightweight, explainable client-side experience runtime built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit)**
-Experience SDK decides if/when/why experiences (banners, modals, tooltips) should render. Every decision comes with structured reasons, making debugging and testing effortless.
+Experience SDK enables marketers and developers to create personalized experiences (modals, banners, inline content) with powerful targeting and explainability. Every decision comes with structured reasons, making debugging and testing effortless.
## Features
- ๐ **Explainability-First** - Every decision includes structured reasons
- ๐งฉ **Plugin-Based** - Built on @lytics/sdk-kit's powerful plugin system
-- ๐ฆ **Script Tag Ready** - Works without build tools
+- ๐จ **Presentation Plugins** - Modal, banner, and inline content rendering
+- ๐ **Built-in Forms** - Email capture, surveys, feedback with validation
+- ๐ฏ **Smart Triggers** - Exit intent, scroll depth, time delay, page visits
+- ๐ฆ **Script Tag Ready** - Works without build tools (marketers love it!)
+- ๐
**CSS Variables** - Easy theming with CSS custom properties
- ๐ฏ **Type-Safe** - Full TypeScript support
-- ๐ชถ **Lightweight** - < 15KB gzipped
+- ๐ชถ **Lightweight** - ~26KB gzipped with all plugins (13.4KB core)
- ๐ง **Developer-Friendly** - Built for inspection and debugging
## Quick Start
-### Script Tag
+### Script Tag (For Marketers)
```html
-
+
```
-### npm
+### npm (For Developers)
```bash
-npm install @prosdevlab/experience-sdk
+npm install @prosdevlab/experience-sdk @prosdevlab/experience-sdk-plugins
```
```typescript
-import experiences from '@prosdevlab/experience-sdk';
+import { createInstance } from '@prosdevlab/experience-sdk';
+import { modalPlugin, inlinePlugin, bannerPlugin } from '@prosdevlab/experience-sdk-plugins';
+
+const sdk = createInstance({ debug: true });
+
+// Use plugins
+sdk.use(modalPlugin);
+sdk.use(inlinePlugin);
+sdk.use(bannerPlugin);
+
+// Register experiences
+sdk.register('feature-tip', {
+ type: 'inline',
+ content: {
+ selector: '#feature-section',
+ position: 'after',
+ message: '
๐ก New: Check out our analytics dashboard!
'
+ },
+ display: {
+ trigger: 'scrollDepth',
+ triggerData: { threshold: 50 }
+ }
+});
-experiences.init({ debug: true });
-experiences.register('welcome', { ... });
-const decision = experiences.evaluate();
+// Listen to events
+sdk.on('experiences:shown', (event) => {
+ analytics.track('Experience Shown', { id: event.experienceId });
+});
```
### Event-Driven Architecture
@@ -90,18 +125,38 @@ See the [Events Reference](https://your-docs-url/api/events) for comprehensive d
## Documentation
-See [notes/IMPLEMENTATION_PLAN.md](notes/IMPLEMENTATION_PLAN.md) for detailed implementation guide.
+- **[Plugin Reference](https://prosdevlab.github.io/experience-sdk/reference/plugins)** - Modal, Banner, Inline plugins
+- **[Theming Guide](https://prosdevlab.github.io/experience-sdk/guides/theming)** - CSS variables customization
+- **[Playground](https://experience-sdk-playground.vercel.app)** - Live demos and use cases
## Project Status
-๐ง **v0.1.0 in development** - Foundation phase
-
-- [ ] Core runtime with explainability
-- [ ] Storage plugin (frequency capping)
-- [ ] Debug plugin (event emission)
-- [ ] Banner plugin (delivery)
-- [ ] Demo site
-- [ ] UMD bundle
+โ
**v0.2.0** - Presentation Layer Complete
+
+**Core Runtime:**
+- โ
Explainability-first evaluation engine
+- โ
Plugin system (sdk-kit)
+- โ
Event-driven architecture
+- โ
Hybrid API (singleton + instance)
+
+**Display Condition Plugins:**
+- โ
Exit Intent - Detect users about to leave
+- โ
Scroll Depth - Trigger at scroll thresholds
+- โ
Time Delay - Time-based triggers
+- โ
Page Visits - Session/total visit tracking
+- โ
Frequency Capping - Impression limits
+
+**Presentation Plugins:**
+- โ
Modal - Announcements, promotions, forms
+- โ
Banner - Top/bottom dismissible messages
+- โ
Inline - Embed content in page DOM
+
+**Features:**
+- โ
Built-in form support (validation, submission)
+- โ
CSS variable theming
+- โ
TypeScript support
+- โ
432 tests passing
+- โ
~26KB gzipped (all plugins)
## Development
@@ -149,13 +204,13 @@ Built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit), Experience SDK sh
## Roadmap
-- **Phase 0 (v0.1.0)**: Foundation - Runtime + 3 plugins + demo
-- **Phase 1 (v0.2.0)**: Chrome Extension - DevTools integration
-- **Phase 2 (v0.3.0)**: Advanced plugins - More experience types
-- **Phase 3 (v0.4.0)**: Developer tools - Playground & testing
-- **Phase 4 (v1.0.0)**: Production-ready
+- โ
**Phase 0 (v0.1.0)**: Foundation - Core runtime, display condition plugins, banner plugin
+- โ
**Phase 1 (v0.2.0)**: Presentation Layer - Modal & inline plugins with forms
+- ๐ง **Phase 2 (v0.3.0)**: Developer Experience - Chrome DevTools extension
+- ๐ง **Phase 3 (v0.4.0)**: Advanced Features - Tooltip plugin, multi-instance support
+- ๐ง **Phase 4 (v1.0.0)**: Production Ready - Performance optimizations, advanced targeting
-See [notes/vision-and-roadmap.md](notes/vision-and-roadmap.md) for full roadmap.
+See the [full roadmap](notes/vision-and-roadmap.md) for details.
## License
diff --git a/biome.json b/biome.json
index 9cb3c0e..5ca1402 100644
--- a/biome.json
+++ b/biome.json
@@ -31,10 +31,16 @@
"includes": [
"packages/core/src/types.ts",
"packages/core/src/runtime.ts",
+ "packages/core/src/singleton.ts",
"packages/plugins/src/types.ts",
"packages/plugins/src/exit-intent/exit-intent.ts",
"packages/plugins/src/scroll-depth/scroll-depth.ts",
"packages/plugins/src/page-visits/page-visits.ts",
+ "packages/plugins/src/time-delay/time-delay.ts",
+ "packages/plugins/src/modal/modal.ts",
+ "packages/plugins/src/modal/types.ts",
+ "packages/plugins/src/inline/inline.ts",
+ "packages/plugins/src/inline/types.ts",
"packages/plugins/src/utils/sanitize.ts",
"specs/**/contracts/types.ts",
"docs/**/*.tsx"
diff --git a/docs/pages/_meta.json b/docs/pages/_meta.json
index 5e2ff93..82c2c37 100644
--- a/docs/pages/_meta.json
+++ b/docs/pages/_meta.json
@@ -1,6 +1,7 @@
{
"index": "Introduction",
"getting-started": "Getting Started",
+ "guides": "Guides",
"demo": "Examples",
"reference": "API Reference"
}
diff --git a/docs/pages/guides/_meta.json b/docs/pages/guides/_meta.json
new file mode 100644
index 0000000..b34db19
--- /dev/null
+++ b/docs/pages/guides/_meta.json
@@ -0,0 +1,4 @@
+{
+ "theming": "CSS Theming"
+}
+
diff --git a/docs/pages/guides/theming.mdx b/docs/pages/guides/theming.mdx
new file mode 100644
index 0000000..ec1f832
--- /dev/null
+++ b/docs/pages/guides/theming.mdx
@@ -0,0 +1,528 @@
+# CSS Theming
+
+The Experience SDK uses CSS custom properties (variables) for comprehensive theming. All layout plugins (Banner, Modal, Inline) support full visual customization without overriding styles.
+
+## Quick Start
+
+Override CSS variables in your stylesheet:
+
+```css
+:root {
+ /* Brand colors */
+ --xp-modal-button-primary-bg: #8b5cf6; /* Purple */
+ --xp-banner-bg: #1f2937; /* Dark gray */
+ --xp-inline-button-primary-bg: #0ea5e9; /* Sky blue */
+}
+```
+
+All changes apply automatically to all experiences.
+
+## Banner Variables
+
+### Container & Layout
+
+```css
+:root {
+ --xp-banner-z-index: 10000;
+ --xp-banner-padding: 16px 20px;
+ --xp-banner-gap: 16px; /* Space between elements */
+}
+```
+
+### Colors & Backgrounds
+
+```css
+:root {
+ /* Background */
+ --xp-banner-bg: #ffffff;
+ --xp-banner-border: 1px solid #e5e7eb;
+ --xp-banner-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+
+ /* Text */
+ --xp-banner-title-color: #111827;
+ --xp-banner-message-color: #374151;
+
+ /* Close button */
+ --xp-banner-close-color: #6b7280;
+ --xp-banner-close-hover-color: #111827;
+}
+```
+
+### Typography
+
+```css
+:root {
+ --xp-banner-title-font-size: 16px;
+ --xp-banner-title-font-weight: 600;
+ --xp-banner-title-margin-bottom: 4px;
+
+ --xp-banner-message-font-size: 14px;
+ --xp-banner-message-line-height: 1.5;
+}
+```
+
+### Buttons
+
+```css
+:root {
+ /* Primary button */
+ --xp-banner-button-primary-bg: #2563eb;
+ --xp-banner-button-primary-color: #ffffff;
+ --xp-banner-button-primary-hover-bg: #1d4ed8;
+
+ /* Secondary button */
+ --xp-banner-button-secondary-bg: #f3f4f6;
+ --xp-banner-button-secondary-color: #374151;
+ --xp-banner-button-secondary-hover-bg: #e5e7eb;
+ --xp-banner-button-secondary-border: 1px solid #e5e7eb;
+
+ /* Link button */
+ --xp-banner-button-link-color: #2563eb;
+ --xp-banner-button-link-hover-bg: #f3f4f6;
+
+ /* Size */
+ --xp-banner-button-padding: 8px 16px;
+ --xp-banner-button-font-size: 14px;
+ --xp-banner-button-font-weight: 500;
+ --xp-banner-button-border-radius: 6px;
+ --xp-banner-buttons-gap: 8px;
+}
+```
+
+## Modal Variables
+
+### Container & Backdrop
+
+```css
+:root {
+ --xp-modal-z-index: 10001;
+ --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.5);
+}
+```
+
+### Dialog
+
+```css
+:root {
+ --xp-modal-dialog-bg: #ffffff;
+ --xp-modal-dialog-border-radius: 8px;
+ --xp-modal-dialog-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --xp-modal-dialog-padding: 24px;
+}
+```
+
+### Content
+
+```css
+:root {
+ /* Title */
+ --xp-modal-title-font-size: 20px;
+ --xp-modal-title-font-weight: 600;
+ --xp-modal-title-color: #111;
+ --xp-modal-title-margin-bottom: 16px;
+
+ /* Message */
+ --xp-modal-message-font-size: 14px;
+ --xp-modal-message-line-height: 1.5;
+ --xp-modal-message-color: #444;
+ --xp-modal-message-margin-bottom: 20px;
+
+ /* Close button */
+ --xp-modal-close-color: #666;
+ --xp-modal-close-hover-color: #111;
+}
+```
+
+### Buttons
+
+```css
+:root {
+ /* Primary button */
+ --xp-modal-button-primary-bg: #2563eb;
+ --xp-modal-button-primary-color: #ffffff;
+ --xp-modal-button-primary-hover-bg: #1d4ed8;
+
+ /* Secondary button */
+ --xp-modal-button-secondary-bg: #f3f4f6;
+ --xp-modal-button-secondary-color: #374151;
+ --xp-modal-button-secondary-hover-bg: #e5e7eb;
+ --xp-modal-button-secondary-border-color: #e5e7eb;
+
+ /* Link button */
+ --xp-modal-button-link-color: #2563eb;
+ --xp-modal-button-link-hover-bg: #f3f4f6;
+
+ /* Size */
+ --xp-modal-button-padding: 10px 20px;
+ --xp-modal-button-font-size: 14px;
+ --xp-modal-button-font-weight: 500;
+ --xp-modal-button-border-radius: 6px;
+ --xp-modal-buttons-gap: 8px;
+}
+```
+
+### Forms
+
+```css
+:root {
+ /* Form container */
+ --xp-form-gap: 16px;
+ --xp-form-field-gap: 8px;
+
+ /* Label */
+ --xp-form-label-font-size: 14px;
+ --xp-form-label-font-weight: 500;
+ --xp-form-label-color: #1f2937;
+ --xp-form-required-color: #ef4444;
+
+ /* Input fields */
+ --xp-input-bg: #ffffff;
+ --xp-input-border-color: #d1d5db;
+ --xp-input-border-radius: 6px;
+ --xp-input-padding: 10px 12px;
+ --xp-input-font-size: 14px;
+ --xp-input-color: #1f2937;
+ --xp-input-placeholder-color: #6b7280;
+
+ /* Focus state */
+ --xp-input-focus-border-color: #2563eb;
+ --xp-input-focus-ring-color: rgba(59, 130, 246, 0.25);
+
+ /* Error state */
+ --xp-input-error-border-color: #ef4444;
+ --xp-input-error-focus-border-color: #dc2626;
+ --xp-input-error-focus-ring-color: rgba(239, 68, 68, 0.25);
+ --xp-error-message-color: #ef4444;
+ --xp-error-message-font-size: 12px;
+
+ /* Submit button */
+ --xp-submit-button-bg-primary: #2563eb;
+ --xp-submit-button-color-primary: #ffffff;
+ --xp-submit-button-hover-bg-primary: #1d4ed8;
+ --xp-submit-button-loading-bg: #e5e7eb;
+ --xp-submit-button-loading-color: #6b7280;
+
+ /* Success/error states */
+ --xp-form-state-success-bg: #ecfdf5;
+ --xp-form-state-success-border: 1px solid #a7f3d0;
+ --xp-form-state-success-color: #065f46;
+
+ --xp-form-state-error-bg: #fef2f2;
+ --xp-form-state-error-border: 1px solid #fca5a5;
+ --xp-form-state-error-color: #991b1b;
+}
+```
+
+## Inline Variables
+
+### Close Button
+
+```css
+:root {
+ --xp-inline-close-color: #666;
+ --xp-inline-close-hover-color: #111;
+ --xp-inline-close-size: 32px;
+ --xp-inline-close-bg: transparent;
+ --xp-inline-close-hover-bg: rgba(0, 0, 0, 0.1);
+ --xp-inline-close-border-radius: 4px;
+}
+```
+
+### Buttons
+
+```css
+:root {
+ /* Primary button */
+ --xp-inline-button-primary-bg: #2563eb;
+ --xp-inline-button-primary-color: #ffffff;
+ --xp-inline-button-primary-hover-bg: #1d4ed8;
+
+ /* Secondary button */
+ --xp-inline-button-secondary-bg: #f3f4f6;
+ --xp-inline-button-secondary-color: #374151;
+ --xp-inline-button-secondary-hover-bg: #e5e7eb;
+ --xp-inline-button-secondary-border-color: #e5e7eb;
+
+ /* Link button */
+ --xp-inline-button-link-color: #2563eb;
+ --xp-inline-button-link-hover-bg: #f3f4f6;
+
+ /* Size */
+ --xp-inline-button-padding: 8px 16px;
+ --xp-inline-button-font-size: 14px;
+ --xp-inline-button-font-weight: 500;
+ --xp-inline-button-border-radius: 4px;
+ --xp-inline-buttons-gap: 8px;
+}
+```
+
+## Dark Mode
+
+All plugins include automatic dark mode support using `prefers-color-scheme`:
+
+```css
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Banner */
+ --xp-banner-bg: #1f2937;
+ --xp-banner-title-color: #f9fafb;
+ --xp-banner-message-color: #d1d5db;
+
+ /* Modal */
+ --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.7);
+ --xp-modal-dialog-bg: #1f2937;
+ --xp-modal-title-color: #f9fafb;
+ --xp-modal-message-color: #d1d5db;
+
+ /* Forms */
+ --xp-input-bg: #1f2937;
+ --xp-input-border-color: #374151;
+ --xp-input-color: #f9fafb;
+
+ /* Buttons */
+ --xp-modal-button-primary-bg: #3b82f6;
+ --xp-modal-button-secondary-bg: #374151;
+ --xp-modal-button-secondary-color: #f9fafb;
+ }
+}
+```
+
+## Complete Examples
+
+### Brand Colors
+
+```css
+:root {
+ /* Apply your brand colors across all experiences */
+ --xp-banner-button-primary-bg: #8b5cf6; /* Purple */
+ --xp-modal-button-primary-bg: #8b5cf6;
+ --xp-inline-button-primary-bg: #8b5cf6;
+
+ --xp-banner-button-primary-hover-bg: #7c3aed;
+ --xp-modal-button-primary-hover-bg: #7c3aed;
+ --xp-inline-button-primary-hover-bg: #7c3aed;
+}
+```
+
+### E-Commerce Theme
+
+```css
+:root {
+ /* High-contrast, conversion-focused */
+ --xp-banner-bg: #000000;
+ --xp-banner-title-color: #ffffff;
+ --xp-banner-message-color: #d1d5db;
+
+ --xp-banner-button-primary-bg: #ef4444; /* Red CTA */
+ --xp-banner-button-primary-color: #ffffff;
+ --xp-banner-button-primary-hover-bg: #dc2626;
+
+ --xp-banner-button-secondary-bg: transparent;
+ --xp-banner-button-secondary-color: #ffffff;
+ --xp-banner-button-secondary-border: 1px solid #ffffff;
+ --xp-banner-button-secondary-hover-bg: rgba(255, 255, 255, 0.1);
+}
+```
+
+### SaaS Theme
+
+```css
+:root {
+ /* Clean, professional */
+ --xp-modal-dialog-border-radius: 12px;
+ --xp-modal-dialog-padding: 32px;
+ --xp-modal-dialog-box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
+
+ --xp-modal-button-primary-bg: #0ea5e9; /* Sky blue */
+ --xp-modal-button-primary-hover-bg: #0284c7;
+ --xp-modal-button-border-radius: 8px;
+
+ --xp-input-border-radius: 8px;
+ --xp-input-padding: 12px 16px;
+ --xp-input-focus-border-color: #0ea5e9;
+ --xp-input-focus-ring-color: rgba(14, 165, 233, 0.25);
+}
+```
+
+### Minimalist Theme
+
+```css
+:root {
+ /* Subtle, understated */
+ --xp-banner-bg: #fafafa;
+ --xp-banner-border: none;
+ --xp-banner-shadow: none;
+ --xp-banner-padding: 12px 16px;
+
+ --xp-banner-title-font-size: 14px;
+ --xp-banner-message-font-size: 13px;
+
+ --xp-banner-button-primary-bg: #171717;
+ --xp-banner-button-primary-color: #ffffff;
+ --xp-banner-button-secondary-bg: transparent;
+ --xp-banner-button-secondary-color: #171717;
+ --xp-banner-button-border-radius: 4px;
+}
+```
+
+## Per-Experience Customization
+
+For experience-specific styling, use `className` or `style` props:
+
+### Using className
+
+```typescript
+experiences.register('sale', {
+ type: 'modal',
+ content: {
+ title: 'Flash Sale!',
+ message: '24 hours only.',
+ className: 'sale-modal', // Custom class
+ buttons: [...]
+ }
+});
+```
+
+```css
+/* Your CSS */
+.sale-modal {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.sale-modal .xp-modal__title {
+ font-size: 32px;
+ text-transform: uppercase;
+}
+```
+
+### Using Inline Styles
+
+```typescript
+{
+ content: {
+ message: 'Limited offer',
+ style: {
+ background: '#fef3c7',
+ border: '2px solid #f59e0b',
+ borderRadius: '12px'
+ }
+ }
+}
+```
+
+## CSS Class Reference
+
+### Banner
+
+- `.xp-banner` - Container
+- `.xp-banner__close` - Close button
+- `.xp-banner__title` - Title text
+- `.xp-banner__message` - Message text
+- `.xp-banner__buttons` - Button container
+- `.xp-banner__button` - Individual button
+- `.xp-banner__button--primary` - Primary variant
+- `.xp-banner__button--secondary` - Secondary variant
+- `.xp-banner__button--link` - Link variant
+
+### Modal
+
+- `.xp-modal` - Container
+- `.xp-modal__backdrop` - Backdrop overlay
+- `.xp-modal__dialog` - Dialog box
+- `.xp-modal__close` - Close button
+- `.xp-modal__hero-image` - Hero image (if present)
+- `.xp-modal__content` - Content wrapper
+- `.xp-modal__title` - Title text
+- `.xp-modal__message` - Message text
+- `.xp-modal__buttons` - Button container
+- `.xp-modal__button` - Individual button
+- `.xp-modal__form` - Form container
+- `.xp-form__field` - Form field wrapper
+- `.xp-form__label` - Field label
+- `.xp-form__input` - Input field
+- `.xp-form__error` - Error message
+- `.xp-form__submit` - Submit button
+
+### Inline
+
+- `.xp-inline` - Container
+- `.xp-inline__close` - Close button
+- `.xp-inline__buttons` - Button container
+- `.xp-inline__button` - Individual button
+
+## Integration with Frameworks
+
+### Tailwind CSS
+
+```typescript
+{
+ content: {
+ message: 'Flash Sale!',
+ className: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-2xl',
+ buttons: [{
+ text: 'Shop Now',
+ className: 'bg-white text-purple-600 hover:bg-gray-100 font-bold'
+ }]
+ }
+}
+```
+
+### CSS Modules
+
+```typescript
+import styles from './experiences.module.css';
+
+{
+ content: {
+ message: 'Special offer',
+ className: styles.promo,
+ buttons: [{
+ text: 'Learn More',
+ className: styles.button
+ }]
+ }
+}
+```
+
+### Styled Components
+
+```typescript
+import { createGlobalStyle } from 'styled-components';
+
+const GlobalStyles = createGlobalStyle`
+ :root {
+ --xp-modal-button-primary-bg: ${props => props.theme.colors.primary};
+ --xp-banner-bg: ${props => props.theme.colors.surface};
+ }
+`;
+```
+
+## Best Practices
+
+1. **Define once, apply everywhere** - Set global CSS variables for consistent branding
+2. **Use semantic naming** - CSS variables describe purpose, not appearance
+3. **Support dark mode** - Include `prefers-color-scheme` overrides
+4. **Test responsively** - Some variables affect mobile differently
+5. **Progressive enhancement** - Styles gracefully degrade if variables unsupported
+6. **Avoid `!important`** - CSS variables have high specificity already
+
+## Browser Support
+
+CSS custom properties are supported in:
+- Chrome 49+
+- Firefox 31+
+- Safari 9.1+
+- Edge 15+
+- iOS Safari 9.3+
+- Chrome Android 49+
+
+For older browsers, fallback values are automatically applied.
+
+## Next Steps
+
+- [Banner Plugin](/reference/plugins/banner) - Banner-specific examples
+- [Modal Plugin](/reference/plugins/modal) - Modal-specific examples
+- [Inline Plugin](/reference/plugins/inline) - Inline-specific examples
+- [Events](/reference/events) - Listen to experience events
+
diff --git a/docs/pages/reference/plugins/_meta.json b/docs/pages/reference/plugins/_meta.json
index 394ecff..c1b8a92 100644
--- a/docs/pages/reference/plugins/_meta.json
+++ b/docs/pages/reference/plugins/_meta.json
@@ -1,6 +1,8 @@
{
"index": "Overview",
"banner": "Banner",
+ "modal": "Modal",
+ "inline": "Inline",
"frequency": "Frequency",
"debug": "Debug",
"exit-intent": "Exit Intent",
diff --git a/docs/pages/reference/plugins/index.mdx b/docs/pages/reference/plugins/index.mdx
index 31dfdbc..eac162e 100644
--- a/docs/pages/reference/plugins/index.mdx
+++ b/docs/pages/reference/plugins/index.mdx
@@ -4,14 +4,18 @@ The Experience SDK uses a plugin architecture powered by [@lytics/sdk-kit](https
## Official Plugins
-### Rendering Plugins
+### Layout Plugins
-- **[Banner](/reference/plugins/banner)** - Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout
+Layout plugins render experiences in the DOM with different presentation styles.
+
+- **[Banner](/reference/plugins/banner)** - Top/bottom notification bars with push-down support
+- **[Modal](/reference/plugins/modal)** - Overlay dialogs with forms, hero images, and animations
+- **[Inline](/reference/plugins/inline)** - In-content experiences using CSS selectors
### Utility Plugins
-- **[Frequency](/reference/plugins/frequency)** - Manages impression tracking and frequency capping using persistent storage
-- **[Debug](/reference/plugins/debug)** - Provides console logging and window event emission for debugging
+- **[Frequency](/reference/plugins/frequency)** - Impression tracking and frequency capping
+- **[Debug](/reference/plugins/debug)** - Console logging and window event emission
### Display Condition Plugins
diff --git a/docs/pages/reference/plugins/inline.mdx b/docs/pages/reference/plugins/inline.mdx
new file mode 100644
index 0000000..523478c
--- /dev/null
+++ b/docs/pages/reference/plugins/inline.mdx
@@ -0,0 +1,489 @@
+# Inline Plugin
+
+Embed experiences directly within page content using CSS selectors. Perfect for in-content promotions, contextual CTAs, and dynamic content replacement.
+
+## Features
+
+- **5 insertion methods** - `replace`, `append`, `prepend`, `before`, `after`
+- **Dismissal with persistence** - Remember dismissed experiences
+- **Multi-instance support** - Multiple inline experiences per page
+- **HTML sanitization** - XSS protection built-in
+- **Custom styling** - `className` and `style` props
+- **CSS Variables** - Full theming support
+
+## Basic Usage
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+experiences.register('promo-banner', {
+ type: 'inline',
+ content: {
+ selector: '#content-sidebar',
+ message: 'Special Offer! Get 20% off with code SAVE20.
',
+ buttons: [
+ { text: 'Shop Now', variant: 'primary', url: '/shop' }
+ ]
+ }
+});
+```
+
+## Insertion Methods
+
+### Replace (Default)
+
+Replaces the target element's content:
+
+```typescript
+{
+ selector: '#announcement',
+ position: 'replace', // or omit (default)
+ message: 'New content here
'
+}
+```
+
+**Before:**
+```html
+
+```
+
+**After:**
+```html
+
+```
+
+### Append
+
+Adds content to the end of the target element:
+
+```typescript
+{
+ selector: '.article-content',
+ position: 'append',
+ message: 'Related: Check out our complete guide.
'
+}
+```
+
+### Prepend
+
+Adds content to the beginning of the target element:
+
+```typescript
+{
+ selector: '.product-list',
+ position: 'prepend',
+ message: '๐ฅ Trending: Most popular items this week.
'
+}
+```
+
+### Before
+
+Inserts content as a sibling before the target element:
+
+```typescript
+{
+ selector: '.checkout-button',
+ position: 'before',
+ message: 'โ Free shipping on orders over $50
'
+}
+```
+
+### After
+
+Inserts content as a sibling after the target element:
+
+```typescript
+{
+ selector: '.blog-post',
+ position: 'after',
+ message: 'Enjoyed this article? Subscribe for more.
'
+}
+```
+
+## Dismissal
+
+Allow users to close inline experiences:
+
+```typescript
+{
+ selector: '#sidebar',
+ message: 'Limited time offer!
',
+ dismissable: true, // Show close button
+ persist: true // Remember dismissal in localStorage
+}
+```
+
+When `persist: true`, the inline experience won't show again after dismissal (stored in `localStorage`).
+
+## Buttons
+
+Add interactive buttons to inline content:
+
+```typescript
+{
+ selector: '#product-details',
+ message: 'Want expert advice?
',
+ buttons: [
+ {
+ text: 'Chat with Us',
+ variant: 'primary',
+ action: 'chat',
+ dismiss: false // Keep inline visible
+ },
+ {
+ text: 'No Thanks',
+ variant: 'link',
+ dismiss: true // Close on click
+ }
+ ]
+}
+```
+
+## Custom Styling
+
+### Using className
+
+```typescript
+{
+ selector: '#hero',
+ message: 'Flash Sale!
',
+ className: 'sale-banner',
+ buttons: [...]
+}
+```
+
+```css
+/* Your CSS */
+.sale-banner {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 20px;
+ border-radius: 8px;
+}
+```
+
+### Using Inline Styles
+
+```typescript
+{
+ selector: '#sidebar',
+ message: 'New feature!
',
+ style: {
+ background: '#f0f9ff',
+ border: '2px solid #0ea5e9',
+ padding: '16px',
+ borderRadius: '8px'
+ }
+}
+```
+
+## Events
+
+Listen to inline experience events:
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+// Inline shown
+experiences.on('experiences:shown', (event) => {
+ if (event.type === 'inline') {
+ console.log('Inline shown:', event.experienceId);
+ console.log('Selector:', event.selector);
+ console.log('Position:', event.position);
+ }
+});
+
+// Button clicked
+experiences.on('experiences:action', (event) => {
+ console.log('Button clicked:', event.action);
+ console.log('Button metadata:', event.metadata);
+});
+
+// Inline dismissed
+experiences.on('experiences:dismissed', (event) => {
+ console.log('Inline dismissed:', event.experienceId);
+});
+
+// Error (selector not found)
+experiences.on('experiences:inline:error', (event) => {
+ console.error('Selector not found:', event.selector);
+});
+```
+
+## API Methods
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+// Show an inline programmatically
+experiences.inline.show(experience);
+
+// Remove a specific inline
+experiences.inline.remove('promo-banner');
+
+// Check if any inline is showing
+experiences.inline.isShowing(); // true/false
+
+// Check if specific inline is showing
+experiences.inline.isShowing('promo-banner'); // true/false
+```
+
+## Error Handling
+
+If the target selector is not found, the plugin emits an error event:
+
+```typescript
+experiences.on('experiences:inline:error', (event) => {
+ console.error('Selector not found:', event.selector);
+ // event.experienceId, event.error ('selector-not-found')
+});
+```
+
+### Retry Logic
+
+Enable automatic retries for dynamic content:
+
+```typescript
+import { createInstance } from '@prosdevlab/experience-sdk';
+
+const experiences = createInstance({
+ inline: {
+ retry: true, // Retry if selector not found
+ retryTimeout: 5000 // Retry for up to 5 seconds
+ }
+});
+```
+
+## CSS Variables
+
+Customize inline appearance:
+
+```css
+:root {
+ /* Close button */
+ --xp-inline-close-color: #666;
+ --xp-inline-close-hover-color: #111;
+ --xp-inline-close-size: 32px;
+ --xp-inline-close-bg: transparent;
+ --xp-inline-close-hover-bg: rgba(0, 0, 0, 0.1);
+ --xp-inline-close-border-radius: 4px;
+
+ /* Buttons */
+ --xp-inline-button-padding: 8px 16px;
+ --xp-inline-button-font-size: 14px;
+ --xp-inline-button-border-radius: 4px;
+ --xp-inline-button-primary-bg: #2563eb;
+ --xp-inline-button-primary-color: #ffffff;
+ --xp-inline-button-secondary-bg: #f3f4f6;
+ --xp-inline-button-secondary-color: #374151;
+ --xp-inline-buttons-gap: 8px;
+}
+
+/* Dark mode */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --xp-inline-close-color: #9ca3af;
+ --xp-inline-close-hover-color: #f9fafb;
+ --xp-inline-button-primary-bg: #3b82f6;
+ --xp-inline-button-secondary-bg: #374151;
+ --xp-inline-button-secondary-color: #f9fafb;
+ }
+}
+```
+
+## HTML Sanitization
+
+All HTML content is automatically sanitized to prevent XSS attacks.
+
+**Allowed tags:**
+- `strong`, `em`, `b`, `i` - Text formatting
+- `a` - Links (with `href` validation)
+- `p`, `span`, `br` - Structure
+
+**Allowed attributes:**
+- `href` (links only, safe URLs)
+- `class`, `style`
+
+**Dangerous tags removed:**
+- `script`, `iframe`, `object`, `embed`
+- `style` tags (use `style` prop instead)
+- Event handlers (`onclick`, `onerror`, etc.)
+
+```typescript
+// Input
+{
+ message: 'Safe content
'
+}
+
+// Output (script removed)
+Safe content
+```
+
+## Examples
+
+### In-Content CTA
+
+```typescript
+experiences.register('article-cta', {
+ type: 'inline',
+ content: {
+ selector: '.article-content',
+ position: 'after',
+ message: 'Want to learn more? Download our free guide.
',
+ buttons: [
+ { text: 'Download Guide', variant: 'primary', url: '/guide.pdf' }
+ ],
+ className: 'article-cta',
+ dismissable: true,
+ persist: true
+ },
+ targeting: {
+ url: { contains: '/blog/' }
+ }
+});
+```
+
+### Product Recommendation
+
+```typescript
+experiences.register('related-product', {
+ type: 'inline',
+ content: {
+ selector: '.product-sidebar',
+ position: 'prepend',
+ message: `
+ You might also like:
+ Premium Wireless Headphones - 20% off
+ `,
+ buttons: [
+ { text: 'View Deal', variant: 'primary', url: '/products/headphones' },
+ { text: 'Dismiss', variant: 'link', dismiss: true }
+ ],
+ style: {
+ background: '#fef3c7',
+ padding: '16px',
+ borderRadius: '8px',
+ marginBottom: '16px'
+ }
+ }
+});
+```
+
+### Survey Prompt
+
+```typescript
+experiences.register('feedback-survey', {
+ type: 'inline',
+ content: {
+ selector: '#main-content',
+ position: 'after',
+ message: 'How was your experience today?
',
+ buttons: [
+ { text: '๐ Great', variant: 'primary', action: 'positive' },
+ { text: '๐ Okay', variant: 'secondary', action: 'neutral' },
+ { text: '๐ Poor', variant: 'secondary', action: 'negative' }
+ ],
+ dismissable: true,
+ persist: true
+ },
+ targeting: {
+ custom: (context) => context.pageVisits.sessionCount === 3
+ }
+});
+```
+
+### Exit Intent Offer
+
+```typescript
+experiences.register('exit-offer', {
+ type: 'inline',
+ content: {
+ selector: 'header',
+ position: 'after',
+ message: '๐ Wait! Get 15% off before you go.
',
+ buttons: [
+ { text: 'Claim Discount', variant: 'primary', url: '/checkout?code=EXIT15' },
+ { text: 'No Thanks', variant: 'link', dismiss: true }
+ ],
+ style: {
+ background: '#dc2626',
+ color: 'white',
+ padding: '12px 20px',
+ textAlign: 'center'
+ }
+ },
+ targeting: {
+ custom: (context) => context.triggers.exitIntent
+ }
+});
+```
+
+### Dynamic Content Replacement
+
+```typescript
+experiences.register('featured-promo', {
+ type: 'inline',
+ content: {
+ selector: '#hero-banner',
+ position: 'replace',
+ message: `
+
+
๐ Black Friday Sale
+
Up to 70% off everything. Today only!
+
+ `,
+ buttons: [
+ { text: 'Shop Now', variant: 'primary', url: '/sale' }
+ ]
+ },
+ targeting: {
+ url: { equals: '/' }
+ }
+});
+```
+
+## Use Cases
+
+- **Content upgrades** - Offer downloads within blog posts
+- **Related products** - Cross-sell in product pages
+- **Survey prompts** - Contextual feedback requests
+- **Announcements** - Temporary notifications in key areas
+- **CTAs** - Strategic calls-to-action in content
+- **Promotions** - Time-sensitive offers
+- **Onboarding tips** - Contextual help for new users
+
+## Best Practices
+
+1. **Use semantic selectors** - IDs and classes are more reliable than tag names
+2. **Test responsiveness** - Ensure inline content works on mobile
+3. **Limit frequency** - Use the frequency plugin to avoid overwhelming users
+4. **Provide value** - Make inline content relevant to the page context
+5. **Allow dismissal** - Let users close promotional content
+6. **Monitor errors** - Listen to `experiences:inline:error` events
+
+## Accessibility
+
+- **Semantic HTML** - Use proper tags in your message content
+- **Close button** - Labeled with `aria-label="Close"`
+- **Keyboard support** - Close button is keyboard accessible
+- **Screen readers** - Content is announced naturally
+
+## Browser Support
+
+- Chrome, Firefox, Safari, Edge (last 2 versions)
+- Mobile: iOS Safari, Chrome Android
+- Requires ES2024+ support (or transpilation)
+
+## Next Steps
+
+- [Modal Plugin](/reference/plugins/modal) - Overlay dialogs
+- [Banner Plugin](/reference/plugins/banner) - Top/bottom bars
+- [Display Conditions](/reference/plugins/exit-intent) - Trigger rules
+- [Events Reference](/reference/events) - Full event documentation
+
diff --git a/docs/pages/reference/plugins/modal.mdx b/docs/pages/reference/plugins/modal.mdx
new file mode 100644
index 0000000..2042585
--- /dev/null
+++ b/docs/pages/reference/plugins/modal.mdx
@@ -0,0 +1,453 @@
+# Modal Plugin
+
+Display experiences in overlay dialogs with backdrop, focus management, and rich content.
+
+## Features
+
+- **Multiple sizes** - `sm`, `md`, `lg`, `fullscreen`, or `auto`
+- **Mobile-optimized** - Automatic fullscreen on mobile for `lg` size
+- **Hero images** - Add visual impact with top images
+- **Animations** - `fade`, `slide-up`, or none
+- **Positioning** - `center` or `bottom` placement
+- **Form support** - Built-in email capture with validation
+- **Accessibility** - Focus trap, ARIA attributes, keyboard support (Escape)
+- **Dismissal** - Configurable close button and backdrop dismiss
+- **CSS Variables** - Full theming support
+
+## Basic Usage
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+experiences.register('welcome-modal', {
+ type: 'modal',
+ content: {
+ title: 'Welcome!',
+ message: 'Get 20% off your first purchase.',
+ buttons: [
+ { text: 'Shop Now', variant: 'primary', url: '/shop' },
+ { text: 'Maybe Later', variant: 'secondary', dismiss: true }
+ ]
+ }
+});
+```
+
+## Content Options
+
+### Basic Modal
+
+```typescript
+{
+ type: 'modal',
+ content: {
+ // Text content
+ title: 'Flash Sale!',
+ message: 'Limited time offer - 50% off everything.',
+
+ // Buttons
+ buttons: [
+ {
+ text: 'Shop Now',
+ variant: 'primary',
+ url: '/shop',
+ dismiss: true // Close modal on click
+ },
+ {
+ text: 'Dismiss',
+ variant: 'link',
+ dismiss: true
+ }
+ ],
+
+ // Size and behavior
+ size: 'md', // sm | md | lg | fullscreen | auto
+ position: 'center', // center | bottom
+ animation: 'fade', // fade | slide-up | none
+ dismissable: true, // Show close button
+ backdropDismiss: true, // Click backdrop to close
+
+ // Hero image (optional)
+ image: {
+ src: '/images/hero.jpg',
+ alt: 'Flash Sale',
+ maxHeight: 300
+ },
+
+ // Custom styling
+ className: 'custom-modal',
+ style: { borderRadius: '16px' }
+ }
+}
+```
+
+### Modal with Form
+
+Capture emails, phone numbers, or custom data with built-in validation.
+
+```typescript
+{
+ type: 'modal',
+ content: {
+ title: 'Stay Updated',
+ message: 'Subscribe to our newsletter for exclusive offers.',
+
+ form: {
+ fields: [
+ {
+ name: 'email',
+ label: 'Email Address',
+ type: 'email',
+ placeholder: 'you@example.com',
+ required: true,
+ errorMessage: 'Please enter a valid email'
+ }
+ ],
+
+ submitButton: {
+ text: 'Subscribe',
+ variant: 'primary',
+ loadingText: 'Subscribing...'
+ },
+
+ // Optional: Custom validation
+ validate: (data) => {
+ if (data.email.includes('test')) {
+ return {
+ valid: false,
+ errors: { email: 'Test emails not allowed' }
+ };
+ }
+ return { valid: true };
+ },
+
+ // Success state
+ successState: {
+ title: 'โ Subscribed!',
+ message: 'Check your inbox for a confirmation email.',
+ buttons: [
+ { text: 'Continue', variant: 'primary', dismiss: true }
+ ]
+ },
+
+ // Error state
+ errorState: {
+ title: 'โ Something went wrong',
+ message: 'Please try again later.',
+ buttons: [
+ { text: 'Try Again', variant: 'primary', action: 'reset' },
+ { text: 'Cancel', variant: 'secondary', dismiss: true }
+ ]
+ }
+ }
+ }
+}
+```
+
+## Form Field Types
+
+```typescript
+{
+ fields: [
+ { name: 'email', type: 'email', required: true },
+ { name: 'name', type: 'text', placeholder: 'Your name' },
+ { name: 'phone', type: 'tel', pattern: '^[0-9]{10}$' },
+ { name: 'website', type: 'url' },
+ { name: 'age', type: 'number' },
+ {
+ name: 'comments',
+ type: 'textarea',
+ placeholder: 'Tell us more...'
+ }
+ ]
+}
+```
+
+## Size Variants
+
+```typescript
+// Small modal (max-width: 400px)
+{ size: 'sm' }
+
+// Medium modal (max-width: 600px) - Default
+{ size: 'md' }
+
+// Large modal (max-width: 800px)
+{ size: 'lg' }
+
+// Full viewport (100vw x 100vh)
+{ size: 'fullscreen' }
+
+// Auto-size based on content
+{ size: 'auto' }
+```
+
+**Mobile behavior:** `lg` size automatically becomes fullscreen on mobile (`< 768px`).
+
+## Animations
+
+```typescript
+// Fade in (default)
+{ animation: 'fade' }
+
+// Slide up from bottom
+{ animation: 'slide-up' }
+
+// No animation
+{ animation: 'none' }
+```
+
+## Hero Images
+
+Add visual impact to modals:
+
+```typescript
+{
+ image: {
+ src: '/promo-banner.jpg',
+ alt: 'Summer Sale',
+ maxHeight: 250 // Max height in pixels
+ }
+}
+```
+
+## Events
+
+Listen to modal lifecycle events:
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+// Modal shown
+experiences.on('experiences:shown', (event) => {
+ if (event.type === 'modal') {
+ console.log('Modal shown:', event.experienceId);
+ }
+});
+
+// Button clicked
+experiences.on('experiences:action', (event) => {
+ console.log('Button clicked:', event.action, event.text);
+});
+
+// Modal dismissed
+experiences.on('experiences:dismissed', (event) => {
+ console.log('Modal dismissed:', event.experienceId);
+});
+
+// Form events
+experiences.on('experiences:modal:form:change', (event) => {
+ console.log('Form field changed:', event.field, event.value);
+});
+
+experiences.on('experiences:modal:form:validation', (event) => {
+ if (!event.valid) {
+ console.log('Validation errors:', event.errors);
+ }
+});
+
+experiences.on('experiences:modal:form:submit', (event) => {
+ console.log('Form submitted:', event.data);
+
+ // Submit to your backend
+ fetch('/api/subscribe', {
+ method: 'POST',
+ body: JSON.stringify(event.data)
+ })
+ .then(() => {
+ // Show success state
+ experiences.modal.showFormState(event.experienceId, 'success');
+ })
+ .catch(() => {
+ // Show error state
+ experiences.modal.showFormState(event.experienceId, 'error');
+ });
+});
+```
+
+## API Methods
+
+```typescript
+import { experiences } from '@prosdevlab/experience-sdk';
+
+// Show a modal programmatically
+experiences.modal.show(experience);
+
+// Hide a specific modal
+experiences.modal.hide('welcome-modal');
+
+// Hide all modals
+experiences.modal.hideAll();
+
+// Check if any modal is showing
+experiences.modal.isShowing(); // true/false
+
+// Check if specific modal is showing
+experiences.modal.isShowing('welcome-modal'); // true/false
+
+// Form methods
+experiences.modal.showFormState('newsletter', 'success');
+experiences.modal.resetForm('newsletter');
+const formData = experiences.modal.getFormData('newsletter');
+```
+
+## CSS Variables
+
+Customize modal appearance:
+
+```css
+:root {
+ /* Modal container */
+ --xp-modal-z-index: 10001;
+
+ /* Backdrop */
+ --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.5);
+
+ /* Dialog */
+ --xp-modal-dialog-bg: #ffffff;
+ --xp-modal-dialog-border-radius: 8px;
+ --xp-modal-dialog-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --xp-modal-dialog-padding: 24px;
+
+ /* Close button */
+ --xp-modal-close-color: #666;
+ --xp-modal-close-hover-color: #111;
+
+ /* Title */
+ --xp-modal-title-font-size: 20px;
+ --xp-modal-title-font-weight: 600;
+ --xp-modal-title-color: #111;
+
+ /* Message */
+ --xp-modal-message-font-size: 14px;
+ --xp-modal-message-color: #444;
+
+ /* Buttons */
+ --xp-modal-button-primary-bg: #2563eb;
+ --xp-modal-button-primary-color: #ffffff;
+ --xp-modal-button-secondary-bg: #f3f4f6;
+ --xp-modal-button-secondary-color: #374151;
+}
+
+/* Dark mode support */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --xp-modal-dialog-bg: #1f2937;
+ --xp-modal-title-color: #f9fafb;
+ --xp-modal-message-color: #d1d5db;
+ }
+}
+```
+
+## Examples
+
+### Email Signup Modal
+
+```typescript
+experiences.register('newsletter', {
+ type: 'modal',
+ content: {
+ title: 'Join Our Newsletter',
+ message: 'Get 10% off your first order.',
+ size: 'sm',
+ animation: 'slide-up',
+
+ form: {
+ fields: [
+ {
+ name: 'email',
+ type: 'email',
+ placeholder: 'you@example.com',
+ required: true
+ }
+ ],
+ submitButton: {
+ text: 'Get Discount',
+ variant: 'primary'
+ },
+ successState: {
+ title: 'โ Check Your Inbox',
+ message: 'Your discount code is on its way!'
+ }
+ }
+ },
+ targeting: {
+ custom: (context) => context.triggers.exitIntent
+ }
+});
+```
+
+### Product Launch Modal
+
+```typescript
+experiences.register('product-launch', {
+ type: 'modal',
+ content: {
+ title: 'Introducing Pro Plan',
+ message: 'Advanced features for power users.',
+ size: 'lg',
+
+ image: {
+ src: '/product-hero.jpg',
+ alt: 'Pro Plan',
+ maxHeight: 300
+ },
+
+ buttons: [
+ { text: 'Learn More', variant: 'primary', url: '/pro' },
+ { text: 'Remind Me Later', variant: 'link', dismiss: true }
+ ]
+ }
+});
+```
+
+### Cookie Consent Modal
+
+```typescript
+experiences.register('cookie-consent', {
+ type: 'modal',
+ content: {
+ title: 'Cookie Settings',
+ message: 'We use cookies to improve your experience.',
+ size: 'md',
+ position: 'bottom',
+ dismissable: false, // Require user action
+ backdropDismiss: false,
+
+ buttons: [
+ {
+ text: 'Accept All',
+ variant: 'primary',
+ action: 'accept',
+ dismiss: true
+ },
+ {
+ text: 'Customize',
+ variant: 'secondary',
+ url: '/cookies'
+ }
+ ]
+ }
+});
+```
+
+## Accessibility
+
+The modal plugin follows WAI-ARIA best practices:
+
+- **Focus management** - Traps focus within modal, restores on close
+- **Keyboard support** - Escape key closes modal (if dismissable)
+- **ARIA attributes** - `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
+- **Screen readers** - Proper labeling and announcements
+
+## Browser Support
+
+- Chrome, Firefox, Safari, Edge (last 2 versions)
+- Mobile: iOS Safari, Chrome Android
+- Requires ES2024+ support (or transpilation)
+
+## Next Steps
+
+- [Exit Intent Plugin](/reference/plugins/exit-intent) - Trigger modals on exit
+- [Frequency Plugin](/reference/plugins/frequency) - Cap modal impressions
+- [Events Reference](/reference/events) - Full event documentation
+
diff --git a/package.json b/package.json
index 5eddd55..ed9a28a 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,8 @@
"lint": "turbo lint",
"test": "vitest run",
"test:watch": "vitest",
+ "test:browser": "vitest --config vitest.browser.config.ts",
+ "test:browser:ui": "vitest --config vitest.browser.config.ts --ui",
"clean": "turbo clean && rm -rf node_modules",
"format": "turbo format",
"typecheck": "turbo typecheck",
@@ -40,9 +42,13 @@
"@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^20.2.0",
"@tsconfig/node-lts": "^24.0.0",
+ "@vitest/browser": "^4.0.16",
+ "@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
+ "happy-dom": "^20.0.11",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
+ "playwright": "^1.57.0",
"tsup": "^8.5.1",
"turbo": "^2.7.2",
"typescript": "^5.9.3",
diff --git a/packages/core/README.md b/packages/core/README.md
index d9e1631..e19840f 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -2,17 +2,20 @@
A lightweight, explainable client-side experience runtime built on [@lytics/sdk-kit](https://github.com/lytics/sdk-kit).
-**Size:** 6.9 KB gzipped (includes core + 3 plugins)
+**Size:** 13.4 KB gzipped core + 12.7 KB gzipped plugins = 26.1 KB total
## Features
- **Explainability-First** - Every decision includes structured reasons
- **Plugin-Based** - Built on sdk-kit's powerful plugin system
-- **Multiple Buttons** - Primary, secondary, and link variants
-- **Frequency Capping** - Control impression limits per session/day/week
+- **Multiple Layouts** - Banners, modals, and inline experiences
+- **Form Support** - Email capture with built-in validation
+- **Display Conditions** - Exit intent, scroll depth, time delays, page visits
+- **Frequency Capping** - Control impression limits per session/lifetime
- **Responsive Layout** - Automatically adapts to mobile/desktop
- **Script Tag Ready** - Works without build tools
- **Type-Safe** - Full TypeScript support
+- **CSS Variables** - Full theming support
## Installation
@@ -41,18 +44,41 @@ const experiences = createInstance({ debug: true });
// Initialize
await experiences.init();
-// Register a banner
+// Register a modal with form
+experiences.register('newsletter', {
+ type: 'modal',
+ targeting: {
+ custom: (context) => context.triggers.exitIntent
+ },
+ content: {
+ title: 'Before You Go!',
+ message: 'Subscribe for 10% off your first order.',
+ size: 'sm',
+ form: {
+ fields: [
+ { name: 'email', type: 'email', required: true, placeholder: 'you@example.com' }
+ ],
+ submitButton: { text: 'Get Discount', variant: 'primary' },
+ successState: {
+ title: 'โ Check Your Inbox',
+ message: 'Your discount code is on the way!'
+ }
+ }
+ }
+});
+
+// Or register a banner
experiences.register('welcome', {
type: 'banner',
targeting: {
url: { contains: '/' }
},
content: {
- title: 'Welcome!',
- message: 'Thanks for visiting.',
+ message: 'Welcome! Get 20% off today.',
buttons: [
- { text: 'Get Started', url: '/start', variant: 'primary' }
- ]
+ { text: 'Shop Now', url: '/shop', variant: 'primary' }
+ ],
+ position: 'top'
},
frequency: {
max: 3,
@@ -128,9 +154,20 @@ experiences.on('experiences:dismissed', ({ experienceId }) => {
## Included Plugins
-This package includes three official plugins:
+This package auto-registers the following official plugins:
+
+### Layout Plugins
+- **Banner Plugin** - Top/bottom notification bars
+- **Modal Plugin** - Overlay dialogs with forms
+- **Inline Plugin** - In-content experiences
+
+### Display Conditions
+- **Exit Intent** - Detect when users are leaving
+- **Scroll Depth** - Track scroll engagement
+- **Page Visits** - Session and lifetime counters
+- **Time Delay** - Show after time elapsed
-- **Banner Plugin** - DOM rendering with responsive layout
+### Utility Plugins
- **Frequency Plugin** - Impression tracking and capping
- **Debug Plugin** - Logging and window events
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c562411..2747b05 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -15,19 +15,22 @@ export {
evaluateExperience,
evaluateUrlRule,
} from './runtime';
-
// Export singleton API
+// Default export for IIFE builds (script tag)
+// This is what gets exposed as window.experiences
export {
createInstance,
destroy,
evaluate,
evaluateAll,
+ experiences, // Named exportexperiences as default,
explain,
getState,
init,
on,
register,
} from './singleton';
+
// Export all types
export type {
BannerContent,
diff --git a/packages/core/src/runtime.test.ts b/packages/core/src/runtime.test.ts
index 395a32f..852ff47 100644
--- a/packages/core/src/runtime.test.ts
+++ b/packages/core/src/runtime.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { ExperienceRuntime, evaluateUrlRule } from './runtime';
+import { ExperienceRuntime, evaluateExperience, evaluateUrlRule } from './runtime';
describe('ExperienceRuntime', () => {
let runtime: ExperienceRuntime;
@@ -447,6 +447,114 @@ describe('ExperienceRuntime', () => {
});
});
+ describe('display trigger evaluation', () => {
+ it('should match experience when scrollDepth threshold is reached', () => {
+ const experience = {
+ id: 'scroll-test',
+ type: 'inline' as const,
+ content: {},
+ targeting: {},
+ display: {
+ trigger: 'scrollDepth',
+ triggerData: { threshold: 50 },
+ },
+ };
+
+ const context = {
+ url: 'https://example.com',
+ timestamp: Date.now(),
+ triggers: {
+ scrollDepth: {
+ triggered: true,
+ threshold: 50,
+ percent: 50.5,
+ },
+ },
+ };
+
+ const result = evaluateExperience(experience, context);
+ expect(result.matched).toBe(true);
+ expect(result.reasons).toContain('Scroll depth threshold (50%) reached');
+ });
+
+ it('should not match experience when scrollDepth threshold does not match', () => {
+ const experience = {
+ id: 'scroll-test',
+ type: 'inline' as const,
+ content: {},
+ targeting: {},
+ display: {
+ trigger: 'scrollDepth',
+ triggerData: { threshold: 50 },
+ },
+ };
+
+ const context = {
+ url: 'https://example.com',
+ timestamp: Date.now(),
+ triggers: {
+ scrollDepth: {
+ triggered: true,
+ threshold: 25, // Different threshold
+ percent: 25.5,
+ },
+ },
+ };
+
+ const result = evaluateExperience(experience, context);
+ expect(result.matched).toBe(false);
+ expect(result.reasons).toContain('Scroll depth threshold mismatch (expected 50%, got 25%)');
+ });
+
+ it('should not match experience when trigger has not fired', () => {
+ const experience = {
+ id: 'scroll-test',
+ type: 'inline' as const,
+ content: {},
+ targeting: {},
+ display: {
+ trigger: 'exitIntent',
+ },
+ };
+
+ const context = {
+ url: 'https://example.com',
+ timestamp: Date.now(),
+ triggers: {},
+ };
+
+ const result = evaluateExperience(experience, context);
+ expect(result.matched).toBe(false);
+ expect(result.reasons).toContain('Waiting for exitIntent trigger');
+ });
+
+ it('should match non-scrollDepth triggers when triggered', () => {
+ const experience = {
+ id: 'exit-test',
+ type: 'modal' as const,
+ content: {},
+ targeting: {},
+ display: {
+ trigger: 'exitIntent',
+ },
+ };
+
+ const context = {
+ url: 'https://example.com',
+ timestamp: Date.now(),
+ triggers: {
+ exitIntent: {
+ triggered: true,
+ },
+ },
+ };
+
+ const result = evaluateExperience(experience, context);
+ expect(result.matched).toBe(true);
+ expect(result.reasons).toContain('exitIntent trigger fired');
+ });
+ });
+
describe('frequency targeting', () => {
it('should track frequency rule in trace', async () => {
await runtime.init();
@@ -622,7 +730,7 @@ describe('ExperienceRuntime', () => {
expect(decisions2.find((d) => d.experienceId === 'capped')?.show).toBe(false);
});
- it('should emit experiences:evaluated event with array', () => {
+ it('should emit experiences:evaluated event for each matched experience', () => {
const handler = vi.fn();
runtime.on('experiences:evaluated', handler);
@@ -640,18 +748,20 @@ describe('ExperienceRuntime', () => {
runtime.evaluateAll();
- expect(handler).toHaveBeenCalledOnce();
- expect(handler).toHaveBeenCalledWith(
- expect.arrayContaining([
- expect.objectContaining({
- decision: expect.objectContaining({ experienceId: 'banner1' }),
- experience: expect.objectContaining({ id: 'banner1' }),
- }),
- expect.objectContaining({
- decision: expect.objectContaining({ experienceId: 'banner2' }),
- experience: expect.objectContaining({ id: 'banner2' }),
- }),
- ])
+ expect(handler).toHaveBeenCalledTimes(2);
+ expect(handler).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ decision: expect.objectContaining({ experienceId: 'banner1' }),
+ experience: expect.objectContaining({ id: 'banner1' }),
+ })
+ );
+ expect(handler).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ decision: expect.objectContaining({ experienceId: 'banner2' }),
+ experience: expect.objectContaining({ id: 'banner2' }),
+ })
);
});
@@ -674,9 +784,9 @@ describe('ExperienceRuntime', () => {
runtime.evaluateAll({ url: 'https://example.com/' });
expect(handler).toHaveBeenCalledOnce();
- const emittedDecisions = handler.mock.calls[0][0];
- expect(emittedDecisions).toHaveLength(1);
- expect(emittedDecisions[0].decision.experienceId).toBe('match');
+ const emittedData = handler.mock.calls[0][0];
+ expect(emittedData.decision.experienceId).toBe('match');
+ expect(emittedData.experience.id).toBe('match');
});
it('should return empty array when no experiences registered', () => {
diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts
index 998057d..06d0b51 100644
--- a/packages/core/src/runtime.ts
+++ b/packages/core/src/runtime.ts
@@ -5,6 +5,8 @@ import {
debugPlugin,
exitIntentPlugin,
frequencyPlugin,
+ inlinePlugin,
+ modalPlugin,
pageVisitsPlugin,
scrollDepthPlugin,
timeDelayPlugin,
@@ -31,7 +33,7 @@ import type {
* - Explainability-first (every decision has reasons)
*/
export class ExperienceRuntime {
- private sdk: SDK;
+ public sdk: SDK; // Public for plugin API access via Proxy (readonly would prevent reinit)
private experiences: Map = new Map();
private decisions: Decision[] = [];
private initialized = false;
@@ -53,6 +55,8 @@ export class ExperienceRuntime {
this.sdk.use(scrollDepthPlugin);
this.sdk.use(pageVisitsPlugin);
this.sdk.use(timeDelayPlugin);
+ this.sdk.use(modalPlugin);
+ this.sdk.use(inlinePlugin);
this.sdk.use(bannerPlugin);
// Listen for trigger events from display condition plugins
@@ -63,23 +67,44 @@ export class ExperienceRuntime {
* Setup listeners for trigger:* events
* This enables event-driven display conditions
*/
+ /**
+ * Setup listeners for trigger:* events
+ * This enables event-driven display conditions
+ *
+ * Note: sdk-kit's emitter passes only the event payload to wildcard listeners,
+ * not the event name. Display condition plugins must include trigger metadata
+ * in their payload (e.g., { trigger: 'exitIntent', ...data })
+ */
private setupTriggerListeners(): void {
- // Listen for all trigger:* events using wildcard
- // When a trigger fires, update context and re-evaluate
- this.sdk.on('trigger:*', (eventName: string, data: any) => {
- const triggerName = eventName.replace('trigger:', '');
-
- // Update trigger context
- this.triggerContext.triggers = this.triggerContext.triggers || {};
- this.triggerContext.triggers[triggerName] = {
- triggered: true,
- timestamp: Date.now(),
- ...data, // Merge trigger-specific data
- };
-
- // Re-evaluate all experiences with updated context
- this.evaluate(this.triggerContext);
- });
+ // Listen for specific trigger events
+ // We can't use 'trigger:*' wildcard because sdk-kit doesn't pass event name
+ // So we listen to each known trigger type individually
+
+ const triggerTypes = [
+ 'exitIntent',
+ 'scrollDepth',
+ 'timeDelay',
+ 'pageVisits',
+ 'modal',
+ 'inline',
+ ];
+
+ for (const triggerType of triggerTypes) {
+ this.sdk.on(`trigger:${triggerType}`, (data: any) => {
+ // Update trigger context
+ this.triggerContext.triggers = this.triggerContext.triggers || {};
+ this.triggerContext.triggers[triggerType] = {
+ triggered: true,
+ timestamp: Date.now(),
+ ...data, // Merge trigger-specific data
+ };
+
+ // Re-evaluate ALL experiences with updated trigger context
+ // Using evaluateAll() to show multiple matching experiences
+ // buildContext() will automatically add the current URL
+ this.evaluateAll(this.triggerContext);
+ });
+ }
}
/**
@@ -313,20 +338,20 @@ export class ExperienceRuntime {
this.decisions.push(decision);
}
- // Emit single event with all decisions (array)
- // Plugins can filter to their relevant experiences
+ // Emit one event per matched experience
+ // This allows plugins to react individually to each experience
const matchedDecisions = decisions.filter((d) => d.show);
- const matchedExperiences = matchedDecisions
- .map((d) => d.experienceId && this.experiences.get(d.experienceId))
- .filter((exp): exp is Experience => exp !== undefined);
-
- this.sdk.emit(
- 'experiences:evaluated',
- matchedDecisions.map((decision, index) => ({
- decision,
- experience: matchedExperiences[index],
- }))
- );
+ for (const decision of matchedDecisions) {
+ const experience = decision.experienceId
+ ? this.experiences.get(decision.experienceId)
+ : undefined;
+ if (experience) {
+ this.sdk.emit('experiences:evaluated', {
+ decision,
+ experience,
+ });
+ }
+ }
return decisions;
}
@@ -419,7 +444,7 @@ export function evaluateExperience(
let matched = true;
// Evaluate URL rule
- if (experience.targeting.url) {
+ if (experience.targeting?.url) {
const urlStart = Date.now();
const urlMatch = evaluateUrlRule(experience.targeting.url, context.url);
@@ -440,6 +465,36 @@ export function evaluateExperience(
}
}
+ // Evaluate display trigger conditions
+ if (experience.display?.trigger && context.triggers) {
+ const triggerType = experience.display.trigger;
+ const triggerData = context.triggers[triggerType];
+
+ // Check if this trigger has fired
+ if (!triggerData?.triggered) {
+ reasons.push(`Waiting for ${triggerType} trigger`);
+ matched = false;
+ } else {
+ // For scrollDepth, check if threshold matches
+ if (triggerType === 'scrollDepth' && experience.display.triggerData?.threshold) {
+ const expectedThreshold = experience.display.triggerData.threshold;
+ const actualThreshold = triggerData.threshold;
+
+ if (actualThreshold === expectedThreshold) {
+ reasons.push(`Scroll depth threshold (${expectedThreshold}%) reached`);
+ } else {
+ reasons.push(
+ `Scroll depth threshold mismatch (expected ${expectedThreshold}%, got ${actualThreshold}%)`
+ );
+ matched = false;
+ }
+ } else {
+ // Other triggers just need to be triggered
+ reasons.push(`${triggerType} trigger fired`);
+ }
+ }
+ }
+
return { matched, reasons, trace };
}
diff --git a/packages/core/src/singleton.ts b/packages/core/src/singleton.ts
index f004802..8161373 100644
--- a/packages/core/src/singleton.ts
+++ b/packages/core/src/singleton.ts
@@ -177,8 +177,10 @@ export async function destroy(): Promise {
* import experiences from '@prosdevlab/experience-sdk';
* await experiences.init();
* ```
+ *
+ * For IIFE builds (script tag), this becomes window.experiences via default export
*/
-export const experiences = {
+const experiencesObject = {
createInstance,
init,
register,
@@ -190,11 +192,15 @@ export const experiences = {
destroy,
};
-/**
- * Global singleton instance for IIFE builds
- *
- * When loaded via script tag, this object is available as `window.experiences`
- */
-if (typeof window !== 'undefined') {
- (window as unknown as Record).experiences = experiences;
-}
+const experiencesProxy = new Proxy(experiencesObject, {
+ get(target, prop) {
+ // Check wrapper functions first
+ if (prop in target && target[prop as keyof typeof target]) {
+ return target[prop as keyof typeof target];
+ }
+ // Fall back to SDK instance for plugin APIs (modal, inline, banner, etc.)
+ return (defaultInstance.sdk as any)[prop];
+ },
+});
+
+export { experiencesProxy as experiences };
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 44fb292..9ce87a4 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -15,7 +15,7 @@ export interface Experience {
/** Unique identifier for the experience */
id: string;
/** Type of experience to render */
- type: 'banner' | 'modal' | 'tooltip';
+ type: 'banner' | 'modal' | 'tooltip' | 'inline';
/** Rules that determine when/where to show this experience */
targeting: TargetingRules;
/** Content to display (type-specific) */
@@ -24,6 +24,8 @@ export interface Experience {
frequency?: FrequencyConfig;
/** Priority for ordering (higher = more important, default: 0) */
priority?: number;
+ /** Display conditions (triggers, timing) */
+ display?: DisplayConditions;
}
/**
@@ -78,6 +80,20 @@ export interface FrequencyConfig {
per: 'session' | 'day' | 'week';
}
+/**
+ * Display Conditions
+ *
+ * Conditions that determine when an experience should be displayed.
+ */
+export interface DisplayConditions {
+ /** Trigger type (e.g., scrollDepth, exitIntent, timeDelay) */
+ trigger?: string;
+ /** Trigger-specific configuration data */
+ triggerData?: any;
+ /** Frequency capping for this experience */
+ frequency?: FrequencyConfig;
+}
+
// Import plugin-specific content types from plugins package
// (Core depends on plugins, so plugins owns these definitions)
import type {
@@ -176,6 +192,15 @@ export interface TriggerState {
/** Number of visibility changes */
visibilityChanges?: number;
};
+ /** Modal trigger state (when modal is shown) */
+ modal?: {
+ triggered: boolean;
+ timestamp?: number;
+ /** Experience ID of the shown modal */
+ experienceId?: string;
+ /** Whether the modal is currently showing */
+ shown?: boolean;
+ };
/** Extensible for future triggers */
[key: string]: any;
}
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
index eb0f88f..8b3e1c5 100644
--- a/packages/plugins/README.md
+++ b/packages/plugins/README.md
@@ -2,100 +2,90 @@
Official plugins for [Experience SDK](https://github.com/prosdevlab/experience-sdk).
-**Size:** 13.4 KB (ESM)
+**Size:** 12.7 KB gzipped (all plugins)
## Included Plugins
-### Banner Plugin
+### Layout Plugins
-Renders banner experiences in the DOM with automatic positioning, theming, and responsive layout.
+**Banner Plugin** - Top/bottom notification bars with push-down support
**Features:**
- Multiple buttons with variants (primary, secondary, link)
- Responsive layout (desktop inline, mobile stack)
-- Automatic theme detection (light/dark mode)
-- Top/bottom positioning
-- Dismissable with close button
-- **CSS customization** via `className` and `style` props
-- Stable `.xp-*` CSS classes for styling
+- Top/bottom positioning with optional push-down
+- HTML sanitization for XSS protection
+- CSS variables for full theming
-```typescript
-import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins';
-
-const sdk = createInstance();
-sdk.use(bannerPlugin);
+**Modal Plugin** - Overlay dialogs with forms and rich content
-sdk.banner.show({
- id: 'welcome',
- type: 'banner',
- content: {
- title: 'Welcome!',
- message: 'Thanks for visiting.',
- buttons: [
- { text: 'Get Started', url: '/start', variant: 'primary' }
- ],
- position: 'top',
- dismissable: true
- }
-});
-```
+**Features:**
+- Multiple sizes (sm, md, lg, fullscreen, auto)
+- Mobile fullscreen for large modals
+- Hero images with max height
+- Animations (fade, slide-up, none)
+- Built-in form support with validation
+- Focus trap and keyboard support (Escape)
+- CSS variables for full theming
-**Customization:**
+**Inline Plugin** - In-content experiences using CSS selectors
-The banner plugin uses `.xp-*` CSS classes and supports custom styling:
+**Features:**
+- 5 insertion methods (replace, append, prepend, before, after)
+- Dismissal with persistence
+- Multi-instance support
+- HTML sanitization
+- Custom styling (className, style props)
+- CSS variables for theming
-```typescript
-// With Tailwind
-content: {
- message: 'Flash Sale!',
- className: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white',
- buttons: [{
- text: 'Shop Now',
- className: 'bg-white text-blue-600 hover:bg-gray-100'
- }]
-}
-
-// With inline styles
-content: {
- message: 'Flash Sale!',
- style: {
- background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
- color: 'white'
- }
-}
-```
+### Display Condition Plugins
-See the [Plugins documentation](https://prosdevlab.github.io/experience-sdk/reference/plugins#customization) for more customization examples.
+**Exit Intent Plugin** - Detect when users are leaving
-### Frequency Plugin
+**Features:**
+- Mouse movement tracking
+- Configurable sensitivity
+- Min time on page
+- Delay before triggering
+- Session persistence
-Manages impression tracking and frequency capping with persistent storage.
+**Scroll Depth Plugin** - Track scroll engagement
**Features:**
-- Session/day/week impression tracking
-- Automatic storage management
-- Manual impression recording
-- Reset capabilities
+- Multiple thresholds (25%, 50%, 75%, 100%)
+- Max scroll tracking
+- Velocity and direction tracking
+- Time-to-threshold metrics
+- Throttling and resize handling
-```typescript
-import { createInstance, frequencyPlugin } from '@prosdevlab/experience-sdk-plugins';
+**Page Visits Plugin** - Session and lifetime counters
-const sdk = createInstance();
-sdk.use(frequencyPlugin);
+**Features:**
+- Session-scoped visits (sessionStorage)
+- Lifetime visits (localStorage)
+- First-visit detection
+- DNT (Do Not Track) support
+- Expiration and cross-tab safety
-// Check impression count
-const count = sdk.frequency.getImpressionCount('welcome', 'session');
+**Time Delay Plugin** - Show after time elapsed
-// Record impression
-sdk.frequency.recordImpression('welcome');
+**Features:**
+- Configurable show delay
+- Auto-hide after duration
+- Pause when page hidden (Page Visibility API)
+- Timer management
-// Reset counts
-sdk.frequency.reset('welcome');
-```
+### Utility Plugins
-### Debug Plugin
+**Frequency Plugin** - Impression tracking and capping
-Provides console logging and window event emission for debugging and Chrome extension integration.
+**Features:**
+- Session/day/week impression tracking
+- Automatic storage management
+- Manual impression recording
+- Reset capabilities
+
+**Debug Plugin** - Logging and window events
**Features:**
- Console logging with prefix
@@ -103,18 +93,87 @@ Provides console logging and window event emission for debugging and Chrome exte
- Automatic decision logging
- Configurable logging levels
+## Quick Examples
+
+### Banner
+
```typescript
-import { createInstance, debugPlugin } from '@prosdevlab/experience-sdk-plugins';
+import { createInstance, bannerPlugin } from '@prosdevlab/experience-sdk-plugins';
-const sdk = createInstance({ debug: true });
-sdk.use(debugPlugin);
+const sdk = createInstance();
+sdk.use(bannerPlugin);
-// Manual logging
-sdk.debug.log('Custom message', { foo: 'bar' });
+sdk.banner.show({
+ id: 'welcome',
+ type: 'banner',
+ content: {
+ message: 'Welcome! Get 20% off today.',
+ buttons: [
+ { text: 'Shop Now', url: '/shop', variant: 'primary' }
+ ],
+ position: 'top'
+ }
+});
+```
-// Listen to debug events
-window.addEventListener('experiences:debug', (event) => {
- console.log(event.detail);
+### Modal with Form
+
+```typescript
+sdk.modal.show({
+ id: 'newsletter',
+ type: 'modal',
+ content: {
+ title: 'Stay Updated',
+ message: 'Subscribe for exclusive offers.',
+ size: 'sm',
+ form: {
+ fields: [
+ { name: 'email', type: 'email', required: true }
+ ],
+ submitButton: { text: 'Subscribe', variant: 'primary' },
+ successState: {
+ title: 'โ Subscribed!',
+ message: 'Check your inbox.'
+ }
+ }
+ }
+});
+```
+
+### Inline Experience
+
+```typescript
+sdk.inline.show({
+ id: 'promo',
+ type: 'inline',
+ content: {
+ selector: '#sidebar',
+ position: 'prepend',
+ message: 'Special Offer! Get 20% off with SAVE20.
',
+ buttons: [
+ { text: 'Shop Now', url: '/shop', variant: 'primary' }
+ ],
+ dismissable: true,
+ persist: true
+ }
+});
+```
+
+### Exit Intent
+
+```typescript
+// Trigger modal on exit intent
+sdk.on('trigger:exitIntent', () => {
+ sdk.modal.show({
+ id: 'exit-offer',
+ content: {
+ title: 'Wait! Before You Go...',
+ message: 'Get 15% off your first order.',
+ buttons: [
+ { text: 'Claim Discount', variant: 'primary', url: '/checkout?code=EXIT15' }
+ ]
+ }
+ });
});
```
@@ -145,8 +204,11 @@ experiences.register('banner', { ... });
## Documentation
- [Full Documentation](https://prosdevlab.github.io/experience-sdk)
-- [Plugins Guide](https://prosdevlab.github.io/experience-sdk/reference/plugins)
-- [Banner Examples](https://prosdevlab.github.io/experience-sdk/demo/banner)
+- [Banner Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/banner)
+- [Modal Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/modal)
+- [Inline Plugin](https://prosdevlab.github.io/experience-sdk/reference/plugins/inline)
+- [Display Conditions](https://prosdevlab.github.io/experience-sdk/reference/plugins/exit-intent)
+- [All Plugins](https://prosdevlab.github.io/experience-sdk/reference/plugins)
## License
diff --git a/packages/plugins/src/banner/banner.ts b/packages/plugins/src/banner/banner.ts
index 0786870..4b10821 100644
--- a/packages/plugins/src/banner/banner.ts
+++ b/packages/plugins/src/banner/banner.ts
@@ -86,15 +86,15 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
left: 0;
right: 0;
width: 100%;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- font-size: 14px;
- line-height: 1.5;
+ font-family: var(--xp-banner-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
+ font-size: var(--xp-banner-font-size, 14px);
+ line-height: var(--xp-banner-line-height, 1.5);
box-sizing: border-box;
- z-index: 10000;
- background: #ffffff;
- color: #111827;
- border-bottom: 1px solid #e5e7eb;
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
+ z-index: var(--xp-banner-z-index, 10000);
+ background: var(--xp-banner-bg, #ffffff);
+ color: var(--xp-banner-color, #111827);
+ border-bottom: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb);
+ box-shadow: var(--xp-banner-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.05));
}
.xp-banner--top {
@@ -104,17 +104,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
.xp-banner--bottom {
bottom: 0;
border-bottom: none;
- border-top: 1px solid #e5e7eb;
- box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05);
+ border-top: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb);
+ box-shadow: var(--xp-banner-shadow-bottom, 0 -1px 3px 0 rgba(0, 0, 0, 0.05));
}
.xp-banner__container {
display: flex;
align-items: center;
- gap: 16px;
- max-width: 1280px;
+ gap: var(--xp-banner-gap, 16px);
+ max-width: var(--xp-banner-max-width, 1280px);
margin: 0 auto;
- padding: 14px 24px;
+ padding: var(--xp-banner-padding, 14px 24px);
}
.xp-banner__content {
@@ -122,36 +122,37 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
min-width: 0;
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: var(--xp-banner-content-gap, 4px);
}
.xp-banner__title {
- font-weight: 600;
+ font-weight: var(--xp-banner-title-weight, 600);
margin: 0;
- font-size: 15px;
- line-height: 1.4;
+ font-size: var(--xp-banner-title-size, 15px);
+ line-height: var(--xp-banner-title-line-height, 1.4);
+ color: var(--xp-banner-title-color, inherit);
}
.xp-banner__message {
margin: 0;
- font-size: 14px;
- line-height: 1.5;
- color: #6b7280;
+ font-size: var(--xp-banner-message-size, 14px);
+ line-height: var(--xp-banner-message-line-height, 1.5);
+ color: var(--xp-banner-message-color, #6b7280);
}
.xp-banner__buttons {
display: flex;
align-items: center;
- gap: 8px;
+ gap: var(--xp-banner-buttons-gap, 8px);
flex-shrink: 0;
}
.xp-banner__button {
- padding: 8px 16px;
+ padding: var(--xp-banner-button-padding, 8px 16px);
border: none;
- border-radius: 6px;
- font-size: 14px;
- font-weight: 500;
+ border-radius: var(--xp-banner-button-radius, 6px);
+ font-size: var(--xp-banner-button-font-size, 14px);
+ font-weight: var(--xp-banner-button-font-weight, 500);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
@@ -162,64 +163,64 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
}
.xp-banner__button--primary {
- background: #2563eb;
- color: #ffffff;
+ background: var(--xp-banner-button-primary-bg, #2563eb);
+ color: var(--xp-banner-button-primary-color, #ffffff);
}
.xp-banner__button--primary:hover {
- background: #1d4ed8;
+ background: var(--xp-banner-button-primary-bg-hover, #1d4ed8);
}
.xp-banner__button--secondary {
- background: #f3f4f6;
- color: #374151;
- border: 1px solid #e5e7eb;
+ background: var(--xp-banner-button-secondary-bg, #f3f4f6);
+ color: var(--xp-banner-button-secondary-color, #374151);
+ border: var(--xp-banner-border-width, 1px) solid var(--xp-banner-button-secondary-border, #e5e7eb);
}
.xp-banner__button--secondary:hover {
- background: #e5e7eb;
+ background: var(--xp-banner-button-secondary-bg-hover, #e5e7eb);
}
.xp-banner__button--link {
background: transparent;
- color: #2563eb;
- padding: 6px 12px;
- font-weight: 400;
+ color: var(--xp-banner-button-link-color, #2563eb);
+ padding: var(--xp-banner-button-link-padding, 6px 12px);
+ font-weight: var(--xp-banner-button-link-font-weight, 400);
}
.xp-banner__button--link:hover {
- background: #f3f4f6;
+ background: var(--xp-banner-button-link-bg-hover, #f3f4f6);
text-decoration: underline;
}
.xp-banner__close {
background: transparent;
border: none;
- color: #9ca3af;
- font-size: 20px;
+ color: var(--xp-banner-close-color, #9ca3af);
+ font-size: var(--xp-banner-close-size, 20px);
line-height: 1;
cursor: pointer;
- padding: 4px;
+ padding: var(--xp-banner-close-padding, 4px);
margin: 0;
transition: color 0.2s;
flex-shrink: 0;
- width: 28px;
- height: 28px;
+ width: var(--xp-banner-close-width, 28px);
+ height: var(--xp-banner-close-height, 28px);
display: flex;
align-items: center;
justify-content: center;
- border-radius: 4px;
+ border-radius: var(--xp-banner-close-radius, 4px);
}
.xp-banner__close:hover {
- color: #111827;
- background: #f3f4f6;
+ color: var(--xp-banner-close-color-hover, #111827);
+ background: var(--xp-banner-close-bg-hover, #f3f4f6);
}
@media (max-width: 640px) {
.xp-banner__container {
flex-wrap: wrap;
- padding: 14px 16px;
+ padding: var(--xp-banner-padding-mobile, 14px 16px);
position: relative;
}
@@ -244,55 +245,55 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
}
}
- /* Dark mode support */
+ /* Dark mode support - override CSS variables */
@media (prefers-color-scheme: dark) {
.xp-banner {
- background: #111827;
- color: #f9fafb;
- border-bottom-color: #1f2937;
+ background: var(--xp-banner-bg-dark, #111827);
+ color: var(--xp-banner-color-dark, #f9fafb);
+ border-bottom-color: var(--xp-banner-border-color-dark, #1f2937);
}
.xp-banner--bottom {
- border-top-color: #1f2937;
+ border-top-color: var(--xp-banner-border-color-dark, #1f2937);
}
.xp-banner__message {
- color: #9ca3af;
+ color: var(--xp-banner-message-color-dark, #9ca3af);
}
.xp-banner__button--primary {
- background: #3b82f6;
+ background: var(--xp-banner-button-primary-bg-dark, #3b82f6);
}
.xp-banner__button--primary:hover {
- background: #2563eb;
+ background: var(--xp-banner-button-primary-bg-hover-dark, #2563eb);
}
.xp-banner__button--secondary {
- background: #1f2937;
- color: #f9fafb;
- border-color: #374151;
+ background: var(--xp-banner-button-secondary-bg-dark, #1f2937);
+ color: var(--xp-banner-button-secondary-color-dark, #f9fafb);
+ border-color: var(--xp-banner-button-secondary-border-dark, #374151);
}
.xp-banner__button--secondary:hover {
- background: #374151;
+ background: var(--xp-banner-button-secondary-bg-hover-dark, #374151);
}
.xp-banner__button--link {
- color: #60a5fa;
+ color: var(--xp-banner-button-link-color-dark, #60a5fa);
}
.xp-banner__button--link:hover {
- background: #1f2937;
+ background: var(--xp-banner-button-link-bg-hover-dark, #1f2937);
}
.xp-banner__close {
- color: #6b7280;
+ color: var(--xp-banner-close-color-dark, #6b7280);
}
.xp-banner__close:hover {
- color: #f9fafb;
- background: #1f2937;
+ color: var(--xp-banner-close-color-hover-dark, #f9fafb);
+ background: var(--xp-banner-close-bg-hover-dark, #1f2937);
}
}
`;
diff --git a/packages/plugins/src/exit-intent/exit-intent.ts b/packages/plugins/src/exit-intent/exit-intent.ts
index 3b38b45..ddfc32d 100644
--- a/packages/plugins/src/exit-intent/exit-intent.ts
+++ b/packages/plugins/src/exit-intent/exit-intent.ts
@@ -365,8 +365,7 @@ export const exitIntentPlugin: PluginFunction = (plugin, instance, config) => {
initialize();
// Cleanup on instance destroy
- const destroyHandler = () => {
+ instance.on('sdk:destroy', () => {
cleanup();
- };
- instance.on('destroy', destroyHandler);
+ });
};
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
index bbfe79c..57a281b 100644
--- a/packages/plugins/src/index.ts
+++ b/packages/plugins/src/index.ts
@@ -10,6 +10,8 @@ export * from './banner';
export * from './debug';
export * from './exit-intent';
export * from './frequency';
+export * from './inline';
+export * from './modal';
export * from './page-visits';
export * from './scroll-depth';
export * from './time-delay';
diff --git a/packages/plugins/src/inline/index.ts b/packages/plugins/src/inline/index.ts
new file mode 100644
index 0000000..f465f02
--- /dev/null
+++ b/packages/plugins/src/inline/index.ts
@@ -0,0 +1,3 @@
+export { inlinePlugin } from './inline';
+export { insertContent, removeContent } from './insertion';
+export type { InlineContent, InlinePlugin, InlinePluginConfig, InsertionPosition } from './types';
diff --git a/packages/plugins/src/inline/inline.test.ts b/packages/plugins/src/inline/inline.test.ts
new file mode 100644
index 0000000..28eeb10
--- /dev/null
+++ b/packages/plugins/src/inline/inline.test.ts
@@ -0,0 +1,620 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { SDK } from '@lytics/sdk-kit';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { inlinePlugin } from './inline';
+
+// Helper to initialize SDK with inline plugin
+function initPlugin(config = {}) {
+ const sdk = new SDK({
+ name: 'test-sdk',
+ ...config,
+ });
+
+ sdk.use(inlinePlugin);
+
+ // Ensure body exists
+ if (!document.body) {
+ document.body = document.createElement('body');
+ }
+
+ return sdk;
+}
+
+describe('Inline Plugin', () => {
+ let sdk: SDK & { inline?: any };
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ sdk = initPlugin();
+ await sdk.init();
+ });
+
+ afterEach(async () => {
+ // Clean up any inline content
+ document.querySelectorAll('.xp-inline').forEach((el) => {
+ el.remove();
+ });
+
+ // Clean up any target elements
+ document.body.innerHTML = '';
+
+ if (sdk) {
+ await sdk.destroy();
+ }
+
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('should register the inline plugin', () => {
+ expect(sdk.inline).toBeDefined();
+ expect(sdk.inline.show).toBeInstanceOf(Function);
+ expect(sdk.inline.remove).toBeInstanceOf(Function);
+ expect(sdk.inline.isShowing).toBeInstanceOf(Function);
+ });
+
+ it('should set default configuration', () => {
+ // Check that defaults were set by verifying plugin registered
+ expect(sdk.inline).toBeDefined();
+ });
+
+ describe('Insertion Methods', () => {
+ it('should insert content with "replace" position', () => {
+ // Create target element
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ target.innerHTML = 'Original content
';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'replace-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ position: 'replace',
+ message: 'New content
',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(inline?.innerHTML).toBe('New content
');
+ expect(target.querySelector('p')?.textContent).toBe('New content'); // Content replaced
+ });
+
+ it('should insert content with "append" position', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ target.innerHTML = 'Original
';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'append-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ position: 'append',
+ message: 'Appended',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(target.children.length).toBe(2); // Original + appended
+ expect(target.children[0].tagName).toBe('P'); // Original first
+ expect(target.children[1].className).toBe('xp-inline'); // Appended second
+ });
+
+ it('should insert content with "prepend" position', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ target.innerHTML = 'Original
';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'prepend-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ position: 'prepend',
+ message: 'Prepended',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(target.children.length).toBe(2); // Prepended + original
+ expect(target.children[0].className).toBe('xp-inline'); // Prepended first
+ expect(target.children[1].tagName).toBe('P'); // Original second
+ });
+
+ it('should insert content with "before" position', () => {
+ const container = document.createElement('div');
+ const target = document.createElement('p');
+ target.id = 'test-target';
+ target.textContent = 'Target';
+ container.appendChild(target);
+ document.body.appendChild(container);
+
+ const experience = {
+ id: 'before-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ position: 'before',
+ message: 'Before',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(container.children.length).toBe(2); // Inline + target
+ expect(container.children[0].className).toBe('xp-inline'); // Inline first
+ expect(container.children[1].id).toBe('test-target'); // Target second
+ });
+
+ it('should insert content with "after" position', () => {
+ const container = document.createElement('div');
+ const target = document.createElement('p');
+ target.id = 'test-target';
+ target.textContent = 'Target';
+ container.appendChild(target);
+ document.body.appendChild(container);
+
+ const experience = {
+ id: 'after-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ position: 'after',
+ message: 'After',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(container.children.length).toBe(2); // Target + inline
+ expect(container.children[0].id).toBe('test-target'); // Target first
+ expect(container.children[1].className).toBe('xp-inline'); // Inline second
+ });
+
+ it('should default to "replace" when position not specified', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ target.innerHTML = 'Original
';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'default-position',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Replaced
',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline).toBeTruthy();
+ expect(target.querySelector('p')).toBeFalsy(); // Original replaced
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should emit error event when selector not found', async () => {
+ const errorHandler = vi.fn();
+ sdk.on('experiences:inline:error', errorHandler);
+
+ const experience = {
+ id: 'not-found',
+ type: 'inline',
+ content: {
+ selector: '#does-not-exist',
+ message: 'Content
',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ await vi.waitFor(() => {
+ expect(errorHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ experienceId: 'not-found',
+ error: 'selector-not-found',
+ selector: '#does-not-exist',
+ })
+ );
+ });
+ });
+
+ it('should not throw error when removing non-existent inline', () => {
+ expect(() => {
+ sdk.inline.remove('does-not-exist');
+ }).not.toThrow();
+ });
+ });
+
+ describe('Dismissal', () => {
+ it('should render close button when dismissable is true', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'dismissable-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ dismissable: true,
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const closeBtn = document.querySelector('.xp-inline__close');
+ expect(closeBtn).toBeTruthy();
+ expect(closeBtn?.getAttribute('aria-label')).toBe('Close');
+ });
+
+ it('should not render close button when dismissable is false', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'not-dismissable',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ dismissable: false,
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const closeBtn = document.querySelector('.xp-inline__close');
+ expect(closeBtn).toBeFalsy();
+ });
+
+ it('should remove inline when close button is clicked', async () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const dismissHandler = vi.fn();
+ sdk.on('experiences:dismissed', dismissHandler);
+
+ const experience = {
+ id: 'dismiss-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ dismissable: true,
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
+ closeBtn.click();
+
+ await vi.waitFor(() => {
+ expect(dismissHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ experienceId: 'dismiss-test',
+ })
+ );
+ });
+
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
+ });
+
+ it('should persist dismissal in localStorage when persist is true', async () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'persist-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ dismissable: true,
+ persist: true,
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
+ closeBtn.click();
+
+ // Try to show again
+ const dismissedHandler = vi.fn();
+ sdk.on('experiences:inline:dismissed', dismissedHandler);
+
+ sdk.inline.show(experience);
+
+ await vi.waitFor(() => {
+ expect(dismissedHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ experienceId: 'persist-test',
+ reason: 'previously-dismissed',
+ })
+ );
+ });
+
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
+ });
+ });
+
+ describe('Custom Styling', () => {
+ it('should apply custom className', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'class-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ className: 'custom-class',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline?.classList.contains('custom-class')).toBe(true);
+ });
+
+ it('should apply custom inline styles', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'style-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ style: {
+ padding: '20px',
+ backgroundColor: 'red',
+ },
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline') as HTMLElement;
+ expect(inline?.style.padding).toBe('20px');
+ expect(inline?.style.backgroundColor).toBe('red');
+ });
+ });
+
+ describe('Multi-instance', () => {
+ it('should support multiple inline experiences', () => {
+ const target1 = document.createElement('div');
+ target1.id = 'target-1';
+ document.body.appendChild(target1);
+
+ const target2 = document.createElement('div');
+ target2.id = 'target-2';
+ document.body.appendChild(target2);
+
+ sdk.inline.show({
+ id: 'inline-1',
+ type: 'inline',
+ content: {
+ selector: '#target-1',
+ message: 'Content 1
',
+ },
+ });
+
+ sdk.inline.show({
+ id: 'inline-2',
+ type: 'inline',
+ content: {
+ selector: '#target-2',
+ message: 'Content 2
',
+ },
+ });
+
+ const inlines = document.querySelectorAll('.xp-inline');
+ expect(inlines.length).toBe(2);
+ });
+
+ it('should prevent duplicate inline experiences', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'duplicate-test',
+ type: 'inline' as const,
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ },
+ };
+
+ // Show the same experience twice
+ sdk.inline.show(experience);
+ sdk.inline.show(experience);
+
+ // Should only insert once
+ const inlines = document.querySelectorAll('[data-xp-id="duplicate-test"]');
+ expect(inlines.length).toBe(1);
+ expect(sdk.inline.isShowing('duplicate-test')).toBe(true);
+ });
+
+ it('should check if specific inline is showing', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ sdk.inline.show({
+ id: 'check-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ },
+ });
+
+ expect(sdk.inline.isShowing('check-test')).toBe(true);
+ expect(sdk.inline.isShowing('does-not-exist')).toBe(false);
+ });
+
+ it('should check if any inline is showing', () => {
+ expect(sdk.inline.isShowing()).toBe(false);
+
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ sdk.inline.show({
+ id: 'any-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ },
+ });
+
+ expect(sdk.inline.isShowing()).toBe(true);
+ });
+ });
+
+ describe('Events', () => {
+ it('should emit shown event when inline is displayed', async () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const shownHandler = vi.fn();
+ sdk.on('experiences:shown', shownHandler);
+
+ sdk.inline.show({
+ id: 'shown-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(shownHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ experienceId: 'shown-test',
+ type: 'inline',
+ selector: '#test-target',
+ position: 'replace',
+ })
+ );
+ });
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('should remove inline on explicit remove call', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ sdk.inline.show({
+ id: 'remove-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Content
',
+ },
+ });
+
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
+
+ sdk.inline.remove('remove-test');
+
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
+ });
+
+ it('should remove all inlines on destroy', async () => {
+ const target1 = document.createElement('div');
+ target1.id = 'target-1';
+ document.body.appendChild(target1);
+
+ const target2 = document.createElement('div');
+ target2.id = 'target-2';
+ document.body.appendChild(target2);
+
+ sdk.inline.show({
+ id: 'destroy-1',
+ type: 'inline',
+ content: {
+ selector: '#target-1',
+ message: 'Content 1
',
+ },
+ });
+
+ sdk.inline.show({
+ id: 'destroy-2',
+ type: 'inline',
+ content: {
+ selector: '#target-2',
+ message: 'Content 2
',
+ },
+ });
+
+ expect(document.querySelectorAll('.xp-inline').length).toBe(2);
+
+ await sdk.destroy();
+
+ expect(document.querySelectorAll('.xp-inline').length).toBe(0);
+ });
+ });
+
+ describe('HTML Sanitization', () => {
+ it('should sanitize HTML content', () => {
+ const target = document.createElement('div');
+ target.id = 'test-target';
+ document.body.appendChild(target);
+
+ const experience = {
+ id: 'sanitize-test',
+ type: 'inline',
+ content: {
+ selector: '#test-target',
+ message: 'Safe content
',
+ },
+ };
+
+ sdk.inline.show(experience);
+
+ const inline = document.querySelector('.xp-inline');
+ expect(inline?.innerHTML).not.toContain('',
+ },
+ };
+
+ sdk.modal.show(experience);
+
+ const message = document.querySelector('.xp-modal__message');
+ expect(message?.innerHTML).toContain('Bold text');
+ expect(message?.innerHTML).not.toContain('