mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user