Refocus welcome tour on v5 wayfinding

Refs #1429
This commit is contained in:
rcourtman
2026-04-19 22:05:41 +01:00
parent e769c3be7f
commit a34bfcc037
5 changed files with 78 additions and 58 deletions
@@ -1317,6 +1317,10 @@ The v6 welcome surface is one guided spotlight tour, not a modal plus a second
dashboard-only migration hint: it must dim the live app, glow the real
primary-navigation target being described, and keep route-orientation copy on
the existing welcome flow instead of layering a duplicate in-product banner.
Its primary job is rapid v5-to-v6 reorientation. The modal should explain
where familiar v5 destinations moved and what each v6 top-level area is for,
so operators who feel briefly lost after upgrading can rebuild their mental
map in one pass.
The guided stop map inside that welcome surface is interactive, not decorative:
operators must be able to jump directly to any tour step from the stop list,
and desktop layouts may widen the panel enough to keep step labels readable
@@ -1329,6 +1333,9 @@ language. The shell, step map, telemetry note, and supporting actions should
use bordered flat fills and normal app radii instead of gradient washes,
glassmorphism, or other marketing-style promo chrome that drifts from the rest
of the product.
Secondary disclosures such as telemetry must stay subordinate to that
orientation job: keep them compact, link to the canonical privacy/settings
surfaces, and do not let them crowd out the migration wayfinding copy.
That state owner now also owns public-demo suppression: the modal must stay
closed until `sessionPresentationPolicyResolved()` is true and must fail closed
when `presentationPolicyIsDemoMode()` resolves true, so the public demo does
@@ -11,17 +11,22 @@ import XIcon from 'lucide-solid/icons/x';
import {
WHATS_NEW_BACK_LABEL,
WHATS_NEW_CLOSE_LABEL,
WHATS_NEW_CURRENT_STEP_LABEL,
WHATS_NEW_DOCS_LABEL,
WHATS_NEW_DOCS_URL,
WHATS_NEW_DO_NOT_SHOW_LABEL,
WHATS_NEW_FEATURE_CARDS,
WHATS_NEW_KICKER_LABEL,
WHATS_NEW_NEXT_LABEL,
WHATS_NEW_PRIMARY_ACTION_LABEL,
WHATS_NEW_PRIVACY_URL,
WHATS_NEW_SKIP_LABEL,
WHATS_NEW_STEP_MAP_HELPER,
WHATS_NEW_STEP_MAP_LABEL,
WHATS_NEW_SUBTITLE,
WHATS_NEW_TELEMETRY_COPY,
WHATS_NEW_TELEMETRY_ENV_VAR,
WHATS_NEW_TELEMETRY_LABEL,
WHATS_NEW_TELEMETRY_PRIVACY_LABEL,
WHATS_NEW_TELEMETRY_SETTINGS_PATH,
WHATS_NEW_TELEMETRY_TITLE,
@@ -90,7 +95,7 @@ export function WhatsNewModal() {
<div class="flex items-start justify-between border-b border-border bg-surface px-6 py-5">
<div>
<div class="inline-flex items-center rounded-full border border-border bg-surface-hover px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
Guided Welcome Tour
{WHATS_NEW_KICKER_LABEL}
</div>
<div class="mt-3 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
Step {state.stepIndex() + 1} of {state.stepCount()}
@@ -118,7 +123,7 @@ export function WhatsNewModal() {
</div>
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] opacity-70">
Now showing
{WHATS_NEW_CURRENT_STEP_LABEL}
</div>
<div class="mt-1 text-base font-semibold text-inherit">{step().title}</div>
<p class="mt-2 text-sm leading-6 text-inherit">{step().description}</p>
@@ -129,11 +134,11 @@ export function WhatsNewModal() {
<div class="space-y-2.5">
<div class="flex items-center gap-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
In This Tour
{WHATS_NEW_STEP_MAP_LABEL}
</div>
<div class="h-px flex-1 bg-border"></div>
</div>
<p class="text-xs text-muted">Jump ahead or follow the highlighted path.</p>
<p class="text-xs text-muted">{WHATS_NEW_STEP_MAP_HELPER}</p>
<div class="grid grid-cols-2 gap-2.5">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card, index) => (
@@ -165,40 +170,42 @@ export function WhatsNewModal() {
</div>
</div>
<div class="rounded-md border border-sky-200 bg-sky-50 p-4 dark:border-sky-800 dark:bg-sky-900/30">
<div class="flex items-start gap-2.5">
<div class="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-sky-200 bg-surface text-sky-800 dark:border-sky-800 dark:bg-slate-900 dark:text-sky-100">
<div class="rounded-md border border-border bg-surface-hover p-3.5">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted">
<ChartBarIcon class="h-4 w-4" />
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">
Privacy note
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
{WHATS_NEW_TELEMETRY_LABEL}
</div>
<div class="mt-1 text-sm font-medium text-sky-900 dark:text-sky-100">
<div class="mt-1 text-sm font-medium text-base-content">
{WHATS_NEW_TELEMETRY_TITLE}
</div>
<p class="mt-1.5 text-xs leading-5 text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[0]}
<p class="mt-1 text-xs leading-5 text-muted">
{WHATS_NEW_TELEMETRY_COPY[0]}{' '}
<a
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-content"
>
{WHATS_NEW_TELEMETRY_PRIVACY_LABEL}
</a>
</p>
<p class="mt-1 text-xs leading-5 text-muted">
{WHATS_NEW_TELEMETRY_COPY[1]}
</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-[10px] text-muted">
<span class="rounded bg-surface px-1.5 py-0.5 font-medium">
{WHATS_NEW_TELEMETRY_SETTINGS_PATH}
</span>
<code class="rounded bg-surface px-1.5 py-0.5 font-mono">
{WHATS_NEW_TELEMETRY_ENV_VAR}
</code>
</div>
</div>
</div>
<p class="mt-1.5 text-xs leading-5 text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[1]} You can disable it any time in{' '}
<span class="font-medium">{WHATS_NEW_TELEMETRY_SETTINGS_PATH}</span> or by
setting{' '}
<code class="rounded bg-sky-100 px-1 py-0.5 text-[10px] font-mono dark:bg-sky-800">
{WHATS_NEW_TELEMETRY_ENV_VAR}
</code>
.{' '}
<a
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-sky-700 dark:hover:text-sky-100"
>
{WHATS_NEW_TELEMETRY_PRIVACY_LABEL}
</a>
</p>
</div>
<div class="flex flex-col gap-3 rounded-md border border-border bg-surface-hover p-3.5 sm:flex-row sm:items-center sm:justify-between">
@@ -56,9 +56,11 @@ describe('WhatsNewModal', () => {
expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL');
expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL');
expect(whatsNewModalModelSource).toContain('PRIVACY_DOC_URL');
expect(whatsNewModalModelSource).toContain('rotating install ID');
expect(whatsNewModalModelSource).toContain('normalized release identity');
expect(whatsNewModalModelSource).toContain('IP addresses are not stored in telemetry rows');
expect(whatsNewModalModelSource).toContain('anonymous daily ping');
expect(whatsNewModalModelSource).toContain('nothing is gone');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_KICKER_LABEL');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_STEP_MAP_LABEL');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_LABEL');
expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/README.md');
expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL');
@@ -71,8 +73,9 @@ describe('WhatsNewModal', () => {
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText('Welcome to Pulse v6')).toBeInTheDocument();
expect(within(dialog).getByText(/nothing is gone/i)).toBeInTheDocument();
expect(within(dialog).getByText('Step 1 of 5')).toBeInTheDocument();
expect(within(dialog).getByText('In This Tour')).toBeInTheDocument();
expect(within(dialog).getByText('Where Things Moved')).toBeInTheDocument();
expect(within(dialog).queryByText(/Stop 1/i)).not.toBeInTheDocument();
expect(within(dialog).getAllByText('Dashboard')).toHaveLength(2);
});
@@ -127,26 +130,26 @@ describe('WhatsNewModal', () => {
it('advances through the guided tour and finishes on the last step', async () => {
render(() => <WhatsNewModal />);
expect(await screen.findByText(/problem-focused summary/i)).toBeInTheDocument();
expect(await screen.findByText(/overall picture: health, alerts, capacity/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/platform roots live here/i)).toBeInTheDocument();
expect(await screen.findByText(/systems themselves: Proxmox nodes, Docker hosts/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/docker update status now share/i)).toBeInTheDocument();
expect(await screen.findByText(/guests or Docker workloads in v5/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/Storage is now a top-level destination/i)).toBeInTheDocument();
expect(await screen.findByText(/Datastores, pools, disks, and capacity moved here/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/Recovery events \(backups, snapshots, and replication\)/i)).toBeInTheDocument();
expect(await screen.findByText(/Backups, snapshots, and replication moved here/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: "Let's go" })).toBeInTheDocument();
});
it('lets the user jump to a tour stop directly from the stop map', async () => {
render(() => <WhatsNewModal />);
expect(await screen.findByText(/problem-focused summary/i)).toBeInTheDocument();
expect(await screen.findByText(/overall picture: health, alerts, capacity/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Workloads/i }));
expect(await screen.findByText(/docker update status now share/i)).toBeInTheDocument();
expect(await screen.findByText(/guests or Docker workloads in v5/i)).toBeInTheDocument();
expect(screen.getByText('Step 3 of 5')).toBeInTheDocument();
});
@@ -15,7 +15,7 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
{
accent: 'border-indigo-200 bg-indigo-50 dark:border-indigo-800 dark:bg-indigo-900',
description:
'Start here for a problem-focused summary. This is the landing page now, not the old Proxmox overview.',
'Start here first when you want the overall picture: health, alerts, capacity, and recent activity across your estate.',
icon: 'dashboard',
target: 'dashboard',
title: 'Dashboard',
@@ -23,7 +23,7 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
{
accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900',
description:
'Platform roots live here: Proxmox nodes, Docker hosts, Kubernetes clusters, PBS, PMG, TrueNAS, and more.',
'Use this when you want the systems themselves: Proxmox nodes, Docker hosts, Kubernetes clusters, PBS, PMG, TrueNAS, and other platform roots.',
icon: 'infrastructure',
target: 'infrastructure',
title: 'Infrastructure',
@@ -31,14 +31,15 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
{
accent: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900',
description:
'VMs, containers, pods, and Docker update status now share one unified workloads surface.',
'VMs, containers, and pods live here now. If you used to drill into guests or Docker workloads in v5, this is the new starting point.',
icon: 'workloads',
target: 'workloads',
title: 'Workloads',
},
{
accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900',
description: 'Storage is now a top-level destination across all systems.',
description:
'Datastores, pools, disks, and capacity moved here so storage is one destination across platforms.',
icon: 'storage',
target: 'storage',
title: 'Storage',
@@ -46,20 +47,25 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
{
accent: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900',
description:
'Recovery events (backups, snapshots, and replication) are now first-class pages.',
'Backups, snapshots, and replication moved here. Open this when you want restore posture or recent recovery activity.',
icon: 'recovery',
target: 'recovery',
title: 'Recovery',
},
];
export const WHATS_NEW_KICKER_LABEL = 'V5 to V6 Guide';
export const WHATS_NEW_CURRENT_STEP_LABEL = 'Now showing';
export const WHATS_NEW_STEP_MAP_LABEL = 'Where Things Moved';
export const WHATS_NEW_STEP_MAP_HELPER = 'Jump ahead or follow the highlighted path.';
export const WHATS_NEW_TITLE = 'Welcome to Pulse v6';
export const WHATS_NEW_SUBTITLE =
'Everything is now organized by what you want to do, not where the data comes from.';
export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous outbound telemetry';
"If you're coming from v5, nothing is gone. Pulse is now grouped by task so you can find things faster.";
export const WHATS_NEW_TELEMETRY_LABEL = 'Telemetry note';
export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous telemetry';
export const WHATS_NEW_TELEMETRY_COPY = [
'Pulse now sends a lightweight anonymous ping once a day — just a rotating install ID, normalized release identity, platform, resource counts, and feature flags. No hostnames, credentials, or personal information are sent, and IP addresses are not stored in telemetry rows.',
'This helps the developer understand how Pulse is used and prioritise what to build next.',
'Pulse also sends a lightweight anonymous daily ping. No hostnames, credentials, or personal information are sent.',
'You can turn it off any time in Settings → System → General or with PULSE_TELEMETRY=false.',
];
export const WHATS_NEW_TELEMETRY_SETTINGS_PATH = 'Settings → System → General';
export const WHATS_NEW_TELEMETRY_ENV_VAR = 'PULSE_TELEMETRY=false';
@@ -153,18 +153,15 @@ test.describe('Telemetry disclosure', () => {
);
await expect(dialog.getByText('Welcome to Pulse v6')).toBeVisible();
await expect(dialog.getByText('Step 1 of 5')).toBeVisible();
await expect(dialog.getByText(/nothing is gone/i)).toBeVisible();
await expect(assistantLauncher).toBeHidden();
await expect(spotlight).toHaveAttribute('data-tour-step', 'dashboard');
await expect(dialog).toHaveAttribute('data-tour-step', 'dashboard');
await expectSpotlightAround(spotlight, dashboardTab);
await expect(
dialog.getByText(
/rotating install ID, normalized release identity, platform, resource counts, and feature flags/i,
),
).toBeVisible();
await expect(
dialog.getByText(/IP addresses are not stored in telemetry rows/i),
).toBeVisible();
await expect(dialog.getByText('Where Things Moved')).toBeVisible();
await expect(dialog.getByText('Telemetry note')).toBeVisible();
await expect(dialog.getByText(/lightweight anonymous daily ping/i)).toBeVisible();
await expect(dialog.getByText('Settings → System → General', { exact: true })).toBeVisible();
const privacyLink = dialog.getByRole('link', { name: 'Full details' });
await expect(privacyLink).toHaveAttribute('href', '/docs/PRIVACY.md');