Skip to content

Commit 00700f3

Browse files
authored
feat: Add onboarding flow (#199)
1 parent e9bbf5a commit 00700f3

19 files changed

Lines changed: 1373 additions & 28 deletions

.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ PUBLIC_TURNSTILE_DEV_BYPASS_VALUE=INVALID
99

1010
PUBLIC_DEVELOPMENT_BANNER=false
1111

12+
PUBLIC_DISABLE_ONBOARDING=false
13+
14+
PUBLIC_DISABLE_SHOCKER_MAP=true
15+
1216
PUBLIC_SITE_URL=https://openshock.app
1317
PUBLIC_SITE_SHORT_URL=https://openshock.app
1418
PUBLIC_BACKEND_API_URL=https://api.openshock.app

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"vitest": "^4.1.6"
7272
},
7373
"dependencies": {
74+
"driver.js": "^1.4.0",
7475
"temporal-polyfill": "^0.3.2",
7576
"vite": "^8.0.13"
7677
},

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,54 @@
250250
display: none;
251251
}
252252
}
253+
254+
/* driver.js theme override — match the dark UI */
255+
.driver-popover {
256+
background: #13131a;
257+
color: rgba(255, 255, 255, 0.85);
258+
border: 1px solid rgba(255, 255, 255, 0.1);
259+
border-radius: 0.75rem;
260+
}
261+
.driver-popover-title {
262+
color: #fff;
263+
font-weight: 600;
264+
}
265+
.driver-popover-description {
266+
color: rgba(255, 255, 255, 0.7);
267+
}
268+
.driver-popover-arrow-side-left.driver-popover-arrow,
269+
.driver-popover-arrow-side-right.driver-popover-arrow,
270+
.driver-popover-arrow-side-top.driver-popover-arrow,
271+
.driver-popover-arrow-side-bottom.driver-popover-arrow {
272+
border-color: #13131a;
273+
}
274+
.driver-popover-progress-text {
275+
color: rgba(255, 255, 255, 0.5);
276+
font-size: 0.75rem;
277+
}
278+
.driver-popover-footer button {
279+
background: rgba(255, 255, 255, 0.08) !important;
280+
color: #fff !important;
281+
border: 1px solid rgba(255, 255, 255, 0.12);
282+
border-radius: 0.5rem;
283+
padding: 0.375rem 0.875rem;
284+
font-size: 0.875rem;
285+
text-shadow: none !important;
286+
}
287+
.driver-popover-footer button:hover {
288+
background: rgba(255, 255, 255, 0.14) !important;
289+
}
290+
.driver-popover-footer .driver-popover-next-btn {
291+
background: #fff !important;
292+
color: #000 !important;
293+
border-color: transparent;
294+
}
295+
.driver-popover-footer .driver-popover-next-btn:hover {
296+
background: rgba(255, 255, 255, 0.9) !important;
297+
}
298+
.driver-popover-close-btn {
299+
color: rgba(255, 255, 255, 0.5);
300+
}
301+
.driver-popover-close-btn:hover {
302+
color: #fff;
303+
}

src/hooks.client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { base } from '$app/paths';
12
import { versionGetBackendInfo } from '$lib/api';
23
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
34
import { authState, startAuthLifecycle } from '$lib/state/auth-state.svelte';
45
import { backendMetadata } from '$lib/state/backend-metadata-state.svelte';
56
import { initializeColorScheme } from '$lib/state/color-scheme-state.svelte';
67
import { userState } from '$lib/state/user-state.svelte';
8+
import { redirectLegacyHashRoute } from '$lib/utils/legacy-hash-redirect';
79

810
async function ensureTemporal(): Promise<void> {
911
if (typeof (globalThis as { Temporal?: unknown }).Temporal === 'undefined') {
@@ -26,6 +28,7 @@ async function clientInit(): Promise<void> {
2628
}
2729

2830
export async function init() {
31+
redirectLegacyHashRoute(base);
2932
await ensureTemporal();
3033
await clientInit().catch(handleApiError);
3134
initializeColorScheme();

src/lib/components/DotGrid.svelte

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<script lang="ts">
2+
let mouseX = $state(-9999);
3+
let mouseY = $state(-9999);
4+
let rafPending = false;
5+
let containerEl: HTMLDivElement | undefined = $state();
6+
7+
export function handlePointerMove(e: PointerEvent) {
8+
if (rafPending) return;
9+
rafPending = true;
10+
requestAnimationFrame(() => {
11+
const rect = containerEl?.getBoundingClientRect();
12+
mouseX = rect ? e.clientX - rect.left : e.clientX;
13+
mouseY = rect ? e.clientY - rect.top : e.clientY;
14+
rafPending = false;
15+
});
16+
}
17+
</script>
18+
19+
<div bind:this={containerEl} class="pointer-events-none absolute inset-0">
20+
<div class="bg-grid absolute inset-0" aria-hidden="true"></div>
21+
<div
22+
class="bg-grid-spotlight absolute inset-0"
23+
style="--mouse-x={mouseX}px --mouse-y={mouseY}px"
24+
aria-hidden="true"
25+
></div>
26+
</div>
27+
28+
<style>
29+
.bg-grid {
30+
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.07) 1px, transparent 1px);
31+
background-size: 28px 28px;
32+
background-position: center center;
33+
}
34+
35+
.bg-grid-spotlight {
36+
background-image: radial-gradient(circle, rgba(225, 74, 109, 0.9) 1px, transparent 1px);
37+
background-size: 28px 28px;
38+
background-position: center center;
39+
mask-image: radial-gradient(
40+
circle 220px at var(--mouse-x, -9999px) var(--mouse-y, -9999px),
41+
black 0%,
42+
transparent 75%
43+
);
44+
-webkit-mask-image: radial-gradient(
45+
circle 220px at var(--mouse-x, -9999px) var(--mouse-y, -9999px),
46+
black 0%,
47+
transparent 75%
48+
);
49+
}
50+
</style>

src/lib/components/ui/sidebar/sidebar-menu-button.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import { tv, type VariantProps } from "tailwind-variants";
33
44
export const sidebarMenuButtonVariants = tv({
5-
base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
5+
base: "ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent/50 data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
66
variants: {
77
variant: {
8-
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
8+
default: "hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground",
99
outline: "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
1010
},
1111
size: {

src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
2020
const mergedProps = $derived({
2121
class: cn(
22-
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
22+
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
2323
className
2424
),
2525
"data-slot": "sidebar-menu-sub-button",

src/lib/state/auth-state.svelte.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
import { dialog } from '$lib/components/dialog-manager/dialog-store.svelte';
12
import { registerOnUnauthorized } from '$lib/errorhandling/apiErrorHandling';
23
import { destroySignalR, initializeSignalR } from '$lib/signalr/user.svelte';
4+
import {
5+
hasCompletedTour,
6+
hasSeenWelcome,
7+
isOnboardingDisabled,
8+
markTourCompleted,
9+
startWelcomeTour,
10+
} from '$lib/tour/welcome-tour';
311
import { userState } from './user-state.svelte';
412

513
export const AuthStatus = {
@@ -25,6 +33,18 @@ export const authState = {
2533
},
2634
};
2735

36+
async function maybeTourPrompt() {
37+
if (isOnboardingDisabled() || hasCompletedTour() || !hasSeenWelcome()) return;
38+
const result = await dialog.confirm({
39+
title: 'Take the quick tour?',
40+
desc: "You skipped it earlier. It only takes a minute and shows you what's new.",
41+
confirmButtonText: 'Sure, show me',
42+
cancelButtonText: 'No thanks',
43+
});
44+
if (result.confirmed) await startWelcomeTour();
45+
else markTourCompleted();
46+
}
47+
2848
/**
2949
* Wires up the reactive side-effects of auth state:
3050
* - SignalR connects/disconnects as the user logs in/out.
@@ -37,12 +57,16 @@ export function startAuthLifecycle() {
3757
registerOnUnauthorized(() => userState.reset());
3858

3959
_stopLifecycle = $effect.root(() => {
60+
let prevSelf: typeof userState.self = null;
4061
$effect(() => {
41-
if (userState.self) {
62+
const self = userState.self;
63+
if (self && !prevSelf) {
64+
void maybeTourPrompt();
4265
void initializeSignalR();
43-
} else {
66+
} else if (!self) {
4467
void destroySignalR();
4568
}
69+
prevSelf = self;
4670
});
4771
});
4872
}

src/lib/tour/onboarding-state.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { PUBLIC_DISABLE_ONBOARDING } from '$env/static/public';
2+
import { isTruthy } from '$lib/utils/parse';
3+
4+
const CURRENT_WELCOME_VERSION = 1;
5+
const WELCOME_VERSION_KEY = 'os.welcomeVersion';
6+
7+
const CURRENT_TOUR_VERSION = 1;
8+
const TOUR_VERSION_KEY = 'os.tourCompletedVersion';
9+
10+
export function isOnboardingDisabled(): boolean {
11+
return isTruthy(PUBLIC_DISABLE_ONBOARDING);
12+
}
13+
14+
export function hasSeenWelcome(): boolean {
15+
try {
16+
const raw = localStorage.getItem(WELCOME_VERSION_KEY);
17+
const seen = raw ? parseInt(raw, 10) : 0;
18+
return Number.isFinite(seen) && seen >= CURRENT_WELCOME_VERSION;
19+
} catch {
20+
return false;
21+
}
22+
}
23+
24+
export function markWelcomed(): void {
25+
try {
26+
localStorage.setItem(WELCOME_VERSION_KEY, String(CURRENT_WELCOME_VERSION));
27+
} catch {
28+
// ignore (private mode, quota, etc.)
29+
}
30+
}
31+
32+
export function shouldShowWelcome(): boolean {
33+
if (isOnboardingDisabled()) return false;
34+
try {
35+
const raw = localStorage.getItem(WELCOME_VERSION_KEY);
36+
const seen = raw ? parseInt(raw, 10) : 0;
37+
return !Number.isFinite(seen) || seen < CURRENT_WELCOME_VERSION;
38+
} catch {
39+
return false;
40+
}
41+
}
42+
43+
export function hasCompletedTour(): boolean {
44+
if (isOnboardingDisabled()) return true;
45+
try {
46+
const raw = localStorage.getItem(TOUR_VERSION_KEY);
47+
const n = raw ? parseInt(raw, 10) : 0;
48+
return Number.isFinite(n) && n >= CURRENT_TOUR_VERSION;
49+
} catch {
50+
return false;
51+
}
52+
}
53+
54+
export function markTourCompleted(): void {
55+
try {
56+
localStorage.setItem(TOUR_VERSION_KEY, String(CURRENT_TOUR_VERSION));
57+
} catch {
58+
// ignore
59+
}
60+
}

0 commit comments

Comments
 (0)