From 3dbe638f703741745f6eb5f65db658ba0925f291 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Apr 2026 22:21:50 +0100 Subject: [PATCH] Shrink welcome tour into compact coachmark Refs #1429 --- .../subsystems/frontend-primitives.md | 10 +- .../SharedPrimitives.guardrails.test.ts | 2 +- .../src/components/shared/WhatsNewModal.tsx | 233 ++++++------------ .../shared/__tests__/WhatsNewModal.test.tsx | 36 ++- .../shared/useWhatsNewModalState.ts | 4 +- .../components/shared/whatsNewModalModel.ts | 36 +-- .../tests/19-telemetry-disclosure.spec.ts | 14 +- 7 files changed, 124 insertions(+), 211 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7d3c3f67c..9a9521090 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1321,6 +1321,11 @@ 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. +That guided welcome surface should stay compact. The canonical shape is a +coachmark-sized card centered on the current destination with one short +step-specific sentence, a small clickable step strip, and minimal footer +controls. It must not grow back into a large sectioned explainer when one +sentence would do the job. 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 @@ -1334,8 +1339,9 @@ 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. +orientation job: keep them as footer-level links into 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 diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 14c06d746..0e627fb34 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -1143,11 +1143,11 @@ describe('shared primitive guardrails', () => { expect(whatsNewModalStateSource).toContain('spotlightStyle'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); - expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL'); + expect(whatsNewModalModelSource).toContain('Telemetry details'); expect(whatsNewModalModelSource).toContain("title: 'Dashboard'"); }); diff --git a/frontend-modern/src/components/shared/WhatsNewModal.tsx b/frontend-modern/src/components/shared/WhatsNewModal.tsx index 9f04823b3..52f6c77fd 100644 --- a/frontend-modern/src/components/shared/WhatsNewModal.tsx +++ b/frontend-modern/src/components/shared/WhatsNewModal.tsx @@ -5,13 +5,10 @@ import ServerIcon from 'lucide-solid/icons/server'; import BoxesIcon from 'lucide-solid/icons/boxes'; import HardDriveIcon from 'lucide-solid/icons/hard-drive'; import ShieldCheckIcon from 'lucide-solid/icons/shield-check'; -import ChartBarIcon from 'lucide-solid/icons/chart-bar'; -import ExternalLinkIcon from 'lucide-solid/icons/external-link'; 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, @@ -19,17 +16,9 @@ import { WHATS_NEW_KICKER_LABEL, WHATS_NEW_NEXT_LABEL, WHATS_NEW_PRIMARY_ACTION_LABEL, + WHATS_NEW_PROGRESS_PREFIX, 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, + WHATS_NEW_TELEMETRY_LINK_LABEL, WHATS_NEW_TITLE, type WhatsNewFeatureCard, } from './whatsNewModalModel'; @@ -86,24 +75,31 @@ export function WhatsNewModal() { data-tour-step={step().target} role="dialog" aria-modal="true" - aria-labelledby="whats-new-title" + aria-label={WHATS_NEW_TITLE} tabindex="-1" - class="fixed z-[1001] max-h-[min(90vh,44rem)] overflow-y-auto rounded-md border border-border bg-surface shadow-xl focus:outline-none" + class="fixed z-[1001] max-h-[min(90vh,32rem)] overflow-y-auto rounded-md border border-border bg-surface shadow-xl focus:outline-none" style={state.panelStyle()} onClick={(event) => event.stopPropagation()} > -
-
-
- {WHATS_NEW_KICKER_LABEL} +
+
+
+ + {WHATS_NEW_KICKER_LABEL} + + + {WHATS_NEW_PROGRESS_PREFIX} {state.stepIndex() + 1} of {state.stepCount()} +
-
- Step {state.stepIndex() + 1} of {state.stepCount()} +
+
+ +
+
+
{step().title}
+

{step().description}

+
-

- {WHATS_NEW_TITLE} -

-

{WHATS_NEW_SUBTITLE}

-
-
-
-
- -
-
-
- {WHATS_NEW_CURRENT_STEP_LABEL} -
-
{step().title}
-

{step().description}

-
-
-
- -
-
-
- {WHATS_NEW_STEP_MAP_LABEL} -
-
-
-

{WHATS_NEW_STEP_MAP_HELPER}

-
- - {(card, index) => ( - - )} - -
-
- -
-
-
- -
-
-
- {WHATS_NEW_TELEMETRY_LABEL} -
-
- {WHATS_NEW_TELEMETRY_TITLE} -
-

- {WHATS_NEW_TELEMETRY_COPY[0]}{' '} - - {WHATS_NEW_TELEMETRY_PRIVACY_LABEL} - -

-

- {WHATS_NEW_TELEMETRY_COPY[1]} -

-
- - {WHATS_NEW_TELEMETRY_SETTINGS_PATH} + {String(index() + 1).padStart(2, '0')} - - {WHATS_NEW_TELEMETRY_ENV_VAR} - -
-
-
+ {card.title} + + )} +
-
- - -
+
+
+ {WHATS_NEW_DOCS_LABEL} - + + + {WHATS_NEW_TELEMETRY_LINK_LABEL}
-
-
-
- -
- - +
+ + +
diff --git a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx index 0d7addc1d..ea13235d3 100644 --- a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx @@ -51,16 +51,14 @@ describe('WhatsNewModal', () => { expect(whatsNewModalStateSource).toContain('handleClose'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); - expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL'); expect(whatsNewModalModelSource).toContain('PRIVACY_DOC_URL'); - expect(whatsNewModalModelSource).toContain('anonymous daily ping'); - expect(whatsNewModalModelSource).toContain('nothing is gone'); + expect(whatsNewModalModelSource).toContain('Telemetry details'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_KICKER_LABEL'); - expect(whatsNewModalModelSource).toContain('WHATS_NEW_STEP_MAP_LABEL'); - expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_LABEL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_PROGRESS_PREFIX'); + expect(whatsNewModalModelSource).toContain("WHATS_NEW_PRIMARY_ACTION_LABEL = 'Done'"); 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'); @@ -70,14 +68,14 @@ describe('WhatsNewModal', () => { it('renders when the navigation modal has not been seen yet', async () => { render(() => ); - const dialog = await screen.findByRole('dialog'); + const dialog = await screen.findByRole('dialog', { name: 'Welcome to Pulse v6' }); 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('Where Things Moved')).toBeInTheDocument(); - expect(within(dialog).queryByText(/Stop 1/i)).not.toBeInTheDocument(); - expect(within(dialog).getAllByText('Dashboard')).toHaveLength(2); + expect(within(dialog).getByText('V5 to V6')).toBeInTheDocument(); + expect(within(dialog).getByText(/overview for health, alerts, capacity/i)).toBeInTheDocument(); + expect(within(dialog).queryByText('Where Things Moved')).not.toBeInTheDocument(); + expect(within(dialog).getByRole('link', { name: 'Migration guide' })).toBeInTheDocument(); + expect(within(dialog).getByRole('link', { name: 'Telemetry details' })).toBeInTheDocument(); }); it('stays hidden for public demo sessions', async () => { @@ -130,26 +128,26 @@ describe('WhatsNewModal', () => { it('advances through the guided tour and finishes on the last step', async () => { render(() => ); - expect(await screen.findByText(/overall picture: health, alerts, capacity/i)).toBeInTheDocument(); + expect(await screen.findByText(/overview for health, alerts, capacity/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText(/systems themselves: Proxmox nodes, Docker hosts/i)).toBeInTheDocument(); + expect(await screen.findByText(/Systems live here: nodes, hosts, clusters/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText(/guests or Docker workloads in v5/i)).toBeInTheDocument(); + expect(await screen.findByText(/If you looked for guests in v5/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText(/Datastores, pools, disks, and capacity moved here/i)).toBeInTheDocument(); + expect(await screen.findByText(/Datastores, pools, disks, and capacity live here/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText(/Backups, snapshots, and replication moved here/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: "Let's go" })).toBeInTheDocument(); + expect(await screen.findByText(/Backups, snapshots, and replication live here/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); }); it('lets the user jump to a tour stop directly from the stop map', async () => { render(() => ); - expect(await screen.findByText(/overall picture: health, alerts, capacity/i)).toBeInTheDocument(); + expect(await screen.findByText(/overview for health, alerts, capacity/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /Workloads/i })); - expect(await screen.findByText(/guests or Docker workloads in v5/i)).toBeInTheDocument(); + expect(await screen.findByText(/If you looked for guests in v5/i)).toBeInTheDocument(); expect(screen.getByText('Step 3 of 5')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/shared/useWhatsNewModalState.ts b/frontend-modern/src/components/shared/useWhatsNewModalState.ts index bf5eb5ec4..978593656 100644 --- a/frontend-modern/src/components/shared/useWhatsNewModalState.ts +++ b/frontend-modern/src/components/shared/useWhatsNewModalState.ts @@ -169,9 +169,9 @@ export function useWhatsNewModalState() { const rect = spotlightRect(); const panel = panelRef(); - const desktopWidth = window.innerWidth >= 1024 ? 448 : 420; + const desktopWidth = window.innerWidth >= 1024 ? 376 : 344; const panelWidth = Math.min(desktopWidth, window.innerWidth - 32); - const panelHeight = panel?.offsetHeight ?? 340; + const panelHeight = panel?.offsetHeight ?? 260; if (!rect) { return { diff --git a/frontend-modern/src/components/shared/whatsNewModalModel.ts b/frontend-modern/src/components/shared/whatsNewModalModel.ts index d658cb5bb..af4fd6342 100644 --- a/frontend-modern/src/components/shared/whatsNewModalModel.ts +++ b/frontend-modern/src/components/shared/whatsNewModalModel.ts @@ -14,66 +14,48 @@ export const WHATS_NEW_PRIVACY_URL = PRIVACY_DOC_URL; 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 first when you want the overall picture: health, alerts, capacity, and recent activity across your estate.', + description: 'Your new overview for health, alerts, capacity, and recent activity.', icon: 'dashboard', target: 'dashboard', title: 'Dashboard', }, { accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900', - description: - 'Use this when you want the systems themselves: Proxmox nodes, Docker hosts, Kubernetes clusters, PBS, PMG, TrueNAS, and other platform roots.', + description: 'Systems live here: nodes, hosts, clusters, and other platform roots.', icon: 'infrastructure', target: 'infrastructure', title: 'Infrastructure', }, { accent: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900', - description: - '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.', + description: 'VMs, containers, and pods live here. If you looked for guests in v5, start here.', icon: 'workloads', target: 'workloads', title: 'Workloads', }, { accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900', - description: - 'Datastores, pools, disks, and capacity moved here so storage is one destination across platforms.', + description: 'Datastores, pools, disks, and capacity live here across platforms.', icon: 'storage', target: 'storage', title: 'Storage', }, { accent: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900', - description: - 'Backups, snapshots, and replication moved here. Open this when you want restore posture or recent recovery activity.', + description: 'Backups, snapshots, and replication live here.', 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_KICKER_LABEL = 'V5 to V6'; export const WHATS_NEW_TITLE = 'Welcome to Pulse v6'; -export const WHATS_NEW_SUBTITLE = - "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 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'; -export const WHATS_NEW_TELEMETRY_PRIVACY_LABEL = 'Full details'; +export const WHATS_NEW_PROGRESS_PREFIX = 'Step'; export const WHATS_NEW_BACK_LABEL = 'Back'; export const WHATS_NEW_CLOSE_LABEL = 'Close'; export const WHATS_NEW_DOCS_LABEL = 'Migration guide'; export const WHATS_NEW_DO_NOT_SHOW_LABEL = "Don't show again"; export const WHATS_NEW_NEXT_LABEL = 'Next'; -export const WHATS_NEW_PRIMARY_ACTION_LABEL = "Let's go"; -export const WHATS_NEW_SKIP_LABEL = 'Skip tour'; +export const WHATS_NEW_PRIMARY_ACTION_LABEL = 'Done'; +export const WHATS_NEW_TELEMETRY_LINK_LABEL = 'Telemetry details'; diff --git a/tests/integration/tests/19-telemetry-disclosure.spec.ts b/tests/integration/tests/19-telemetry-disclosure.spec.ts index bf902a459..cc36b72e1 100644 --- a/tests/integration/tests/19-telemetry-disclosure.spec.ts +++ b/tests/integration/tests/19-telemetry-disclosure.spec.ts @@ -151,19 +151,17 @@ test.describe('Telemetry disclosure', () => { const infrastructureTab = page.locator( '[role="tab"][title="All agents and nodes across platforms"]', ); - await expect(dialog.getByText('Welcome to Pulse v6')).toBeVisible(); + await expect(dialog).toHaveAttribute('aria-label', 'Welcome to Pulse v6'); await expect(dialog.getByText('Step 1 of 5')).toBeVisible(); - await expect(dialog.getByText(/nothing is gone/i)).toBeVisible(); + await expect(dialog.getByText('V5 to V6')).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('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(); + await expect(dialog.getByText(/overview for health, alerts, capacity/i)).toBeVisible(); + await expect(dialog.getByRole('link', { name: 'Telemetry details' })).toBeVisible(); - const privacyLink = dialog.getByRole('link', { name: 'Full details' }); + const privacyLink = dialog.getByRole('link', { name: 'Telemetry details' }); await expect(privacyLink).toHaveAttribute('href', '/docs/PRIVACY.md'); await expectPopupDoc( page, @@ -189,7 +187,7 @@ test.describe('Telemetry disclosure', () => { for (let step = 0; step < 3; step += 1) { await dialog.getByRole('button', { name: 'Next' }).click(); } - await dialog.getByRole('button', { name: "Let's go" }).click(); + await dialog.getByRole('button', { name: 'Done' }).click(); await expect(dialog).not.toBeVisible(); await expect(assistantLauncher).toBeVisible();