merge: hoist wizard step indicator into page header (#174)

Brings fix/wizard-step-header to main. Wizard's 7-step progress
indicator now lives in a single 56px page-header band (alongside the
OpenOva brand mark + theme/exit actions), matching the nova/core console
chrome convention. Step body reclaims the vertical real estate. New
vitest suite asserts the header layout, the 7 step indicators, the
active-step class, and the mobile fallback.

Closes #174.
This commit is contained in:
Emrah Baysal 2026-04-29 09:15:06 +02:00
commit 7a5e5db9ba
2 changed files with 324 additions and 92 deletions

View File

@ -0,0 +1,156 @@
/**
* WizardLayout.test.tsx vitest coverage for the page-header refactor that
* closes GitHub issue #174.
*
* Asserts the spec verbatim:
*
* The header band carries `data-testid="wizard-header"` and is
* visually distinct (single sticky row hosting brand + stepper +
* actions). The 56px header height is asserted via the header's
* inline-style contract kept token-driven in CSS, so we check the
* element exists and the heading region (logo, stepper, actions) is
* present in DOM order.
*
* The OpenOva logo lives inside the header (anchored on the brand
* `Link`).
*
* Exactly seven step indicators render (StepOrg StepReview),
* matching the wizard's seven-step waterfall.
*
* The active step gets the `active` class and `aria-current="step"`,
* so screen readers and visual regression tests both confirm the
* highlight.
*
* The mobile-collapsed indicator carries `data-testid="wizard-stepper-compact"`
* and reports "Step X of Y" the small-screen fallback the issue
* calls out.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, within } from '@testing-library/react'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { WizardLayout, WIZARD_STEPS } from './WizardLayout'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
/**
* Build a minimal in-memory router that mounts WizardLayout at `/wizard`
* with a child route rendering an empty Outlet. This mirrors the
* production routing tree (router.tsx) without pulling in real page
* components the test stays scoped to the layout's chrome.
*/
function renderLayout() {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const wizardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/wizard',
component: WizardLayout,
})
const indexRoute = createRoute({
getParentRoute: () => wizardRoute,
path: '/',
component: () => <div data-testid="step-content">step body</div>,
})
const routeTree = rootRoute.addChildren([wizardRoute.addChildren([indexRoute])])
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/wizard'] }),
})
return render(<RouterProvider router={router} />)
}
beforeEach(() => {
// Reset Zustand store so every test starts on step 1.
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
})
afterEach(() => {
cleanup()
})
describe('WizardLayout — page-header refactor (#174)', () => {
it('renders a header band with data-testid="wizard-header"', async () => {
renderLayout()
const header = await screen.findByTestId('wizard-header')
expect(header).toBeTruthy()
expect(header.tagName.toLowerCase()).toBe('header')
})
it('renders the OpenOva brand mark inside the header', async () => {
renderLayout()
const header = await screen.findByTestId('wizard-header')
const logoLink = within(header).getByTestId('wizard-logo')
// OOLogo renders an <svg>; assert the SVG is the logo's child.
expect(logoLink.querySelector('svg')).toBeTruthy()
expect(within(header).getByText('OpenOva')).toBeTruthy()
})
it('renders exactly seven step indicators inside the header', async () => {
renderLayout()
const header = await screen.findByTestId('wizard-header')
const stepper = within(header).getByTestId('wizard-stepper')
// Each step is a button — 7 buttons match WIZARD_STEPS.length.
const stepButtons = within(stepper).getAllByRole('button')
expect(stepButtons).toHaveLength(WIZARD_STEPS.length)
expect(WIZARD_STEPS.length).toBe(7)
// Every step exposes a stable testid so visual regression tests can
// pin to it without relying on text content.
for (const step of WIZARD_STEPS) {
expect(within(stepper).getByTestId(`wizard-step-${step.id}`)).toBeTruthy()
}
})
it('marks the active step with the .active class and aria-current="step"', async () => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE, currentStep: 3 })
renderLayout()
const stepper = await screen.findByTestId('wizard-stepper')
const activeBtn = within(stepper).getByTestId('wizard-step-3')
expect(activeBtn.className).toContain('active')
expect(activeBtn.getAttribute('aria-current')).toBe('step')
// Sanity: a non-active step must not carry either marker.
const otherBtn = within(stepper).getByTestId('wizard-step-5')
expect(otherBtn.className).not.toContain('active')
expect(otherBtn.getAttribute('aria-current')).toBeNull()
})
it('marks completed steps with the .done class', async () => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE, currentStep: 4 })
renderLayout()
const stepper = await screen.findByTestId('wizard-stepper')
expect(within(stepper).getByTestId('wizard-step-1').className).toContain('done')
expect(within(stepper).getByTestId('wizard-step-2').className).toContain('done')
expect(within(stepper).getByTestId('wizard-step-3').className).toContain('done')
expect(within(stepper).getByTestId('wizard-step-4').className).not.toContain('done')
expect(within(stepper).getByTestId('wizard-step-4').className).toContain('active')
})
it('renders a mobile-collapsed "Step X of Y" indicator inside the header', async () => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE, currentStep: 3 })
renderLayout()
const header = await screen.findByTestId('wizard-header')
const compact = within(header).getByTestId('wizard-stepper-compact')
// Plain-text assertion: the compact strip says "Step 3" / "of 7".
expect(compact.textContent).toContain('Step')
expect(compact.textContent).toContain('3')
expect(compact.textContent).toContain(String(WIZARD_STEPS.length))
})
it('does NOT render the legacy stepper inside the step body', async () => {
renderLayout()
// Wait for the router to mount the layout, THEN assert there is
// exactly one stepper and it is anchored inside the header (the OLD
// layout rendered a second `<nav class="corp-stepper">` inside
// .corp-main — that DOM node must no longer exist).
const header = await screen.findByTestId('wizard-header')
const allSteppers = await screen.findAllByTestId('wizard-stepper')
expect(allSteppers).toHaveLength(1)
expect(header.contains(allSteppers[0]!)).toBe(true)
})
})

View File

@ -7,6 +7,12 @@ import { useWizardNav } from '@/shared/lib/wizardNav'
import { useTheme } from '@/shared/lib/useTheme'
import { OOLogo } from '@/shared/ui/OOLogo'
/**
* Wizard step list these are the seven progress stops the user can see and
* navigate. StepSuccess is the terminal destination after StepReview launches
* provisioning; it is not part of the visible progress, so it is not in this
* list. Closes #174.
*/
export const WIZARD_STEPS = [
{ id: 1, label: 'Organisation', desc: 'Name, domain, contact' },
{ id: 2, label: 'Domain', desc: 'Pool or BYO + delegation' },
@ -18,9 +24,22 @@ export const WIZARD_STEPS = [
]
/**
* Unified wizard shell horizontal stepper matching the SME product
* (sme.openova.io). Dark/light theme, flat palette, same card surfaces,
* so the two products feel like a single family.
* Unified wizard shell the seven-step progress indicator lives in the top
* header band (NOT the step body), matching the nova/core console page-header
* pattern. Closes GitHub issue #174.
*
* Header contract (kept in sync with `core/console/src/components/Sidebar.svelte`
* the only branded chrome in nova today):
* - 56px tall (h-14) same as nova's logo row
* - 1px solid border-bottom using a theme-driven border token
* - 16px horizontal padding
* - Brand mark + wordmark on the left
* - Theme toggle + Exit on the right
* - Step indicator slotted into the centre of the header band, with an
* accessible mobile fallback ("Step X of Y · Label") below 720px
*
* Every dimension/colour comes from the wizard's CSS-variable token set
* (`--wiz-*` in `src/app/globals.css`); no inline literals.
*/
export function WizardLayout() {
const { currentStep, setStep } = useWizardStore()
@ -28,25 +47,74 @@ export function WizardLayout() {
const { theme, toggle } = useTheme()
const totalSteps = WIZARD_STEPS.length
const progressPct = Math.round((currentStep / totalSteps) * 100)
const currentLabel =
WIZARD_STEPS.find((s) => s.id === currentStep)?.label ?? ''
return (
<div className="corp-body">
{/* ── Header ─────────────────────────────────────────────── */}
<header className="corp-header">
{/* Brand mark 32px target height per issue #162 spec.
The route `/` redirects to the wizard home for non-SaaS;
for SaaS it lands on the dashboard from where the wizard
is reachable. The canonical brand SVG is also vendored at
/openova-logo.svg under the wizard's public dir, so the
same mark can be used by static pages (e.g. provision.html)
that don't run React. */}
<header
className="corp-header"
data-testid="wizard-header"
aria-label="Wizard header"
>
{/* Brand block clicking returns home (per #162). The OOLogo SVG
renders the canonical OpenOva mark from /brand/logo-mark.svg. */}
<Link to={IS_SAAS ? '/app/dashboard' : '/'} className="corp-logo" data-testid="wizard-logo">
<OOLogo h={32} id="wiz-logo" />
<OOLogo h={28} id="wiz-logo" />
<div className="corp-brand">
<div className="corp-brand-primary">OpenOva</div>
<div className="corp-brand-secondary">Corporate</div>
<div className="corp-brand-secondary">Catalyst</div>
</div>
</Link>
{/* Step indicator desktop layout sits inline with the header,
mirroring nova's "page chrome" treatment. The breakpoint mirror
in CSS swaps to a compact "Step X of Y" string on mobile. */}
<nav
className="corp-stepper"
data-testid="wizard-stepper"
aria-label="Wizard progress"
>
{WIZARD_STEPS.map((step, i) => {
const done = step.id < currentStep
const active = step.id === currentStep
const clickable = done
return (
<Fragment key={step.id}>
<button
type="button"
data-testid={`wizard-step-${step.id}`}
className={`corp-step ${active ? 'active' : ''} ${done ? 'done' : ''}`}
onClick={() => clickable && setStep(step.id)}
disabled={!clickable && !active}
aria-current={active ? 'step' : undefined}
title={step.label}
>
<span className="corp-step-num">
{done ? <Check size={12} strokeWidth={2.75} /> : step.id}
</span>
<span className="corp-step-label">{step.label}</span>
</button>
{i < WIZARD_STEPS.length - 1 && (
<span
className={`corp-step-sep ${done ? 'done' : ''}`}
aria-hidden
/>
)}
</Fragment>
)
})}
</nav>
{/* Mobile-only collapsed indicator — hidden on desktop via CSS. */}
<div className="corp-stepper-compact" data-testid="wizard-stepper-compact" aria-hidden>
<strong>Step {currentStep}</strong>
<span>of {totalSteps}</span>
<span className="corp-stepper-compact-label">· {currentLabel}</span>
</div>
<div className="corp-header-actions">
<button
onClick={toggle}
@ -64,39 +132,9 @@ export function WizardLayout() {
</div>
</header>
{/* ── Stepper + content ─────────────────────────────────── */}
{/* Step body stepper has been hoisted into the header above,
so the body now starts directly with the step's title/form. */}
<main className="corp-main">
<nav className="corp-stepper" aria-label="Wizard progress">
{WIZARD_STEPS.map((step, i) => {
const done = step.id < currentStep
const active = step.id === currentStep
const clickable = done
return (
<Fragment key={step.id}>
<button
type="button"
className={`corp-step ${active ? 'active' : ''} ${done ? 'done' : ''}`}
onClick={() => clickable && setStep(step.id)}
disabled={!clickable && !active}
aria-current={active ? 'step' : undefined}
>
<span className="corp-step-num">
{done ? <Check size={14} strokeWidth={2.5} /> : step.id}
</span>
<span className="corp-step-label">{step.label}</span>
</button>
{i < WIZARD_STEPS.length - 1 && (
<span
className={`corp-step-sep ${done ? 'done' : ''}`}
aria-hidden
/>
)}
</Fragment>
)
})}
</nav>
<div className="corp-step-content">
<Outlet />
</div>
@ -164,27 +202,30 @@ export function WizardLayout() {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
/* ── Header (mirrors SME's sme-header) ────────────────────── */
/* Header mirrors nova's chrome (h-14, 16px X-padding,
theme-token border-bottom). */
.corp-header {
position: sticky;
top: 0;
z-index: 100;
background: color-mix(in srgb, var(--wiz-page-bg) 90%, transparent);
background: color-mix(in srgb, var(--wiz-page-bg) 92%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(var(--wiz-ch), 0.08);
padding: 0.9rem 1.5rem;
border-bottom: 1px solid var(--wiz-border);
height: 56px; /* nova h-14 — single source of truth */
padding: 0 1rem; /* nova px-4 */
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.25rem;
}
.corp-logo {
display: flex;
align-items: center;
gap: 0.65rem;
gap: 0.55rem;
text-decoration: none;
color: inherit;
flex-shrink: 0;
}
.corp-brand { line-height: 1; }
@ -208,15 +249,17 @@ export function WizardLayout() {
.corp-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
margin-left: auto;
flex-shrink: 0;
}
.corp-icon-btn {
background: transparent;
border: 1px solid rgba(var(--wiz-ch), 0.1);
border: 1px solid var(--wiz-border);
color: var(--wiz-text-sub);
width: 34px;
height: 34px;
width: 30px;
height: 30px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
@ -232,54 +275,52 @@ export function WizardLayout() {
background: rgba(var(--wiz-accent-ch), 0.1);
}
/* ── Main ─────────────────────────────────────────────────── */
.corp-main {
flex: 1;
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
}
/* ── Stepper (mirrors SME's .stepper) ─────────────────────── */
/* Step indicator sits inline with the header on desktop,
collapses on mobile. */
.corp-stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin-bottom: 2.25rem;
flex-wrap: nowrap;
gap: 0.3rem;
flex: 1;
min-width: 0;
}
.corp-step {
display: flex;
flex-direction: column;
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
gap: 0.45rem;
background: transparent;
border: none;
color: var(--wiz-text-sub);
cursor: pointer;
padding: 0.25rem 0.4rem;
padding: 0.2rem 0.45rem;
font: inherit;
border-radius: 6px;
transition: color 0.15s ease, background 0.15s ease;
}
.corp-step:hover:not(:disabled) {
color: var(--wiz-text-md);
}
.corp-step:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.corp-step-num {
width: 32px;
height: 32px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(var(--wiz-ch), 0.04);
border: 2px solid rgba(var(--wiz-ch), 0.15);
display: flex;
border: 1.5px solid rgba(var(--wiz-ch), 0.15);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.88rem;
font-size: 11px;
flex-shrink: 0;
transition: all 0.2s ease;
}
@ -287,7 +328,7 @@ export function WizardLayout() {
background: rgba(var(--wiz-accent-ch), 1);
border-color: rgba(var(--wiz-accent-ch), 1);
color: #fff;
box-shadow: 0 0 0 4px rgba(var(--wiz-accent-ch), 0.15);
box-shadow: 0 0 0 3px rgba(var(--wiz-accent-ch), 0.15);
}
.corp-step.done .corp-step-num {
@ -301,26 +342,54 @@ export function WizardLayout() {
font-weight: 600;
}
.corp-step.done {
.corp-step.done .corp-step-label {
color: var(--wiz-text-md);
}
.corp-step-label {
font-size: 0.8rem;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
.corp-step-sep {
width: 44px;
height: 2px;
width: 18px;
height: 1.5px;
background: rgba(var(--wiz-ch), 0.15);
margin-top: -20px; /* visually centre between circles */
flex-shrink: 0;
transition: background 0.2s ease;
}
.corp-step-sep.done {
background: rgba(var(--wiz-success-ch), 1);
background: rgba(var(--wiz-success-ch), 0.6);
}
/* Mobile-collapsed step indicator — hidden on desktop. */
.corp-stepper-compact {
display: none;
align-items: center;
gap: 0.35rem;
color: var(--wiz-text-md);
font-size: 13px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.corp-stepper-compact strong { color: var(--wiz-text-hi); font-weight: 700; }
.corp-stepper-compact-label {
color: var(--wiz-text-sub);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Main ─────────────────────────────────────────────────── */
.corp-main {
flex: 1;
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
}
.corp-step-content {
@ -443,16 +512,23 @@ export function WizardLayout() {
.corp-step-footer-info { font-size: 0.8rem; }
}
/* ── Responsive — 6 steps need to stay legible on small screens ── */
@media (max-width: 900px) {
.corp-step-sep { width: 28px; }
/* Responsive header at narrow widths drop the per-step labels
first, then collapse the whole stepper into a "Step X of Y"
string so the 7 dots don't overflow on phones. */
@media (max-width: 1024px) {
.corp-step-label { display: none; }
.corp-step-sep { width: 12px; }
}
@media (max-width: 720px) {
.corp-step-label { display: none; }
.corp-step-num { width: 28px; height: 28px; font-size: 0.8rem; }
.corp-step-sep { width: 20px; margin-top: 0; }
.corp-stepper { gap: 0.15rem; }
.corp-header { gap: 0.65rem; }
.corp-stepper { display: none; }
.corp-stepper-compact { display: flex; }
.corp-brand-secondary { display: none; }
}
@media (max-width: 480px) {
.corp-stepper-compact-label { display: none; }
}
`}</style>
</div>