Polish welcome tour layout

Refs #1429
This commit is contained in:
rcourtman 2026-04-19 21:41:14 +01:00
parent 3ff7c2f2e3
commit 5c5425aea8
4 changed files with 107 additions and 39 deletions

View file

@ -1308,7 +1308,7 @@ 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, and overlay placement/runtime behavior, and
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,
@ -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.
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 stop labels readable
without overlapping or collapsing into clipped pills.
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

View file

@ -83,13 +83,16 @@ export function WhatsNewModal() {
aria-modal="true"
aria-labelledby="whats-new-title"
tabindex="-1"
class="fixed z-[1001] max-h-[min(90vh,42rem)] overflow-y-auto rounded-2xl border border-border bg-surface shadow-2xl focus:outline-none"
class="fixed z-[1001] max-h-[min(90vh,44rem)] overflow-y-auto rounded-[1.75rem] border border-border bg-surface shadow-2xl backdrop-blur-sm focus:outline-none"
style={state.panelStyle()}
onClick={(event) => event.stopPropagation()}
>
<div class="flex items-start justify-between border-b border-border px-5 py-4">
<div class="flex items-start justify-between border-b border-border bg-gradient-to-r from-blue-50/90 via-surface to-slate-50/80 px-6 py-5 dark:from-blue-950/30 dark:via-surface dark:to-slate-950/40">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-blue-600 dark:text-blue-300">
<div class="inline-flex items-center rounded-full border border-blue-200 bg-white/80 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-700 shadow-sm dark:border-blue-800 dark:bg-slate-950/60 dark:text-blue-200">
Guided Welcome Tour
</div>
<div class="mt-3 text-xs font-semibold uppercase tracking-[0.18em] text-blue-600 dark:text-blue-300">
Step {state.stepIndex() + 1} of {state.stepCount()}
</div>
<h2 id="whats-new-title" class="mt-1 text-xl font-semibold text-base-content">
@ -107,39 +110,83 @@ export function WhatsNewModal() {
</button>
</div>
<div class="space-y-5 px-5 py-5">
<div class={`rounded-2xl border p-4 ${step().accent}`}>
<div class="flex items-center gap-2 text-sm font-semibold text-inherit">
<WhatsNewFeatureIcon card={step()} />
{step().title}
</div>
<p class="mt-2 text-sm leading-6 text-inherit">{step().description}</p>
</div>
<div class="grid gap-2 sm:grid-cols-5">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card, index) => (
<div
class={`rounded-xl border px-2.5 py-2 text-left text-xs ${
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-muted'
}`}
>
{card.title}
<div class="space-y-5 px-6 py-5">
<div class={`rounded-[1.5rem] border p-5 shadow-sm ${step().accent}`}>
<div class="flex items-start gap-4">
<div class="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl bg-white/80 text-inherit shadow-sm dark:bg-slate-950/40">
<WhatsNewFeatureIcon card={step()} />
</div>
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] opacity-70">
Now showing
</div>
)}
</For>
<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>
</div>
</div>
</div>
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-800 dark:bg-sky-900/40">
<div class="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<ChartBarIcon class="h-4 w-4 flex-shrink-0" />
{WHATS_NEW_TELEMETRY_TITLE}
<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">
Tour Stops
</div>
<div class="h-px flex-1 bg-border"></div>
</div>
<p class="text-xs text-muted">Click any stop to jump there.</p>
<div class="grid grid-cols-2 gap-2.5">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card, index) => (
<button
type="button"
onClick={() => state.handleSelectStep(index())}
aria-current={index() === state.stepIndex() ? 'step' : undefined}
class={`min-h-[3.25rem] rounded-2xl border px-3 py-2.5 text-left transition-colors ${
index() === state.stepIndex()
? 'border-blue-300 bg-blue-50 text-blue-900 shadow-sm dark:border-blue-700 dark:bg-blue-950 dark:text-blue-100'
: 'border-border bg-surface-hover text-muted hover:border-slate-300 hover:bg-surface dark:hover:border-slate-700'
}`}
>
<div class="flex items-center gap-2.5">
<div
class={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full ${
index() === state.stepIndex()
? 'bg-white/80 text-blue-700 dark:bg-slate-950/50 dark:text-blue-200'
: 'bg-surface text-muted'
}`}
>
<WhatsNewFeatureIcon card={card} />
</div>
<div class="min-w-0">
<div class="truncate text-xs font-semibold">{card.title}</div>
<div class="text-[10px] uppercase tracking-[0.14em] opacity-70">
Stop {index() + 1}
</div>
</div>
</div>
</button>
)}
</For>
</div>
</div>
<div class="rounded-[1.35rem] border border-sky-200 bg-gradient-to-br from-sky-50/95 to-white p-4 dark:border-sky-800 dark:from-sky-900/45 dark:to-slate-950">
<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-xl bg-white/70 text-sky-800 shadow-sm dark:bg-slate-950/40 dark:text-sky-100">
<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>
<div class="mt-1 text-sm font-medium text-sky-900 dark:text-sky-100">
{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>
</div>
</div>
<p class="mt-1.5 text-xs leading-5 text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[0]}
</p>
<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
@ -159,7 +206,7 @@ export function WhatsNewModal() {
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-3 rounded-2xl border border-border bg-surface-hover/70 p-3.5 sm:flex-row sm:items-center sm:justify-between">
<label class="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
@ -175,7 +222,7 @@ export function WhatsNewModal() {
href={WHATS_NEW_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
class="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-white px-3 py-1.5 text-sm font-medium text-blue-700 shadow-sm transition-colors hover:border-blue-300 hover:text-blue-800 dark:border-blue-900 dark:bg-slate-950 dark:text-blue-300 dark:hover:border-blue-700 dark:hover:text-blue-200"
>
{WHATS_NEW_DOCS_LABEL}
<ExternalLinkIcon class="h-4 w-4" />
@ -184,7 +231,7 @@ export function WhatsNewModal() {
</div>
</div>
<div class="flex items-center justify-between border-t border-border bg-surface-hover px-5 py-4">
<div class="flex items-center justify-between border-t border-border bg-surface-hover px-6 py-4">
<button
type="button"
onClick={state.handleClose}
@ -197,13 +244,13 @@ export function WhatsNewModal() {
type="button"
onClick={state.handlePrevious}
disabled={state.isFirstStep()}
class="rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
class="rounded-xl border border-border px-3.5 py-2 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-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700"
type="button"
>
{state.isLastStep() ? WHATS_NEW_PRIMARY_ACTION_LABEL : WHATS_NEW_NEXT_LABEL}

View file

@ -135,6 +135,17 @@ describe('WhatsNewModal', () => {
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();
fireEvent.click(screen.getByRole('button', { name: /Workloads/i }));
expect(await screen.findByText(/docker update status now share/i)).toBeInTheDocument();
expect(screen.getByText('Step 3 of 5')).toBeInTheDocument();
});
it('routes the docs CTA through the migration guide', async () => {
render(() => <WhatsNewModal />);

View file

@ -107,6 +107,10 @@ export function useWhatsNewModalState() {
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));
};
createEffect(() => {
if (!isOpen()) return;
@ -165,7 +169,8 @@ export function useWhatsNewModalState() {
const rect = spotlightRect();
const panel = panelRef();
const panelWidth = Math.min(384, window.innerWidth - 32);
const desktopWidth = window.innerWidth >= 1024 ? 448 : 420;
const panelWidth = Math.min(desktopWidth, window.innerWidth - 32);
const panelHeight = panel?.offsetHeight ?? 340;
if (!rect) {
@ -213,6 +218,7 @@ export function useWhatsNewModalState() {
handleClose,
handleNext,
handlePrevious,
handleSelectStep,
isFirstStep,
isLastStep,
isOpen,