@syntrologie/adapt-overlays 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/WorkflowTracker.d.ts +10 -0
- package/dist/WorkflowTracker.d.ts.map +1 -0
- package/dist/WorkflowTracker.js +19 -0
- package/dist/WorkflowWidget.d.ts +70 -0
- package/dist/WorkflowWidget.d.ts.map +1 -0
- package/dist/WorkflowWidget.js +329 -0
- package/dist/cdn.d.ts +2 -2
- package/dist/celebrations/__tests__/engine.test.d.ts +2 -0
- package/dist/celebrations/__tests__/engine.test.d.ts.map +1 -0
- package/dist/celebrations/__tests__/engine.test.js +130 -0
- package/dist/celebrations/__tests__/executor.test.d.ts +2 -0
- package/dist/celebrations/__tests__/executor.test.d.ts.map +1 -0
- package/dist/celebrations/__tests__/executor.test.js +102 -0
- package/dist/celebrations/effects/__tests__/confetti.test.d.ts +2 -0
- package/dist/celebrations/effects/__tests__/confetti.test.d.ts.map +1 -0
- package/dist/celebrations/effects/__tests__/confetti.test.js +89 -0
- package/dist/celebrations/effects/__tests__/emoji-rain.test.d.ts +2 -0
- package/dist/celebrations/effects/__tests__/emoji-rain.test.d.ts.map +1 -0
- package/dist/celebrations/effects/__tests__/emoji-rain.test.js +88 -0
- package/dist/celebrations/effects/__tests__/fireworks.test.d.ts +2 -0
- package/dist/celebrations/effects/__tests__/fireworks.test.d.ts.map +1 -0
- package/dist/celebrations/effects/__tests__/fireworks.test.js +87 -0
- package/dist/celebrations/effects/__tests__/sparkles.test.d.ts +2 -0
- package/dist/celebrations/effects/__tests__/sparkles.test.d.ts.map +1 -0
- package/dist/celebrations/effects/__tests__/sparkles.test.js +79 -0
- package/dist/celebrations/effects/confetti.d.ts +3 -0
- package/dist/celebrations/effects/confetti.d.ts.map +1 -0
- package/dist/celebrations/effects/confetti.js +80 -0
- package/dist/celebrations/effects/emoji-rain.d.ts +3 -0
- package/dist/celebrations/effects/emoji-rain.d.ts.map +1 -0
- package/dist/celebrations/effects/emoji-rain.js +73 -0
- package/dist/celebrations/effects/fireworks.d.ts +3 -0
- package/dist/celebrations/effects/fireworks.d.ts.map +1 -0
- package/dist/celebrations/effects/fireworks.js +69 -0
- package/dist/celebrations/effects/sparkles.d.ts +3 -0
- package/dist/celebrations/effects/sparkles.d.ts.map +1 -0
- package/dist/celebrations/effects/sparkles.js +83 -0
- package/dist/celebrations/engine.d.ts +16 -0
- package/dist/celebrations/engine.d.ts.map +1 -0
- package/dist/celebrations/engine.js +89 -0
- package/dist/celebrations/index.d.ts +3 -0
- package/dist/celebrations/index.d.ts.map +1 -0
- package/dist/celebrations/index.js +73 -0
- package/dist/celebrations/types.d.ts +34 -0
- package/dist/celebrations/types.d.ts.map +1 -0
- package/dist/celebrations/types.js +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +59 -5
- package/dist/executors/tour.d.ts +20 -0
- package/dist/executors/tour.d.ts.map +1 -0
- package/dist/executors/tour.js +335 -0
- package/dist/modal.d.ts +2 -0
- package/dist/modal.d.ts.map +1 -1
- package/dist/modal.js +18 -8
- package/dist/runtime.d.ts +25 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +141 -24
- package/dist/schema.d.ts +684 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +36 -0
- package/dist/summarize.d.ts.map +1 -1
- package/dist/summarize.js +15 -4
- package/dist/tooltip.d.ts.map +1 -1
- package/dist/tooltip.js +26 -12
- package/dist/tour-types.d.ts +34 -0
- package/dist/tour-types.d.ts.map +1 -0
- package/dist/tour-types.js +7 -0
- package/dist/types.d.ts +20 -85
- package/dist/types.d.ts.map +1 -1
- package/dist/workflow-types.d.ts +15 -0
- package/dist/workflow-types.d.ts.map +1 -0
- package/dist/workflow-types.js +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.d.ts +2 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/AnchorPicker.test.js +224 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/ConditionStatusLine.test.js +102 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/DetectionBadge.test.js +58 -6
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/DismissedSection.test.js +18 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/EditorCard.test.js +61 -2
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/EditorPanelShell.test.js +478 -7
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/ElementHighlight.test.js +54 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts +2 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/selectorGenerator.test.js +257 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.d.ts +2 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/__tests__/useTriggerWhenStatus.test.js +1015 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/AnchorPicker.js +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.d.ts +4 -4
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/ConditionStatusLine.js +2 -2
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.d.ts +2 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/DetectionBadge.js +20 -3
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.d.ts +10 -8
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/EditorPanelShell.js +350 -87
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/ElementHighlight.js +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.d.ts +3 -3
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/components/TriggerJourney.js +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.d.ts +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/formatConditionLabel.js +5 -2
- package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useTriggerWhenStatus.d.ts +24 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useTriggerWhenStatus.d.ts.map +1 -0
- package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/{useShowWhenStatus.js → useTriggerWhenStatus.js} +18 -15
- package/node_modules/@syntrologie/shared-editor-ui/dist/index.d.ts +3 -3
- package/node_modules/@syntrologie/shared-editor-ui/dist/index.d.ts.map +1 -1
- package/node_modules/@syntrologie/shared-editor-ui/dist/index.js +1 -1
- package/package.json +3 -2
- package/node_modules/@syntrologie/sdk-contracts/dist/index.d.ts +0 -26
- package/node_modules/@syntrologie/sdk-contracts/dist/index.js +0 -13
- package/node_modules/@syntrologie/sdk-contracts/dist/schemas.d.ts +0 -1428
- package/node_modules/@syntrologie/sdk-contracts/dist/schemas.js +0 -142
- package/node_modules/@syntrologie/sdk-contracts/package.json +0 -33
- package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useShowWhenStatus.d.ts +0 -24
- package/node_modules/@syntrologie/shared-editor-ui/dist/hooks/useShowWhenStatus.d.ts.map +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
/** Stub CanvasRenderingContext2D for jsdom */
|
|
3
|
+
function stubCanvasContext() {
|
|
4
|
+
const ctx = {
|
|
5
|
+
scale: vi.fn(),
|
|
6
|
+
clearRect: vi.fn(),
|
|
7
|
+
save: vi.fn(),
|
|
8
|
+
restore: vi.fn(),
|
|
9
|
+
translate: vi.fn(),
|
|
10
|
+
rotate: vi.fn(),
|
|
11
|
+
fillRect: vi.fn(),
|
|
12
|
+
beginPath: vi.fn(),
|
|
13
|
+
arc: vi.fn(),
|
|
14
|
+
fill: vi.fn(),
|
|
15
|
+
fillText: vi.fn(),
|
|
16
|
+
moveTo: vi.fn(),
|
|
17
|
+
lineTo: vi.fn(),
|
|
18
|
+
closePath: vi.fn(),
|
|
19
|
+
globalAlpha: 1,
|
|
20
|
+
fillStyle: '',
|
|
21
|
+
font: '',
|
|
22
|
+
shadowBlur: 0,
|
|
23
|
+
shadowColor: '',
|
|
24
|
+
textAlign: 'center',
|
|
25
|
+
textBaseline: 'middle',
|
|
26
|
+
};
|
|
27
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx);
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
function mockContext() {
|
|
31
|
+
const overlayRoot = document.createElement('div');
|
|
32
|
+
document.body.appendChild(overlayRoot);
|
|
33
|
+
return {
|
|
34
|
+
overlayRoot,
|
|
35
|
+
resolveAnchor: vi.fn(() => null),
|
|
36
|
+
generateId: vi.fn(() => 'test-id'),
|
|
37
|
+
publishEvent: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe('executeCelebrate', () => {
|
|
41
|
+
let context;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
context = mockContext();
|
|
44
|
+
stubCanvasContext();
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
context.overlayRoot.remove();
|
|
51
|
+
});
|
|
52
|
+
it('creates engine and starts confetti effect', async () => {
|
|
53
|
+
const { executeCelebrate } = await import('../index');
|
|
54
|
+
const result = await executeCelebrate({ kind: 'overlays:celebrate', effect: 'confetti' }, context);
|
|
55
|
+
const canvas = context.overlayRoot.querySelector('canvas[data-syntro-celebrate]');
|
|
56
|
+
expect(canvas).not.toBeNull();
|
|
57
|
+
result.cleanup();
|
|
58
|
+
});
|
|
59
|
+
it('returns cleanup that stops the engine', async () => {
|
|
60
|
+
const { executeCelebrate } = await import('../index');
|
|
61
|
+
const result = await executeCelebrate({ kind: 'overlays:celebrate', effect: 'confetti' }, context);
|
|
62
|
+
result.cleanup();
|
|
63
|
+
const canvas = context.overlayRoot.querySelector('canvas[data-syntro-celebrate]');
|
|
64
|
+
expect(canvas).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
it('handles unknown effect name gracefully', async () => {
|
|
67
|
+
const { executeCelebrate } = await import('../index');
|
|
68
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
69
|
+
const result = await executeCelebrate({ kind: 'overlays:celebrate', effect: 'unknown-effect' }, context);
|
|
70
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-effect'));
|
|
71
|
+
// Should still return a valid cleanup
|
|
72
|
+
expect(typeof result.cleanup).toBe('function');
|
|
73
|
+
result.cleanup();
|
|
74
|
+
warnSpy.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
it('publishes action.applied event', async () => {
|
|
77
|
+
const { executeCelebrate } = await import('../index');
|
|
78
|
+
await executeCelebrate({ kind: 'overlays:celebrate', effect: 'confetti' }, context);
|
|
79
|
+
expect(context.publishEvent).toHaveBeenCalledWith('action.applied', expect.objectContaining({ kind: 'overlays:celebrate', effect: 'confetti' }));
|
|
80
|
+
// Cleanup
|
|
81
|
+
const canvas = context.overlayRoot.querySelector('canvas[data-syntro-celebrate]');
|
|
82
|
+
canvas?.remove();
|
|
83
|
+
});
|
|
84
|
+
it('uses default config values when not specified', async () => {
|
|
85
|
+
const { executeCelebrate } = await import('../index');
|
|
86
|
+
const result = await executeCelebrate({ kind: 'overlays:celebrate', effect: 'fireworks' }, context);
|
|
87
|
+
// Should not throw — defaults are applied
|
|
88
|
+
const canvas = context.overlayRoot.querySelector('canvas[data-syntro-celebrate]');
|
|
89
|
+
expect(canvas).not.toBeNull();
|
|
90
|
+
result.cleanup();
|
|
91
|
+
});
|
|
92
|
+
it('supports all registered effect names', async () => {
|
|
93
|
+
const { executeCelebrate } = await import('../index');
|
|
94
|
+
const effectNames = ['confetti', 'fireworks', 'sparkles', 'emoji-rain'];
|
|
95
|
+
for (const effect of effectNames) {
|
|
96
|
+
const result = await executeCelebrate({ kind: 'overlays:celebrate', effect }, context);
|
|
97
|
+
const canvas = context.overlayRoot.querySelector('canvas[data-syntro-celebrate]');
|
|
98
|
+
expect(canvas).not.toBeNull();
|
|
99
|
+
result.cleanup();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confetti.test.d.ts","sourceRoot":"","sources":["../../../../src/celebrations/effects/__tests__/confetti.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const defaultConfig = {
|
|
3
|
+
duration: 3000,
|
|
4
|
+
intensity: 'medium',
|
|
5
|
+
colors: ['#ff0000', '#00ff00', '#0000ff'],
|
|
6
|
+
};
|
|
7
|
+
describe('confetti effect', () => {
|
|
8
|
+
describe('init', () => {
|
|
9
|
+
it('creates correct particle count for light intensity', async () => {
|
|
10
|
+
const { confettiEffect } = await import('../confetti');
|
|
11
|
+
const particles = confettiEffect.init(800, 600, { ...defaultConfig, intensity: 'light' });
|
|
12
|
+
expect(particles).toHaveLength(50);
|
|
13
|
+
});
|
|
14
|
+
it('creates correct particle count for medium intensity', async () => {
|
|
15
|
+
const { confettiEffect } = await import('../confetti');
|
|
16
|
+
const particles = confettiEffect.init(800, 600, { ...defaultConfig, intensity: 'medium' });
|
|
17
|
+
expect(particles).toHaveLength(100);
|
|
18
|
+
});
|
|
19
|
+
it('creates correct particle count for heavy intensity', async () => {
|
|
20
|
+
const { confettiEffect } = await import('../confetti');
|
|
21
|
+
const particles = confettiEffect.init(800, 600, { ...defaultConfig, intensity: 'heavy' });
|
|
22
|
+
expect(particles).toHaveLength(200);
|
|
23
|
+
});
|
|
24
|
+
it('uses colors from config', async () => {
|
|
25
|
+
const { confettiEffect } = await import('../confetti');
|
|
26
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
27
|
+
const usedColors = new Set(particles.map((p) => p.color));
|
|
28
|
+
// All particle colors should be from the provided palette
|
|
29
|
+
for (const color of usedColors) {
|
|
30
|
+
expect(defaultConfig.colors).toContain(color);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
it('creates particles with rect or circle shapes', async () => {
|
|
34
|
+
const { confettiEffect } = await import('../confetti');
|
|
35
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
36
|
+
const shapes = new Set(particles.map((p) => p.shape));
|
|
37
|
+
expect(shapes.size).toBeGreaterThanOrEqual(1);
|
|
38
|
+
for (const shape of shapes) {
|
|
39
|
+
expect(['rect', 'circle']).toContain(shape);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('update', () => {
|
|
44
|
+
it('applies gravity (vy increases each frame)', async () => {
|
|
45
|
+
const { confettiEffect } = await import('../confetti');
|
|
46
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
47
|
+
const initialVy = particles[0].vy;
|
|
48
|
+
confettiEffect.update(particles, 16, 16);
|
|
49
|
+
expect(particles[0].vy).toBeGreaterThan(initialVy);
|
|
50
|
+
});
|
|
51
|
+
it('returns true while particles are still visible', async () => {
|
|
52
|
+
const { confettiEffect } = await import('../confetti');
|
|
53
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
54
|
+
const alive = confettiEffect.update(particles, 16, 16);
|
|
55
|
+
expect(alive).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('returns false when all particles have faded', async () => {
|
|
58
|
+
const { confettiEffect } = await import('../confetti');
|
|
59
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
60
|
+
// Force all particles to be fully faded
|
|
61
|
+
for (const p of particles) {
|
|
62
|
+
p.opacity = 0;
|
|
63
|
+
}
|
|
64
|
+
const alive = confettiEffect.update(particles, 16, 5000);
|
|
65
|
+
expect(alive).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('render', () => {
|
|
69
|
+
it('draws to canvas context', async () => {
|
|
70
|
+
const { confettiEffect } = await import('../confetti');
|
|
71
|
+
const particles = confettiEffect.init(800, 600, defaultConfig);
|
|
72
|
+
const ctx = {
|
|
73
|
+
save: vi.fn(),
|
|
74
|
+
restore: vi.fn(),
|
|
75
|
+
translate: vi.fn(),
|
|
76
|
+
rotate: vi.fn(),
|
|
77
|
+
fillRect: vi.fn(),
|
|
78
|
+
beginPath: vi.fn(),
|
|
79
|
+
arc: vi.fn(),
|
|
80
|
+
fill: vi.fn(),
|
|
81
|
+
globalAlpha: 1,
|
|
82
|
+
fillStyle: '',
|
|
83
|
+
};
|
|
84
|
+
confettiEffect.render(ctx, particles);
|
|
85
|
+
expect(ctx.save).toHaveBeenCalled();
|
|
86
|
+
expect(ctx.restore).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emoji-rain.test.d.ts","sourceRoot":"","sources":["../../../../src/celebrations/effects/__tests__/emoji-rain.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const defaultConfig = {
|
|
3
|
+
duration: 3000,
|
|
4
|
+
intensity: 'medium',
|
|
5
|
+
colors: ['#000000'],
|
|
6
|
+
};
|
|
7
|
+
describe('emoji-rain effect', () => {
|
|
8
|
+
describe('init', () => {
|
|
9
|
+
it('creates emoji particles across the top of the screen', async () => {
|
|
10
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
11
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
12
|
+
expect(particles.length).toBeGreaterThan(0);
|
|
13
|
+
for (const p of particles) {
|
|
14
|
+
expect(p.shape).toBe('emoji');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('uses default emoji when none specified in props', async () => {
|
|
18
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
19
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
20
|
+
for (const p of particles) {
|
|
21
|
+
expect(p.emoji).toBeDefined();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
it('uses emoji from config.props when provided', async () => {
|
|
25
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
26
|
+
const particles = emojiRainEffect.init(800, 600, {
|
|
27
|
+
...defaultConfig,
|
|
28
|
+
props: { emoji: '🔥' },
|
|
29
|
+
});
|
|
30
|
+
for (const p of particles) {
|
|
31
|
+
expect(p.emoji).toBe('🔥');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it('scales count with intensity', async () => {
|
|
35
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
36
|
+
const light = emojiRainEffect.init(800, 600, { ...defaultConfig, intensity: 'light' });
|
|
37
|
+
const heavy = emojiRainEffect.init(800, 600, { ...defaultConfig, intensity: 'heavy' });
|
|
38
|
+
expect(heavy.length).toBeGreaterThan(light.length);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('update', () => {
|
|
42
|
+
it('moves particles downward (vy is positive)', async () => {
|
|
43
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
44
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
45
|
+
const initialY = particles[0].y;
|
|
46
|
+
emojiRainEffect.update(particles, 16, 16);
|
|
47
|
+
expect(particles[0].y).toBeGreaterThan(initialY);
|
|
48
|
+
});
|
|
49
|
+
it('applies horizontal wobble', async () => {
|
|
50
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
51
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
52
|
+
const initialX = particles[0].x;
|
|
53
|
+
// Run several frames to see wobble effect
|
|
54
|
+
for (let i = 0; i < 10; i++) {
|
|
55
|
+
emojiRainEffect.update(particles, 16, i * 16);
|
|
56
|
+
}
|
|
57
|
+
// X should have changed due to wobble
|
|
58
|
+
expect(particles[0].x).not.toBe(initialX);
|
|
59
|
+
});
|
|
60
|
+
it('returns false when all particles have faded', async () => {
|
|
61
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
62
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
63
|
+
for (const p of particles) {
|
|
64
|
+
p.opacity = 0;
|
|
65
|
+
}
|
|
66
|
+
expect(emojiRainEffect.update(particles, 16, 5000)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('render', () => {
|
|
70
|
+
it('renders emoji via fillText', async () => {
|
|
71
|
+
const { emojiRainEffect } = await import('../emoji-rain');
|
|
72
|
+
const particles = emojiRainEffect.init(800, 600, defaultConfig);
|
|
73
|
+
const ctx = {
|
|
74
|
+
save: vi.fn(),
|
|
75
|
+
restore: vi.fn(),
|
|
76
|
+
fillText: vi.fn(),
|
|
77
|
+
globalAlpha: 1,
|
|
78
|
+
font: '',
|
|
79
|
+
textAlign: 'center',
|
|
80
|
+
textBaseline: 'middle',
|
|
81
|
+
};
|
|
82
|
+
emojiRainEffect.render(ctx, particles);
|
|
83
|
+
expect(ctx.fillText).toHaveBeenCalled();
|
|
84
|
+
expect(ctx.save).toHaveBeenCalled();
|
|
85
|
+
expect(ctx.restore).toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fireworks.test.d.ts","sourceRoot":"","sources":["../../../../src/celebrations/effects/__tests__/fireworks.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const defaultConfig = {
|
|
3
|
+
duration: 3000,
|
|
4
|
+
intensity: 'medium',
|
|
5
|
+
colors: ['#ff0000', '#00ff00', '#0000ff'],
|
|
6
|
+
};
|
|
7
|
+
describe('fireworks effect', () => {
|
|
8
|
+
describe('init', () => {
|
|
9
|
+
it('creates burst groups with particles radiating outward', async () => {
|
|
10
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
11
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
12
|
+
// Medium intensity: 3-5 bursts × 40 particles each = 120-200
|
|
13
|
+
expect(particles.length).toBeGreaterThanOrEqual(60);
|
|
14
|
+
expect(particles.length).toBeLessThanOrEqual(400);
|
|
15
|
+
});
|
|
16
|
+
it('scales particle count with intensity', async () => {
|
|
17
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
18
|
+
const light = fireworksEffect.init(800, 600, { ...defaultConfig, intensity: 'light' });
|
|
19
|
+
const heavy = fireworksEffect.init(800, 600, { ...defaultConfig, intensity: 'heavy' });
|
|
20
|
+
expect(heavy.length).toBeGreaterThan(light.length);
|
|
21
|
+
});
|
|
22
|
+
it('positions bursts in upper 60% of screen', async () => {
|
|
23
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
24
|
+
const height = 600;
|
|
25
|
+
const particles = fireworksEffect.init(800, height, defaultConfig);
|
|
26
|
+
// Burst centers (stored in data.centerY) should be in upper 60%
|
|
27
|
+
for (const p of particles) {
|
|
28
|
+
if (p.data?.centerY !== undefined) {
|
|
29
|
+
expect(p.data.centerY).toBeLessThanOrEqual(height * 0.6);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
it('uses colors from config', async () => {
|
|
34
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
35
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
36
|
+
const usedColors = new Set(particles.map((p) => p.color));
|
|
37
|
+
for (const color of usedColors) {
|
|
38
|
+
expect(defaultConfig.colors).toContain(color);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('update', () => {
|
|
43
|
+
it('applies deceleration (velocity decreases each frame)', async () => {
|
|
44
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
45
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
46
|
+
const initialSpeed = Math.sqrt(particles[0].vx ** 2 + particles[0].vy ** 2);
|
|
47
|
+
fireworksEffect.update(particles, 16, 16);
|
|
48
|
+
const newSpeed = Math.sqrt(particles[0].vx ** 2 + particles[0].vy ** 2);
|
|
49
|
+
expect(newSpeed).toBeLessThan(initialSpeed);
|
|
50
|
+
});
|
|
51
|
+
it('returns true while particles are visible', async () => {
|
|
52
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
53
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
54
|
+
expect(fireworksEffect.update(particles, 16, 16)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
it('returns false when all particles have faded', async () => {
|
|
57
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
58
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
59
|
+
for (const p of particles) {
|
|
60
|
+
p.opacity = 0;
|
|
61
|
+
}
|
|
62
|
+
expect(fireworksEffect.update(particles, 16, 5000)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('render', () => {
|
|
66
|
+
it('draws circles with glow effect', async () => {
|
|
67
|
+
const { fireworksEffect } = await import('../fireworks');
|
|
68
|
+
const particles = fireworksEffect.init(800, 600, defaultConfig);
|
|
69
|
+
const ctx = {
|
|
70
|
+
save: vi.fn(),
|
|
71
|
+
restore: vi.fn(),
|
|
72
|
+
beginPath: vi.fn(),
|
|
73
|
+
arc: vi.fn(),
|
|
74
|
+
fill: vi.fn(),
|
|
75
|
+
globalAlpha: 1,
|
|
76
|
+
fillStyle: '',
|
|
77
|
+
shadowBlur: 0,
|
|
78
|
+
shadowColor: '',
|
|
79
|
+
};
|
|
80
|
+
fireworksEffect.render(ctx, particles);
|
|
81
|
+
expect(ctx.save).toHaveBeenCalled();
|
|
82
|
+
expect(ctx.beginPath).toHaveBeenCalled();
|
|
83
|
+
expect(ctx.arc).toHaveBeenCalled();
|
|
84
|
+
expect(ctx.restore).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sparkles.test.d.ts","sourceRoot":"","sources":["../../../../src/celebrations/effects/__tests__/sparkles.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const defaultConfig = {
|
|
3
|
+
duration: 3000,
|
|
4
|
+
intensity: 'medium',
|
|
5
|
+
colors: ['#ffd700', '#ffffff', '#fffacd'],
|
|
6
|
+
};
|
|
7
|
+
describe('sparkles effect', () => {
|
|
8
|
+
describe('init', () => {
|
|
9
|
+
it('creates particles at random positions across the screen', async () => {
|
|
10
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
11
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
12
|
+
expect(particles.length).toBeGreaterThan(0);
|
|
13
|
+
// Particles should be spread across the viewport
|
|
14
|
+
const xs = particles.map((p) => p.x);
|
|
15
|
+
const minX = Math.min(...xs);
|
|
16
|
+
const maxX = Math.max(...xs);
|
|
17
|
+
expect(maxX - minX).toBeGreaterThan(100);
|
|
18
|
+
});
|
|
19
|
+
it('scales count with intensity', async () => {
|
|
20
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
21
|
+
const light = sparklesEffect.init(800, 600, { ...defaultConfig, intensity: 'light' });
|
|
22
|
+
const heavy = sparklesEffect.init(800, 600, { ...defaultConfig, intensity: 'heavy' });
|
|
23
|
+
expect(heavy.length).toBeGreaterThan(light.length);
|
|
24
|
+
});
|
|
25
|
+
it('uses colors from config', async () => {
|
|
26
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
27
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
28
|
+
for (const p of particles) {
|
|
29
|
+
expect(defaultConfig.colors).toContain(p.color);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('update', () => {
|
|
34
|
+
it('moves particles upward (vy is negative)', async () => {
|
|
35
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
36
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
37
|
+
const initialY = particles[0].y;
|
|
38
|
+
sparklesEffect.update(particles, 16, 16);
|
|
39
|
+
expect(particles[0].y).toBeLessThan(initialY);
|
|
40
|
+
});
|
|
41
|
+
it('returns true while particles are visible', async () => {
|
|
42
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
43
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
44
|
+
expect(sparklesEffect.update(particles, 16, 16)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('returns false when all particles have faded', async () => {
|
|
47
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
48
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
49
|
+
for (const p of particles) {
|
|
50
|
+
p.opacity = 0;
|
|
51
|
+
if (p.data)
|
|
52
|
+
p.data.baseOpacity = 0;
|
|
53
|
+
}
|
|
54
|
+
expect(sparklesEffect.update(particles, 16, 5000)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('render', () => {
|
|
58
|
+
it('draws to canvas context', async () => {
|
|
59
|
+
const { sparklesEffect } = await import('../sparkles');
|
|
60
|
+
const particles = sparklesEffect.init(800, 600, defaultConfig);
|
|
61
|
+
const ctx = {
|
|
62
|
+
save: vi.fn(),
|
|
63
|
+
restore: vi.fn(),
|
|
64
|
+
translate: vi.fn(),
|
|
65
|
+
rotate: vi.fn(),
|
|
66
|
+
beginPath: vi.fn(),
|
|
67
|
+
moveTo: vi.fn(),
|
|
68
|
+
lineTo: vi.fn(),
|
|
69
|
+
closePath: vi.fn(),
|
|
70
|
+
fill: vi.fn(),
|
|
71
|
+
globalAlpha: 1,
|
|
72
|
+
fillStyle: '',
|
|
73
|
+
};
|
|
74
|
+
sparklesEffect.render(ctx, particles);
|
|
75
|
+
expect(ctx.save).toHaveBeenCalled();
|
|
76
|
+
expect(ctx.restore).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confetti.d.ts","sourceRoot":"","sources":["../../../src/celebrations/effects/confetti.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,iBAAiB,EAAY,MAAM,UAAU,CAAC;AAe/E,eAAO,MAAM,cAAc,EAAE,iBA2E5B,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const INTENSITY_COUNTS = { light: 50, medium: 100, heavy: 200 };
|
|
2
|
+
const DEFAULT_COLORS = [
|
|
3
|
+
'#ff0000',
|
|
4
|
+
'#00ff00',
|
|
5
|
+
'#0000ff',
|
|
6
|
+
'#ffff00',
|
|
7
|
+
'#ff00ff',
|
|
8
|
+
'#00ffff',
|
|
9
|
+
'#ff8800',
|
|
10
|
+
'#8800ff',
|
|
11
|
+
];
|
|
12
|
+
export const confettiEffect = {
|
|
13
|
+
init(width, height, config) {
|
|
14
|
+
const count = INTENSITY_COUNTS[config.intensity];
|
|
15
|
+
const colors = config.colors.length > 0 ? config.colors : DEFAULT_COLORS;
|
|
16
|
+
const particles = [];
|
|
17
|
+
for (let i = 0; i < count; i++) {
|
|
18
|
+
particles.push({
|
|
19
|
+
x: Math.random() * width,
|
|
20
|
+
y: Math.random() * -height * 0.3,
|
|
21
|
+
vx: (Math.random() - 0.5) * 4,
|
|
22
|
+
vy: Math.random() * 2 + 1,
|
|
23
|
+
rotation: Math.random() * Math.PI * 2,
|
|
24
|
+
rotationSpeed: (Math.random() - 0.5) * 0.2,
|
|
25
|
+
size: Math.random() * 6 + 4,
|
|
26
|
+
color: colors[Math.floor(Math.random() * colors.length)],
|
|
27
|
+
opacity: 1,
|
|
28
|
+
shape: Math.random() > 0.5 ? 'rect' : 'circle',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return particles;
|
|
32
|
+
},
|
|
33
|
+
update(particles, _dt, _elapsed) {
|
|
34
|
+
let anyVisible = false;
|
|
35
|
+
for (const p of particles) {
|
|
36
|
+
// Gravity
|
|
37
|
+
p.vy += 0.15;
|
|
38
|
+
// Air resistance
|
|
39
|
+
p.vx *= 0.99;
|
|
40
|
+
// Movement
|
|
41
|
+
p.x += p.vx;
|
|
42
|
+
p.y += p.vy;
|
|
43
|
+
// Rotation
|
|
44
|
+
p.rotation += p.rotationSpeed;
|
|
45
|
+
// Fade when past 80% of a typical viewport height (use a reasonable default)
|
|
46
|
+
if (p.y > 0 && p.opacity > 0) {
|
|
47
|
+
// Gradually fade based on how far the particle has traveled
|
|
48
|
+
if (p.y > 500) {
|
|
49
|
+
p.opacity -= 0.02;
|
|
50
|
+
if (p.opacity < 0)
|
|
51
|
+
p.opacity = 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (p.opacity > 0.01) {
|
|
55
|
+
anyVisible = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return anyVisible;
|
|
59
|
+
},
|
|
60
|
+
render(ctx, particles) {
|
|
61
|
+
for (const p of particles) {
|
|
62
|
+
if (p.opacity < 0.01)
|
|
63
|
+
continue;
|
|
64
|
+
ctx.save();
|
|
65
|
+
ctx.globalAlpha = p.opacity;
|
|
66
|
+
ctx.fillStyle = p.color;
|
|
67
|
+
ctx.translate(p.x, p.y);
|
|
68
|
+
ctx.rotate(p.rotation);
|
|
69
|
+
if (p.shape === 'circle') {
|
|
70
|
+
ctx.beginPath();
|
|
71
|
+
ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
|
|
72
|
+
ctx.fill();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
|
|
76
|
+
}
|
|
77
|
+
ctx.restore();
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emoji-rain.d.ts","sourceRoot":"","sources":["../../../src/celebrations/effects/emoji-rain.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,iBAAiB,EAAY,MAAM,UAAU,CAAC;AAK/E,eAAO,MAAM,eAAe,EAAE,iBA8E7B,CAAC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const INTENSITY_COUNTS = { light: 20, medium: 40, heavy: 80 };
|
|
2
|
+
const DEFAULT_EMOJI = '🎉';
|
|
3
|
+
export const emojiRainEffect = {
|
|
4
|
+
init(width, _height, config) {
|
|
5
|
+
const count = INTENSITY_COUNTS[config.intensity];
|
|
6
|
+
const emoji = typeof config.props?.emoji === 'string' ? config.props.emoji : DEFAULT_EMOJI;
|
|
7
|
+
const particles = [];
|
|
8
|
+
for (let i = 0; i < count; i++) {
|
|
9
|
+
particles.push({
|
|
10
|
+
x: Math.random() * width,
|
|
11
|
+
y: Math.random() * -200,
|
|
12
|
+
vx: 0,
|
|
13
|
+
vy: Math.random() * 1.5 + 1,
|
|
14
|
+
rotation: 0,
|
|
15
|
+
rotationSpeed: 0,
|
|
16
|
+
size: Math.random() * 12 + 16,
|
|
17
|
+
color: '#000000',
|
|
18
|
+
opacity: 1,
|
|
19
|
+
shape: 'emoji',
|
|
20
|
+
emoji,
|
|
21
|
+
data: {
|
|
22
|
+
/** Phase offset for horizontal wobble */
|
|
23
|
+
wobblePhase: Math.random() * Math.PI * 2,
|
|
24
|
+
/** Amplitude of horizontal wobble */
|
|
25
|
+
wobbleAmp: Math.random() * 1.5 + 0.5,
|
|
26
|
+
/** Original x for wobble base */
|
|
27
|
+
originX: 0,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// Store originX after x is set
|
|
32
|
+
for (const p of particles) {
|
|
33
|
+
if (p.data)
|
|
34
|
+
p.data.originX = p.x;
|
|
35
|
+
}
|
|
36
|
+
return particles;
|
|
37
|
+
},
|
|
38
|
+
update(particles, _dt, elapsed) {
|
|
39
|
+
let anyVisible = false;
|
|
40
|
+
for (const p of particles) {
|
|
41
|
+
// Fall downward
|
|
42
|
+
p.y += p.vy;
|
|
43
|
+
// Horizontal wobble via sine wave
|
|
44
|
+
const phase = p.data?.wobblePhase ?? 0;
|
|
45
|
+
const amp = p.data?.wobbleAmp ?? 1;
|
|
46
|
+
const originX = p.data?.originX ?? p.x;
|
|
47
|
+
p.x = originX + Math.sin(elapsed * 0.003 + phase) * amp * 20;
|
|
48
|
+
// Fade when past bottom of viewport
|
|
49
|
+
if (p.y > 600) {
|
|
50
|
+
p.opacity -= 0.02;
|
|
51
|
+
if (p.opacity < 0)
|
|
52
|
+
p.opacity = 0;
|
|
53
|
+
}
|
|
54
|
+
if (p.opacity > 0.01) {
|
|
55
|
+
anyVisible = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return anyVisible;
|
|
59
|
+
},
|
|
60
|
+
render(ctx, particles) {
|
|
61
|
+
for (const p of particles) {
|
|
62
|
+
if (p.opacity < 0.01 || !p.emoji)
|
|
63
|
+
continue;
|
|
64
|
+
ctx.save();
|
|
65
|
+
ctx.globalAlpha = p.opacity;
|
|
66
|
+
ctx.font = `${p.size}px serif`;
|
|
67
|
+
ctx.textAlign = 'center';
|
|
68
|
+
ctx.textBaseline = 'middle';
|
|
69
|
+
ctx.fillText(p.emoji, p.x, p.y);
|
|
70
|
+
ctx.restore();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fireworks.d.ts","sourceRoot":"","sources":["../../../src/celebrations/effects/fireworks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,iBAAiB,EAAY,MAAM,UAAU,CAAC;AAK/E,eAAO,MAAM,eAAe,EAAE,iBA4E7B,CAAC"}
|