@adia-ai/web-components 0.6.38 → 0.6.40

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.
@@ -0,0 +1,92 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/TourStep.json",
4
+ "title": "TourStep",
5
+ "description": "Single step inside a `<tour-ui>` walkthrough. Declares a target\nelement (via CSS selector), a title, and body content. The host\n`<tour-ui>` reads these declaratively and renders them in the\npopover as the user advances through the tour.\n\n`<tour-step>` itself renders nothing — it's a data carrier (light\nDOM source of truth). The tour-ui orchestrator clones the body\ncontent into the spotlight popover when the step becomes active.\n",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "title": {
17
+ "description": "Step heading rendered in the popover.",
18
+ "type": "string",
19
+ "default": ""
20
+ },
21
+ "component": {
22
+ "const": "TourStep"
23
+ },
24
+ "placement": {
25
+ "description": "Preferred popover placement relative to the target. Same vocabulary\nas `core/anchor.js` (`top`, `bottom`, `left`, `right`,\n`bottom-start`, `bottom-end`, etc.). Auto-flips on viewport overflow\nvia position-try-fallbacks.\n",
26
+ "type": "string",
27
+ "enum": [
28
+ "top",
29
+ "bottom",
30
+ "left",
31
+ "right",
32
+ "top-start",
33
+ "top-end",
34
+ "bottom-start",
35
+ "bottom-end",
36
+ "left-start",
37
+ "left-end",
38
+ "right-start",
39
+ "right-end"
40
+ ],
41
+ "default": "bottom"
42
+ },
43
+ "target": {
44
+ "description": "CSS selector for the element to spotlight when this step is\nactive. Resolved via `document.querySelector(target)` at step\nactivation time. If the selector matches nothing, the spotlight\nfalls back to viewport-center and the popover anchors to the\npage (no halo cutout).\n",
45
+ "type": "string",
46
+ "default": ""
47
+ }
48
+ },
49
+ "required": [
50
+ "component"
51
+ ],
52
+ "unevaluatedProperties": false,
53
+ "x-adiaui": {
54
+ "anti_patterns": [
55
+ {
56
+ "fix": "<tour-step target=\"#dashboard\">",
57
+ "why": "target is a CSS selector — bare \"dashboard\" doesn't match.",
58
+ "wrong": "<tour-step target=\"dashboard\">"
59
+ }
60
+ ],
61
+ "category": "feedback",
62
+ "composes": [],
63
+ "events": {},
64
+ "examples": [],
65
+ "keywords": [
66
+ "tour-step",
67
+ "walkthrough-step",
68
+ "guided-step"
69
+ ],
70
+ "name": "UITourStep",
71
+ "related": [
72
+ "tour"
73
+ ],
74
+ "slots": {
75
+ "default": {
76
+ "description": "Body content rendered inside the step popover (plain text or inline HTML)."
77
+ }
78
+ },
79
+ "states": [],
80
+ "status": "stable",
81
+ "synonyms": {
82
+ "step": [
83
+ "tour-step",
84
+ "walkthrough-step"
85
+ ]
86
+ },
87
+ "tag": "tour-step-ui",
88
+ "tokens": {},
89
+ "traits": [],
90
+ "version": 1
91
+ }
92
+ }
@@ -0,0 +1,84 @@
1
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
2
+ name: UITourStep
3
+ tag: tour-step-ui
4
+ status: stable
5
+ component: TourStep
6
+ category: feedback
7
+ version: 1
8
+ description: |
9
+ Single step inside a `<tour-ui>` walkthrough. Declares a target
10
+ element (via CSS selector), a title, and body content. The host
11
+ `<tour-ui>` reads these declaratively and renders them in the
12
+ popover as the user advances through the tour.
13
+
14
+ `<tour-step>` itself renders nothing — it's a data carrier (light
15
+ DOM source of truth). The tour-ui orchestrator clones the body
16
+ content into the spotlight popover when the step becomes active.
17
+ props:
18
+ target:
19
+ description: |
20
+ CSS selector for the element to spotlight when this step is
21
+ active. Resolved via `document.querySelector(target)` at step
22
+ activation time. If the selector matches nothing, the spotlight
23
+ falls back to viewport-center and the popover anchors to the
24
+ page (no halo cutout).
25
+ type: string
26
+ default: ''
27
+ reflect: true
28
+ title:
29
+ description: Step heading rendered in the popover.
30
+ type: string
31
+ default: ''
32
+ reflect: true
33
+ placement:
34
+ description: |
35
+ Preferred popover placement relative to the target. Same vocabulary
36
+ as `core/anchor.js` (`top`, `bottom`, `left`, `right`,
37
+ `bottom-start`, `bottom-end`, etc.). Auto-flips on viewport overflow
38
+ via position-try-fallbacks.
39
+ type: string
40
+ default: bottom
41
+ enum:
42
+ - top
43
+ - bottom
44
+ - left
45
+ - right
46
+ - top-start
47
+ - top-end
48
+ - bottom-start
49
+ - bottom-end
50
+ - left-start
51
+ - left-end
52
+ - right-start
53
+ - right-end
54
+ reflect: true
55
+ events: {}
56
+ slots:
57
+ default:
58
+ description: 'Body content rendered inside the step popover (plain text or inline HTML).'
59
+ states: []
60
+ traits: []
61
+ tokens: {}
62
+ a2ui:
63
+ rules:
64
+ - rule: 'Single step inside <tour-ui>. Declares target (CSS selector) + title + body content.'
65
+ reason: 'Data carrier for tour-ui.'
66
+ - rule: 'target is a CSS selector (e.g. "#dashboard", ".filters button"), NOT a bare ID. Use the # prefix.'
67
+ reason: 'Selector contract.'
68
+ - rule: 'Renders nothing on its own — tour-ui orchestrates the popover stamping when the step becomes active.'
69
+ reason: 'Stamp-nothing wrapper.'
70
+ anti_patterns:
71
+ - wrong: '<tour-step target="dashboard">'
72
+ why: 'target is a CSS selector — bare "dashboard" doesn''t match.'
73
+ fix: '<tour-step target="#dashboard">'
74
+ examples: []
75
+ keywords:
76
+ - tour-step
77
+ - walkthrough-step
78
+ - guided-step
79
+ synonyms:
80
+ step:
81
+ - tour-step
82
+ - walkthrough-step
83
+ related:
84
+ - tour
@@ -0,0 +1,172 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/Tour.json",
4
+ "title": "Tour",
5
+ "description": "Spotlight-driven product tour / guided walkthrough. Hosts a sequence\nof `<tour-step>` children, each targeting an element via CSS selector.\nWhen active, dims the page with a scrim, cuts a \"hole\" around the\ncurrent step's target, and renders a popover next to it with step\ncontent + Skip / Previous / Next navigation.\n\nUse for first-run onboarding tours, feature introductions after a\nrelease, and inline walkthroughs the user opts into (\"Take the tour\").\nDistinct from `<onboarding-checklist-ui>` (persistent todo-list of\nsetup steps the user works through at their own pace) and from\n`<tooltip-ui>` (single hover hint with no orchestration).\n\nArchitecture: tour-ui is a behavioral wrapper (`display: contents`)\nthat reads target / title / content from `<tour-step>` children. On\nstart, mounts a spotlight + popover surface into `document.body`\n(top-layer via Popover API). The spotlight is a transparent\nposition:fixed element with a huge box-shadow that produces the\ndimmed scrim outside its bounds (single-element backdrop, no clip-path\nor SVG mask needed). The popover is anchored to the target via the\nsame `core/anchor.js` pattern used by menu-ui / context-menu-ui /\npopover-ui.\n",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "active": {
17
+ "description": "Whether the tour is currently running. Setting to `true` mounts\nthe spotlight + popover; setting to `false` tears down.\n",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
21
+ "auto-start": {
22
+ "description": "Start the tour automatically when the element connects. Useful for\nfirst-run flows gated by a storage flag on the consumer side.\n",
23
+ "type": "boolean",
24
+ "default": false
25
+ },
26
+ "component": {
27
+ "const": "Tour"
28
+ },
29
+ "step": {
30
+ "description": "Current step index (0-based). Setting this property advances the\ntour to that step (updating spotlight + popover position).\n",
31
+ "type": "number",
32
+ "default": 0
33
+ },
34
+ "storage-key": {
35
+ "description": "Optional localStorage key. When set, the tour records its\ncompletion state (`\"done\"`) to that key on finish/skip — and\nrefuses to auto-start on subsequent loads if the key is \"done\".\nUseful for \"show this tour once per user\" flows.\n",
36
+ "type": "string",
37
+ "default": ""
38
+ }
39
+ },
40
+ "required": [
41
+ "component"
42
+ ],
43
+ "unevaluatedProperties": false,
44
+ "x-adiaui": {
45
+ "anti_patterns": [
46
+ {
47
+ "fix": "<tour-ui><tour-step target=\"#dashboard\" title=\"Dashboard\">Tour the dashboard…</tour-step>…</tour-ui>",
48
+ "why": "Bare divs have no target / title — tour-ui can't position the spotlight.",
49
+ "wrong": "<tour-ui><div>step 1 content</div><div>step 2 content</div></tour-ui>"
50
+ },
51
+ {
52
+ "fix": "<tour-step target=\"#dashboard\">",
53
+ "why": "target is a CSS selector — bare \"dashboard\" matches a <dashboard> element, NOT an id; use the # prefix.",
54
+ "wrong": "<tour-step target=\"dashboard\">"
55
+ }
56
+ ],
57
+ "category": "feedback",
58
+ "composes": [
59
+ "button-ui",
60
+ "tour-step-ui"
61
+ ],
62
+ "events": {
63
+ "tour-finish": {
64
+ "description": "Fired when the user completes the last step. detail = { totalSteps }. Bubbles."
65
+ },
66
+ "tour-skip": {
67
+ "description": "Fired when the user skips the tour mid-way. detail = { step, totalSteps }. Bubbles."
68
+ },
69
+ "tour-start": {
70
+ "description": "Fired when the tour starts (after spotlight + popover mount). detail = { step }. Bubbles."
71
+ },
72
+ "tour-step-change": {
73
+ "description": "Fired when the active step changes. detail = { from, to, totalSteps }. Bubbles."
74
+ }
75
+ },
76
+ "examples": [
77
+ {
78
+ "description": "A 3-step product tour anchored to selector-resolved elements.",
79
+ "a2ui": "[\n { \"id\": \"tour\", \"component\": \"Tour\", \"auto-start\": true, \"children\": [\"s1\", \"s2\", \"s3\"] },\n { \"id\": \"s1\", \"component\": \"TourStep\", \"target\": \"#dashboard\", \"title\": \"Your dashboard\", \"textContent\": \"Here's where you'll see your latest activity.\" },\n { \"id\": \"s2\", \"component\": \"TourStep\", \"target\": \"#filters\", \"title\": \"Filters\", \"textContent\": \"Narrow results by status, owner, or date.\" },\n { \"id\": \"s3\", \"component\": \"TourStep\", \"target\": \"#export\", \"title\": \"Export\", \"textContent\": \"Download a CSV of the current view.\" }\n]\n",
80
+ "name": "basic-tour"
81
+ }
82
+ ],
83
+ "keywords": [
84
+ "tour",
85
+ "product-tour",
86
+ "walkthrough",
87
+ "guided-tour",
88
+ "spotlight",
89
+ "coachmark",
90
+ "feature-introduction"
91
+ ],
92
+ "name": "UITour",
93
+ "related": [
94
+ "tooltip",
95
+ "popover",
96
+ "onboarding-checklist"
97
+ ],
98
+ "slots": {
99
+ "default": {
100
+ "description": "<tour-step> children — each declares target + title + body content."
101
+ }
102
+ },
103
+ "states": [
104
+ {
105
+ "description": "Tour is dormant; no scrim / popover rendered.",
106
+ "name": "idle"
107
+ },
108
+ {
109
+ "description": "Tour is running; scrim dims the page, current target is spotlighted.",
110
+ "name": "active"
111
+ },
112
+ {
113
+ "description": "Last step reached (Done is shown instead of Next).",
114
+ "name": "complete"
115
+ }
116
+ ],
117
+ "status": "stable",
118
+ "synonyms": {
119
+ "spotlight": [
120
+ "coachmark",
121
+ "feature-highlight"
122
+ ],
123
+ "tour": [
124
+ "product-tour",
125
+ "guided-tour",
126
+ "walkthrough",
127
+ "feature-intro"
128
+ ]
129
+ },
130
+ "tag": "tour-ui",
131
+ "tokens": {
132
+ "--tour-popover-bg": {
133
+ "description": "Background of the step-content popover.",
134
+ "default": "var(--a-bg-subtle)"
135
+ },
136
+ "--tour-popover-border": {
137
+ "description": "Border color of the popover.",
138
+ "default": "var(--a-border-subtle)"
139
+ },
140
+ "--tour-popover-max-width": {
141
+ "description": "Maximum width of the popover.",
142
+ "default": "22rem"
143
+ },
144
+ "--tour-popover-min-width": {
145
+ "description": "Minimum width of the popover (so step text wraps reasonably).",
146
+ "default": "18rem"
147
+ },
148
+ "--tour-popover-radius": {
149
+ "description": "Border-radius of the popover.",
150
+ "default": "var(--a-radius-lg)"
151
+ },
152
+ "--tour-popover-shadow": {
153
+ "description": "Shadow of the popover.",
154
+ "default": "var(--a-shadow-lg)"
155
+ },
156
+ "--tour-scrim": {
157
+ "description": "Backdrop scrim color (everything outside the spotlight).",
158
+ "default": "var(--a-scrim-dialog)"
159
+ },
160
+ "--tour-spotlight-padding": {
161
+ "description": "Padding around the target bounding box (the spotlight's \"halo\").",
162
+ "default": "var(--a-space-2)"
163
+ },
164
+ "--tour-spotlight-radius": {
165
+ "description": "Border radius of the spotlight cutout.",
166
+ "default": "var(--a-radius-md)"
167
+ }
168
+ },
169
+ "traits": [],
170
+ "version": 1
171
+ }
172
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `<tour-ui>` — spotlight-driven product tour / guided walkthrough.
3
+ *
4
+ * Orchestrates a sequence of `<tour-step>` children, each declaring a
5
+ * target element via CSS selector. When active, dims the page with a
6
+ * scrim, cuts a "hole" around the current step's target, and renders
7
+ * a popover next to it with step content + Skip / Previous / Next.
8
+ *
9
+ * Architecture:
10
+ * - Host is `display: contents` (no chrome).
11
+ * - On start, mounts two surfaces into `document.body`:
12
+ * 1. [data-tour-spotlight] — transparent rect with huge box-shadow
13
+ * scrim. Position via top/left, size via width/height (animated).
14
+ * 2. [data-tour-popover] — manual popover with step content + nav,
15
+ * anchored to the target via core/anchor.js (same mechanism as
16
+ * menu-ui / context-menu-ui / popover-ui).
17
+ * - Spotlight repositions on window resize/scroll while active.
18
+ *
19
+ * @see ../../core/anchor.js
20
+ * @see ../onboarding-checklist/ — peer onboarding primitive (user-paced).
21
+ */
22
+
23
+ import { UIElement } from '../../core/element.js';
24
+ import { anchorPopover } from '../../core/anchor.js';
25
+
26
+ export class UITour extends UIElement {
27
+ static properties = {
28
+ active: { type: Boolean, default: false, reflect: true },
29
+ step: { type: Number, default: 0, reflect: false },
30
+ autoStart: { type: Boolean, default: false, reflect: true, attribute: 'auto-start' },
31
+ storageKey: { type: String, default: '', reflect: true, attribute: 'storage-key' },
32
+ };
33
+
34
+ static template = () => null;
35
+
36
+ #spotlight = null;
37
+ #popover = null;
38
+ #anchorCleanup = null;
39
+ #onResize = null;
40
+ #onScroll = null;
41
+ #onKeydown = null;
42
+
43
+ connected() {
44
+ super.connected();
45
+ if (this.autoStart && !this.#alreadyDone()) {
46
+ // Defer one frame so consumer targets (which may also mount on the
47
+ // same tick) have a chance to land in the DOM first.
48
+ requestAnimationFrame(() => {
49
+ if (this.isConnected && this.autoStart) this.start();
50
+ });
51
+ }
52
+ }
53
+
54
+ disconnected() {
55
+ super.disconnected();
56
+ this.#teardown();
57
+ }
58
+
59
+ /* ── Public API ──────────────────────────────────────────────────── */
60
+
61
+ start() {
62
+ if (this.active) return;
63
+ if (!this.#steps.length) {
64
+ // eslint-disable-next-line no-console
65
+ console.warn('[tour-ui] start() called with no <tour-step> children — nothing to render.');
66
+ return;
67
+ }
68
+ this.step = 0;
69
+ this.active = true;
70
+ this.#mount();
71
+ this.#renderStep();
72
+ this.dispatchEvent(new CustomEvent('tour-start', {
73
+ bubbles: true,
74
+ detail: { step: 0 },
75
+ }));
76
+ }
77
+
78
+ next() {
79
+ if (!this.active) return;
80
+ const total = this.#steps.length;
81
+ if (this.step >= total - 1) { this.finish(); return; }
82
+ const from = this.step;
83
+ this.step = from + 1;
84
+ this.#renderStep();
85
+ this.dispatchEvent(new CustomEvent('tour-step-change', {
86
+ bubbles: true,
87
+ detail: { from, to: this.step, totalSteps: total },
88
+ }));
89
+ }
90
+
91
+ previous() {
92
+ if (!this.active) return;
93
+ if (this.step <= 0) return;
94
+ const from = this.step;
95
+ this.step = from - 1;
96
+ this.#renderStep();
97
+ this.dispatchEvent(new CustomEvent('tour-step-change', {
98
+ bubbles: true,
99
+ detail: { from, to: this.step, totalSteps: this.#steps.length },
100
+ }));
101
+ }
102
+
103
+ skip() {
104
+ if (!this.active) return;
105
+ const total = this.#steps.length;
106
+ const at = this.step;
107
+ this.#recordDone();
108
+ this.active = false;
109
+ this.#teardown();
110
+ this.dispatchEvent(new CustomEvent('tour-skip', {
111
+ bubbles: true,
112
+ detail: { step: at, totalSteps: total },
113
+ }));
114
+ }
115
+
116
+ finish() {
117
+ if (!this.active) return;
118
+ const total = this.#steps.length;
119
+ this.#recordDone();
120
+ this.active = false;
121
+ this.#teardown();
122
+ this.dispatchEvent(new CustomEvent('tour-finish', {
123
+ bubbles: true,
124
+ detail: { totalSteps: total },
125
+ }));
126
+ }
127
+
128
+ /* ── Internals ───────────────────────────────────────────────────── */
129
+
130
+ get #steps() {
131
+ return [...this.querySelectorAll(':scope > tour-step-ui')];
132
+ }
133
+
134
+ #alreadyDone() {
135
+ if (!this.storageKey) return false;
136
+ try { return localStorage.getItem(this.storageKey) === 'done'; }
137
+ catch { return false; }
138
+ }
139
+
140
+ #recordDone() {
141
+ if (!this.storageKey) return;
142
+ try { localStorage.setItem(this.storageKey, 'done'); }
143
+ catch { /* quota / privacy mode — silently ignore */ }
144
+ }
145
+
146
+ #mount() {
147
+ if (!this.#spotlight) {
148
+ const sp = document.createElement('div');
149
+ sp.setAttribute('data-tour-spotlight', '');
150
+ sp.setAttribute('aria-hidden', 'true');
151
+ document.body.appendChild(sp);
152
+ this.#spotlight = sp;
153
+ }
154
+ if (!this.#popover) {
155
+ const pop = document.createElement('div');
156
+ pop.setAttribute('data-tour-popover', '');
157
+ pop.setAttribute('popover', 'manual');
158
+ pop.setAttribute('role', 'dialog');
159
+ pop.setAttribute('aria-modal', 'false');
160
+ pop.tabIndex = -1;
161
+ pop.style.outline = 'none';
162
+ pop.addEventListener('click', this.#onPopoverClick);
163
+ document.body.appendChild(pop);
164
+ this.#popover = pop;
165
+ }
166
+
167
+ // Reposition spotlight on viewport changes while active. anchorPopover
168
+ // (called per-step in #renderStep) handles popover repositioning.
169
+ this.#onResize = () => this.#positionSpotlight();
170
+ this.#onScroll = () => this.#positionSpotlight();
171
+ window.addEventListener('resize', this.#onResize);
172
+ window.addEventListener('scroll', this.#onScroll, true /* capture */);
173
+
174
+ // Keyboard navigation
175
+ this.#onKeydown = (e) => {
176
+ if (!this.active) return;
177
+ if (e.key === 'Escape') { e.preventDefault(); this.skip(); }
178
+ else if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
179
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); this.previous(); }
180
+ };
181
+ document.addEventListener('keydown', this.#onKeydown);
182
+ }
183
+
184
+ #teardown() {
185
+ this.#anchorCleanup?.();
186
+ this.#anchorCleanup = null;
187
+ if (this.#onResize) window.removeEventListener('resize', this.#onResize);
188
+ if (this.#onScroll) window.removeEventListener('scroll', this.#onScroll, true);
189
+ if (this.#onKeydown) document.removeEventListener('keydown', this.#onKeydown);
190
+ this.#onResize = this.#onScroll = this.#onKeydown = null;
191
+ try { this.#popover?.hidePopover?.(); } catch { /* noop */ }
192
+ this.#popover?.removeEventListener('click', this.#onPopoverClick);
193
+ this.#popover?.remove();
194
+ this.#popover = null;
195
+ this.#spotlight?.remove();
196
+ this.#spotlight = null;
197
+ }
198
+
199
+ #renderStep() {
200
+ const step = this.#steps[this.step];
201
+ if (!step) return;
202
+
203
+ const total = this.#steps.length;
204
+ const isLast = this.step === total - 1;
205
+ const isFirst = this.step === 0;
206
+ const title = step.getAttribute('title') || '';
207
+ const placement = step.getAttribute('placement') || 'bottom';
208
+ const targetSel = step.getAttribute('target') || '';
209
+ const target = targetSel ? document.querySelector(targetSel) : null;
210
+
211
+ // Spotlight position
212
+ this.#currentTarget = target;
213
+ this.#positionSpotlight();
214
+
215
+ // Popover content
216
+ const indicator = `${this.step + 1} of ${total}`;
217
+ const bodyHTML = step.innerHTML;
218
+ this.#popover.innerHTML = `
219
+ <div data-tour-header>
220
+ <span data-tour-indicator>${indicator}</span>
221
+ <span></span>
222
+ </div>
223
+ <h3 data-tour-title>${escapeHtml(title)}</h3>
224
+ <div data-tour-body>${bodyHTML}</div>
225
+ <div data-tour-footer>
226
+ <button-ui data-tour-skip variant="ghost" size="sm" text="Skip"></button-ui>
227
+ <div data-tour-nav>
228
+ ${isFirst ? '' : '<button-ui data-tour-previous variant="outline" size="sm" text="Back"></button-ui>'}
229
+ <button-ui data-tour-next variant="primary" size="sm" text="${isLast ? 'Done' : 'Next →'}"></button-ui>
230
+ </div>
231
+ </div>
232
+ `;
233
+
234
+ // Open the popover (idempotent) + position it.
235
+ try { this.#popover.showPopover(); } catch { /* popover API unavailable */ }
236
+ this.#anchorCleanup?.();
237
+ if (target) {
238
+ this.#anchorCleanup = anchorPopover(target, this.#popover, { placement, gap: 12 });
239
+ } else {
240
+ // No target — center the popover in the viewport
241
+ this.#popover.style.position = 'fixed';
242
+ this.#popover.style.top = '50%';
243
+ this.#popover.style.left = '50%';
244
+ this.#popover.style.transform = 'translate(-50%, -50%)';
245
+ }
246
+
247
+ // Move focus to the popover so keyboard nav works immediately
248
+ queueMicrotask(() => this.#popover?.focus?.());
249
+ }
250
+
251
+ #currentTarget = null;
252
+
253
+ #positionSpotlight() {
254
+ if (!this.#spotlight) return;
255
+ const target = this.#currentTarget;
256
+ if (!target) {
257
+ this.#spotlight.setAttribute('data-tour-no-target', '');
258
+ return;
259
+ }
260
+ this.#spotlight.removeAttribute('data-tour-no-target');
261
+ const rect = target.getBoundingClientRect();
262
+ // Add halo padding around the target
263
+ const cs = getComputedStyle(this);
264
+ const halo = parseFloat(cs.getPropertyValue('--tour-spotlight-padding')) || 8;
265
+ const top = Math.max(0, rect.top - halo);
266
+ const left = Math.max(0, rect.left - halo);
267
+ const w = rect.width + halo * 2;
268
+ const h = rect.height + halo * 2;
269
+ this.#spotlight.style.top = `${top}px`;
270
+ this.#spotlight.style.left = `${left}px`;
271
+ this.#spotlight.style.width = `${w}px`;
272
+ this.#spotlight.style.height = `${h}px`;
273
+ }
274
+
275
+ #onPopoverClick = (e) => {
276
+ const t = e.target;
277
+ if (!t || !t.closest) return;
278
+ if (t.closest('[data-tour-skip]')) { this.skip(); return; }
279
+ if (t.closest('[data-tour-previous]')) { this.previous(); return; }
280
+ if (t.closest('[data-tour-next]')) { this.next(); return; }
281
+ };
282
+ }
283
+
284
+ /**
285
+ * `<tour-step>` — data carrier for a single step inside `<tour-ui>`.
286
+ *
287
+ * Renders nothing on its own (`display: none` via tour.css). tour-ui
288
+ * reads target / title / placement / body content from it on activation.
289
+ */
290
+ export class UITourStep extends UIElement {
291
+ static properties = {
292
+ target: { type: String, default: '', reflect: true },
293
+ title: { type: String, default: '', reflect: true },
294
+ placement: { type: String, default: 'bottom', reflect: true },
295
+ };
296
+
297
+ static template = () => null;
298
+
299
+ // No connected/disconnected behavior needed — pure data carrier.
300
+ }
301
+
302
+ function escapeHtml(s) {
303
+ return String(s ?? '')
304
+ .replace(/&/g, '&amp;')
305
+ .replace(/</g, '&lt;')
306
+ .replace(/>/g, '&gt;')
307
+ .replace(/"/g, '&quot;')
308
+ .replace(/'/g, '&#39;');
309
+ }