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.
Files changed (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. 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
+ }
@@ -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
+ }
@@ -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
+ `