@syntrologie/adapt-overlays 2.4.1 → 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,10 @@
|
|
|
1
|
+
import type { WorkflowEntry } from './workflow-types';
|
|
2
|
+
interface WorkflowTrackerProps {
|
|
3
|
+
workflows: WorkflowEntry[];
|
|
4
|
+
expanded?: boolean;
|
|
5
|
+
onStepClick: (tourId: string, stepId: string) => void;
|
|
6
|
+
onDismiss: (tourId: string) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function WorkflowTracker({ workflows, expanded, onStepClick, onDismiss, }: WorkflowTrackerProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=WorkflowTracker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WorkflowTracker.d.ts","sourceRoot":"","sources":["../src/WorkflowTracker.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,UAAU,oBAAoB;IAC5B,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAkID,wBAAgB,eAAe,CAAC,EAC9B,SAAS,EACT,QAAQ,EACR,WAAW,EACX,SAAS,GACV,EAAE,oBAAoB,2CAsBtB"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
function ProgressBar({ completed, total }) {
|
|
3
|
+
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
4
|
+
return (_jsx("div", { className: "se-w-full se-h-1.5 se-rounded-full se-bg-white/[0.08] se-overflow-hidden", children: _jsx("div", { role: "progressbar", "aria-valuenow": percent, "aria-valuemin": 0, "aria-valuemax": 100, className: "se-h-full se-rounded-full se-bg-blue-5 se-transition-all se-duration-300", style: { width: `${percent}%` } }) }));
|
|
5
|
+
}
|
|
6
|
+
function StepItem({ step, isCompleted, isCurrent, tourId, onStepClick, }) {
|
|
7
|
+
return (_jsxs("button", { type: "button", "data-testid": `step-${step.id}`, "data-current": isCurrent ? 'true' : undefined, "data-completed": isCompleted ? 'true' : undefined, "aria-current": isCurrent ? 'step' : undefined, className: `se-flex se-items-center se-gap-2 se-w-full se-py-1.5 se-px-2 se-rounded se-border-none se-bg-transparent se-cursor-pointer se-text-left se-text-xs ${isCurrent ? 'se-font-semibold se-text-slate-grey-10' : 'se-text-slate-grey-8'}`, onClick: () => onStepClick(tourId, step.id), children: [_jsx("span", { className: "se-shrink-0 se-w-4 se-text-center", children: isCompleted ? (_jsx("span", { role: "img", "aria-label": "completed", className: "se-text-green-5", children: "\u2713" })) : isCurrent ? (_jsx("span", { className: "se-inline-block se-w-1.5 se-h-1.5 se-rounded-full se-bg-blue-5" })) : (_jsx("span", { className: "se-inline-block se-w-1.5 se-h-1.5 se-rounded-full se-bg-white/[0.12]" })) }), _jsx("span", { className: "se-flex-1 se-overflow-hidden se-text-ellipsis se-whitespace-nowrap", children: step.title })] }));
|
|
8
|
+
}
|
|
9
|
+
function WorkflowCard({ workflow, expanded, onStepClick, onDismiss, }) {
|
|
10
|
+
const completedCount = workflow.completedSteps.length;
|
|
11
|
+
const totalSteps = workflow.steps.length;
|
|
12
|
+
return (_jsxs("div", { className: "se-p-3 se-rounded-lg se-border se-border-white/[0.08] se-bg-white/[0.02]", children: [_jsxs("div", { className: "se-flex se-items-center se-gap-2 se-mb-2", children: [workflow.meta.icon && (_jsx("span", { "data-testid": "workflow-icon", className: "se-shrink-0 se-text-sm", children: workflow.meta.icon })), _jsx("span", { className: "se-flex-1 se-text-[13px] se-font-semibold se-text-slate-grey-10 se-overflow-hidden se-text-ellipsis se-whitespace-nowrap", children: workflow.meta.title }), _jsx("button", { type: "button", "data-testid": `dismiss-${workflow.tourId}`, className: "se-shrink-0 se-py-0.5 se-px-1.5 se-rounded se-border-none se-bg-transparent se-text-slate-grey-7 se-text-xs se-cursor-pointer se-leading-none", onClick: () => onDismiss(workflow.tourId), "aria-label": `Dismiss ${workflow.meta.title}`, children: "\u2715" })] }), _jsxs("div", { className: "se-mb-2", children: [_jsx(ProgressBar, { completed: completedCount, total: totalSteps }), _jsxs("div", { className: "se-text-[10px] se-text-slate-grey-7 se-mt-1", children: [completedCount, " of ", totalSteps, " steps"] })] }), _jsx("div", { className: "se-flex se-flex-col", children: workflow.steps.map((step) => (_jsx(StepItem, { step: step, isCompleted: workflow.completedSteps.includes(step.id), isCurrent: workflow.currentStepId === step.id, tourId: workflow.tourId, onStepClick: onStepClick }, step.id))) }), expanded && workflow.meta.description && (_jsx("div", { className: "se-mt-2 se-text-[11px] se-text-slate-grey-7", children: workflow.meta.description }))] }));
|
|
13
|
+
}
|
|
14
|
+
export function WorkflowTracker({ workflows, expanded, onStepClick, onDismiss, }) {
|
|
15
|
+
if (workflows.length === 0) {
|
|
16
|
+
return (_jsx("div", { className: "se-flex se-items-center se-justify-center se-py-6 se-text-xs se-text-slate-grey-7", children: "No active workflows" }));
|
|
17
|
+
}
|
|
18
|
+
return (_jsx("div", { className: "se-flex se-flex-col se-gap-2", children: workflows.map((workflow) => (_jsx(WorkflowCard, { workflow: workflow, expanded: expanded, onStepClick: onStepClick, onDismiss: onDismiss }, workflow.tourId))) }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Widget - MountableWidget for the WorkflowTracker
|
|
3
|
+
*
|
|
4
|
+
* Bridges runtime.actions.getActive() (tour actions with workflow metadata),
|
|
5
|
+
* runtime events (tour.started, tour.step_started, etc.), and the
|
|
6
|
+
* WorkflowTracker presentational component.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* 1. Scan runtime.actions.getActive() for tours with `workflow` field
|
|
10
|
+
* 2. Re-scan on tour.started events (tiles render before actions are applied)
|
|
11
|
+
* 3. Load persisted state from runtime.state.user namespace
|
|
12
|
+
* 4. Subscribe to tour.* events and update workflow entries
|
|
13
|
+
* 5. Render WorkflowTracker with derived workflow entries
|
|
14
|
+
* 6. Handle step clicks (publish workflow:jump_to_step)
|
|
15
|
+
* 7. Handle dismiss (persist and re-render)
|
|
16
|
+
*/
|
|
17
|
+
interface ActiveAction {
|
|
18
|
+
id: string;
|
|
19
|
+
action: Record<string, unknown>;
|
|
20
|
+
adaptiveId?: string;
|
|
21
|
+
appliedTs: number;
|
|
22
|
+
state: string;
|
|
23
|
+
}
|
|
24
|
+
interface WorkflowRuntime {
|
|
25
|
+
events: {
|
|
26
|
+
subscribe: (filter: {
|
|
27
|
+
patterns?: string[];
|
|
28
|
+
names?: string[];
|
|
29
|
+
}, callback: (event: {
|
|
30
|
+
name: string;
|
|
31
|
+
props?: Record<string, unknown>;
|
|
32
|
+
ts: number;
|
|
33
|
+
}) => void) => () => void;
|
|
34
|
+
publish: (name: string, props?: Record<string, unknown>) => void;
|
|
35
|
+
};
|
|
36
|
+
actions: {
|
|
37
|
+
getActive: () => ActiveAction[];
|
|
38
|
+
};
|
|
39
|
+
state?: {
|
|
40
|
+
user?: {
|
|
41
|
+
ns?: (namespace: string) => {
|
|
42
|
+
get: (key: string) => unknown;
|
|
43
|
+
set: (key: string, value: unknown) => void;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* MountableWidget contract — matches the interface from @syntrologie/runtime-sdk
|
|
50
|
+
* Defined locally to avoid adding runtime-sdk as a dependency (same pattern as other adaptives).
|
|
51
|
+
*/
|
|
52
|
+
export interface MountableWidget {
|
|
53
|
+
mount(container: HTMLElement, config?: Record<string, unknown>): (() => void) | void;
|
|
54
|
+
update?(container: HTMLElement, config?: Record<string, unknown>): void;
|
|
55
|
+
}
|
|
56
|
+
interface WorkflowWidgetInnerProps {
|
|
57
|
+
runtime: WorkflowRuntime | null;
|
|
58
|
+
}
|
|
59
|
+
export declare function WorkflowWidgetInner({ runtime }: WorkflowWidgetInnerProps): import("react/jsx-runtime").JSX.Element;
|
|
60
|
+
/**
|
|
61
|
+
* Mountable widget interface for the runtime's WidgetRegistry.
|
|
62
|
+
*
|
|
63
|
+
* Follows the MountableWidget contract:
|
|
64
|
+
* mount(container, config?) -> cleanup | void
|
|
65
|
+
*
|
|
66
|
+
* Config is enriched with `runtime` by WidgetRegistry.bindRuntime().
|
|
67
|
+
*/
|
|
68
|
+
export declare const WorkflowMountableWidget: MountableWidget;
|
|
69
|
+
export {};
|
|
70
|
+
//# sourceMappingURL=WorkflowWidget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WorkflowWidget.d.ts","sourceRoot":"","sources":["../src/WorkflowWidget.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAYH,UAAU,YAAY;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,UAAU,eAAe;IACvB,MAAM,EAAE;QACN,SAAS,EAAE,CACT,MAAM,EAAE;YAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;SAAE,EACjD,QAAQ,EAAE,CAAC,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAAC,EAAE,EAAE,MAAM,CAAA;SAAE,KAAK,IAAI,KACrF,MAAM,IAAI,CAAC;QAChB,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;KAClE,CAAC;IACF,OAAO,EAAE;QACP,SAAS,EAAE,MAAM,YAAY,EAAE,CAAC;KACjC,CAAC;IACF,KAAK,CAAC,EAAE;QACN,IAAI,CAAC,EAAE;YACL,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK;gBAC1B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;gBAC9B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;aAC5C,CAAC;SACH,CAAC;KACH,CAAC;CACH;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,SAAS,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IACrF,MAAM,CAAC,CAAC,SAAS,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzE;AAsGD,UAAU,wBAAwB;IAChC,OAAO,EAAE,eAAe,GAAG,IAAI,CAAC;CACjC;AAED,wBAAgB,mBAAmB,CAAC,EAAE,OAAO,EAAE,EAAE,wBAAwB,2CA0QxE;AAMD;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,EAAE,eAiBrC,CAAC"}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Workflow Widget - MountableWidget for the WorkflowTracker
|
|
4
|
+
*
|
|
5
|
+
* Bridges runtime.actions.getActive() (tour actions with workflow metadata),
|
|
6
|
+
* runtime events (tour.started, tour.step_started, etc.), and the
|
|
7
|
+
* WorkflowTracker presentational component.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* 1. Scan runtime.actions.getActive() for tours with `workflow` field
|
|
11
|
+
* 2. Re-scan on tour.started events (tiles render before actions are applied)
|
|
12
|
+
* 3. Load persisted state from runtime.state.user namespace
|
|
13
|
+
* 4. Subscribe to tour.* events and update workflow entries
|
|
14
|
+
* 5. Render WorkflowTracker with derived workflow entries
|
|
15
|
+
* 6. Handle step clicks (publish workflow:jump_to_step)
|
|
16
|
+
* 7. Handle dismiss (persist and re-render)
|
|
17
|
+
*/
|
|
18
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import { createRoot } from 'react-dom/client';
|
|
20
|
+
import { WorkflowTracker } from './WorkflowTracker';
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
/**
|
|
25
|
+
* Show a toast notification for a workflow tour.
|
|
26
|
+
* Creates a DOM element, appends it to the container, and auto-removes after 4 seconds.
|
|
27
|
+
*/
|
|
28
|
+
function showWorkflowToast(container, notification) {
|
|
29
|
+
const toast = document.createElement('div');
|
|
30
|
+
toast.setAttribute('data-testid', 'workflow-toast');
|
|
31
|
+
toast.className = 'se-fixed se-bottom-4 se-right-4 se-z-50';
|
|
32
|
+
Object.assign(toast.style, {
|
|
33
|
+
position: 'fixed',
|
|
34
|
+
bottom: '16px',
|
|
35
|
+
right: '16px',
|
|
36
|
+
zIndex: '2147483646',
|
|
37
|
+
padding: '12px 16px',
|
|
38
|
+
borderRadius: '8px',
|
|
39
|
+
backgroundColor: 'var(--se-color-bg-surface, #fff)',
|
|
40
|
+
color: 'var(--se-color-text-primary, #1a1a1a)',
|
|
41
|
+
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
|
42
|
+
maxWidth: '320px',
|
|
43
|
+
fontFamily: 'var(--se-font-family, system-ui, sans-serif)',
|
|
44
|
+
fontSize: '14px',
|
|
45
|
+
lineHeight: '1.4',
|
|
46
|
+
transition: 'opacity 0.3s ease',
|
|
47
|
+
});
|
|
48
|
+
const titleEl = document.createElement('div');
|
|
49
|
+
titleEl.className = 'se-font-semibold';
|
|
50
|
+
titleEl.style.fontWeight = '600';
|
|
51
|
+
titleEl.textContent = notification.title;
|
|
52
|
+
toast.appendChild(titleEl);
|
|
53
|
+
if (notification.body) {
|
|
54
|
+
const bodyEl = document.createElement('div');
|
|
55
|
+
bodyEl.className = 'se-mt-1 se-text-sm';
|
|
56
|
+
bodyEl.style.marginTop = '4px';
|
|
57
|
+
bodyEl.style.fontSize = '13px';
|
|
58
|
+
bodyEl.style.color = 'var(--se-color-text-secondary, #666)';
|
|
59
|
+
bodyEl.textContent = notification.body;
|
|
60
|
+
toast.appendChild(bodyEl);
|
|
61
|
+
}
|
|
62
|
+
container.appendChild(toast);
|
|
63
|
+
// Auto-remove after 4 seconds
|
|
64
|
+
let removeTimer;
|
|
65
|
+
const fadeTimer = setTimeout(() => {
|
|
66
|
+
toast.style.opacity = '0';
|
|
67
|
+
removeTimer = setTimeout(() => {
|
|
68
|
+
toast.remove();
|
|
69
|
+
}, 300);
|
|
70
|
+
}, 4000);
|
|
71
|
+
// Return cleanup function for unmount safety
|
|
72
|
+
return () => {
|
|
73
|
+
clearTimeout(fadeTimer);
|
|
74
|
+
clearTimeout(removeTimer);
|
|
75
|
+
toast.remove();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract workflow-enabled tours from active actions (runtime.actions.getActive()).
|
|
80
|
+
* Only actions with kind 'core:tour', a tourId, and a workflow field are included.
|
|
81
|
+
*/
|
|
82
|
+
function extractWorkflowsFromActive(activeActions) {
|
|
83
|
+
const workflows = new Map();
|
|
84
|
+
for (const entry of activeActions) {
|
|
85
|
+
const action = entry.action;
|
|
86
|
+
if (action.kind === 'core:tour' && action.workflow && action.tourId) {
|
|
87
|
+
const meta = action.workflow;
|
|
88
|
+
const rawSteps = action.steps || [];
|
|
89
|
+
const steps = rawSteps.map((s) => ({
|
|
90
|
+
id: s.id,
|
|
91
|
+
title: meta.stepTitles?.[s.id] || s.id,
|
|
92
|
+
}));
|
|
93
|
+
workflows.set(action.tourId, { meta, steps });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return workflows;
|
|
97
|
+
}
|
|
98
|
+
export function WorkflowWidgetInner({ runtime }) {
|
|
99
|
+
// Scan active actions for workflow-enabled tours.
|
|
100
|
+
// Re-scans when actionVersion bumps (triggered by tour.started events).
|
|
101
|
+
const [actionVersion, setActionVersion] = useState(0);
|
|
102
|
+
const tourWorkflows = useMemo(() => {
|
|
103
|
+
const active = runtime?.actions?.getActive?.() || [];
|
|
104
|
+
return extractWorkflowsFromActive(active);
|
|
105
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
|
+
}, [runtime, actionVersion]);
|
|
107
|
+
// Keep a ref so event handlers always see the latest tourWorkflows
|
|
108
|
+
const tourWorkflowsRef = useRef(tourWorkflows);
|
|
109
|
+
tourWorkflowsRef.current = tourWorkflows;
|
|
110
|
+
// Re-scan active actions when a tour starts (handles tile-renders-before-actions timing)
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!runtime?.events?.subscribe)
|
|
113
|
+
return;
|
|
114
|
+
return runtime.events.subscribe({ names: ['tour.started', 'tour.resumed'] }, () => setActionVersion((v) => v + 1));
|
|
115
|
+
}, [runtime]);
|
|
116
|
+
// Load persisted state
|
|
117
|
+
const stateNs = useMemo(() => runtime?.state?.user?.ns?.('workflows'), [runtime]);
|
|
118
|
+
// Ref to track which tours have had a toast notification
|
|
119
|
+
const notifiedRef = useRef(new Set());
|
|
120
|
+
// Ref to track toast cleanup functions for unmount safety
|
|
121
|
+
const toastCleanupsRef = useRef([]);
|
|
122
|
+
// Ref to track the container element for toast rendering
|
|
123
|
+
const containerRef = useRef(null);
|
|
124
|
+
// Ref to track completed tour timestamps
|
|
125
|
+
const completedMapRef = useRef({});
|
|
126
|
+
// Build workflow entries from discovered tours + persisted state
|
|
127
|
+
const [workflowEntries, setWorkflowEntries] = useState([]);
|
|
128
|
+
// Initialize persisted state refs once
|
|
129
|
+
const persistInitialized = useRef(false);
|
|
130
|
+
if (!persistInitialized.current && stateNs) {
|
|
131
|
+
const notified = stateNs.get?.('notified') || [];
|
|
132
|
+
for (const id of notified) {
|
|
133
|
+
notifiedRef.current.add(id);
|
|
134
|
+
}
|
|
135
|
+
const completed = stateNs.get?.('completed') || {};
|
|
136
|
+
completedMapRef.current = { ...completed };
|
|
137
|
+
persistInitialized.current = true;
|
|
138
|
+
}
|
|
139
|
+
// Sync workflowEntries when tourWorkflows changes (new tours discovered via getActive)
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (tourWorkflows.size === 0)
|
|
142
|
+
return;
|
|
143
|
+
const dismissed = stateNs?.get?.('dismissed') || [];
|
|
144
|
+
const completed = stateNs?.get?.('completed') || {};
|
|
145
|
+
setWorkflowEntries((prev) => {
|
|
146
|
+
const existingIds = new Set(prev.map((e) => e.tourId));
|
|
147
|
+
const newEntries = [];
|
|
148
|
+
for (const [tourId, { meta, steps }] of tourWorkflows) {
|
|
149
|
+
if (existingIds.has(tourId))
|
|
150
|
+
continue; // Already tracked
|
|
151
|
+
let status = 'active';
|
|
152
|
+
if (dismissed.includes(tourId)) {
|
|
153
|
+
status = 'dismissed';
|
|
154
|
+
}
|
|
155
|
+
else if (completed[tourId]) {
|
|
156
|
+
status = 'completed';
|
|
157
|
+
}
|
|
158
|
+
newEntries.push({
|
|
159
|
+
tourId,
|
|
160
|
+
meta,
|
|
161
|
+
steps,
|
|
162
|
+
currentStepId: null,
|
|
163
|
+
completedSteps: [],
|
|
164
|
+
status,
|
|
165
|
+
completedAt: completed[tourId] || undefined,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return newEntries.length > 0 ? [...prev, ...newEntries] : prev;
|
|
169
|
+
});
|
|
170
|
+
// Fire toast for newly discovered active tours.
|
|
171
|
+
// This handles the race condition where tour.started fires before the
|
|
172
|
+
// widget discovers the tour via getActive() re-scan.
|
|
173
|
+
for (const [tourId, { meta }] of tourWorkflows) {
|
|
174
|
+
if (!notifiedRef.current.has(tourId) &&
|
|
175
|
+
meta.notification &&
|
|
176
|
+
containerRef.current &&
|
|
177
|
+
!dismissed.includes(tourId) &&
|
|
178
|
+
!completed[tourId]) {
|
|
179
|
+
notifiedRef.current.add(tourId);
|
|
180
|
+
stateNs?.set?.('notified', [...notifiedRef.current]);
|
|
181
|
+
const cleanup = showWorkflowToast(containerRef.current, meta.notification);
|
|
182
|
+
toastCleanupsRef.current.push(cleanup);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, [tourWorkflows, stateNs]);
|
|
186
|
+
// Subscribe to tour events
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!runtime?.events?.subscribe)
|
|
189
|
+
return;
|
|
190
|
+
const unsubscribe = runtime.events.subscribe({ patterns: ['^tour\\.'] }, (event) => {
|
|
191
|
+
const tourId = event.props?.tourId;
|
|
192
|
+
if (!tourId)
|
|
193
|
+
return;
|
|
194
|
+
// Re-scan active actions on tour.started (handles tile-renders-before-actions timing)
|
|
195
|
+
const currentWorkflows = tourWorkflowsRef.current;
|
|
196
|
+
if (!currentWorkflows.has(tourId) && event.name === 'tour.started') {
|
|
197
|
+
// Tour just became active — re-scan will pick it up on next render
|
|
198
|
+
setActionVersion((v) => v + 1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Only process events for tours that have workflow metadata
|
|
202
|
+
if (!currentWorkflows.has(tourId))
|
|
203
|
+
return;
|
|
204
|
+
setWorkflowEntries((prev) => {
|
|
205
|
+
const updated = prev.map((entry) => {
|
|
206
|
+
if (entry.tourId !== tourId)
|
|
207
|
+
return entry;
|
|
208
|
+
switch (event.name) {
|
|
209
|
+
case 'tour.started': {
|
|
210
|
+
const startStepId = event.props?.startStepId || entry.steps[0]?.id || null;
|
|
211
|
+
// Fire toast notification if not already notified
|
|
212
|
+
if (!notifiedRef.current.has(tourId)) {
|
|
213
|
+
notifiedRef.current.add(tourId);
|
|
214
|
+
stateNs?.set?.('notified', [...notifiedRef.current]);
|
|
215
|
+
const workflow = currentWorkflows.get(tourId);
|
|
216
|
+
if (workflow?.meta.notification && containerRef.current) {
|
|
217
|
+
const cleanup = showWorkflowToast(containerRef.current, workflow.meta.notification);
|
|
218
|
+
toastCleanupsRef.current.push(cleanup);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Persist active state
|
|
222
|
+
const activeIds = prev
|
|
223
|
+
.filter((e) => e.status === 'active' || e.tourId === tourId)
|
|
224
|
+
.map((e) => e.tourId);
|
|
225
|
+
if (!activeIds.includes(tourId)) {
|
|
226
|
+
activeIds.push(tourId);
|
|
227
|
+
}
|
|
228
|
+
stateNs?.set?.('active', [...new Set(activeIds)]);
|
|
229
|
+
return {
|
|
230
|
+
...entry,
|
|
231
|
+
status: 'active',
|
|
232
|
+
currentStepId: startStepId,
|
|
233
|
+
completedSteps: entry.status === 'active' ? entry.completedSteps : [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
case 'tour.step_started': {
|
|
237
|
+
const stepId = event.props?.stepId;
|
|
238
|
+
return {
|
|
239
|
+
...entry,
|
|
240
|
+
currentStepId: stepId || entry.currentStepId,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
case 'tour.step_changed': {
|
|
244
|
+
const previousStepId = event.props?.previousStepId;
|
|
245
|
+
const nextStepId = event.props?.nextStepId;
|
|
246
|
+
const completedSteps = previousStepId && !entry.completedSteps.includes(previousStepId)
|
|
247
|
+
? [...entry.completedSteps, previousStepId]
|
|
248
|
+
: entry.completedSteps;
|
|
249
|
+
return {
|
|
250
|
+
...entry,
|
|
251
|
+
currentStepId: nextStepId || entry.currentStepId,
|
|
252
|
+
completedSteps,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
case 'tour.completed': {
|
|
256
|
+
const completedAt = Date.now();
|
|
257
|
+
// Persist completed state with timestamp
|
|
258
|
+
completedMapRef.current[tourId] = completedAt;
|
|
259
|
+
stateNs?.set?.('completed', { ...completedMapRef.current });
|
|
260
|
+
return {
|
|
261
|
+
...entry,
|
|
262
|
+
status: 'completed',
|
|
263
|
+
currentStepId: null,
|
|
264
|
+
completedSteps: entry.steps.map((s) => s.id),
|
|
265
|
+
completedAt,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
case 'tour.paused': {
|
|
269
|
+
// Keep current state, just note it was paused
|
|
270
|
+
return entry;
|
|
271
|
+
}
|
|
272
|
+
default:
|
|
273
|
+
return entry;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return updated;
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
return () => {
|
|
280
|
+
unsubscribe();
|
|
281
|
+
// Clean up any pending toast timers
|
|
282
|
+
for (const cleanup of toastCleanupsRef.current) {
|
|
283
|
+
cleanup();
|
|
284
|
+
}
|
|
285
|
+
toastCleanupsRef.current = [];
|
|
286
|
+
};
|
|
287
|
+
}, [runtime, stateNs]);
|
|
288
|
+
// Handle step click — publish jump event
|
|
289
|
+
const handleStepClick = useCallback((tourId, stepId) => {
|
|
290
|
+
runtime?.events?.publish?.('workflow:jump_to_step', { tourId, stepId });
|
|
291
|
+
}, [runtime]);
|
|
292
|
+
// Handle dismiss
|
|
293
|
+
const handleDismiss = useCallback((tourId) => {
|
|
294
|
+
setWorkflowEntries((prev) => {
|
|
295
|
+
const updated = prev.map((entry) => entry.tourId === tourId ? { ...entry, status: 'dismissed' } : entry);
|
|
296
|
+
// Persist dismissed state
|
|
297
|
+
const dismissedIds = updated.filter((e) => e.status === 'dismissed').map((e) => e.tourId);
|
|
298
|
+
stateNs?.set?.('dismissed', dismissedIds);
|
|
299
|
+
return updated;
|
|
300
|
+
});
|
|
301
|
+
}, [stateNs]);
|
|
302
|
+
// Filter to active workflows only for rendering
|
|
303
|
+
const activeWorkflows = useMemo(() => workflowEntries.filter((w) => w.status === 'active'), [workflowEntries]);
|
|
304
|
+
return (_jsx("div", { ref: containerRef, children: _jsx(WorkflowTracker, { workflows: activeWorkflows, onStepClick: handleStepClick, onDismiss: handleDismiss }) }));
|
|
305
|
+
}
|
|
306
|
+
// ============================================================================
|
|
307
|
+
// MountableWidget Interface
|
|
308
|
+
// ============================================================================
|
|
309
|
+
/**
|
|
310
|
+
* Mountable widget interface for the runtime's WidgetRegistry.
|
|
311
|
+
*
|
|
312
|
+
* Follows the MountableWidget contract:
|
|
313
|
+
* mount(container, config?) -> cleanup | void
|
|
314
|
+
*
|
|
315
|
+
* Config is enriched with `runtime` by WidgetRegistry.bindRuntime().
|
|
316
|
+
*/
|
|
317
|
+
export const WorkflowMountableWidget = {
|
|
318
|
+
mount(container, config) {
|
|
319
|
+
const runtime = config?.runtime || null;
|
|
320
|
+
const root = createRoot(container);
|
|
321
|
+
root.render(React.createElement(WorkflowWidgetInner, {
|
|
322
|
+
runtime,
|
|
323
|
+
}));
|
|
324
|
+
// Return cleanup function
|
|
325
|
+
return () => {
|
|
326
|
+
root.unmount();
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
};
|
package/dist/cdn.d.ts
CHANGED
|
@@ -15,8 +15,8 @@ export declare const manifest: {
|
|
|
15
15
|
description: string;
|
|
16
16
|
runtime: {
|
|
17
17
|
actions: {
|
|
18
|
-
kind: "overlays:highlight" | "overlays:pulse" | "overlays:badge" | "overlays:tooltip" | "overlays:modal";
|
|
19
|
-
executor: import("packages/sdk-contracts/dist").ActionExecutor<import("./types").ModalAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").HighlightAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").PulseAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").BadgeAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").TooltipAction>;
|
|
18
|
+
kind: "core:tour" | "overlays:celebrate" | "overlays:highlight" | "overlays:pulse" | "overlays:badge" | "overlays:tooltip" | "overlays:modal";
|
|
19
|
+
executor: import("packages/sdk-contracts/dist").ActionExecutor<import("./types").CelebrateAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./tour-types").TourAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").ModalAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").HighlightAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").PulseAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").BadgeAction> | import("packages/sdk-contracts/dist").ActionExecutor<import("./types").TooltipAction>;
|
|
20
20
|
}[];
|
|
21
21
|
};
|
|
22
22
|
editor: {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.test.d.ts","sourceRoot":"","sources":["../../../src/celebrations/__tests__/engine.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
/** Stub CanvasRenderingContext2D for jsdom (which lacks canvas support) */
|
|
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
|
+
globalAlpha: 1,
|
|
17
|
+
fillStyle: '',
|
|
18
|
+
font: '',
|
|
19
|
+
shadowBlur: 0,
|
|
20
|
+
shadowColor: '',
|
|
21
|
+
};
|
|
22
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx);
|
|
23
|
+
return ctx;
|
|
24
|
+
}
|
|
25
|
+
function mockEffect() {
|
|
26
|
+
return {
|
|
27
|
+
init: vi.fn(() => [
|
|
28
|
+
{
|
|
29
|
+
x: 100,
|
|
30
|
+
y: 0,
|
|
31
|
+
vx: 0,
|
|
32
|
+
vy: 1,
|
|
33
|
+
rotation: 0,
|
|
34
|
+
rotationSpeed: 0,
|
|
35
|
+
size: 5,
|
|
36
|
+
color: '#ff0000',
|
|
37
|
+
opacity: 1,
|
|
38
|
+
},
|
|
39
|
+
]),
|
|
40
|
+
update: vi.fn(() => true),
|
|
41
|
+
render: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const defaultConfig = {
|
|
45
|
+
duration: 3000,
|
|
46
|
+
intensity: 'medium',
|
|
47
|
+
colors: ['#ff0000', '#00ff00'],
|
|
48
|
+
};
|
|
49
|
+
describe('CelebrationEngine', () => {
|
|
50
|
+
let container;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
container = document.createElement('div');
|
|
53
|
+
document.body.appendChild(container);
|
|
54
|
+
stubCanvasContext();
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
container.remove();
|
|
61
|
+
});
|
|
62
|
+
it('creates a canvas element when started', async () => {
|
|
63
|
+
const { CelebrationEngine } = await import('../engine');
|
|
64
|
+
const engine = new CelebrationEngine();
|
|
65
|
+
const effect = mockEffect();
|
|
66
|
+
engine.start(container, effect, defaultConfig);
|
|
67
|
+
const canvas = container.querySelector('canvas[data-syntro-celebrate]');
|
|
68
|
+
expect(canvas).not.toBeNull();
|
|
69
|
+
expect(canvas?.style.position).toBe('fixed');
|
|
70
|
+
expect(canvas?.style.pointerEvents).toBe('none');
|
|
71
|
+
engine.stop();
|
|
72
|
+
});
|
|
73
|
+
it('removes canvas when stopped', async () => {
|
|
74
|
+
const { CelebrationEngine } = await import('../engine');
|
|
75
|
+
const engine = new CelebrationEngine();
|
|
76
|
+
const effect = mockEffect();
|
|
77
|
+
engine.start(container, effect, defaultConfig);
|
|
78
|
+
expect(container.querySelector('canvas[data-syntro-celebrate]')).not.toBeNull();
|
|
79
|
+
engine.stop();
|
|
80
|
+
expect(container.querySelector('canvas[data-syntro-celebrate]')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it('calls effect.init with canvas dimensions', async () => {
|
|
83
|
+
const { CelebrationEngine } = await import('../engine');
|
|
84
|
+
const engine = new CelebrationEngine();
|
|
85
|
+
const effect = mockEffect();
|
|
86
|
+
engine.start(container, effect, defaultConfig);
|
|
87
|
+
expect(effect.init).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), defaultConfig);
|
|
88
|
+
engine.stop();
|
|
89
|
+
});
|
|
90
|
+
it('calls effect.update and effect.render on animation frame', async () => {
|
|
91
|
+
const { CelebrationEngine } = await import('../engine');
|
|
92
|
+
const engine = new CelebrationEngine();
|
|
93
|
+
const effect = mockEffect();
|
|
94
|
+
engine.start(container, effect, defaultConfig);
|
|
95
|
+
// Trigger an animation frame
|
|
96
|
+
vi.advanceTimersByTime(16);
|
|
97
|
+
expect(effect.update).toHaveBeenCalled();
|
|
98
|
+
expect(effect.render).toHaveBeenCalled();
|
|
99
|
+
engine.stop();
|
|
100
|
+
});
|
|
101
|
+
it('auto-stops after duration expires', async () => {
|
|
102
|
+
const { CelebrationEngine } = await import('../engine');
|
|
103
|
+
const engine = new CelebrationEngine();
|
|
104
|
+
const effect = mockEffect();
|
|
105
|
+
engine.start(container, effect, { ...defaultConfig, duration: 1000 });
|
|
106
|
+
// Advance past duration
|
|
107
|
+
vi.advanceTimersByTime(1100);
|
|
108
|
+
expect(container.querySelector('canvas[data-syntro-celebrate]')).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
it('auto-stops when effect.update returns false', async () => {
|
|
111
|
+
const { CelebrationEngine } = await import('../engine');
|
|
112
|
+
const engine = new CelebrationEngine();
|
|
113
|
+
const effect = mockEffect();
|
|
114
|
+
// After first frame, update returns false (all particles done)
|
|
115
|
+
effect.update.mockReturnValue(false);
|
|
116
|
+
engine.start(container, effect, defaultConfig);
|
|
117
|
+
// Trigger animation frame
|
|
118
|
+
vi.advanceTimersByTime(16);
|
|
119
|
+
expect(container.querySelector('canvas[data-syntro-celebrate]')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
it('stop() is safe to call multiple times', async () => {
|
|
122
|
+
const { CelebrationEngine } = await import('../engine');
|
|
123
|
+
const engine = new CelebrationEngine();
|
|
124
|
+
const effect = mockEffect();
|
|
125
|
+
engine.start(container, effect, defaultConfig);
|
|
126
|
+
engine.stop();
|
|
127
|
+
engine.stop(); // should not throw
|
|
128
|
+
expect(container.querySelector('canvas[data-syntro-celebrate]')).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"executor.test.d.ts","sourceRoot":"","sources":["../../../src/celebrations/__tests__/executor.test.ts"],"names":[],"mappings":""}
|