Remove navigation guide modal and reopen control

The four-step coachmark over the top tabs was a tour pretending to be
guidance: each step duplicated the tab title in one sentence, and the
Reopen control on /settings/system-general spawned a centered panel with
no spotlight target because the tabs only exist on dashboard routes.

Delete the modal, the localStorage dismissal key, the reopen event, the
Reopen row in General settings, and the matching guardrails so the
shared-primitives tests stop pinning the deleted owner split. Drop the
WhatsNew dismissal helpers and addInitScript bypasses from the
integration suite, and the dedicated tour test in
19-telemetry-disclosure.
This commit is contained in:
rcourtman 2026-05-06 09:49:15 +01:00
parent 0895916283
commit 2f8e5184bd
31 changed files with 9 additions and 1016 deletions

View file

@ -1062,7 +1062,6 @@ and existing alert-history shells instead of introducing VMware-only labels,
badges, or panel copy just because the underlying signal came from vSphere.
That same shared settings and modal boundary now also owns the public usage-data
vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
`frontend-modern/src/components/shared/whatsNewModalModel.ts`,
`frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and
`frontend-modern/src/utils/systemSettingsPresentation.ts` must present one
explicit `Usage data and privacy` model centered on `Anonymous outbound
@ -1725,60 +1724,10 @@ runtime, and `frontend-modern/src/components/shared/searchTipsPopoverModel.ts`
owns trigger variant, label/id defaults, hover policy, and trigger/popover
class selection. Future search-tips work should extend those owners instead of
pushing listener lifecycle or trigger policy back into the shared shell.
The shared what's-new modal now follows that same owner split.
`frontend-modern/src/components/shared/WhatsNewModal.tsx` stays the render
shell, `frontend-modern/src/components/shared/useWhatsNewModalState.ts` owns
local-storage dismissal, session dismissal, step progression, spotlight target
resolution, direct stop selection, and overlay placement/runtime behavior, and
`frontend-modern/src/components/shared/whatsNewModalModel.ts` owns the feature
tour catalog, telemetry copy, labels, and canonical docs/privacy links. Future
what's-new work should extend those owners instead of pushing dismissal state,
spotlight runtime, product copy, or external links back into the shared shell.
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 fast route orientation. The modal should explain what each
top-level area is for in plain product language, so operators can understand
the new navigation in one pass without needing historical layout context.
That copy should stay direct and present-tense. Each guided step should say
what the destination does, not depend on v5 comparisons, migration framing, or
older information architecture to make sense.
The Infrastructure tour step must describe the source model directly: platform
API inventory, Pulse Agent telemetry, and discovered candidates are managed as
infrastructure sources in one place.
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
without overlapping or collapsing into clipped pills. That map should read as
numbered wayfinding, not placeholder onboarding chrome: concise numeric badges
plus section titles are preferred over repeated `Stop N` copy or other filler
labels that add noise without helping orientation.
That same welcome surface must stay inside Pulse's existing flat visual
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 as footer-level links into the canonical
privacy/settings surfaces, and do not let them crowd out the migration
wayfinding copy. The supporting docs CTA on that surface should likewise stay
route-oriented: use a neutral `Navigation guide` label and plain present-tense
copy that helps operators understand the current IA, rather than reviving
`Migration guide` branding that pulls the tour back into v5 historical framing.
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
not front-load product migration onboarding ahead of the actual surface.
Canonical customer disclosures inside those shared shells now route through
`frontend-modern/src/utils/docsLinks.ts`, so settings and what's-new privacy
links resolve to shipped `/docs/...` assets instead of hard-coded GitHub
`main` URLs that can drift from the running build.
Canonical customer disclosures inside shared shells route through
`frontend-modern/src/utils/docsLinks.ts`, so settings privacy links resolve to
shipped `/docs/...` assets instead of hard-coded GitHub `main` URLs that can
drift from the running build.
The shared summary strip primitives now follow that same owner split.
`frontend-modern/src/components/shared/SummaryPanel.tsx` and
`frontend-modern/src/components/shared/SummaryMetricCard.tsx` stay the render

View file

@ -3553,7 +3553,6 @@
"frontend-modern/src/components/shared/__tests__/TagBadges.test.tsx",
"frontend-modern/src/components/shared/__tests__/UpgradeLink.test.tsx",
"frontend-modern/src/components/shared/__tests__/WebInterfaceUrlField.test.tsx",
"frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx",
"frontend-modern/src/components/shared/ColumnPicker.test.tsx",
"frontend-modern/src/components/shared/FilterToolbar.test.tsx",
"frontend-modern/src/components/shared/PageControls.guardrails.test.ts",

View file

@ -315,11 +315,10 @@ abuse controls, `docs/PRIVACY.md` and the shipped
`frontend-modern/public/docs/PRIVACY.md` copy must say so explicitly rather
than implying the server stores nothing at all.
That same rule also applies to the short in-product summary on the shared
General settings privacy surface and the whats-new disclosure copy. Those
surfaces may stay concise, but they must not claim a stronger privacy posture
than the governed docs; if telemetry rows are retained for a fixed window and
IP addresses are not stored rather than “never seen,” the summary copy must
say that plainly.
General settings privacy surface. That surface may stay concise, but it must
not claim a stronger privacy posture than the governed docs; if telemetry rows
are retained for a fixed window and IP addresses are not stored rather than
“never seen,” the summary copy must say that plainly.
That same shared trust boundary now also owns the TLS floor used by pinned-
fingerprint runtime clients. `pkg/tlsutil/fingerprint.go` may support
certificate-fingerprint capture and verification for self-signed deployments,

View file

@ -9,7 +9,6 @@ import { logger } from './utils/logger';
import { UpdateBanner } from './components/UpdateBanner';
import { DemoBanner } from './components/DemoBanner';
import { GitHubStarBanner } from './components/GitHubStarBanner';
import { WhatsNewModal } from './components/shared/WhatsNewModal';
import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal';
import { CommandPaletteModal } from './components/shared/CommandPaletteModal';
import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';
@ -379,7 +378,6 @@ function App() {
<DemoBanner />
<UpdateBanner />
<GitHubStarBanner />
<WhatsNewModal />
<GlobalUpdateProgressWatcher />
</Show>
{/* Main layout container - flexbox to allow AI panel to push content */}

View file

@ -9,10 +9,8 @@ import Sun from 'lucide-solid/icons/sun';
import Moon from 'lucide-solid/icons/moon';
import Thermometer from 'lucide-solid/icons/thermometer';
import Maximize2 from 'lucide-solid/icons/maximize-2';
import Compass from 'lucide-solid/icons/compass';
import { temperatureStore } from '@/utils/temperature';
import { layoutStore } from '@/utils/layout';
import { WHATS_NEW_REOPEN_EVENT } from '@/components/shared/whatsNewModalModel';
import {
PVE_POLLING_MAX_SECONDS,
PVE_POLLING_MIN_SECONDS,
@ -167,31 +165,6 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
onChange={() => layoutStore.toggle()}
/>
</div>
{/* Reopen Navigation Guide */}
<div class="flex items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
<Compass class="w-5 h-5 text-slate-500" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">Navigation guide</p>
<p class="text-xs text-muted line-clamp-2">
Replay the four-stop walkthrough of Infrastructure, Workloads, Storage, and
Recovery.
</p>
</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent(WHATS_NEW_REOPEN_EVENT));
}}
class="shrink-0 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Reopen
</button>
</div>
</SettingsPanel>
{/* Usage Data + Privacy Card */}

View file

@ -36,8 +36,6 @@ import infrastructureSelectorSource from '@/components/shared/InfrastructureSele
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import progressBarSource from '@/components/shared/ProgressBar.tsx?raw';
import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw';
import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw';
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
@ -102,7 +100,6 @@ import infrastructureDetailsDrawerStateSource from '@/components/shared/useInfra
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
@ -1309,39 +1306,6 @@ describe('shared primitive guardrails', () => {
expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover');
});
it('keeps whats new modal on shell, runtime, and model owners', () => {
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
expect(whatsNewModalSource).toContain('useDialogState');
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
expect(whatsNewModalSource).toContain('Portal');
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalSource).not.toContain('createSignal');
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalSource).not.toContain('Migration guide');
expect(whatsNewModalSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md',
);
expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState');
expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalStateSource).toContain('createSignal');
expect(whatsNewModalStateSource).toContain('createMemo');
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved');
expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode');
expect(whatsNewModalStateSource).toContain('handleClose');
expect(whatsNewModalStateSource).toContain('handleNext');
expect(whatsNewModalStateSource).toContain('spotlightStyle');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
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: 'Infrastructure'");
});
it('keeps dialog stack visibility in the shared dialog runtime', () => {
expect(dialogStateSource).toContain('export function dialogStackHasBlockingDialog');
expect(dialogStateSource).toContain('createSignal');

View file

@ -1,195 +0,0 @@
import { For, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
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 XIcon from 'lucide-solid/icons/x';
import {
WHATS_NEW_BACK_LABEL,
WHATS_NEW_CLOSE_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_PROGRESS_PREFIX,
WHATS_NEW_PRIVACY_URL,
WHATS_NEW_TELEMETRY_LINK_LABEL,
WHATS_NEW_TITLE,
type WhatsNewFeatureCard,
} from './whatsNewModalModel';
import { useDialogState } from './useDialogState';
import { useWhatsNewModalState } from './useWhatsNewModalState';
function WhatsNewFeatureIcon(props: { card: WhatsNewFeatureCard }) {
switch (props.card.icon) {
case 'infrastructure':
return <ServerIcon class="h-4 w-4" />;
case 'workloads':
return <BoxesIcon class="h-4 w-4" />;
case 'storage':
return <HardDriveIcon class="h-4 w-4" />;
case 'recovery':
return <ShieldCheckIcon class="h-4 w-4" />;
}
}
export function WhatsNewModal() {
const state = useWhatsNewModalState();
const dialogState = useDialogState({
get isOpen() {
return state.isOpen();
},
onClose: state.handleClose,
});
const step = () => state.currentStep();
const setPanelRef = (element: HTMLDivElement) => {
state.setPanelRef(element);
dialogState.setPanelRef(element);
};
return (
<Show when={state.isOpen()}>
<Portal mount={document.body}>
<div class="fixed inset-0 z-[1000]">
<div class="absolute inset-0" data-dialog-backdrop onClick={dialogState.handleBackdropClick} />
<Show when={state.spotlightStyle()}>
{(style) => (
<div
data-tour-spotlight=""
data-tour-step={step().target}
class="pointer-events-none absolute rounded-[1.25rem] border border-blue-300/80 bg-white/10 transition-all duration-200 dark:border-blue-300/60"
style={style()}
/>
)}
</Show>
<div
ref={setPanelRef}
data-tour-panel=""
data-tour-step={step().target}
role="dialog"
aria-modal="true"
aria-label={WHATS_NEW_TITLE}
tabindex="-1"
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()}
>
<div class="flex items-start justify-between border-b border-border bg-surface px-5 py-4">
<div class="min-w-0">
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted">
<span class="inline-flex items-center rounded-full border border-border bg-surface-hover px-2 py-1">
{WHATS_NEW_KICKER_LABEL}
</span>
<span>
{WHATS_NEW_PROGRESS_PREFIX} {state.stepIndex() + 1} of {state.stepCount()}
</span>
</div>
<div class="mt-3 flex items-start gap-3">
<div class={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md border bg-surface ${step().accent}`}>
<WhatsNewFeatureIcon card={step()} />
</div>
<div class="min-w-0">
<div class="text-lg font-semibold text-base-content">{step().title}</div>
<p class="mt-1 text-sm leading-6 text-muted">{step().description}</p>
</div>
</div>
</div>
<button
onClick={state.handleClose}
class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-surface-hover hover:text-muted"
aria-label={WHATS_NEW_CLOSE_LABEL}
type="button"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="space-y-4 px-5 py-4">
<div class="flex flex-wrap gap-2">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card, index) => (
<button
type="button"
onClick={() => state.handleSelectStep(index())}
aria-current={index() === state.stepIndex() ? 'step' : undefined}
class={`inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
index() === state.stepIndex()
? 'border-blue-300 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-100'
: 'border-border bg-surface-hover text-base-content hover:border-slate-300 hover:bg-surface dark:hover:border-slate-700'
}`}
>
<span
class={`inline-flex h-5 w-5 items-center justify-center rounded-sm border text-[10px] font-semibold font-mono ${
index() === state.stepIndex()
? 'border-blue-200 bg-surface text-blue-700 dark:border-blue-800 dark:bg-slate-900 dark:text-blue-200'
: 'border-border bg-surface text-muted'
}`}
>
{String(index() + 1).padStart(2, '0')}
</span>
<span>{card.title}</span>
</button>
)}
</For>
</div>
<div class="flex flex-col gap-3 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={state.dontShowAgain()}
onChange={(event) => state.setDontShowAgain(event.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
{WHATS_NEW_DO_NOT_SHOW_LABEL}
</label>
<a
href={WHATS_NEW_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-content"
>
{WHATS_NEW_DOCS_LABEL}
</a>
<a
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-content"
>
{WHATS_NEW_TELEMETRY_LINK_LABEL}
</a>
</div>
<div class="flex items-center gap-2 sm:justify-end">
<button
type="button"
onClick={state.handlePrevious}
disabled={state.isFirstStep()}
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
{WHATS_NEW_BACK_LABEL}
</button>
<button
onClick={state.handleNext}
class="rounded-md bg-blue-600 px-3.5 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
type="button"
>
{state.isLastStep() ? WHATS_NEW_PRIMARY_ACTION_LABEL : WHATS_NEW_NEXT_LABEL}
</button>
</div>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
}
export default WhatsNewModal;

View file

@ -1,185 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { WhatsNewModal } from '@/components/shared/WhatsNewModal';
import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw';
import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw';
import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw';
import { STORAGE_KEYS } from '@/utils/localStorage';
const presentationPolicyIsDemoModeMock = vi.hoisted(() => vi.fn(() => false));
const sessionPresentationPolicyResolvedMock = vi.hoisted(() => vi.fn(() => true));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyIsDemoMode: presentationPolicyIsDemoModeMock,
sessionPresentationPolicyResolved: sessionPresentationPolicyResolvedMock,
}));
describe('WhatsNewModal', () => {
beforeEach(() => {
window.history.pushState({}, '', '/');
localStorage.clear();
presentationPolicyIsDemoModeMock.mockReturnValue(false);
sessionPresentationPolicyResolvedMock.mockReturnValue(true);
});
afterEach(() => {
cleanup();
});
it('keeps whats new modal on shell, runtime, and model owners', () => {
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
expect(whatsNewModalSource).toContain('useDialogState');
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
expect(whatsNewModalSource).toContain('Portal');
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalSource).not.toContain('createSignal');
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalSource).not.toContain('Migration guide');
expect(whatsNewModalSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md',
);
expect(whatsNewModalSource).not.toContain('bg-gradient');
expect(whatsNewModalSource).not.toContain('backdrop-blur-sm');
expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState');
expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalStateSource).toContain('createSignal');
expect(whatsNewModalStateSource).toContain('createMemo');
expect(whatsNewModalStateSource).toContain('handleNext');
expect(whatsNewModalStateSource).toContain('handlePrevious');
expect(whatsNewModalStateSource).toContain('spotlightStyle');
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved');
expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode');
expect(whatsNewModalStateSource).toContain('handleClose');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
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('Telemetry details');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_KICKER_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');
expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'");
});
it('renders when the navigation modal has not been seen yet', async () => {
render(() => <WhatsNewModal />);
const dialog = await screen.findByRole('dialog', { name: 'Pulse navigation guide' });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText('Step 1 of 4')).toBeInTheDocument();
expect(within(dialog).getByText('Nav guide')).toBeInTheDocument();
expect(
within(dialog).getByText(/Start here to add, inspect, and manage infrastructure sources/i),
).toBeInTheDocument();
expect(within(dialog).queryByText('Where Things Moved')).not.toBeInTheDocument();
expect(within(dialog).getByRole('link', { name: 'Navigation guide' })).toBeInTheDocument();
expect(within(dialog).getByRole('link', { name: 'Telemetry details' })).toBeInTheDocument();
});
it('stays hidden for public demo sessions', async () => {
presentationPolicyIsDemoModeMock.mockReturnValue(true);
render(() => <WhatsNewModal />);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('closes on backdrop click and records the modal as seen by default', async () => {
render(() => <WhatsNewModal />);
const backdrop = await waitFor(() => {
const element = document.querySelector('[data-dialog-backdrop]') as HTMLElement | null;
expect(element).not.toBeNull();
return element!;
});
fireEvent.click(backdrop);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN)).toBe('true');
});
it('supports a session-only dismissal when "Don\'t show again" is unchecked', async () => {
render(() => <WhatsNewModal />);
const checkbox = await screen.findByRole('checkbox', { name: "Don't show again" });
fireEvent.click(checkbox);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN)).toBe('false');
cleanup();
render(() => <WhatsNewModal />);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('advances through the guided tour and finishes on the last step', async () => {
render(() => <WhatsNewModal />);
expect(
await screen.findByText(/Start here to add, inspect, and manage infrastructure sources/i),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/Use this for VMs, containers, pods/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(await screen.findByText(/Use this for pools, datastores, disks/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(
await screen.findByText(/Use this for backup coverage, snapshots, replication/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(() => <WhatsNewModal />);
expect(
await screen.findByText(/Start here to add, inspect, and manage infrastructure sources/i),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Workloads/i }));
expect(await screen.findByText(/Use this for VMs, containers, pods/i)).toBeInTheDocument();
expect(screen.getByText('Step 2 of 4')).toBeInTheDocument();
});
it('routes the docs CTA through the navigation guide', async () => {
render(() => <WhatsNewModal />);
const docsLink = await screen.findByRole('link', { name: 'Navigation guide' });
expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md');
});
it('starts the navigation guide on recovery when opened from recovery', async () => {
window.history.pushState({}, '', '/recovery?rollupId=res%3Asystem-container-1');
render(() => <WhatsNewModal />);
const dialog = await screen.findByRole('dialog', { name: 'Pulse navigation guide' });
expect(within(dialog).getByText('Step 4 of 4')).toBeInTheDocument();
expect(
within(dialog).getByText(/Use this for backup coverage, snapshots, replication/i),
).toBeInTheDocument();
});
});

View file

@ -1,254 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
import {
presentationPolicyIsDemoMode,
sessionPresentationPolicyResolved,
} from '@/stores/sessionPresentationPolicy';
import { WHATS_NEW_FEATURE_CARDS, WHATS_NEW_REOPEN_EVENT } from './whatsNewModalModel';
type SpotlightRect = {
top: number;
left: number;
width: number;
height: number;
};
const DESKTOP_TAB_SELECTOR_BY_TARGET = {
infrastructure: '[role="tab"][title="All agents and nodes across platforms"]',
workloads: '[role="tab"][title="VMs, containers, and Kubernetes workloads"]',
storage: '[role="tab"][title="Storage pools, disks, and datastores"]',
recovery: '[role="tab"][title="Backup, snapshot, and replication activity"]',
} as const;
const MOBILE_TAB_SELECTOR_BY_TARGET = {
infrastructure: 'button[data-tab-id="infrastructure"]',
workloads: 'button[data-tab-id="workloads"]',
storage: 'button[data-tab-id="storage"]',
recovery: 'button[data-tab-id="recovery"]',
} as const;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const getInitialStepIndexForPath = (): number => {
if (typeof window === 'undefined') return 0;
const path = window.location.pathname.toLowerCase();
const target = (() => {
if (path.startsWith('/recovery')) return 'recovery';
if (path.startsWith('/storage')) return 'storage';
if (path.startsWith('/workloads')) return 'workloads';
if (path.startsWith('/infrastructure')) return 'infrastructure';
return 'infrastructure';
})();
const index = WHATS_NEW_FEATURE_CARDS.findIndex((card) => card.target === target);
return index >= 0 ? index : 0;
};
const isVisibleElement = (element: Element | null): element is HTMLElement => {
if (!(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
};
const selectFirstVisible = (...selectors: string[]): HTMLElement | null => {
for (const selector of selectors) {
const visible = Array.from(document.querySelectorAll(selector)).find((candidate) =>
isVisibleElement(candidate),
);
if (visible && visible instanceof HTMLElement) {
return visible;
}
}
return null;
};
export function useWhatsNewModalState() {
const [hasSeen, setHasSeen] = createLocalStorageBooleanSignal(
STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN,
false,
);
const [dontShowAgain, setDontShowAgain] = createSignal(true);
const [dismissedForSession, setDismissedForSession] = createSignal(false);
const [stepIndex, setStepIndex] = createSignal(getInitialStepIndexForPath());
const [panelRef, setPanelRef] = createSignal<HTMLDivElement | null>(null);
const [spotlightRect, setSpotlightRect] = createSignal<SpotlightRect | null>(null);
const isOpen = () =>
sessionPresentationPolicyResolved() &&
!presentationPolicyIsDemoMode() &&
!hasSeen() &&
!dismissedForSession();
const currentStep = createMemo(() => {
const index = clamp(stepIndex(), 0, WHATS_NEW_FEATURE_CARDS.length - 1);
return WHATS_NEW_FEATURE_CARDS[index];
});
const isFirstStep = createMemo(() => stepIndex() === 0);
const isLastStep = createMemo(() => stepIndex() >= WHATS_NEW_FEATURE_CARDS.length - 1);
const resetTourState = () => {
setStepIndex(getInitialStepIndexForPath());
setSpotlightRect(null);
};
const closeTour = () => {
resetTourState();
if (dontShowAgain()) {
setHasSeen(true);
return;
}
setDismissedForSession(true);
};
const handleClose = () => {
closeTour();
};
const handleNext = () => {
if (isLastStep()) {
closeTour();
return;
}
setStepIndex((current) => clamp(current + 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
};
const handlePrevious = () => {
setStepIndex((current) => clamp(current - 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
};
const handleSelectStep = (index: number) => {
setStepIndex(clamp(index, 0, WHATS_NEW_FEATURE_CARDS.length - 1));
};
if (typeof window !== 'undefined') {
const handleReopen = () => {
setHasSeen(false);
setDismissedForSession(false);
setStepIndex(getInitialStepIndexForPath());
};
window.addEventListener(WHATS_NEW_REOPEN_EVENT, handleReopen);
onCleanup(() => window.removeEventListener(WHATS_NEW_REOPEN_EVENT, handleReopen));
}
createEffect(() => {
if (!isOpen()) return;
const updateSpotlight = () => {
const step = currentStep();
const target = selectFirstVisible(
DESKTOP_TAB_SELECTOR_BY_TARGET[step.target],
MOBILE_TAB_SELECTOR_BY_TARGET[step.target],
);
if (!target) {
setSpotlightRect(null);
return;
}
const rect = target.getBoundingClientRect();
const padding = 10;
setSpotlightRect({
top: Math.max(12, rect.top - padding),
left: Math.max(12, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2,
});
};
updateSpotlight();
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => {
updateSpotlight();
});
const panel = panelRef();
if (resizeObserver && panel) {
resizeObserver.observe(panel);
}
window.addEventListener('resize', updateSpotlight);
window.addEventListener('scroll', updateSpotlight, true);
onCleanup(() => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateSpotlight);
window.removeEventListener('scroll', updateSpotlight, true);
});
});
const panelStyle = createMemo(() => {
if (typeof window === 'undefined') {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
const rect = spotlightRect();
const panel = panelRef();
const desktopWidth = window.innerWidth >= 1024 ? 376 : 344;
const panelWidth = Math.min(desktopWidth, window.innerWidth - 32);
const panelHeight = panel?.offsetHeight ?? 260;
if (!rect) {
return {
width: `${panelWidth}px`,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
const spaceBelow = window.innerHeight - (rect.top + rect.height);
const prefersAbove = spaceBelow < panelHeight + 24 && rect.top > panelHeight + 24;
const unclampedTop = prefersAbove ? rect.top - panelHeight - 20 : rect.top + rect.height + 20;
const maxTop = Math.max(16, window.innerHeight - panelHeight - 16);
const top = clamp(unclampedTop, 16, maxTop);
const unclampedLeft = rect.left + rect.width / 2 - panelWidth / 2;
const maxLeft = Math.max(16, window.innerWidth - panelWidth - 16);
const left = clamp(unclampedLeft, 16, maxLeft);
return {
width: `${panelWidth}px`,
top: `${top}px`,
left: `${left}px`,
};
});
const spotlightStyle = createMemo(() => {
const rect = spotlightRect();
if (!rect) return null;
return {
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
'box-shadow':
'0 0 0 9999px rgba(15, 23, 42, 0.78), 0 0 0 2px rgba(255, 255, 255, 0.4), 0 0 40px rgba(96, 165, 250, 0.75)',
};
});
return {
currentStep,
dontShowAgain,
handleClose,
handleNext,
handlePrevious,
handleSelectStep,
isFirstStep,
isLastStep,
isOpen,
panelStyle,
setDontShowAgain,
setPanelRef,
spotlightStyle,
stepCount: () => WHATS_NEW_FEATURE_CARDS.length,
stepIndex,
};
}

View file

@ -1,57 +0,0 @@
import { MIGRATION_GUIDE_DOC_URL, PRIVACY_DOC_URL } from '@/utils/docsLinks';
export interface WhatsNewFeatureCard {
accent: string;
description: string;
icon: 'infrastructure' | 'workloads' | 'storage' | 'recovery';
target: 'infrastructure' | 'workloads' | 'storage' | 'recovery';
title: string;
}
export const WHATS_NEW_DOCS_URL = MIGRATION_GUIDE_DOC_URL;
export const WHATS_NEW_PRIVACY_URL = PRIVACY_DOC_URL;
export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [
{
accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900',
description:
'Start here to add, inspect, and manage infrastructure sources: platform API inventory, Pulse Agent telemetry, and discovered candidates.',
icon: 'infrastructure',
target: 'infrastructure',
title: 'Infrastructure',
},
{
accent: 'border-rose-200 bg-rose-50 dark:border-rose-800 dark:bg-rose-900',
description: 'Use this for VMs, containers, pods, and other running workloads.',
icon: 'workloads',
target: 'workloads',
title: 'Workloads',
},
{
accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900',
description: 'Use this for pools, datastores, disks, datasets, and capacity.',
icon: 'storage',
target: 'storage',
title: 'Storage',
},
{
accent: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900',
description: 'Use this for backup coverage, snapshots, replication, and restore readiness.',
icon: 'recovery',
target: 'recovery',
title: 'Recovery',
},
];
export const WHATS_NEW_REOPEN_EVENT = 'pulse:reopen-nav-guide';
export const WHATS_NEW_KICKER_LABEL = 'Nav guide';
export const WHATS_NEW_TITLE = 'Pulse navigation guide';
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 = 'Navigation 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 = 'Done';
export const WHATS_NEW_TELEMETRY_LINK_LABEL = 'Telemetry details';

View file

@ -36,8 +36,6 @@ import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?ra
import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw';
import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw';
import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw';
import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
@ -77,7 +75,6 @@ import historyChartStateSource from '@/components/shared/useHistoryChartState.ts
import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw';
import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import searchInputEnhancementsStateSource from '@/components/shared/useSearchInputEnhancements.ts?raw';
@ -2916,30 +2913,6 @@ describe('frontend resource type boundaries', () => {
expect(monitoredSystemPresentationSource).toContain(
'export function getMonitoredSystemDisclosureToggleLabel',
);
expect(whatsNewModalSource).toContain('useWhatsNewModalState');
expect(whatsNewModalSource).toContain('useDialogState');
expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS');
expect(whatsNewModalSource).toContain('Portal');
expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalSource).not.toContain('createSignal');
expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalSource).not.toContain('Migration guide');
expect(whatsNewModalSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md',
);
expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal');
expect(whatsNewModalStateSource).toContain('createSignal');
expect(whatsNewModalStateSource).toContain('createMemo');
expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN');
expect(whatsNewModalStateSource).toContain('handleClose');
expect(whatsNewModalStateSource).toContain('handleNext');
expect(whatsNewModalStateSource).toContain('spotlightStyle');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS');
expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_LINK_LABEL');
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(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');
expect(tooltipSource).not.toContain('createSignal');

View file

@ -208,7 +208,6 @@ export const STORAGE_KEYS = {
// Feature discovery
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
WHATS_NEW_NAV_V2_SHOWN: 'pulse_whats_new_v2_shown',
DEBUG_MODE: 'pulse_debug_mode',
// GitHub star prompt

View file

@ -1,5 +1,5 @@
import { test, expect, devices } from '@playwright/test';
import { dismissWhatsNewModal, ensureAuthenticated } from './helpers';
import { ensureAuthenticated } from './helpers';
const getViewportWidth = async (page: import('@playwright/test').Page): Promise<number> => {
const size = page.viewportSize();
@ -13,7 +13,6 @@ test.describe('Mobile viewport flows', () => {
});
test('bottom nav bar is visible on mobile', async ({ page }) => {
await dismissWhatsNewModal(page);
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
@ -39,7 +38,6 @@ test.describe('Mobile viewport flows', () => {
});
test('MobileNavBar has safe-area padding on nav', async ({ page }) => {
await dismissWhatsNewModal(page);
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
@ -55,8 +53,6 @@ test.describe('Mobile viewport flows', () => {
});
test('Infrastructure filter bar does not overflow horizontally', async ({ page }) => {
// Prevent WhatsNew modal from blocking the page.
await dismissWhatsNewModal(page);
await page.goto('/infrastructure');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
@ -71,7 +67,6 @@ test.describe('Mobile viewport flows', () => {
});
test('Infrastructure table wrapper enables horizontal overflow when needed', async ({ page }) => {
await dismissWhatsNewModal(page);
await page.goto('/infrastructure');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
@ -104,8 +99,6 @@ test.describe('Mobile viewport flows', () => {
});
test('Tapping a resource row opens the detail drawer', async ({ page }) => {
// Prevent WhatsNew modal from intercepting row clicks.
await dismissWhatsNewModal(page);
await page.goto('/infrastructure');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();

View file

@ -53,7 +53,6 @@ async function applyThemePreference(
localStorage.setItem('pulseThemePreference', pref);
localStorage.setItem('darkMode', String(pref === 'dark'));
localStorage.removeItem('pulse_dark_mode');
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
if (forceLoggedOut) {
localStorage.setItem('just_logged_out', 'true');
}

View file

@ -50,27 +50,6 @@ async function expectPopupDoc(
await popup.close();
}
async function expectSpotlightAround(spotlight: Locator, target: Locator) {
await expect(spotlight).toBeVisible();
await expect(target).toBeVisible();
await expect
.poll(async () => {
const spotlightBox = await spotlight.boundingBox();
const targetBox = await target.boundingBox();
if (!spotlightBox || !targetBox) {
return false;
}
return (
spotlightBox.x <= targetBox.x + 1 &&
spotlightBox.y <= targetBox.y + 1 &&
spotlightBox.x + spotlightBox.width >= targetBox.x + targetBox.width - 1 &&
spotlightBox.y + spotlightBox.height >= targetBox.y + targetBox.height - 1
);
})
.toBe(true);
}
async function readTelemetryPreview(page: Page) {
const preview = page.locator('pre[aria-label="Telemetry payload preview"]');
await expect(preview).toBeVisible();
@ -132,66 +111,4 @@ test.describe('Telemetry disclosure', () => {
})
.not.toBe(initialPreview.install_id);
});
test('whats-new tour opens shipped privacy and navigation guide pages', async ({ page }, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only telemetry disclosure coverage');
await page.addInitScript(() => {
localStorage.removeItem('pulse_whats_new_v2_shown');
});
await page.goto('/infrastructure', { waitUntil: 'domcontentloaded' });
const dialog = page.getByRole('dialog');
const spotlight = page.locator('[data-tour-spotlight]');
const assistantLauncher = page.getByRole('button', { name: 'Expand Pulse Assistant' });
const infrastructureTab = page.locator(
'[role="tab"][title="All agents and nodes across platforms"]',
);
await expect(dialog).toHaveAttribute('aria-label', 'Pulse navigation guide');
await expect(dialog.getByText('Step 1 of 4')).toBeVisible();
await expect(dialog.getByText('Nav guide')).toBeVisible();
await expect(assistantLauncher).toBeHidden();
await expect(spotlight).toHaveAttribute('data-tour-step', 'infrastructure');
await expect(dialog).toHaveAttribute('data-tour-step', 'infrastructure');
await expectSpotlightAround(spotlight, infrastructureTab);
await expect(
dialog.getByText(/Start here to add, inspect, and manage infrastructure sources/i),
).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Telemetry details' })).toBeVisible();
const privacyLink = dialog.getByRole('link', { name: 'Telemetry details' });
await expect(privacyLink).toHaveAttribute('href', '/docs/PRIVACY.md');
await expectPopupDoc(
page,
privacyLink,
'/docs/PRIVACY.md',
'Pulse currently has two usage-data scopes',
);
const docsLink = dialog.getByRole('link', { name: 'Navigation guide' });
await expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md');
await expectPopupDoc(
page,
docsLink,
'/docs/MIGRATION_UNIFIED_NAV.md',
'Migration Guide: Unified Navigation',
);
for (let step = 0; step < 3; step += 1) {
await dialog.getByRole('button', { name: 'Next' }).click();
}
await dialog.getByRole('button', { name: 'Done' }).click();
await expect(dialog).not.toBeVisible();
await expect(assistantLauncher).toBeVisible();
await assistantLauncher.click();
await expect(page.getByRole('heading', { name: 'Pulse Assistant' })).toBeVisible();
await expect(
page.getByText('Observed context, provider-backed reasoning, and governed actions.'),
).toBeVisible();
await page.getByTitle('Pulse Assistant sessions').click();
await expect(page.getByText('No previous assistant sessions')).toBeVisible();
});
});

View file

@ -68,15 +68,6 @@ async function ensureMockModeEnabled(page: import('@playwright/test').Page): Pro
}
}
async function dismissWhatsNewModal(page: import('@playwright/test').Page): Promise<void> {
const modalTitle = page.getByText('Welcome to Pulse v6');
if (!(await modalTitle.isVisible().catch(() => false))) {
return;
}
await page.getByRole('button', { name: 'Skip tour' }).click();
await expect(modalTitle).toHaveCount(0);
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
@ -161,7 +152,6 @@ test.describe.serial('Workloads memory tail', () => {
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.getByTestId('workloads-summary')).toBeVisible();
await dismissWhatsNewModal(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();

View file

@ -98,9 +98,6 @@ test.describe.serial('Storage summary chart continuity', () => {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.goto('/storage', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/storage/);
await expect(page.getByTestId('storage-summary')).toBeVisible();

View file

@ -231,10 +231,6 @@ base.describe('Demo mode commercial boundary', () => {
let billingStateRequests = 0;
let checkoutStartRequests = 0;
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.route('**/api/security/status', async (route) => {
await route.fulfill({
status: 200,
@ -424,9 +420,6 @@ base.describe('Managed demo runtime commercial boundary', () => {
base(
'hides commercial surfaces and APIs without browser route stubs',
async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await ensureAuthenticated(page);
const hiddenCommercialRequests = trackBrowserRequests(

View file

@ -173,10 +173,6 @@ async function configureMonitoredSystemBillingFixtures(
const runtimeCapabilities =
fixtures.runtimeCapabilities ?? MONITORED_SYSTEM_RUNTIME_CAPABILITIES;
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await page.route("**/api/security/status", async (route) => {
await route.fulfill({
status: 200,

View file

@ -351,10 +351,6 @@ async function configureBillingFixtures(
) {
const activationState = options.activationState ?? { current: false };
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await context.route("**/api/security/status", async (route) => {
await fulfillJSON(route, {
hasAuthentication: true,

View file

@ -18,10 +18,6 @@ test.describe('Release candidate shell', () => {
await waitForPulseReady(page);
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.route('**/api/security/status', (route) =>
route.fulfill({
status: 200,

View file

@ -139,9 +139,6 @@ test.describe.serial('Workloads column layout', () => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
await page.setViewportSize({ width: 1440, height: 1200 });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await ensureMockModeEnabled(page);
await page.goto('/workloads', { waitUntil: 'domcontentloaded' });

View file

@ -165,7 +165,6 @@ test("renders the commercial funnel diagnostics card in the browser", async ({
}),
);
sessionStorage.setItem("pulse_auth_user", "admin");
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await page.route("**/api/security/status", async (route) => {

View file

@ -110,7 +110,6 @@ test.describe.serial('Infrastructure column layout', () => {
await page.setViewportSize({ width: 1920, height: 1200 });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
localStorage.setItem('fullWidthMode', 'full-width');
});

View file

@ -39,12 +39,6 @@ const test = base.extend<{}, WorkerFixtures>({
test.describe("runtime-home onboarding contract", () => {
test.setTimeout(180_000);
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
});
test("normalizes the agent install handoff onto the shared infrastructure workspace", async ({
page,
}) => {

View file

@ -150,10 +150,6 @@ test.describe.serial('Storage growth column', () => {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.goto('/storage', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/storage/);
await expect(page.getByTestId('storage-summary')).toBeVisible();

View file

@ -139,9 +139,6 @@ test.describe.serial("Workloads Proxmox refresh stability", () => {
);
await ensureMockModeEnabled(page);
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await page.goto("/workloads?type=vm&platform=proxmox-pve", {
waitUntil: "domcontentloaded",

View file

@ -134,10 +134,6 @@ test.describe('Offline Proxmox node visibility', () => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.route('**/api/resources**', async (route) => {
const requestUrl = new URL(route.request().url());

View file

@ -81,7 +81,6 @@ test.describe('Organization sharing approval UI', () => {
};
await page.addInitScript((orgId) => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
sessionStorage.setItem('pulse_org_id', orgId);
localStorage.setItem('pulse_org_id', orgId);
document.cookie = `pulse_org_id=${encodeURIComponent(orgId)}; Path=/; SameSite=Lax`;

View file

@ -88,10 +88,6 @@ async function recordUpgradeMetricEvents(
}
async function prepareOnboardingPage(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await stubConnectionsList(page);
}

View file

@ -359,9 +359,6 @@ export async function ensureFirstRunExperience(
page: Page,
options: CompleteSetupWizardOptions = {},
) {
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await waitForPulseReady(page);
const completionTarget = options.completionTarget ?? "install";
@ -506,24 +503,7 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) {
.toBe("authenticated");
}
/**
* Dismiss the WhatsNew modal that appears on first visit by marking it as seen
* in localStorage. This prevents the "fixed inset-0 z-50" overlay from blocking
* clicks (logout button, row clicks, etc.) in tests.
*/
export async function dismissWhatsNewModal(page: Page): Promise<void> {
await page.evaluate(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
}
export async function ensureAuthenticated(page: Page) {
// Pre-set the WhatsNew modal localStorage key via an init script that runs before
// any page script on every navigation. This prevents the "fixed inset-0 z-50"
// overlay from appearing and blocking clicks (logout, row taps, etc.) in tests.
await page.addInitScript(() => {
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await waitForPulseReady(page);
await maybeCompleteSetupWizard(page);
await login(page);