feat(onboarding): first-run spotlight tour — dimmed backdrop, sidebar highlights, relaunchable

A guided tour for new operators: a dimmed+blurred overlay with an enterprise description card
(mono eyebrow "01 / 06", display title, body, progress bar, back/skip/next). A step with a
target spotlights the matching sidebar nav item (a box-shadow "hole" lights it while everything
else dims); welcome + finish are centred cards. On narrow viewports (sidebar hidden) it falls
back to centred cards.

- auto-opens once per account (users.onboarding_tour_completed_at is null → autostart); skip or
  finish calls Tour::markSeen() which stamps it, so it never auto-opens again.
- relaunchable any time from Settings (a client-side 'onboarding:start' window event — no DB change).
- @persist'd in the layout + a sessionStorage guard so a wire:navigate or fast reload right after
  dismissal can't re-open it before markSeen persists.
- nav-item now merges its attribute bag (data-tour passes through without duplicating class/href).

Browser-verified: auto-open → spotlight walks Dashboard/Servers/Terminal/Settings → skip stamps +
closes → navigation doesn't reopen → Settings relaunch works; zero console errors. 4 feature tests;
full suite 473 pass; Codex review CLEAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-02 20:00:19 +02:00
parent cc4cc3db4c
commit dc26dccaa3
12 changed files with 356 additions and 7 deletions

View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Onboarding;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
/**
* First-run onboarding tour. Renders a dimmed spotlight overlay (the Alpine island in the view)
* that walks the operator through the panel. It auto-opens once when the account has never
* dismissed it and can be relaunched any time from Settings (a client-side window event, no
* server round-trip). `markSeen` stamps the account so it never auto-opens again.
*/
class Tour extends Component
{
/** True on the first panel load for an account that has never finished/skipped the tour. */
public bool $autostart = false;
public function mount(): void
{
$this->autostart = Auth::user()?->onboarding_tour_completed_at === null;
}
/** Persist that the tour has been finished or skipped, so it won't auto-open again. */
public function markSeen(): void
{
$user = Auth::user();
if ($user !== null && $user->onboarding_tour_completed_at === null) {
$user->forceFill(['onboarding_tour_completed_at' => now()])->save();
}
}
/**
* The tour steps. A null `target` is a centred card (welcome / finish); a non-null `target`
* matches a `data-tour="…"` attribute on a sidebar nav item and is spotlighted.
*
* @return array<int, array<string, string|null>>
*/
public function steps(): array
{
return [
['target' => null, 'icon' => 'command', 'eyebrow' => __('onboarding.welcome_eyebrow'), 'title' => __('onboarding.welcome_title'), 'body' => __('onboarding.welcome_body')],
['target' => 'dashboard', 'icon' => 'dashboard', 'eyebrow' => __('onboarding.dashboard_eyebrow'), 'title' => __('onboarding.dashboard_title'), 'body' => __('onboarding.dashboard_body')],
['target' => 'servers', 'icon' => 'server', 'eyebrow' => __('onboarding.servers_eyebrow'), 'title' => __('onboarding.servers_title'), 'body' => __('onboarding.servers_body')],
['target' => 'terminal', 'icon' => 'terminal', 'eyebrow' => __('onboarding.terminal_eyebrow'), 'title' => __('onboarding.terminal_title'), 'body' => __('onboarding.terminal_body')],
['target' => 'settings', 'icon' => 'settings', 'eyebrow' => __('onboarding.settings_eyebrow'), 'title' => __('onboarding.settings_title'), 'body' => __('onboarding.settings_body')],
['target' => null, 'icon' => 'shield', 'eyebrow' => __('onboarding.done_eyebrow'), 'title' => __('onboarding.done_title'), 'body' => __('onboarding.done_body')],
];
}
public function render()
{
return view('livewire.onboarding.tour', ['steps' => $this->steps()]);
}
}

View File

@ -31,6 +31,7 @@ class User extends Authenticatable
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
'must_change_password' => 'boolean',
'onboarding_tour_completed_at' => 'datetime',
];
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* When the operator first finishes or skips the onboarding tour we stamp this; a null value means the
* tour has never been dismissed, so it auto-opens once on the first panel load. It can always be
* relaunched from Settings (that does NOT clear the stamp the relaunch is a client-side event).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('onboarding_tour_completed_at')->nullable()->after('must_change_password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('onboarding_tour_completed_at');
});
}
};

36
lang/de/onboarding.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// Onboarding tour (first-run spotlight walkthrough). Relaunchable from Settings.
return [
'aria' => 'Einführungs-Tour',
'welcome_eyebrow' => 'Willkommen',
'welcome_title' => 'Willkommen bei Clusev',
'welcome_body' => 'Eine Konsole für deine gesamte Server-Flotte — agentenlos über SSH. Diese kurze Tour zeigt dir die wichtigsten Bereiche. Du kannst sie jederzeit überspringen.',
'dashboard_eyebrow' => 'Übersicht',
'dashboard_title' => 'Dashboard',
'dashboard_body' => 'Live-Metriken deiner Flotte auf einen Blick — CPU, RAM und der Status jedes Servers in Echtzeit.',
'servers_eyebrow' => 'Flotte',
'servers_title' => 'Server',
'servers_body' => 'Server hinzufügen und verwalten. Der Zugang läuft agentenlos über SSH; die Zugangsdaten liegen verschlüsselt im Vault.',
'terminal_eyebrow' => 'Betrieb',
'terminal_title' => 'Terminal',
'terminal_body' => 'Ein vollwertiges SSH-Terminal pro Server — und für den Clusev-Host selbst. Mit Host-Key-Pinning gegen MITM.',
'settings_eyebrow' => 'Konto',
'settings_title' => 'Einstellungen',
'settings_body' => 'Profil, 2FA, Konten und Sicherheit. Diese Tour kannst du hier jederzeit erneut starten.',
'done_eyebrow' => 'Fertig',
'done_title' => 'Alles bereit',
'done_body' => 'Das war die Kurztour. Leg los — und starte sie bei Bedarf jederzeit in den Einstellungen neu.',
'skip' => 'Überspringen',
'back' => 'Zurück',
'next' => 'Weiter',
'done' => 'Fertig',
'relaunch' => 'Tour starten',
];

36
lang/en/onboarding.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// Onboarding tour (first-run spotlight walkthrough). Relaunchable from Settings.
return [
'aria' => 'Onboarding tour',
'welcome_eyebrow' => 'Welcome',
'welcome_title' => 'Welcome to Clusev',
'welcome_body' => 'One console for your entire server fleet — agentless over SSH. This short tour points out the key areas. You can skip it any time.',
'dashboard_eyebrow' => 'Overview',
'dashboard_title' => 'Dashboard',
'dashboard_body' => 'Your fleet\'s live metrics at a glance — CPU, RAM and each server\'s status in real time.',
'servers_eyebrow' => 'Fleet',
'servers_title' => 'Servers',
'servers_body' => 'Add and manage servers. Access is agentless over SSH; credentials are held encrypted in the vault.',
'terminal_eyebrow' => 'Operations',
'terminal_title' => 'Terminal',
'terminal_body' => 'A full SSH terminal per server — and for the Clusev host itself. With host-key pinning against MITM.',
'settings_eyebrow' => 'Account',
'settings_title' => 'Settings',
'settings_body' => 'Profile, 2FA, accounts and security. You can relaunch this tour from here any time.',
'done_eyebrow' => 'Done',
'done_title' => 'You\'re all set',
'done_body' => 'That\'s the quick tour. Dive in — and relaunch it any time from Settings if you need it.',
'skip' => 'Skip',
'back' => 'Back',
'next' => 'Next',
'done' => 'Done',
'relaunch' => 'Start tour',
];

View File

@ -145,6 +145,71 @@ document.addEventListener('alpine:init', () => {
window.LivewireUIModal = guarded;
}
// ── Onboarding tour ─────────────────────────────────────────────────
// First-run spotlight tour. `steps` come from the Livewire component; a step with a `target`
// spotlights the sidebar nav item carrying data-tour="<target>", otherwise the card is centred.
// Auto-opens once (autostart), relaunchable via the `onboarding:start` window event; finishing
// or skipping calls $wire.markSeen() so it never auto-opens again.
window.Alpine.data('onboardingTour', (steps = [], autostart = false) => ({
steps,
open: false,
i: 0,
rect: null, // spotlight rect (fixed-viewport coords) or null → centred card
get step() { return this.steps[this.i] || {}; },
get isLast() { return this.i === this.steps.length - 1; },
init() {
this._start = () => this.launch();
this._reflow = () => { if (this.open) this.place(); };
window.addEventListener('onboarding:start', this._start);
window.addEventListener('resize', this._reflow);
window.addEventListener('scroll', this._reflow, true);
// Auto-open once — but not if it was already dismissed this session (guards the window
// between finish() and markSeen() persisting, e.g. a fast reload). @persist covers navigation.
if (autostart && !this._dismissed()) this.$nextTick(() => this.launch());
},
_dismissed() { try { return sessionStorage.getItem('clusev.tour.dismissed') === '1'; } catch (_) { return false; } },
destroy() {
window.removeEventListener('onboarding:start', this._start);
window.removeEventListener('resize', this._reflow);
window.removeEventListener('scroll', this._reflow, true);
},
launch() { this.i = 0; this.open = true; this.$nextTick(() => this.place()); },
place() {
const t = this.step.target;
if (!t) { this.rect = null; return; }
const el = document.querySelector(`[data-tour="${t}"]`);
const r = el && el.getBoundingClientRect();
// Hidden target (e.g. the mobile drawer is closed) → fall back to a centred card.
this.rect = (r && r.width > 0 && r.height > 0) ? { top: r.top, left: r.left, width: r.width, height: r.height } : null;
},
next() { if (this.isLast) return this.finish(); this.i++; this.$nextTick(() => this.place()); },
prev() { if (this.i > 0) { this.i--; this.$nextTick(() => this.place()); } },
skip() { this.finish(); },
finish() {
this.open = false;
this.rect = null;
try { sessionStorage.setItem('clusev.tour.dismissed', '1'); } catch (_) {}
try { this.$wire.markSeen(); } catch (_) {}
},
spotStyle() {
const r = this.rect; if (!r) return 'display:none';
const p = 6;
return `top:${r.top - p}px;left:${r.left - p}px;width:${r.width + p * 2}px;height:${r.height + p * 2}px`;
},
cardStyle() {
const r = this.rect; if (!r) return '';
const gap = 18, w = 380, h = 250;
// Prefer to the right of the target (the sidebar sits on the left); flip left if it overflows.
let left = r.left + r.width + gap;
if (left + w > window.innerWidth - 12) left = Math.max(12, r.left - w - gap);
const top = Math.max(12, Math.min(r.top, window.innerHeight - h - 12));
return `top:${top}px;left:${left}px`;
},
}));
// ── Modal trigger ───────────────────────────────────────────────────
// Reused by <x-modal-trigger>. Gives EVERY modal-opening control an immediate
// spinner (pending flips synchronously on click — no round-trip needed) that clears

View File

@ -1,10 +1,9 @@
@props(['icon', 'href' => '#', 'active' => false, 'badge' => null])
<a href="{{ $href }}"
@class([
<a {{ $attributes->class([
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
'border-accent/25 bg-accent/10 text-ink shadow-[inset_2px_0_0_var(--color-accent)]' => $active,
'border-transparent text-ink-2 hover:bg-raised hover:text-ink' => ! $active,
])
])->merge(['href' => $href]) }}
@if ($active) aria-current="page" @endif>
<x-icon :name="$icon" @class(['h-[18px] w-[18px] shrink-0', 'text-accent' => $active]) />
<span class="truncate">{{ $slot }}</span>

View File

@ -28,16 +28,16 @@
{{-- Nav --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p>
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')">{{ __('shell.nav_dashboard') }}</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">{{ __('shell.nav_servers') }}</x-nav-item>
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')">{{ __('shell.nav_terminal') }}</x-nav-item>
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item>
@if (config('clusev.release_controls'))

View File

@ -53,6 +53,13 @@
</div>
@include('partials.toaster')
{{-- First-run onboarding tour (dimmed spotlight overlay; auto-opens once, relaunchable from Settings).
@persist so it does NOT re-mount on wire:navigate otherwise mount() would re-read the flag and
could re-open the tour in the window before markSeen() persists. --}}
@persist('onboarding-tour')
<livewire:onboarding.tour />
@endpersist
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
<x-command-palette />

View File

@ -0,0 +1,70 @@
<div x-data="onboardingTour(@js($steps), @js($autostart))">
{{-- The whole overlay only exists while the tour is open. --}}
<div x-show="open" x-cloak class="fixed inset-0 z-[80]" role="dialog" aria-modal="true" aria-label="{{ __('onboarding.aria') }}">
{{-- Click-catcher: blocks the app underneath (no accidental skip dismissal is explicit). --}}
<div class="absolute inset-0"></div>
{{-- Centred steps (welcome / finish, or when the target isn't visible) get a full dim + blur. --}}
<div x-show="! rect" x-transition.opacity class="absolute inset-0 bg-void/80 backdrop-blur-sm"></div>
{{-- Spotlight: a transparent box whose huge box-shadow dims everything around the target. --}}
<div x-show="rect" x-cloak :style="spotStyle()"
class="pointer-events-none absolute rounded-lg ring-2 ring-accent/70 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--color-void)_84%,transparent)] transition-all duration-300 ease-out"></div>
{{-- The description card. --}}
<div x-cloak
:class="rect ? '' : 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'"
:style="rect ? cardStyle() : ''"
class="pointer-events-auto absolute w-[min(92vw,380px)] overflow-hidden rounded-xl border border-line bg-surface shadow-panel"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-1" x-transition:enter-end="opacity-100 translate-y-0">
{{-- Accent hairline top edge. --}}
<div class="h-0.5 w-full bg-gradient-to-r from-accent via-accent/40 to-transparent"></div>
<div class="p-5">
<button type="button" @click="skip()" aria-label="{{ __('onboarding.skip') }}"
class="absolute right-3 top-3 grid h-7 w-7 place-items-center rounded-md text-ink-4 transition-colors hover:bg-raised hover:text-ink-2">
<x-icon name="x" class="h-3.5 w-3.5" />
</button>
<div class="flex items-start gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-lg border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-4px_var(--color-accent)]">
<x-icon name="command" class="h-4 w-4" x-show="step.icon==='command'" />
<x-icon name="dashboard" class="h-4 w-4" x-show="step.icon==='dashboard'" x-cloak />
<x-icon name="server" class="h-4 w-4" x-show="step.icon==='server'" x-cloak />
<x-icon name="terminal" class="h-4 w-4" x-show="step.icon==='terminal'" x-cloak />
<x-icon name="settings" class="h-4 w-4" x-show="step.icon==='settings'" x-cloak />
<x-icon name="shield" class="h-4 w-4" x-show="step.icon==='shield'" x-cloak />
</span>
<div class="min-w-0 flex-1 pr-6">
<p class="font-mono text-[10px] uppercase tracking-[0.2em] text-accent-text">
<span x-text="'0' + (i + 1)"></span><span class="text-ink-4"> / 0<span x-text="steps.length"></span></span>
<span class="text-ink-4"> · </span><span x-text="step.eyebrow"></span>
</p>
<h2 class="mt-1 font-display text-lg font-semibold leading-tight text-ink" x-text="step.title"></h2>
</div>
</div>
<p class="mt-3 text-sm leading-relaxed text-ink-2" x-text="step.body"></p>
{{-- Progress bar. --}}
<div class="mt-4 h-1 w-full overflow-hidden rounded-full bg-inset">
<div class="h-full rounded-full bg-accent transition-all duration-300" :style="'width:' + Math.round(((i + 1) / steps.length) * 100) + '%'"></div>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
<button type="button" @click="skip()" class="font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">{{ __('onboarding.skip') }}</button>
<div class="flex items-center gap-2">
<x-btn variant="secondary" size="sm" x-show="i > 0" x-cloak @click="prev()">
<x-icon name="chevron-left" class="h-3.5 w-3.5" />{{ __('onboarding.back') }}
</x-btn>
<x-btn variant="primary" size="sm" @click="next()">
<span x-show="! isLast">{{ __('onboarding.next') }}</span>
<span x-show="isLast" x-cloak>{{ __('onboarding.done') }}</span>
<x-icon name="chevron-right" class="h-3.5 w-3.5" x-show="! isLast" />
</x-btn>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -25,6 +25,10 @@
<p class="truncate font-mono text-[11px] text-ink-3">{{ $u->email }}</p>
</div>
<div class="flex items-center gap-2">
<button type="button" x-data @click="window.dispatchEvent(new CustomEvent('onboarding:start'))"
class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-line px-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/40 hover:text-accent-text">
<x-icon name="help-circle" class="h-3.5 w-3.5" />{{ __('onboarding.relaunch') }}
</button>
<x-badge tone="neutral">{{ __('settings.role_admin') }}</x-badge>
<x-two-factor-badge :enabled="$twoFactorEnabled" />
</div>

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Feature;
use App\Livewire\Onboarding\Tour;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class OnboardingTourTest extends TestCase
{
use RefreshDatabase;
public function test_autostart_is_true_only_when_the_tour_was_never_dismissed(): void
{
Livewire::actingAs(User::factory()->create(['onboarding_tour_completed_at' => null]))
->test(Tour::class)->assertSet('autostart', true);
Livewire::actingAs(User::factory()->create(['onboarding_tour_completed_at' => now()]))
->test(Tour::class)->assertSet('autostart', false);
}
public function test_mark_seen_stamps_the_account_once(): void
{
$user = User::factory()->create(['onboarding_tour_completed_at' => null]);
Livewire::actingAs($user)->test(Tour::class)->call('markSeen');
$this->assertNotNull($user->fresh()->onboarding_tour_completed_at);
}
public function test_mark_seen_does_not_overwrite_an_existing_stamp(): void
{
$stamp = now()->subDays(3);
$user = User::factory()->create(['onboarding_tour_completed_at' => $stamp]);
Livewire::actingAs($user)->test(Tour::class)->call('markSeen');
$this->assertEquals($stamp->timestamp, $user->fresh()->onboarding_tour_completed_at->timestamp);
}
public function test_the_tour_renders_all_six_steps_on_the_panel(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$this->get('/')->assertOk()->assertSee(__('onboarding.welcome_title'))->assertSee(__('onboarding.done_title'));
}
}