onboardme-sdk 0.0.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/ARCHITECTURE-v2.md +225 -0
- package/dist/sdk.iife.js +348 -0
- package/package.json +22 -0
- package/src/__tests__/day1.test.ts +37 -0
- package/src/__tests__/day2.test.ts +447 -0
- package/src/__tests__/day3.test.ts +110 -0
- package/src/__tests__/day4.test.ts +115 -0
- package/src/__tests__/day5.test.ts +102 -0
- package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
- package/src/__tests__/snapshot-sender.test.ts +111 -0
- package/src/__tests__/v2-integration.test.ts +305 -0
- package/src/__tests__/v2-positioner.test.ts +115 -0
- package/src/__tests__/v2-renderer.test.ts +189 -0
- package/src/__tests__/v2-types.test.ts +74 -0
- package/src/__tests__/week2-day1.test.ts +62 -0
- package/src/__tests__/week2-day2.test.ts +128 -0
- package/src/__tests__/week2-day3.test.ts +128 -0
- package/src/__tests__/week2-day4.test.ts +177 -0
- package/src/__tests__/week2-day5.test.ts +294 -0
- package/src/__tests__/week3-day1.test.ts +169 -0
- package/src/__tests__/week3-day2.test.ts +267 -0
- package/src/__tests__/week3-day3.test.ts +213 -0
- package/src/__tests__/week3-day4.test.ts +213 -0
- package/src/__tests__/week3-day5.test.ts +350 -0
- package/src/__tests__/week4-day1.test.ts +277 -0
- package/src/__tests__/week4-day2.test.ts +227 -0
- package/src/__tests__/week4-day3.test.ts +323 -0
- package/src/__tests__/week4-day4.test.ts +210 -0
- package/src/__tests__/week4-day5.test.ts +503 -0
- package/src/__tests__/week5-day1.test.ts +152 -0
- package/src/__tests__/week5-day2.test.ts +222 -0
- package/src/__tests__/week5-day3.test.ts +297 -0
- package/src/__tests__/week5-day4.test.ts +306 -0
- package/src/__tests__/week5-day5.test.ts +345 -0
- package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
- package/src/auto-generate/context-collector.ts +47 -0
- package/src/auto-generate/flow-generator-client.ts +97 -0
- package/src/browser.ts +5 -0
- package/src/components/celebration.ts +44 -0
- package/src/components/checklist-css.ts +159 -0
- package/src/components/checklist.ts +295 -0
- package/src/components/modal-css.ts +96 -0
- package/src/components/modal.ts +171 -0
- package/src/components/shadow-host.ts +30 -0
- package/src/core/api-client.ts +39 -0
- package/src/core/api-flows.ts +204 -0
- package/src/core/config.ts +37 -0
- package/src/core/event-batcher.ts +169 -0
- package/src/core/sdk.ts +301 -0
- package/src/detection/user-detection.ts +55 -0
- package/src/index.ts +95 -0
- package/src/snapshot/dom-collector.ts +193 -0
- package/src/snapshot/sender.ts +105 -0
- package/src/storage/event-listener.ts +59 -0
- package/src/storage/progress-tracker.ts +78 -0
- package/src/styles/checklist-css.ts +159 -0
- package/src/styles/checklist.css +166 -0
- package/src/styles/modal-css.ts +96 -0
- package/src/styles/modal.css +102 -0
- package/src/utils/dom.ts +49 -0
- package/src/utils/fingerprint.ts +20 -0
- package/src/utils/logger.ts +17 -0
- package/src/v2/positioner.ts +105 -0
- package/src/v2/renderer.ts +287 -0
- package/src/v2/styles.ts +89 -0
- package/src/v2/types.ts +53 -0
- package/tsconfig.json +11 -0
- package/vite.config.ts +28 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
OnboardMe — Modal styles
|
|
3
|
+
Scoped inside Shadow DOM — cannot affect the host page.
|
|
4
|
+
============================================================ */
|
|
5
|
+
|
|
6
|
+
/* Full-screen fixed backdrop */
|
|
7
|
+
.om-overlay {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
background: rgba(0, 0, 0, 0.45);
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
z-index: 2147483647; /* max z-index — always on top */
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Centred card */
|
|
18
|
+
.om-modal {
|
|
19
|
+
background: #ffffff;
|
|
20
|
+
border-radius: 12px;
|
|
21
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
22
|
+
max-width: 480px;
|
|
23
|
+
width: calc(100% - 32px);
|
|
24
|
+
padding: 32px;
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
27
|
+
color: #111827;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Heading */
|
|
31
|
+
.om-modal__title {
|
|
32
|
+
margin: 0 0 12px;
|
|
33
|
+
font-size: 20px;
|
|
34
|
+
font-weight: 700;
|
|
35
|
+
line-height: 1.3;
|
|
36
|
+
color: #111827;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Body text */
|
|
40
|
+
.om-modal__body {
|
|
41
|
+
margin: 0 0 24px;
|
|
42
|
+
font-size: 15px;
|
|
43
|
+
line-height: 1.6;
|
|
44
|
+
color: #4b5563;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Button row */
|
|
48
|
+
.om-modal__actions {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-direction: column;
|
|
51
|
+
gap: 10px;
|
|
52
|
+
align-items: stretch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Primary CTA */
|
|
56
|
+
.om-btn-primary {
|
|
57
|
+
display: block;
|
|
58
|
+
width: 100%;
|
|
59
|
+
padding: 12px 20px;
|
|
60
|
+
background: #4f46e5;
|
|
61
|
+
color: #ffffff;
|
|
62
|
+
font-size: 15px;
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
border: none;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
text-align: center;
|
|
68
|
+
transition: background 0.15s ease;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.om-btn-primary:hover {
|
|
72
|
+
background: #4338ca;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.om-btn-primary:focus-visible {
|
|
76
|
+
outline: 3px solid #818cf8;
|
|
77
|
+
outline-offset: 2px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Skip / dismiss link */
|
|
81
|
+
.om-btn-skip {
|
|
82
|
+
display: block;
|
|
83
|
+
background: none;
|
|
84
|
+
border: none;
|
|
85
|
+
padding: 6px 0;
|
|
86
|
+
color: #6b7280;
|
|
87
|
+
font-size: 14px;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
text-align: center;
|
|
90
|
+
text-decoration: underline;
|
|
91
|
+
transition: color 0.15s ease;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.om-btn-skip:hover {
|
|
95
|
+
color: #374151;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.om-btn-skip:focus-visible {
|
|
99
|
+
outline: 2px solid #818cf8;
|
|
100
|
+
outline-offset: 2px;
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
}
|
package/src/utils/dom.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
2
|
+
const POLL_INTERVAL_MS = 50;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves when the element matching `selector` appears in the DOM.
|
|
6
|
+
* Needed because the SDK may load before the host app's framework renders its tree.
|
|
7
|
+
* Rejects after `timeout` ms if the element never appears.
|
|
8
|
+
*/
|
|
9
|
+
export function waitForElement(
|
|
10
|
+
selector: string,
|
|
11
|
+
timeout = DEFAULT_TIMEOUT_MS
|
|
12
|
+
): Promise<Element> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const existing = document.querySelector(selector);
|
|
15
|
+
if (existing) {
|
|
16
|
+
resolve(existing);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const deadline = Date.now() + timeout;
|
|
21
|
+
|
|
22
|
+
const interval = setInterval(() => {
|
|
23
|
+
const el = document.querySelector(selector);
|
|
24
|
+
if (el) {
|
|
25
|
+
clearInterval(interval);
|
|
26
|
+
resolve(el);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (Date.now() >= deadline) {
|
|
30
|
+
clearInterval(interval);
|
|
31
|
+
reject(new Error(`[OnboardMe] waitForElement: "${selector}" not found within ${timeout}ms`));
|
|
32
|
+
}
|
|
33
|
+
}, POLL_INTERVAL_MS);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns true if the element exists in the viewport and is not hidden via
|
|
39
|
+
* CSS visibility, display, or opacity.
|
|
40
|
+
*/
|
|
41
|
+
export function isVisible(element: Element): boolean {
|
|
42
|
+
const el = element as HTMLElement;
|
|
43
|
+
if (getComputedStyle(el).display === 'none') return false;
|
|
44
|
+
if (getComputedStyle(el).visibility === 'hidden') return false;
|
|
45
|
+
if (getComputedStyle(el).opacity === '0') return false;
|
|
46
|
+
|
|
47
|
+
const rect = el.getBoundingClientRect();
|
|
48
|
+
return rect.width > 0 && rect.height > 0;
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous ID — stable per visitor per product.
|
|
3
|
+
* Generated on first visit via crypto.randomUUID(), stored in localStorage.
|
|
4
|
+
* Allows pre-login behaviour to be tracked across page loads.
|
|
5
|
+
*/
|
|
6
|
+
export function getAnonymousId(productId: string): string {
|
|
7
|
+
const key = `onboardme_anon_${productId}`;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const existing = localStorage.getItem(key);
|
|
11
|
+
if (existing) return existing;
|
|
12
|
+
|
|
13
|
+
const id = crypto.randomUUID();
|
|
14
|
+
localStorage.setItem(key, id);
|
|
15
|
+
return id;
|
|
16
|
+
} catch {
|
|
17
|
+
// localStorage unavailable (private browsing restrictions, etc.) — generate ephemeral ID
|
|
18
|
+
return crypto.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let debugEnabled = false;
|
|
2
|
+
|
|
3
|
+
export function setDebug(enabled: boolean): void {
|
|
4
|
+
debugEnabled = enabled;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const logger = {
|
|
8
|
+
log(message: string, ...args: unknown[]): void {
|
|
9
|
+
if (debugEnabled) console.log(`[OnboardMe] ${message}`, ...args);
|
|
10
|
+
},
|
|
11
|
+
warn(message: string, ...args: unknown[]): void {
|
|
12
|
+
if (debugEnabled) console.warn(`[OnboardMe] ${message}`, ...args);
|
|
13
|
+
},
|
|
14
|
+
error(message: string, ...args: unknown[]): void {
|
|
15
|
+
if (debugEnabled) console.error(`[OnboardMe] ${message}`, ...args);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { V2Placement } from './types'
|
|
2
|
+
|
|
3
|
+
// Pure positioning math (Architecture v2 — Phase F).
|
|
4
|
+
//
|
|
5
|
+
// Takes the target element's bounding rect and a placement directive, returns
|
|
6
|
+
// page-absolute (top, left) coordinates for a tooltip/highlight. Pure so it
|
|
7
|
+
// is exhaustively testable without rendering anything.
|
|
8
|
+
//
|
|
9
|
+
// Coordinates are in viewport space relative to the document; the renderer
|
|
10
|
+
// adds window scroll offsets when applying inline styles.
|
|
11
|
+
|
|
12
|
+
export interface Rect {
|
|
13
|
+
top: number
|
|
14
|
+
left: number
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PositionResult {
|
|
20
|
+
top: number // page-absolute (rect.top + scrollY)
|
|
21
|
+
left: number // page-absolute (rect.left + scrollX)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GAP = 8 // px gap between target and tooltip
|
|
25
|
+
|
|
26
|
+
export interface PositionInput {
|
|
27
|
+
targetRect: Rect
|
|
28
|
+
tooltipWidth: number
|
|
29
|
+
tooltipHeight: number
|
|
30
|
+
scrollX: number
|
|
31
|
+
scrollY: number
|
|
32
|
+
placement: V2Placement
|
|
33
|
+
viewportWidth: number
|
|
34
|
+
viewportHeight: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function computeTooltipPosition(input: PositionInput): PositionResult {
|
|
38
|
+
const {
|
|
39
|
+
targetRect: r,
|
|
40
|
+
tooltipWidth: tw,
|
|
41
|
+
tooltipHeight: th,
|
|
42
|
+
scrollX, scrollY,
|
|
43
|
+
placement,
|
|
44
|
+
viewportWidth: vw,
|
|
45
|
+
viewportHeight: vh,
|
|
46
|
+
} = input
|
|
47
|
+
|
|
48
|
+
let top: number
|
|
49
|
+
let left: number
|
|
50
|
+
|
|
51
|
+
switch (placement) {
|
|
52
|
+
case 'top':
|
|
53
|
+
top = r.top - th - GAP
|
|
54
|
+
left = r.left + r.width / 2 - tw / 2
|
|
55
|
+
break
|
|
56
|
+
case 'bottom':
|
|
57
|
+
top = r.top + r.height + GAP
|
|
58
|
+
left = r.left + r.width / 2 - tw / 2
|
|
59
|
+
break
|
|
60
|
+
case 'left':
|
|
61
|
+
top = r.top + r.height / 2 - th / 2
|
|
62
|
+
left = r.left - tw - GAP
|
|
63
|
+
break
|
|
64
|
+
case 'right':
|
|
65
|
+
top = r.top + r.height / 2 - th / 2
|
|
66
|
+
left = r.left + r.width + GAP
|
|
67
|
+
break
|
|
68
|
+
case 'center':
|
|
69
|
+
default:
|
|
70
|
+
top = vh / 2 - th / 2
|
|
71
|
+
left = vw / 2 - tw / 2
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Clamp into viewport so the tooltip never escapes off-screen. Centred
|
|
76
|
+
// placement has already chosen a viewport-anchored position; clamping is
|
|
77
|
+
// still safe (no-op when the tooltip fits).
|
|
78
|
+
if (left < GAP) left = GAP
|
|
79
|
+
if (left + tw > vw - GAP) left = vw - tw - GAP
|
|
80
|
+
if (top < GAP) top = GAP
|
|
81
|
+
if (top + th > vh - GAP) top = vh - th - GAP
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
top: top + scrollY,
|
|
85
|
+
left: left + scrollX,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Highlight outline: matches the target element exactly with a small inflate
|
|
90
|
+
// so a 2px ring sits just outside the element's edges.
|
|
91
|
+
const HIGHLIGHT_INFLATE = 4
|
|
92
|
+
|
|
93
|
+
export function computeHighlightRect(input: {
|
|
94
|
+
targetRect: Rect
|
|
95
|
+
scrollX: number
|
|
96
|
+
scrollY: number
|
|
97
|
+
}): { top: number; left: number; width: number; height: number } {
|
|
98
|
+
const { targetRect: r, scrollX, scrollY } = input
|
|
99
|
+
return {
|
|
100
|
+
top: r.top + scrollY - HIGHLIGHT_INFLATE,
|
|
101
|
+
left: r.left + scrollX - HIGHLIGHT_INFLATE,
|
|
102
|
+
width: r.width + HIGHLIGHT_INFLATE * 2,
|
|
103
|
+
height: r.height + HIGHLIGHT_INFLATE * 2,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js'
|
|
2
|
+
import { V2_STYLES } from './styles.js'
|
|
3
|
+
import { computeTooltipPosition, computeHighlightRect } from './positioner.js'
|
|
4
|
+
import type { V2Step, V2FlowConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
// v2 step renderer (Architecture v2 — Phase F).
|
|
7
|
+
//
|
|
8
|
+
// Walks a v2 FlowConfig step-by-step, mounting one renderable element per
|
|
9
|
+
// step into the SDK's Shadow DOM. Three step types share a small DOM core:
|
|
10
|
+
//
|
|
11
|
+
// - tooltip: anchored card pointing at a target (selector + placement)
|
|
12
|
+
// - modal: centered card with backdrop overlay (placement: center)
|
|
13
|
+
// - highlight: outline ring around a target (no copy attached)
|
|
14
|
+
//
|
|
15
|
+
// The action button advances the flow:
|
|
16
|
+
// - 'next' → render the next step
|
|
17
|
+
// - 'skip' → tear down (whole flow dismissed)
|
|
18
|
+
// - 'complete' → tear down (flow finished — usually the last step's action)
|
|
19
|
+
//
|
|
20
|
+
// We do NOT use a session guard like the legacy modal does. The PM controls
|
|
21
|
+
// when a flow is shown via Module 2 (publish). Re-renders on the same page
|
|
22
|
+
// are harmless — the SDK only re-fetches when the checksumHash changes.
|
|
23
|
+
|
|
24
|
+
const STYLE_MARKER = 'data-onboardme-v2-styles'
|
|
25
|
+
const ROOT_CLASS = 'om2-root'
|
|
26
|
+
|
|
27
|
+
// ─── Public entry point ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface V2RenderHandle {
|
|
30
|
+
/** Tear down all DOM owned by this render — safe to call multiple times. */
|
|
31
|
+
destroy(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderV2Flow(config: V2FlowConfig, shadowRoot: ShadowRoot): V2RenderHandle | null {
|
|
35
|
+
if (!config.steps || config.steps.length === 0) {
|
|
36
|
+
logger.warn('v2 flow has no steps — nothing to render')
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ensureStyles(shadowRoot)
|
|
41
|
+
|
|
42
|
+
const ctx: RenderContext = {
|
|
43
|
+
shadowRoot,
|
|
44
|
+
steps: config.steps,
|
|
45
|
+
index: 0,
|
|
46
|
+
mounted: [],
|
|
47
|
+
destroyed: false,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
renderCurrentStep(ctx)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
destroy() { teardown(ctx) },
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Internals ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface RenderContext {
|
|
60
|
+
shadowRoot: ShadowRoot
|
|
61
|
+
steps: V2Step[]
|
|
62
|
+
index: number
|
|
63
|
+
mounted: HTMLElement[]
|
|
64
|
+
destroyed: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderCurrentStep(ctx: RenderContext): void {
|
|
68
|
+
if (ctx.destroyed) return
|
|
69
|
+
|
|
70
|
+
// Clear previous step's DOM before mounting the next one.
|
|
71
|
+
for (const el of ctx.mounted) el.remove()
|
|
72
|
+
ctx.mounted = []
|
|
73
|
+
|
|
74
|
+
const step = ctx.steps[ctx.index]
|
|
75
|
+
if (!step) {
|
|
76
|
+
teardown(ctx)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (step.type === 'modal') {
|
|
81
|
+
mountModal(ctx, step)
|
|
82
|
+
} else if (step.type === 'tooltip') {
|
|
83
|
+
mountTooltip(ctx, step)
|
|
84
|
+
} else {
|
|
85
|
+
mountHighlight(ctx, step)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function advance(ctx: RenderContext, step: V2Step): void {
|
|
90
|
+
if (step.action.type === 'skip' || step.action.type === 'complete') {
|
|
91
|
+
teardown(ctx)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
// 'next'
|
|
95
|
+
ctx.index += 1
|
|
96
|
+
if (ctx.index >= ctx.steps.length) {
|
|
97
|
+
teardown(ctx)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
renderCurrentStep(ctx)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function teardown(ctx: RenderContext): void {
|
|
104
|
+
if (ctx.destroyed) return
|
|
105
|
+
ctx.destroyed = true
|
|
106
|
+
for (const el of ctx.mounted) el.remove()
|
|
107
|
+
ctx.mounted = []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Step type: modal ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function mountModal(ctx: RenderContext, step: V2Step): void {
|
|
113
|
+
const overlay = el('div', { class: 'om2-overlay' })
|
|
114
|
+
const card = buildCard(ctx, step, /* isModal */ true)
|
|
115
|
+
|
|
116
|
+
// Place the modal by absolute positioning so it sits above the overlay.
|
|
117
|
+
card.style.top = '50%'
|
|
118
|
+
card.style.left = '50%'
|
|
119
|
+
card.style.transform = 'translate(-50%, -50%)'
|
|
120
|
+
|
|
121
|
+
ctx.shadowRoot.appendChild(overlay)
|
|
122
|
+
ctx.shadowRoot.appendChild(card)
|
|
123
|
+
ctx.mounted.push(overlay, card)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Step type: tooltip ─────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function mountTooltip(ctx: RenderContext, step: V2Step): void {
|
|
129
|
+
const card = buildCard(ctx, step, /* isModal */ false)
|
|
130
|
+
|
|
131
|
+
// Mount first so we can measure, then position.
|
|
132
|
+
ctx.shadowRoot.appendChild(card)
|
|
133
|
+
ctx.mounted.push(card)
|
|
134
|
+
|
|
135
|
+
const target = querySafe(step.position.selector)
|
|
136
|
+
if (!target) {
|
|
137
|
+
// Fall back to centred placement if the selector misses — mirrors legacy
|
|
138
|
+
// SDK behaviour ("never throw, always render something useful").
|
|
139
|
+
logger.warn(`v2 tooltip selector did not match: ${step.position.selector} — centring`)
|
|
140
|
+
centreOnViewport(card)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rect = target.getBoundingClientRect()
|
|
145
|
+
const cardRect = card.getBoundingClientRect()
|
|
146
|
+
const pos = computeTooltipPosition({
|
|
147
|
+
targetRect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
|
|
148
|
+
tooltipWidth: cardRect.width || 280,
|
|
149
|
+
tooltipHeight: cardRect.height || 100,
|
|
150
|
+
scrollX: window.scrollX,
|
|
151
|
+
scrollY: window.scrollY,
|
|
152
|
+
placement: step.position.placement,
|
|
153
|
+
viewportWidth: window.innerWidth || 1280,
|
|
154
|
+
viewportHeight: window.innerHeight || 800,
|
|
155
|
+
})
|
|
156
|
+
card.style.top = `${pos.top}px`
|
|
157
|
+
card.style.left = `${pos.left}px`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Step type: highlight ───────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function mountHighlight(ctx: RenderContext, step: V2Step): void {
|
|
163
|
+
const target = querySafe(step.position.selector)
|
|
164
|
+
if (!target) {
|
|
165
|
+
logger.warn(`v2 highlight selector did not match: ${step.position.selector}`)
|
|
166
|
+
// Even without a target we still want to surface the action so the flow
|
|
167
|
+
// can advance; render a centred fallback card.
|
|
168
|
+
const fallback = buildCard(ctx, step, /* isModal */ true)
|
|
169
|
+
centreOnViewport(fallback)
|
|
170
|
+
ctx.shadowRoot.appendChild(fallback)
|
|
171
|
+
ctx.mounted.push(fallback)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rect = target.getBoundingClientRect()
|
|
176
|
+
const ring = computeHighlightRect({
|
|
177
|
+
targetRect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
|
|
178
|
+
scrollX: window.scrollX,
|
|
179
|
+
scrollY: window.scrollY,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const ringEl = el('div', { class: 'om2-highlight' })
|
|
183
|
+
Object.assign(ringEl.style, {
|
|
184
|
+
top: `${ring.top}px`,
|
|
185
|
+
left: `${ring.left}px`,
|
|
186
|
+
width: `${ring.width}px`,
|
|
187
|
+
height: `${ring.height}px`,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Tooltip card alongside the highlight so the user can act on it.
|
|
191
|
+
const card = buildCard(ctx, step, /* isModal */ false)
|
|
192
|
+
ctx.shadowRoot.appendChild(ringEl)
|
|
193
|
+
ctx.shadowRoot.appendChild(card)
|
|
194
|
+
ctx.mounted.push(ringEl, card)
|
|
195
|
+
|
|
196
|
+
// Position the card relative to the highlighted element.
|
|
197
|
+
const cardRect = card.getBoundingClientRect()
|
|
198
|
+
const pos = computeTooltipPosition({
|
|
199
|
+
targetRect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
|
|
200
|
+
tooltipWidth: cardRect.width || 280,
|
|
201
|
+
tooltipHeight: cardRect.height || 100,
|
|
202
|
+
scrollX: window.scrollX,
|
|
203
|
+
scrollY: window.scrollY,
|
|
204
|
+
placement: step.position.placement,
|
|
205
|
+
viewportWidth: window.innerWidth || 1280,
|
|
206
|
+
viewportHeight: window.innerHeight || 800,
|
|
207
|
+
})
|
|
208
|
+
card.style.top = `${pos.top}px`
|
|
209
|
+
card.style.left = `${pos.left}px`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Card builder (shared by modal + tooltip + highlight fallback) ──────────
|
|
213
|
+
|
|
214
|
+
function buildCard(ctx: RenderContext, step: V2Step, isModal: boolean): HTMLElement {
|
|
215
|
+
const card = el('div', {
|
|
216
|
+
class: `${ROOT_CLASS} ${isModal ? 'om2-modal-card' : 'om2-tooltip'}`,
|
|
217
|
+
'data-step-id': step.id,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const titleEl = el('h3', { class: 'om2-title' })
|
|
221
|
+
titleEl.textContent = step.title || ''
|
|
222
|
+
card.appendChild(titleEl)
|
|
223
|
+
|
|
224
|
+
if (step.description) {
|
|
225
|
+
const descEl = el('p', { class: 'om2-description' })
|
|
226
|
+
descEl.textContent = step.description
|
|
227
|
+
card.appendChild(descEl)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const actions = el('div', { class: 'om2-actions' })
|
|
231
|
+
const counter = el('span', { class: 'om2-step-counter' })
|
|
232
|
+
counter.textContent = `${ctx.index + 1} / ${ctx.steps.length}`
|
|
233
|
+
actions.appendChild(counter)
|
|
234
|
+
|
|
235
|
+
// Skip (secondary) — only when more than one step remains AND the action
|
|
236
|
+
// isn't already 'skip' itself.
|
|
237
|
+
if (step.action.type !== 'skip' && ctx.steps.length > 1 && ctx.index < ctx.steps.length - 1) {
|
|
238
|
+
const skipBtn = el('button', { class: 'om2-btn om2-btn-secondary', type: 'button' })
|
|
239
|
+
skipBtn.textContent = 'Skip'
|
|
240
|
+
skipBtn.addEventListener('click', () => teardown(ctx))
|
|
241
|
+
actions.appendChild(skipBtn)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const primary = el('button', { class: 'om2-btn om2-btn-primary', type: 'button' })
|
|
245
|
+
primary.textContent = step.action.label || (step.action.type === 'complete' ? 'Done' : 'Next')
|
|
246
|
+
primary.addEventListener('click', () => advance(ctx, step))
|
|
247
|
+
actions.appendChild(primary)
|
|
248
|
+
|
|
249
|
+
card.appendChild(actions)
|
|
250
|
+
return card
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Small DOM helpers ───────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function el(tag: string, attrs: Record<string, string> = {}): HTMLElement {
|
|
256
|
+
const node = document.createElement(tag)
|
|
257
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
258
|
+
if (k === 'class') node.className = v
|
|
259
|
+
else node.setAttribute(k, v)
|
|
260
|
+
}
|
|
261
|
+
return node
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function querySafe(selector: string): Element | null {
|
|
265
|
+
try {
|
|
266
|
+
return document.querySelector(selector)
|
|
267
|
+
} catch {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ensureStyles(shadowRoot: ShadowRoot): void {
|
|
273
|
+
// Inject styles only once per shadow root — repeated init() calls or
|
|
274
|
+
// page navigations don't pile up duplicates.
|
|
275
|
+
const existing = shadowRoot.querySelector(`style[${STYLE_MARKER}]`)
|
|
276
|
+
if (existing) return
|
|
277
|
+
const style = document.createElement('style')
|
|
278
|
+
style.setAttribute(STYLE_MARKER, '')
|
|
279
|
+
style.textContent = V2_STYLES
|
|
280
|
+
shadowRoot.appendChild(style)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function centreOnViewport(card: HTMLElement): void {
|
|
284
|
+
card.style.top = '50%'
|
|
285
|
+
card.style.left = '50%'
|
|
286
|
+
card.style.transform = 'translate(-50%, -50%)'
|
|
287
|
+
}
|
package/src/v2/styles.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Scoped styles for v2 components (Architecture v2 — Phase F).
|
|
2
|
+
// Injected once into the Shadow DOM root so they cannot leak into the host
|
|
3
|
+
// page and the host page's CSS cannot leak into us.
|
|
4
|
+
|
|
5
|
+
export const V2_STYLES = `
|
|
6
|
+
.om2-overlay {
|
|
7
|
+
position: fixed;
|
|
8
|
+
inset: 0;
|
|
9
|
+
background: rgba(0, 0, 0, 0.4);
|
|
10
|
+
z-index: 2147483646;
|
|
11
|
+
}
|
|
12
|
+
.om2-tooltip,
|
|
13
|
+
.om2-modal-card {
|
|
14
|
+
position: absolute;
|
|
15
|
+
background: #ffffff;
|
|
16
|
+
color: #111827;
|
|
17
|
+
border-radius: 8px;
|
|
18
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
19
|
+
padding: 16px 18px;
|
|
20
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
21
|
+
font-size: 14px;
|
|
22
|
+
line-height: 1.5;
|
|
23
|
+
max-width: 320px;
|
|
24
|
+
z-index: 2147483647;
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
}
|
|
27
|
+
.om2-modal-card {
|
|
28
|
+
max-width: 440px;
|
|
29
|
+
padding: 24px 28px;
|
|
30
|
+
}
|
|
31
|
+
.om2-title {
|
|
32
|
+
margin: 0 0 6px 0;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
font-size: 15px;
|
|
35
|
+
line-height: 1.3;
|
|
36
|
+
}
|
|
37
|
+
.om2-modal-card .om2-title {
|
|
38
|
+
font-size: 18px;
|
|
39
|
+
}
|
|
40
|
+
.om2-description {
|
|
41
|
+
margin: 0 0 14px 0;
|
|
42
|
+
color: #4b5563;
|
|
43
|
+
font-size: 13px;
|
|
44
|
+
}
|
|
45
|
+
.om2-modal-card .om2-description {
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
margin-bottom: 18px;
|
|
48
|
+
}
|
|
49
|
+
.om2-actions {
|
|
50
|
+
display: flex;
|
|
51
|
+
gap: 8px;
|
|
52
|
+
justify-content: flex-end;
|
|
53
|
+
align-items: center;
|
|
54
|
+
}
|
|
55
|
+
.om2-step-counter {
|
|
56
|
+
margin-right: auto;
|
|
57
|
+
font-size: 11px;
|
|
58
|
+
color: #9ca3af;
|
|
59
|
+
font-variant-numeric: tabular-nums;
|
|
60
|
+
}
|
|
61
|
+
.om2-btn {
|
|
62
|
+
font: inherit;
|
|
63
|
+
padding: 6px 14px;
|
|
64
|
+
border-radius: 6px;
|
|
65
|
+
border: none;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
font-size: 13px;
|
|
69
|
+
}
|
|
70
|
+
.om2-btn-primary {
|
|
71
|
+
background: #2563eb;
|
|
72
|
+
color: #ffffff;
|
|
73
|
+
}
|
|
74
|
+
.om2-btn-primary:hover { background: #1d4ed8; }
|
|
75
|
+
.om2-btn-secondary {
|
|
76
|
+
background: transparent;
|
|
77
|
+
color: #6b7280;
|
|
78
|
+
}
|
|
79
|
+
.om2-btn-secondary:hover { color: #374151; }
|
|
80
|
+
.om2-highlight {
|
|
81
|
+
position: absolute;
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
border: 2px solid #2563eb;
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
|
|
86
|
+
z-index: 2147483646;
|
|
87
|
+
transition: top 0.15s ease, left 0.15s ease, width 0.15s ease, height 0.15s ease;
|
|
88
|
+
}
|
|
89
|
+
`
|