Skip to content

Commit 2fc279c

Browse files
committed
fix: inline plugin stability and scroll depth trigger filtering
- Prevent duplicate inline experiences from being shown - Fix scroll depth threshold evaluation to match specific percentages - Remove trigger:inline emission that caused infinite event loops - Add scrollDepth.reset() method to clear triggered thresholds - Expand HTML sanitizer to allow div, ul, li tags for rich content - Add smooth fade-in animations to inline experiences - Add display conditions evaluation for trigger-based experiences - Add inline to Experience type union - Add tests for new functionality (432 tests passing)
1 parent cab271d commit 2fc279c

File tree

10 files changed

+401
-71
lines changed

10 files changed

+401
-71
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
"@prosdevlab/experience-sdk": minor
3+
"@prosdevlab/experience-sdk-plugins": minor
4+
---
5+
6+
**Phase 2: Presentation Layer - Modal & Inline Plugins**
7+
8+
This release introduces two powerful new rendering plugins with built-in form support, completing the presentation layer for the Experience SDK.
9+
10+
## 🎉 New Features
11+
12+
### Modal Plugin
13+
- **Rich Content Modals**: Display announcements, promotions, and interactive content
14+
- **Built-in Forms**: Email capture, surveys, and feedback forms with validation
15+
- **Size Variants**: Small, medium, large, and extra-large sizes
16+
- **Hero Images**: Full-width images for visual impact
17+
- **Responsive Design**: Mobile fullscreen mode for optimal UX
18+
- **Keyboard Navigation**: Focus trap, Escape key, Tab navigation
19+
- **Animations**: Smooth fade-in/fade-out transitions
20+
- **Form States**: Success, error, and loading states
21+
- **API Methods**: `show()`, `remove()`, `isShowing()`, `showFormState()`, `resetForm()`, `getFormData()`
22+
23+
### Inline Plugin
24+
- **DOM Insertion**: Embed content anywhere in your page
25+
- **5 Insertion Methods**: `replace`, `append`, `prepend`, `before`, `after`
26+
- **CSS Selector Targeting**: Use any valid selector to target elements
27+
- **Dismissal with Persistence**: Users can dismiss and it persists in localStorage
28+
- **No Layout Disruption**: Seamlessly integrates with existing page structure
29+
- **API Methods**: `show()`, `remove()`, `isShowing()`
30+
31+
### Forms (Built into Modal)
32+
- **Field Types**: text, email, url, tel, number, textarea, select, checkbox, radio
33+
- **Validation**: Required, email, URL, pattern, custom validation, min/max length
34+
- **Real-time Feedback**: Validates on blur, shows errors inline
35+
- **Submission Handling**: Emits `experiences:modal:form:submit` event
36+
- **Success/Error States**: Built-in UI for post-submission states
37+
- **Pure Functions**: Validation and rendering logic easily extractable
38+
39+
## 🎨 Theming & Customization
40+
41+
### CSS Variables
42+
All plugins now support CSS variable theming:
43+
- **Modal**: `--xp-modal-*` variables for backdrop, dialog, title, content, buttons
44+
- **Forms**: `--xp-form-*` variables for inputs, labels, errors, submit button
45+
- **Banner**: `--xp-banner-*` variables (refactored from inline styles)
46+
- **Inline**: `--xp-inline-*` variables for custom styling
47+
48+
See the [Theming Guide](https://prosdevlab.github.io/experience-sdk/guides/theming) for full reference.
49+
50+
## 🔧 API Improvements
51+
52+
### Runtime
53+
- **Auto-registration**: Modal and inline plugins are automatically registered
54+
- **Plugin API Access**: Expose plugin APIs via singleton (`experiences.modal.show()`)
55+
- **Trigger Event Handling**: Explicit listeners for each trigger type (exit intent, scroll depth, time delay)
56+
57+
### Display Conditions
58+
Seamless integration with existing display condition plugins:
59+
- **Exit Intent + Modal**: Capture emails before users leave
60+
- **Scroll Depth + Inline**: Progressive feature discovery
61+
- **Time Delay + Modal**: Time-sensitive promotions
62+
- **Page Visits + Banner**: Returning user messages
63+
64+
## 📦 Bundle Size
65+
- **Core SDK**: 13.4 KB gzipped (under 15 KB target ✅)
66+
- **All Plugins**: ~26 KB gzipped total (smaller than competitors like Pathfora at ~47 KB)
67+
- **Excellent Compression**: CSS-in-JS with CSS variables maintains small footprint
68+
69+
## 🧪 Testing
70+
- **427 tests passing** (unit, integration, browser tests)
71+
- **Modal Plugin**: 50+ tests for core functionality, forms, keyboard nav, accessibility
72+
- **Inline Plugin**: 24+ tests for DOM insertion, dismissal, persistence
73+
- **Form Validation**: 35+ tests for all field types and edge cases
74+
- **Integration Tests**: 10+ tests for plugin interactions
75+
- **Browser Tests**: 5+ tests with Playwright for real browser behavior
76+
77+
## 📚 Documentation
78+
- **Modal Plugin Reference**: Complete API docs with examples
79+
- **Inline Plugin Reference**: Full insertion method documentation
80+
- **Theming Guide**: CSS variable reference with examples
81+
- **Use Case Examples**: 4 complete implementation guides in playground
82+
83+
## 🚀 Playground Enhancements
84+
- **Layout Gallery Hub**: Visual directory for banner, modal, and inline layouts
85+
- **Navigation System**: Breadcrumbs and sub-navigation tabs
86+
- **Use Case Examples**:
87+
- Exit Intent Email Capture (exit intent + modal forms)
88+
- Feature Discovery Journey (scroll depth + inline + modal)
89+
- Time-Delayed Promotions (time delay + hero image modal)
90+
- Promotions & Announcements (banner examples)
91+
- **Interactive Demos**: Live examples with SDK integration
92+
93+
## ⚠️ Breaking Changes
94+
None. This is a **minor** release with backward compatibility.
95+
96+
## 🔜 Next Steps (Phase 3+)
97+
- Browser tests for form focus management
98+
- Composable form plugin (separate from modal)
99+
- Additional layout plugins (tooltip, slideout, sticky bar)
100+
- Multi-instance support with `instanceId` tracking
101+
102+
---
103+
104+
**Migration Guide**: No migration needed. Simply upgrade and start using the new plugins!
105+
106+
**Full Changelog**: See [Phase 2 Spec](https://github.com/prosdevlab/experience-sdk/blob/main/specs/phase-2-presentation-layer/spec.md)
107+

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"includes": [
3232
"packages/core/src/types.ts",
3333
"packages/core/src/runtime.ts",
34+
"packages/core/src/singleton.ts",
3435
"packages/plugins/src/types.ts",
3536
"packages/plugins/src/exit-intent/exit-intent.ts",
3637
"packages/plugins/src/scroll-depth/scroll-depth.ts",

packages/core/src/runtime.test.ts

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { ExperienceRuntime, evaluateUrlRule } from './runtime';
2+
import { ExperienceRuntime, evaluateExperience, evaluateUrlRule } from './runtime';
33

44
describe('ExperienceRuntime', () => {
55
let runtime: ExperienceRuntime;
@@ -447,6 +447,114 @@ describe('ExperienceRuntime', () => {
447447
});
448448
});
449449

450+
describe('display trigger evaluation', () => {
451+
it('should match experience when scrollDepth threshold is reached', () => {
452+
const experience = {
453+
id: 'scroll-test',
454+
type: 'inline' as const,
455+
content: {},
456+
targeting: {},
457+
display: {
458+
trigger: 'scrollDepth',
459+
triggerData: { threshold: 50 },
460+
},
461+
};
462+
463+
const context = {
464+
url: 'https://example.com',
465+
timestamp: Date.now(),
466+
triggers: {
467+
scrollDepth: {
468+
triggered: true,
469+
threshold: 50,
470+
percent: 50.5,
471+
},
472+
},
473+
};
474+
475+
const result = evaluateExperience(experience, context);
476+
expect(result.matched).toBe(true);
477+
expect(result.reasons).toContain('Scroll depth threshold (50%) reached');
478+
});
479+
480+
it('should not match experience when scrollDepth threshold does not match', () => {
481+
const experience = {
482+
id: 'scroll-test',
483+
type: 'inline' as const,
484+
content: {},
485+
targeting: {},
486+
display: {
487+
trigger: 'scrollDepth',
488+
triggerData: { threshold: 50 },
489+
},
490+
};
491+
492+
const context = {
493+
url: 'https://example.com',
494+
timestamp: Date.now(),
495+
triggers: {
496+
scrollDepth: {
497+
triggered: true,
498+
threshold: 25, // Different threshold
499+
percent: 25.5,
500+
},
501+
},
502+
};
503+
504+
const result = evaluateExperience(experience, context);
505+
expect(result.matched).toBe(false);
506+
expect(result.reasons).toContain('Scroll depth threshold mismatch (expected 50%, got 25%)');
507+
});
508+
509+
it('should not match experience when trigger has not fired', () => {
510+
const experience = {
511+
id: 'scroll-test',
512+
type: 'inline' as const,
513+
content: {},
514+
targeting: {},
515+
display: {
516+
trigger: 'exitIntent',
517+
},
518+
};
519+
520+
const context = {
521+
url: 'https://example.com',
522+
timestamp: Date.now(),
523+
triggers: {},
524+
};
525+
526+
const result = evaluateExperience(experience, context);
527+
expect(result.matched).toBe(false);
528+
expect(result.reasons).toContain('Waiting for exitIntent trigger');
529+
});
530+
531+
it('should match non-scrollDepth triggers when triggered', () => {
532+
const experience = {
533+
id: 'exit-test',
534+
type: 'modal' as const,
535+
content: {},
536+
targeting: {},
537+
display: {
538+
trigger: 'exitIntent',
539+
},
540+
};
541+
542+
const context = {
543+
url: 'https://example.com',
544+
timestamp: Date.now(),
545+
triggers: {
546+
exitIntent: {
547+
triggered: true,
548+
},
549+
},
550+
};
551+
552+
const result = evaluateExperience(experience, context);
553+
expect(result.matched).toBe(true);
554+
expect(result.reasons).toContain('exitIntent trigger fired');
555+
});
556+
});
557+
450558
describe('frequency targeting', () => {
451559
it('should track frequency rule in trace', async () => {
452560
await runtime.init();
@@ -622,7 +730,7 @@ describe('ExperienceRuntime', () => {
622730
expect(decisions2.find((d) => d.experienceId === 'capped')?.show).toBe(false);
623731
});
624732

625-
it('should emit experiences:evaluated event with array', () => {
733+
it('should emit experiences:evaluated event for each matched experience', () => {
626734
const handler = vi.fn();
627735
runtime.on('experiences:evaluated', handler);
628736

@@ -640,18 +748,20 @@ describe('ExperienceRuntime', () => {
640748

641749
runtime.evaluateAll();
642750

643-
expect(handler).toHaveBeenCalledOnce();
644-
expect(handler).toHaveBeenCalledWith(
645-
expect.arrayContaining([
646-
expect.objectContaining({
647-
decision: expect.objectContaining({ experienceId: 'banner1' }),
648-
experience: expect.objectContaining({ id: 'banner1' }),
649-
}),
650-
expect.objectContaining({
651-
decision: expect.objectContaining({ experienceId: 'banner2' }),
652-
experience: expect.objectContaining({ id: 'banner2' }),
653-
}),
654-
])
751+
expect(handler).toHaveBeenCalledTimes(2);
752+
expect(handler).toHaveBeenNthCalledWith(
753+
1,
754+
expect.objectContaining({
755+
decision: expect.objectContaining({ experienceId: 'banner1' }),
756+
experience: expect.objectContaining({ id: 'banner1' }),
757+
})
758+
);
759+
expect(handler).toHaveBeenNthCalledWith(
760+
2,
761+
expect.objectContaining({
762+
decision: expect.objectContaining({ experienceId: 'banner2' }),
763+
experience: expect.objectContaining({ id: 'banner2' }),
764+
})
655765
);
656766
});
657767

@@ -674,9 +784,9 @@ describe('ExperienceRuntime', () => {
674784
runtime.evaluateAll({ url: 'https://example.com/' });
675785

676786
expect(handler).toHaveBeenCalledOnce();
677-
const emittedDecisions = handler.mock.calls[0][0];
678-
expect(emittedDecisions).toHaveLength(1);
679-
expect(emittedDecisions[0].decision.experienceId).toBe('match');
787+
const emittedData = handler.mock.calls[0][0];
788+
expect(emittedData.decision.experienceId).toBe('match');
789+
expect(emittedData.experience.id).toBe('match');
680790
});
681791

682792
it('should return empty array when no experiences registered', () => {

packages/core/src/runtime.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,10 @@ export class ExperienceRuntime {
9999
...data, // Merge trigger-specific data
100100
};
101101

102-
// Re-evaluate all experiences with updated context
103-
this.evaluate(this.triggerContext);
102+
// Re-evaluate ALL experiences with updated trigger context
103+
// Using evaluateAll() to show multiple matching experiences
104+
// buildContext() will automatically add the current URL
105+
this.evaluateAll(this.triggerContext);
104106
});
105107
}
106108
}
@@ -336,20 +338,20 @@ export class ExperienceRuntime {
336338
this.decisions.push(decision);
337339
}
338340

339-
// Emit single event with all decisions (array)
340-
// Plugins can filter to their relevant experiences
341+
// Emit one event per matched experience
342+
// This allows plugins to react individually to each experience
341343
const matchedDecisions = decisions.filter((d) => d.show);
342-
const matchedExperiences = matchedDecisions
343-
.map((d) => d.experienceId && this.experiences.get(d.experienceId))
344-
.filter((exp): exp is Experience => exp !== undefined);
345-
346-
this.sdk.emit(
347-
'experiences:evaluated',
348-
matchedDecisions.map((decision, index) => ({
349-
decision,
350-
experience: matchedExperiences[index],
351-
}))
352-
);
344+
for (const decision of matchedDecisions) {
345+
const experience = decision.experienceId
346+
? this.experiences.get(decision.experienceId)
347+
: undefined;
348+
if (experience) {
349+
this.sdk.emit('experiences:evaluated', {
350+
decision,
351+
experience,
352+
});
353+
}
354+
}
353355

354356
return decisions;
355357
}
@@ -442,7 +444,7 @@ export function evaluateExperience(
442444
let matched = true;
443445

444446
// Evaluate URL rule
445-
if (experience.targeting.url) {
447+
if (experience.targeting?.url) {
446448
const urlStart = Date.now();
447449
const urlMatch = evaluateUrlRule(experience.targeting.url, context.url);
448450

@@ -463,6 +465,36 @@ export function evaluateExperience(
463465
}
464466
}
465467

468+
// Evaluate display trigger conditions
469+
if (experience.display?.trigger && context.triggers) {
470+
const triggerType = experience.display.trigger;
471+
const triggerData = context.triggers[triggerType];
472+
473+
// Check if this trigger has fired
474+
if (!triggerData?.triggered) {
475+
reasons.push(`Waiting for ${triggerType} trigger`);
476+
matched = false;
477+
} else {
478+
// For scrollDepth, check if threshold matches
479+
if (triggerType === 'scrollDepth' && experience.display.triggerData?.threshold) {
480+
const expectedThreshold = experience.display.triggerData.threshold;
481+
const actualThreshold = triggerData.threshold;
482+
483+
if (actualThreshold === expectedThreshold) {
484+
reasons.push(`Scroll depth threshold (${expectedThreshold}%) reached`);
485+
} else {
486+
reasons.push(
487+
`Scroll depth threshold mismatch (expected ${expectedThreshold}%, got ${actualThreshold}%)`
488+
);
489+
matched = false;
490+
}
491+
} else {
492+
// Other triggers just need to be triggered
493+
reasons.push(`${triggerType} trigger fired`);
494+
}
495+
}
496+
}
497+
466498
return { matched, reasons, trace };
467499
}
468500

0 commit comments

Comments
 (0)