feat(catalyst): unify wizard with SME — horizontal stepper, flat palette

User request: unify both wizards on the horizontal pattern and bring
Corporate in line with SME's look-and-feel (dark/light mode, colors,
cards) with minimum changes.

Minimum-touch changes:
- globals.css: flatten --wiz-page-bg from radial gradient to solid
  #0b1220 (dark) / #f8fafc (light) — matches SME's flat bg.
  --wiz-panel-bg bumped to #111827 (dark) / #ffffff (light) to match
  SME card surfaces.
- WizardLayout.tsx: complete rewrite as a horizontal top-stepper
  (header + stepper row + content), mirroring the SME stepper pattern
  (32px numbered circles + labels below + 44px connecting lines).
  Done circles turn green with a check; active is accent blue with a
  soft ring; pending stays as a hollow circle.
- Responsive: labels hide below 720px, circles shrink to 28px so 6
  steps remain legible on tablets and phones.

Step content components (StepOrganisation, StepTopology, ...) are
unchanged — they inherit the new palette via the existing --wiz-*
variables.
This commit is contained in:
e3mrah 2026-04-16 08:57:34 +02:00
parent afd9df01de
commit 7ed9239c2d
2 changed files with 247 additions and 290 deletions

View File

@ -49,8 +49,8 @@
--wiz-ch: 255, 255, 255; /* fg channel — white in dark mode */ --wiz-ch: 255, 255, 255; /* fg channel — white in dark mode */
--wiz-accent-ch: 56, 189, 248; /* brand blue rgb */ --wiz-accent-ch: 56, 189, 248; /* brand blue rgb */
--wiz-accent: rgba(56,189,248,0.8); /* accent text (links, labels) */ --wiz-accent: rgba(56,189,248,0.8); /* accent text (links, labels) */
--wiz-page-bg: radial-gradient(ellipse at 30% 20%, #0c1e40 0%, #06080f 70%); --wiz-page-bg: #0b1220; /* flat dark — matches SME */
--wiz-panel-bg: #131320; --wiz-panel-bg: #111827; /* surface — matches SME */
--wiz-deep-bg: #1a1a1a; --wiz-deep-bg: #1a1a1a;
--wiz-glow-1: rgba(56,189,248,0.06); --wiz-glow-1: rgba(56,189,248,0.06);
--wiz-glow-2: rgba(129,140,248,0.05); --wiz-glow-2: rgba(129,140,248,0.05);
@ -100,9 +100,9 @@
--wiz-ch: 0, 0, 0; --wiz-ch: 0, 0, 0;
--wiz-accent-ch: 2, 132, 199; --wiz-accent-ch: 2, 132, 199;
--wiz-accent: #0284c7; /* sky-600 — 4.6:1 on white (WCAG AA ✓) */ --wiz-accent: #0284c7; /* sky-600 — 4.6:1 on white (WCAG AA ✓) */
--wiz-page-bg: radial-gradient(ellipse at 30% 20%, #e0f2fe 0%, #f0f9ff 70%); --wiz-page-bg: #f8fafc; /* flat light — matches SME */
--wiz-panel-bg: #f1f5f9; /* dropdown panel — distinguishable from white card */ --wiz-panel-bg: #ffffff; /* card white — matches SME */
--wiz-deep-bg: #f4f4f5; --wiz-deep-bg: #f1f5f9;
--wiz-glow-1: rgba(2,132,199,0.08); --wiz-glow-1: rgba(2,132,199,0.08);
--wiz-glow-2: rgba(99,102,241,0.05); --wiz-glow-2: rgba(99,102,241,0.05);

View File

@ -1,9 +1,9 @@
import { Fragment } from 'react'
import { Outlet, Link } from '@tanstack/react-router' import { Outlet, Link } from '@tanstack/react-router'
import { X, Sun, Moon, Check } from 'lucide-react' import { X, Sun, Moon, Check } from 'lucide-react'
import { IS_SAAS } from '@/shared/constants/env' import { IS_SAAS } from '@/shared/constants/env'
import { useWizardStore } from '@/entities/deployment/store' import { useWizardStore } from '@/entities/deployment/store'
import { useTheme } from '@/shared/lib/useTheme' import { useTheme } from '@/shared/lib/useTheme'
import { useBreakpoint } from '@/shared/lib/useBreakpoint'
import { OOLogo } from '@/shared/ui/OOLogo' import { OOLogo } from '@/shared/ui/OOLogo'
export const WIZARD_STEPS = [ export const WIZARD_STEPS = [
@ -15,307 +15,264 @@ export const WIZARD_STEPS = [
{ id: 6, label: 'Review', desc: 'Confirm and provision' }, { id: 6, label: 'Review', desc: 'Confirm and provision' },
] ]
/**
* 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.
*/
export function WizardLayout() { export function WizardLayout() {
const { currentStep, setStep } = useWizardStore() const { currentStep, setStep } = useWizardStore()
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const bp = useBreakpoint()
const isMobile = bp === 'mobile'
const isTablet = bp === 'tablet'
const isDesktop = bp === 'desktop'
const progressPct = Math.round(((currentStep - 1) / WIZARD_STEPS.length) * 100)
return ( return (
<div style={{ <div className="corp-body">
position: 'fixed', inset: 0, {/* ── Header ─────────────────────────────────────────────── */}
display: 'flex', <header className="corp-header">
flexDirection: isMobile ? 'column' : 'row', <Link to={IS_SAAS ? '/app/dashboard' : '/'} className="corp-logo">
background: 'var(--wiz-page-bg)', <OOLogo h={22} id="wiz-logo" />
fontFamily: 'Inter, sans-serif', <div className="corp-brand">
overflow: 'hidden', <div className="corp-brand-primary">OpenOva</div>
}}> <div className="corp-brand-secondary">Corporate</div>
{/* Ambient glows */}
<div style={{ position: 'absolute', top: '-10%', left: '25%', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(56,189,248,0.06) 0%, transparent 65%)', pointerEvents: 'none', zIndex: 0 }} />
<div style={{ position: 'absolute', bottom: '-10%', right: '5%', width: 400, height: 400, borderRadius: '50%', background: 'radial-gradient(circle, rgba(129,140,248,0.05) 0%, transparent 65%)', pointerEvents: 'none', zIndex: 0 }} />
{/* ── MOBILE: top bar with progress pill ───────────────────────── */}
{isMobile && (
<div style={{
flexShrink: 0, height: 56, zIndex: 10,
background: 'rgba(var(--wiz-ch), 0.025)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: '1px solid var(--wiz-border-sub)',
display: 'flex', alignItems: 'center', padding: '0 16px', gap: 14,
}}>
<OOLogo h={20} id="wiz-logo-m" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 10, color: 'var(--wiz-text-sub)', letterSpacing: '0.06em' }}>
STEP {currentStep} OF {WIZARD_STEPS.length}
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--wiz-text-hi)', lineHeight: 1.2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{WIZARD_STEPS[currentStep - 1].label}
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{WIZARD_STEPS.map(s => (
<div
key={s.id}
onClick={() => s.id < currentStep && setStep(s.id)}
style={{
height: 6, borderRadius: 3,
width: s.id === currentStep ? 18 : 6,
background: s.id < currentStep
? 'linear-gradient(90deg, #38BDF8, #818CF8)'
: s.id === currentStep ? '#38BDF8' : 'var(--wiz-border)',
cursor: s.id < currentStep ? 'pointer' : 'default',
transition: 'all 0.3s',
}}
/>
))}
</div> </div>
</Link>
<div className="corp-header-actions">
<button
onClick={toggle}
aria-label="Toggle theme"
title="Toggle light / dark"
className="corp-icon-btn"
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
<Link to={IS_SAAS ? '/app/dashboard' : '/'}>
<button className="corp-icon-btn" aria-label="Exit wizard" title="Exit wizard">
<X size={14} />
</button>
</Link>
</div> </div>
)} </header>
{/* ── TABLET: icon-only rail, balls-only (no bg, right-aligned) ─── */} {/* ── Stepper + content ─────────────────────────────────── */}
{isTablet && ( <main className="corp-main">
<div style={{ <nav className="corp-stepper" aria-label="Wizard progress">
width: 52, flexShrink: 0, zIndex: 10, {WIZARD_STEPS.map((step, i) => {
/* NO bg, NO border — fully transparent */ const done = step.id < currentStep
display: 'flex', flexDirection: 'column', alignItems: 'center', const active = step.id === currentStep
padding: '20px 12px 16px', const clickable = done
}}>
<OOLogo h={18} id="wiz-logo-t" />
<div style={{ height: 28 }} />
{/* Balls only — fixed 24 px gap between them */} return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, alignItems: 'center' }}> <Fragment key={step.id}>
{WIZARD_STEPS.map((step, i) => { <button
const done = step.id < currentStep type="button"
const current = step.id === currentStep className={`corp-step ${active ? 'active' : ''} ${done ? 'done' : ''}`}
const prevFilled = step.id - 1 < currentStep
return (
<div key={step.id} style={{ position: 'relative' }}>
{/* Rail above (except first) */}
{i > 0 && (
<div style={{
position: 'absolute',
top: -24, left: '50%', transform: 'translateX(-50%)',
width: 2, height: 24,
borderRadius: 1,
background: prevFilled
? 'linear-gradient(180deg, rgba(56,189,248,0.7), rgba(129,140,248,0.5))'
: 'rgba(var(--wiz-ch), 0.2)',
transition: 'background 0.3s',
}} />
)}
{/* Ball */}
<div
onClick={() => done && setStep(step.id)}
title={step.label}
style={{
width: 28, height: 28, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
background: done
? 'linear-gradient(135deg, #38BDF8, #818CF8)'
: current ? 'rgba(56,189,248,0.15)' : 'transparent',
border: current
? '2px solid #38BDF8'
: done ? 'none' : '1.5px solid var(--wiz-border)',
color: done ? '#fff' : current ? '#38BDF8' : 'var(--wiz-text-hint)',
boxShadow: current ? '0 0 0 4px rgba(56,189,248,0.15)' : 'none',
cursor: done ? 'pointer' : 'default',
position: 'relative',
}}
>
{done ? <Check size={11} strokeWidth={2.5} /> : step.id}
</div>
</div>
)
})}
</div>
<div style={{ flex: 1 }} />
<div style={{ fontSize: 10, color: 'var(--wiz-text-sub)', fontWeight: 600 }}>{progressPct}%</div>
</div>
)}
{/* ── DESKTOP: transparent column, right-aligned stepper, labels-left ── */}
{isDesktop && (
<div style={{
width: 200, flexShrink: 0, zIndex: 10,
/* NO bg, NO border — fully transparent */
display: 'flex', flexDirection: 'column',
/* Zero right padding — balls sit flush against main content */
padding: '28px 0 28px 20px',
}}>
{/* Logo — right-aligned to match stepper direction */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'flex-end', marginBottom: 44, paddingRight: 6 }}>
<OOLogo h={22} id="wiz-logo-d" />
<div style={{ lineHeight: 1, textAlign: 'right' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--wiz-text-hi)', letterSpacing: '-0.01em' }}>OpenOva</div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--wiz-text-sub)', letterSpacing: '0.18em', textTransform: 'uppercase', marginTop: 2 }}>Corporate</div>
</div>
</div>
{/* Stepper fixed 28 px gap (no stretch), right-aligned so balls
sit flush against the content card. Labels render LEFT of balls. */}
<div style={{
display: 'flex', flexDirection: 'column',
gap: 28,
alignItems: 'flex-end',
}}>
{WIZARD_STEPS.map((step, i) => {
const done = step.id < currentStep
const current = step.id === currentStep
const prevFilled = step.id - 1 < currentStep
const clickable = done
return (
<div
key={step.id}
onClick={() => clickable && setStep(step.id)} onClick={() => clickable && setStep(step.id)}
style={{ disabled={!clickable && !active}
position: 'relative', aria-current={active ? 'step' : undefined}
display: 'flex', alignItems: 'center', gap: 12,
cursor: clickable ? 'pointer' : 'default',
}}
> >
{/* Rail above this ball (except first) — connects from previous */} <span className="corp-step-num">
{i > 0 && ( {done ? <Check size={14} strokeWidth={2.5} /> : step.id}
<div style={{
position: 'absolute',
top: -28, right: 13, /* ball_width/2 - rail_width/2 = 14 - 1 */
width: 2, height: 28,
borderRadius: 1,
background: prevFilled
? 'linear-gradient(180deg, rgba(56,189,248,0.7), rgba(129,140,248,0.5))'
: 'rgba(var(--wiz-ch), 0.2)',
transition: 'background 0.3s',
}} />
)}
{/* Label — LEFT of ball, one word */}
<span style={{
fontSize: 12,
fontWeight: current ? 700 : done ? 500 : 400,
color: current
? 'var(--wiz-text-hi)'
: done ? 'var(--wiz-text-md)' : 'var(--wiz-text-sub)',
transition: 'all 0.2s',
whiteSpace: 'nowrap',
letterSpacing: '-0.005em',
}}>
{step.label}
</span> </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>
{/* Ball */} <div className="corp-step-content">
<div style={{
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
background: done
? 'linear-gradient(135deg, #38BDF8, #818CF8)'
: current ? 'rgba(56,189,248,0.15)' : 'transparent',
border: current
? '2px solid #38BDF8'
: done ? 'none' : '1.5px solid var(--wiz-border)',
color: done ? '#fff' : current ? '#38BDF8' : 'var(--wiz-text-hint)',
boxShadow: current ? '0 0 0 4px rgba(56,189,248,0.15)' : 'none',
transition: 'all 0.25s',
position: 'relative',
zIndex: 2,
}}>
{done ? <Check size={12} strokeWidth={2.5} /> : step.id}
{/* Pulse ring for current step */}
{current && (
<span
aria-hidden
style={{
position: 'absolute', inset: -4,
borderRadius: '50%',
border: '1.5px solid rgba(56,189,248,0.5)',
animation: 'wiz-step-pulse 2.2s ease-in-out infinite',
pointerEvents: 'none',
}}
/>
)}
</div>
</div>
)
})}
</div>
{/* Spacer so progress sits at bottom */}
<div style={{ flex: 1, minHeight: 40 }} />
{/* Progress — compact, right-aligned */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
<span style={{ fontSize: 10, color: 'var(--wiz-text-sub)', fontWeight: 500 }}>Progress</span>
<div style={{ width: 72, height: 2, borderRadius: 1, background: 'rgba(var(--wiz-ch), 0.1)' }}>
<div style={{
height: '100%',
width: `${progressPct}%`,
borderRadius: 1,
background: 'linear-gradient(90deg, #38BDF8, #818CF8)',
transition: 'width 0.4s',
}} />
</div>
<span style={{ fontSize: 10, fontWeight: 700, color: '#38BDF8', minWidth: 26, textAlign: 'right' }}>{progressPct}%</span>
</div>
</div>
)}
{/* ── SCROLLABLE CONTENT ────────────────────────────────────────── */}
<div id="wizard-body" style={{
flex: 1, overflowY: 'auto',
display: 'flex', flexDirection: 'column',
/* Desktop: 8 px left pad only — content card sits adjacent to balls */
padding: isMobile
? '20px 16px 40px'
: isTablet
? '28px 28px 48px 12px'
: '36px 40px 56px 8px',
zIndex: 1,
}}>
<div style={{
width: '100%',
maxWidth: isDesktop ? 1000 : '100%',
/* Left-aligned on desktop so card hugs the stepper; centered on others */
marginLeft: isDesktop ? 0 : 'auto',
marginRight: 'auto',
}}>
<Outlet /> <Outlet />
</div> </div>
</div> </main>
{/* ── TOP-RIGHT CONTROLS ───────────────────────────────────────── */}
<div style={{ position: 'absolute', top: isMobile ? 12 : 18, right: 16, display: 'flex', gap: 8, zIndex: 20, alignItems: 'center' }}>
<button
onClick={toggle}
aria-label="Toggle theme"
style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--wiz-border-sub)', border: '1px solid var(--wiz-border)', color: 'var(--wiz-text-sub)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
{theme === 'dark' ? <Sun size={13} /> : <Moon size={13} />}
</button>
<Link to={IS_SAAS ? '/app/dashboard' : '/'}>
<button
aria-label="Exit wizard"
style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--wiz-border-sub)', border: '1px solid var(--wiz-border)', color: 'var(--wiz-text-sub)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
>
<X size={13} />
</button>
</Link>
</div>
{/* Pulse animation — injected once at layout level */}
<style>{` <style>{`
@keyframes wiz-step-pulse { .corp-body {
0%, 100% { opacity: 0.4; transform: scale(1); } min-height: 100vh;
50% { opacity: 0; transform: scale(1.35); } background: var(--wiz-page-bg);
color: var(--wiz-text-md);
display: flex;
flex-direction: column;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
/* ── Header (mirrors SME's sme-header) ────────────────────── */
.corp-header {
position: sticky;
top: 0;
z-index: 100;
background: color-mix(in srgb, var(--wiz-page-bg) 90%, 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;
display: flex;
justify-content: space-between;
align-items: center;
}
.corp-logo {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: inherit;
}
.corp-brand { line-height: 1; }
.corp-brand-primary {
font-size: 13px;
font-weight: 700;
color: var(--wiz-text-hi);
letter-spacing: -0.01em;
}
.corp-brand-secondary {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--wiz-text-sub);
margin-top: 2px;
}
.corp-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.corp-icon-btn {
background: transparent;
border: 1px solid rgba(var(--wiz-ch), 0.1);
color: var(--wiz-text-sub);
width: 34px;
height: 34px;
border-radius: 7px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, color 0.15s, background 0.15s;
padding: 0;
}
.corp-icon-btn:hover {
border-color: rgba(var(--wiz-accent-ch), 0.6);
color: var(--wiz-text-hi);
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) ─────────────────────── */
.corp-stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
margin-bottom: 2.25rem;
flex-wrap: nowrap;
}
.corp-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
background: transparent;
border: none;
color: var(--wiz-text-sub);
cursor: pointer;
padding: 0.25rem 0.4rem;
font: inherit;
}
.corp-step:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.corp-step-num {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(var(--wiz-ch), 0.04);
border: 2px solid rgba(var(--wiz-ch), 0.15);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.88rem;
transition: all 0.2s ease;
}
.corp-step.active .corp-step-num {
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);
}
.corp-step.done .corp-step-num {
background: #22C55E;
border-color: #22C55E;
color: #fff;
}
.corp-step.active .corp-step-label {
color: var(--wiz-text-hi);
font-weight: 600;
}
.corp-step.done {
color: var(--wiz-text-md);
}
.corp-step-label {
font-size: 0.8rem;
line-height: 1.2;
white-space: nowrap;
}
.corp-step-sep {
width: 44px;
height: 2px;
background: rgba(var(--wiz-ch), 0.15);
margin-top: -20px; /* visually centre between circles */
transition: background 0.2s ease;
}
.corp-step-sep.done {
background: #22C55E;
}
.corp-step-content {
width: 100%;
}
/* ── Responsive — 6 steps need to stay legible on small screens ── */
@media (max-width: 900px) {
.corp-step-sep { width: 28px; }
}
@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; }
} }
`}</style> `}</style>
</div> </div>