Skip to content

Commit d71d700

Browse files
authored
Add tour infrastructure (#966)
* add tour infrastructure * forbid settings change during tour * refactor api * CR suggestions * refactor * add pubsub options to api * remove radius * update tour elements * change elements from id to selectors * refactor api * remove outdated doc * add background * add id * build assets
1 parent 6f01892 commit d71d700

File tree

27 files changed

+838
-55
lines changed

27 files changed

+838
-55
lines changed

assets/app/app.css

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,69 @@ body {
7979
background-color: var(--search-highlight-bg);
8080
color: var(--search-highlight-text);
8181
}
82+
83+
/* Tour */
84+
@keyframes tour-highlight-pulse {
85+
0% {
86+
outline-color: var(--swm-pink-100);
87+
outline-offset: -3px;
88+
box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.5);
89+
}
90+
50% {
91+
outline-color: var(--swm-pink-80);
92+
outline-offset: 2px;
93+
box-shadow: 0 0 16px 4px rgba(255, 98, 89, 0.25);
94+
}
95+
100% {
96+
outline-color: var(--swm-pink-100);
97+
outline-offset: -3px;
98+
box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.5);
99+
}
100+
}
101+
102+
@keyframes tour-spotlight-pop {
103+
0% {
104+
transform: scale(0.97);
105+
box-shadow: 0 0 0 0 rgba(255, 98, 89, 0.6);
106+
}
107+
40% {
108+
transform: scale(1.02);
109+
box-shadow: 0 0 24px 8px rgba(255, 98, 89, 0.3);
110+
}
111+
100% {
112+
transform: scale(1);
113+
box-shadow: 0 0 12px 4px rgba(255, 98, 89, 0.15);
114+
}
115+
}
116+
117+
@keyframes tour-overlay-fade {
118+
from {
119+
opacity: 0;
120+
}
121+
to {
122+
opacity: 1;
123+
}
124+
}
125+
126+
.tour-highlight {
127+
outline: 3px solid var(--swm-pink-100);
128+
outline-offset: -3px;
129+
animation: tour-highlight-pulse 1.5s ease-in-out infinite;
130+
}
131+
132+
.tour-overlay {
133+
position: fixed;
134+
inset: 0;
135+
z-index: 9998;
136+
background: rgba(0, 0, 0, 0.5);
137+
pointer-events: auto;
138+
animation: tour-overlay-fade 0.3s ease-out;
139+
}
140+
141+
.tour-spotlight-target {
142+
position: relative;
143+
z-index: 9999;
144+
pointer-events: auto;
145+
animation: tour-spotlight-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
146+
box-shadow: 0 0 12px 4px rgba(255, 98, 89, 0.15);
147+
}

assets/app/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import OpenComponentsTree from './hooks/open_components_tree';
2121
import CloseSidebarOnResize from './hooks/close_sidebar_on_resize';
2222
import CodeMirrorTextarea from './hooks/code_mirror_textarea';
2323
import SurveyBanner from './hooks/survey_banner';
24+
import Tour from './hooks/tour';
2425

2526
import topbar from './vendor/topbar';
2627

@@ -53,6 +54,7 @@ function createHooks() {
5354
OpenComponentsTree,
5455
CloseSidebarOnResize,
5556
SurveyBanner,
57+
Tour,
5658
};
5759
}
5860

assets/app/hooks/tour.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
const OVERLAY_ID = 'tour-overlay';
2+
const PENDING_ACTION_KEY = 'lvdbg-tour-pending';
3+
4+
const classGuardians = new Set();
5+
6+
function clearAll() {
7+
const overlay = document.getElementById(OVERLAY_ID);
8+
if (overlay) overlay.remove();
9+
10+
classGuardians.forEach((guardian) => guardian.disconnect());
11+
classGuardians.clear();
12+
13+
document
14+
.querySelectorAll('.tour-highlight, .tour-spotlight-target')
15+
.forEach((el) => {
16+
el.classList.remove('tour-highlight', 'tour-spotlight-target');
17+
});
18+
}
19+
20+
function guardClass(target, className) {
21+
const observer = new MutationObserver(() => {
22+
if (!target.classList.contains(className)) {
23+
target.classList.add(className);
24+
}
25+
});
26+
27+
observer.observe(target, {
28+
attributes: true,
29+
attributeFilter: ['class'],
30+
});
31+
32+
classGuardians.add(observer);
33+
}
34+
35+
function highlight(target) {
36+
target.scrollIntoView({
37+
behavior: 'smooth',
38+
block: 'center',
39+
});
40+
target.classList.add('tour-highlight');
41+
42+
guardClass(target, 'tour-highlight');
43+
}
44+
45+
function createOverlay() {
46+
if (!document.getElementById(OVERLAY_ID)) {
47+
const overlay = document.createElement('div');
48+
overlay.id = OVERLAY_ID;
49+
overlay.className = 'tour-overlay';
50+
document.body.appendChild(overlay);
51+
}
52+
}
53+
54+
function spotlight(target) {
55+
createOverlay();
56+
57+
target.scrollIntoView({
58+
behavior: 'smooth',
59+
block: 'center',
60+
});
61+
target.classList.add('tour-spotlight-target');
62+
63+
guardClass(target, 'tour-spotlight-target');
64+
}
65+
66+
const Tour = {
67+
mounted() {
68+
this._cleanups = new Set();
69+
const pending = sessionStorage.getItem(PENDING_ACTION_KEY);
70+
if (pending) {
71+
sessionStorage.removeItem(PENDING_ACTION_KEY);
72+
this._applyAction(JSON.parse(pending));
73+
}
74+
75+
this.handleEvent('tour-action', (payload) => {
76+
this._applyAction(payload);
77+
});
78+
},
79+
80+
_applyAction(payload) {
81+
const {
82+
action,
83+
target: selector,
84+
dismiss,
85+
url,
86+
then: nextAction,
87+
clear = true,
88+
} = payload;
89+
90+
if (clear || action === 'clear') {
91+
this._cleanupListeners();
92+
clearAll();
93+
}
94+
95+
if (action === 'clear') return;
96+
97+
if (action === 'redirect') {
98+
if (nextAction) {
99+
sessionStorage.setItem(PENDING_ACTION_KEY, JSON.stringify(nextAction));
100+
}
101+
this.pushEvent('tour-redirect', { url });
102+
return;
103+
}
104+
105+
const target = document.querySelector(selector);
106+
if (!target) {
107+
this._waitForTarget(selector, () =>
108+
this._applyToTarget(selector, payload)
109+
);
110+
return;
111+
}
112+
113+
this._applyToTarget(selector, payload);
114+
},
115+
116+
_applyToTarget(selector, payload) {
117+
const { action, dismiss } = payload;
118+
const target = document.querySelector(selector);
119+
if (!target) return;
120+
121+
switch (action) {
122+
case 'highlight':
123+
highlight(target);
124+
break;
125+
case 'spotlight':
126+
spotlight(target);
127+
break;
128+
default:
129+
console.warn(`[Tour] Unknown action: ${action}`);
130+
return;
131+
}
132+
133+
if (dismiss === 'click-anywhere') {
134+
this._setupClickAnywhereDismiss();
135+
} else if (dismiss === 'click-target') {
136+
this._setupClickTargetDismiss(target, selector);
137+
}
138+
},
139+
140+
_waitForTarget(selector, callback) {
141+
const observer = new MutationObserver(() => {
142+
if (document.querySelector(selector)) {
143+
observer.disconnect();
144+
this._cleanups.delete(cleanup);
145+
callback();
146+
}
147+
});
148+
149+
observer.observe(document.body, { childList: true, subtree: true });
150+
151+
const cleanup = () => observer.disconnect();
152+
this._cleanups.add(cleanup);
153+
},
154+
155+
_cleanupListeners() {
156+
this._cleanups.forEach((cleanup) => cleanup());
157+
this._cleanups.clear();
158+
},
159+
160+
_setupClickAnywhereDismiss() {
161+
const controller = new AbortController();
162+
163+
const handler = () => {
164+
clearAll();
165+
this._cleanupListeners();
166+
this.pushEvent('step-completed', { target: 'anywhere' });
167+
};
168+
169+
const cleanup = () => controller.abort();
170+
this._cleanups.add(cleanup);
171+
172+
setTimeout(() => {
173+
if (!controller.signal.aborted) {
174+
document.addEventListener('click', handler, {
175+
once: true,
176+
signal: controller.signal,
177+
});
178+
}
179+
}, 0);
180+
},
181+
182+
_setupClickTargetDismiss(target, selector) {
183+
const overlay = document.getElementById(OVERLAY_ID);
184+
185+
const handler = () => {
186+
clearAll();
187+
this._cleanupListeners();
188+
this.pushEvent('step-completed', { target: selector });
189+
};
190+
191+
const overlayHandler = (e) => e.stopPropagation();
192+
if (overlay) {
193+
overlay.addEventListener('click', overlayHandler);
194+
}
195+
196+
target.addEventListener('click', handler, { once: true });
197+
198+
const cleanup = () => {
199+
target.removeEventListener('click', handler);
200+
if (overlay) overlay.removeEventListener('click', overlayHandler);
201+
};
202+
203+
this._cleanups.add(cleanup);
204+
},
205+
206+
destroyed() {
207+
this._cleanupListeners();
208+
clearAll();
209+
},
210+
};
211+
212+
export default Tour;

assets/client/client.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ window.document.addEventListener('DOMContentLoaded', async () => {
4242
initTooltip();
4343
initDebugMenu(metaTag, sessionURL, debugChannel);
4444
initHighlight(debugChannel);
45+
46+
document.addEventListener('lvdbg:tour', (e) => {
47+
const { command, ...payload } = e.detail;
48+
debugChannel.push(command, payload);
49+
});
4550
}
4651

4752
console.info(`LiveDebugger available at: ${baseURL}`);

dev/live_views/main.ex

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule LiveDebuggerDev.LiveViews.Main do
22
use DevWeb, :live_view
33

44
alias LiveDebuggerDev.LiveComponents
5+
alias LiveDebugger.Tour
6+
alias LiveDebugger.TourElements
57

68
@long_text """
79
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
@@ -33,7 +35,9 @@ defmodule LiveDebuggerDev.LiveViews.Main do
3335
|> assign(long_assign: @long_text)
3436
|> assign(deep_assign: %{b: %{c: %{d: %{e: %{f: %{g: "deep value"}}}}}})
3537
|> assign(message: nil)
38+
|> assign(current_step: "clear")
3639

40+
Tour.redirect("/settings")
3741
{:ok, socket, temporary_assigns: [message: nil]}
3842
end
3943

@@ -46,6 +50,59 @@ defmodule LiveDebuggerDev.LiveViews.Main do
4650
</p>
4751
</div>
4852
<div class="flex flex-col gap-2">
53+
<div class="flex flex-col gap-1 border-2 border-purple-300 rounded p-2">
54+
<span class="text-sm font-bold text-purple-600">Tour API Demo</span>
55+
<div class="flex items-center gap-2 flex-wrap">
56+
<span class="text-xs text-gray-500">Actions:</span>
57+
<.button
58+
color="purple"
59+
phx-click={:navbar |> TourElements.id() |> Tour.spotlight_JS(dismiss: "click-anywhere")}
60+
>
61+
Spotlight Navbar
62+
</.button>
63+
<.button color="purple" phx-click={Tour.highlight_JS(:navbar_connected, clear: false)}>
64+
Highlight PID
65+
</.button>
66+
<.button color="blue" phx-click={Tour.spotlight_JS(:callback_traces_first_trace)}>
67+
Spotlight "Send Event"
68+
</.button>
69+
<.button color="gray" phx-click={Tour.clear_JS()}>
70+
Clear
71+
</.button>
72+
</div>
73+
74+
<div class="flex items-center gap-2 flex-wrap">
75+
<span class="text-xs text-gray-500">Redirect:</span>
76+
<.button
77+
color="purple"
78+
phx-click={
79+
Tour.redirect_JS("/settings", then: Tour.step(:highlight, :refresh_tracing_button))
80+
}
81+
>
82+
Redirect to Settings + Highlight
83+
</.button>
84+
<.button
85+
color="purple"
86+
phx-click={
87+
Tour.redirect_JS("/",
88+
then: Tour.step(:spotlight, :navbar, dismiss: "click-anywhere")
89+
)
90+
}
91+
>
92+
Redirect to Discovery + Spotlight
93+
</.button>
94+
</div>
95+
96+
<div class="flex items-center gap-2 flex-wrap">
97+
<span class="text-xs text-gray-500">Settings:</span>
98+
<.button color="green" phx-click={Tour.enable_settings_JS()}>
99+
Enable Settings
100+
</.button>
101+
<.button color="red" phx-click={Tour.disable_settings_JS()}>
102+
Disable Settings
103+
</.button>
104+
</div>
105+
</div>
49106
<div class="flex items-center gap-2">
50107
<.button id="append-message" phx-click="append-message" color="green">
51108
Append message

lib/live_debugger/api/settings_storage.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ defmodule LiveDebugger.API.SettingsStorage do
104104
# User still can change it in settings, and until next app restart it will be used.
105105
SettingsStorage.available_settings()
106106
|> Enum.map(fn setting ->
107-
{setting, Application.get_env(:live_debugger, setting, fetch_setting(setting))}
107+
{setting, fetch_setting(setting)}
108108
end)
109109
|> Enum.each(fn {setting, value} -> save(setting, value) end)
110110

0 commit comments

Comments
 (0)