feat(welcome): apply step wiring + Mercury styling

Wire ApplyStep with public StartAsync(), IProgress<ApplyProgress> marshalled
via InvokeAsync(StateHasChanged), OnComplete EventCallback (host advances to
Done), and failure surface + Retry button. Add _Imports.razor Apply using.
Wire Routes.razor AdvanceToDone handler. Add Mercury CSS: slate-void palette,
DM Mono typography, layered radial gradients, staggered step-enter animation,
styled wizard chrome/cards/fields/progress bar/buttons. 17/17 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysadmin
2026-06-09 03:20:39 +01:00
parent a393ded7c6
commit ea5adacac3
5 changed files with 914 additions and 58 deletions

View File

@@ -46,7 +46,7 @@
<PrefsStep />
break;
case 4:
<ApplyStep />
<ApplyStep OnComplete="AdvanceToDone" />
break;
case 5:
<DoneStep />
@@ -124,4 +124,12 @@
if (_currentStep > 0)
_currentStep--;
}
void AdvanceToDone()
{
// Called by ApplyStep when configuration completes successfully.
// Step index 5 = Done.
_currentStep = 5;
StateHasChanged();
}
}

View File

@@ -1,16 +1,98 @@
@* Minimal placeholder — full wiring in Task 11 *@
@inject IApplyService ApplyService
@inject WizardState State
<div class="step apply-step">
<h1>Applying Configuration</h1>
<p class="step-subtitle">Your settings will be applied now.</p>
<button class="btn-primary" @onclick="Start" disabled="@_started">Start</button>
@if (_started)
<div class="apply-header">
<h1>Applying Configuration</h1>
<p class="step-subtitle">Your SilverOS is being configured. This may take a few minutes.</p>
</div>
@if (_errorMessage is not null)
{
<p class="apply-status">Working… please wait.</p>
<div class="apply-error">
<div class="apply-error-icon">&#x26A0;</div>
<p class="apply-error-title">Configuration failed</p>
<p class="apply-error-detail">@_errorMessage</p>
<button class="btn-primary btn-retry" @onclick="() => _ = StartAsync()">Retry</button>
</div>
}
else if (!_running && !_complete)
{
<div class="apply-ready">
<p class="apply-ready-text">Ready to apply your selections. Click Start to begin.</p>
<button class="btn-primary btn-start" @onclick="() => _ = StartAsync()" disabled="@_running">Start</button>
</div>
}
else
{
<div class="apply-progress-container">
<div class="apply-stage-label">@_stageLabel</div>
<div class="apply-progress-track">
<div class="apply-progress-bar" style="width: @(_percent)%"></div>
</div>
<div class="apply-percent-label">@(_percent)%</div>
</div>
@if (_complete)
{
<div class="apply-complete">
<div class="apply-complete-icon">&#x2713;</div>
<p>Configuration complete.</p>
</div>
}
}
</div>
@code {
private bool _started;
[Parameter] public EventCallback OnComplete { get; set; }
void Start() => _started = true;
private bool _running;
private bool _complete;
private int _percent;
private string _stageLabel = "Preparing…";
private string? _errorMessage;
public async Task StartAsync()
{
_running = true;
_complete = false;
_errorMessage = null;
_percent = 0;
_stageLabel = "Preparing…";
StateHasChanged();
var req = new ApplyRequest(
Flavour: State.Flavour!,
Username: State.Username,
Password: State.Password,
AdminPassword: State.AdminPassword,
BitLockerPin: State.BitLockerPin,
BootstrapUser: "sm-bootstrap");
var progress = new Progress<ApplyProgress>(p =>
{
_ = InvokeAsync(() =>
{
_percent = p.Percent;
_stageLabel = p.Stage;
StateHasChanged();
});
});
try
{
await ApplyService.RunAsync(req, progress);
_complete = true;
_running = false;
_percent = 100;
StateHasChanged();
await OnComplete.InvokeAsync();
}
catch (Exception ex)
{
_running = false;
_errorMessage = ex.Message;
StateHasChanged();
}
}
}

View File

@@ -9,3 +9,4 @@
@using SilverOS.Welcome.App.Components
@using SilverOS.Welcome.App.Components.Steps
@using SilverOS.Welcome.Core.Flavours
@using SilverOS.Welcome.Core.Apply

View File

@@ -1,72 +1,765 @@
/*#if (SampleContent)*/
/* ===================================================================
SilverOS Welcome — Mercury Aesthetic
Theme: precision dark, slate-steel dominant, electric-ice accent.
Typography: "DM Mono" for headings/UI text, fallback to Courier
variant stacks for the "terminal-built" feel of a security OS.
Atmosphere: layered radial + linear gradients, subtle scanline texture.
Motion: staggered CSS entrance on .step reveal — one orchestrated
arrival, not scattered micro-animations.
=================================================================== */
/* ── Web Font ───────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Inter:wght@300;400;500&display=swap');
/* ── Design Tokens ──────────────────────────────────────────────────── */
:root {
/* Palette */
--clr-void: #0b0f14; /* near-black background */
--clr-surface: #111720; /* card / panel surface */
--clr-surface-2: #19202e; /* raised surface */
--clr-border: #1e2a3a; /* subtle border */
--clr-border-hi: #2a3d56; /* highlighted border */
--clr-accent: #00d4ff; /* electric ice — primary CTA, progress */
--clr-accent-dim: #0099bb; /* dimmer accent for hover states */
--clr-accent-glow: rgba(0,212,255,0.18);
--clr-success: #00e5a0; /* completion green */
--clr-warn: #f5a623; /* error / warning amber */
--clr-text-hi: #e8edf5; /* high-emphasis text */
--clr-text-mid: #8fa4bc; /* mid-emphasis: labels, subtitles */
--clr-text-lo: #4a5f78; /* low-emphasis: placeholders, hints */
/* Typography */
--font-mono: 'DM Mono', 'Fira Code', 'Consolas', monospace;
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
/* Geometry */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Motion */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
}
/* ── Reset & Base ───────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
height: 100%;
background-color: var(--clr-void);
color: var(--clr-text-hi);
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
/*#endif*/
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
/* Atmospheric background — layered radial glow on deep void */
body {
background:
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(0,100,180,0.18) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 90% 100%, rgba(0,180,140,0.08) 0%, transparent 60%),
var(--clr-void);
min-height: 100vh;
}
/* ── Blazor error overlay (keep readable) ──────────────────────────── */
#blazor-error-ui {
background: lightyellow;
background: #1a0a0a;
border-top: 2px solid var(--clr-warn);
color: var(--clr-warn);
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-shadow: 0 -2px 12px rgba(245,166,35,0.15);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
font-family: var(--font-mono);
font-size: 0.8rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
color: var(--clr-text-mid);
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
background: #1a0a0a;
border: 1px solid var(--clr-warn);
padding: 1rem 1rem 1rem 3.7rem;
color: white;
color: var(--clr-warn);
border-radius: var(--radius-md);
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
/* ── Validation ─────────────────────────────────────────────────────── */
h1:focus { outline: none; }
.valid.modified:not([type=checkbox]) {
outline: 1px solid var(--clr-success);
}
.invalid {
outline: 1px solid var(--clr-warn);
}
.validation-message {
color: var(--clr-warn);
font-size: 0.75rem;
font-family: var(--font-mono);
}
/* ══════════════════════════════════════════════════════════════════════
WIZARD CHROME
══════════════════════════════════════════════════════════════════════ */
.wizard {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
max-width: 760px;
margin: 0 auto;
}
/* ── Step indicator ─────────────────────────────────────────────────── */
.wizard-header {
padding: 1.5rem 2rem 1rem;
border-bottom: 1px solid var(--clr-border);
background: linear-gradient(to bottom, rgba(17,23,32,0.95), transparent);
position: sticky;
top: 0;
z-index: 10;
}
.wizard-steps-indicator {
display: flex;
gap: 0;
align-items: center;
}
.wizard-step-dot {
font-family: var(--font-mono);
font-size: 0.7rem;
font-weight: 400;
letter-spacing: 0.05em;
color: var(--clr-text-lo);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
transition: color 0.25s var(--ease-out), background 0.25s var(--ease-out);
position: relative;
white-space: nowrap;
}
.wizard-step-dot + .wizard-step-dot::before {
content: '';
position: absolute;
left: -2px;
color: var(--clr-border-hi);
font-size: 0.9rem;
top: 50%;
transform: translateY(-50%);
}
.wizard-step-dot.done {
color: var(--clr-success);
}
.wizard-step-dot.done::after {
content: ' ✓';
font-size: 0.6rem;
}
.wizard-step-dot.active {
color: var(--clr-accent);
background: var(--clr-accent-glow);
font-weight: 500;
}
/* ── Wizard body ────────────────────────────────────────────────────── */
.wizard-body {
padding: 2.5rem 2rem;
overflow-y: auto;
}
/* ── Wizard footer ──────────────────────────────────────────────────── */
.wizard-footer {
padding: 1.25rem 2rem;
border-top: 1px solid var(--clr-border);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(to top, rgba(11,15,20,0.98), transparent);
gap: 1rem;
}
/* ── Loading / error states ─────────────────────────────────────────── */
.loading {
font-family: var(--font-mono);
color: var(--clr-text-lo);
font-size: 0.85rem;
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.error-panel {
border: 1px solid var(--clr-warn);
border-radius: var(--radius-md);
padding: 1.5rem;
background: rgba(245,166,35,0.06);
}
.error {
color: var(--clr-warn);
font-weight: 500;
margin-bottom: 0.4rem;
}
.error-detail {
color: var(--clr-text-lo);
font-family: var(--font-mono);
font-size: 0.75rem;
margin-bottom: 1rem;
}
/* ══════════════════════════════════════════════════════════════════════
BUTTONS
══════════════════════════════════════════════════════════════════════ */
.btn-primary,
.btn-secondary {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition:
background 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
opacity 0.15s;
outline-offset: 3px;
}
.btn-primary {
background: var(--clr-accent);
color: var(--clr-void);
}
.btn-primary:hover:not(:disabled) {
background: #1ae0ff;
box-shadow: 0 0 18px var(--clr-accent-glow);
}
.btn-primary:focus-visible {
outline: 2px solid var(--clr-accent);
}
.btn-primary:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--clr-text-mid);
border: 1px solid var(--clr-border-hi);
}
.btn-secondary:hover:not(:disabled) {
background: var(--clr-surface-2);
color: var(--clr-text-hi);
border-color: var(--clr-accent-dim);
}
.btn-secondary:focus-visible {
outline: 2px solid var(--clr-accent);
}
.btn-secondary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ══════════════════════════════════════════════════════════════════════
STEP ENTRANCE ANIMATION (orchestrated stagger)
══════════════════════════════════════════════════════════════════════ */
.step {
animation: step-enter 0.45s var(--ease-out) both;
}
@keyframes step-enter {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* heading and subtitle arrive slightly after the container */
.step h1 {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 300;
letter-spacing: -0.02em;
color: var(--clr-text-hi);
line-height: 1.2;
margin-bottom: 0.4rem;
animation: step-enter 0.5s 0.05s var(--ease-out) both;
}
.step-subtitle {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--clr-text-mid);
margin-bottom: 2rem;
animation: step-enter 0.5s 0.1s var(--ease-out) both;
}
/* ══════════════════════════════════════════════════════════════════════
WELCOME STEP
══════════════════════════════════════════════════════════════════════ */
.welcome-step {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 50vh;
}
.welcome-hero {
animation: step-enter 0.55s 0.05s var(--ease-out) both;
}
.welcome-hero h1 {
font-size: 2.2rem;
font-weight: 300;
color: var(--clr-text-hi);
margin-bottom: 0.5rem;
/* Accent underline */
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: step-enter 0.55s 0.1s var(--ease-out) both;
}
.tagline {
font-size: 1.05rem;
color: var(--clr-text-mid);
font-style: italic;
margin-bottom: 1.5rem;
animation: step-enter 0.5s 0.15s var(--ease-out) both;
}
.time-estimate {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--clr-text-lo);
margin-top: 1.5rem;
animation: step-enter 0.5s 0.2s var(--ease-out) both;
}
/* ══════════════════════════════════════════════════════════════════════
FLAVOUR STEP
══════════════════════════════════════════════════════════════════════ */
.flavour-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
margin-top: 0.5rem;
}
.flavour-card {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-lg);
padding: 1.5rem;
cursor: pointer;
transition:
border-color 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out),
background 0.2s var(--ease-out);
position: relative;
overflow: hidden;
}
.flavour-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--clr-accent-glow) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.25s var(--ease-out);
border-radius: inherit;
}
.flavour-card:hover {
border-color: var(--clr-border-hi);
box-shadow: 0 0 0 1px var(--clr-border-hi), 0 4px 20px rgba(0,0,0,0.4);
}
.flavour-card:hover::before {
opacity: 0.5;
}
.flavour-card.selected {
border-color: var(--clr-accent);
box-shadow: 0 0 0 1px var(--clr-accent), 0 0 28px var(--clr-accent-glow);
background: var(--clr-surface-2);
}
.flavour-card.selected::before {
opacity: 1;
}
.flavour-card h3 {
font-family: var(--font-mono);
font-size: 0.95rem;
font-weight: 500;
color: var(--clr-text-hi);
letter-spacing: 0.03em;
margin-bottom: 0.5rem;
}
.flavour-card.selected h3 {
color: var(--clr-accent);
}
.flavour-card p {
font-size: 0.82rem;
color: var(--clr-text-mid);
line-height: 1.5;
}
/* Staggered card entrance */
.flavour-card:nth-child(1) { animation: step-enter 0.45s 0.1s var(--ease-out) both; }
.flavour-card:nth-child(2) { animation: step-enter 0.45s 0.18s var(--ease-out) both; }
.flavour-card:nth-child(3) { animation: step-enter 0.45s 0.26s var(--ease-out) both; }
.flavour-card:nth-child(4) { animation: step-enter 0.45s 0.34s var(--ease-out) both; }
/* ══════════════════════════════════════════════════════════════════════
ACCOUNT STEP / FORM FIELDS
══════════════════════════════════════════════════════════════════════ */
.field-group {
margin-bottom: 1.4rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.field-group:nth-child(2) { animation-delay: 0.06s; }
.field-group:nth-child(3) { animation-delay: 0.12s; }
.field-group:nth-child(4) { animation-delay: 0.18s; }
.field-group label {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--clr-text-mid);
margin-bottom: 0.4rem;
}
.field-group input[type="text"],
.field-group input[type="password"] {
width: 100%;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-sm);
padding: 0.65rem 0.9rem;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--clr-text-hi);
outline: none;
transition:
border-color 0.2s var(--ease-out),
box-shadow 0.2s var(--ease-out);
}
.field-group input:focus {
border-color: var(--clr-accent);
box-shadow: 0 0 0 3px var(--clr-accent-glow);
}
.field-group input::placeholder {
color: var(--clr-text-lo);
font-style: italic;
}
.field-error {
display: block;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-warn);
margin-top: 0.3rem;
}
/* ══════════════════════════════════════════════════════════════════════
PREFS STEP
══════════════════════════════════════════════════════════════════════ */
.prefs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pref-item {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-md);
padding: 0.9rem 1.1rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.pref-item:nth-child(2) { animation-delay: 0.07s; }
.pref-item:nth-child(3) { animation-delay: 0.14s; }
.pref-item label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.88rem;
color: var(--clr-text-hi);
}
.pref-item input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--clr-accent);
cursor: pointer;
flex-shrink: 0;
}
.pref-item small {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-text-lo);
}
/* ══════════════════════════════════════════════════════════════════════
APPLY STEP
══════════════════════════════════════════════════════════════════════ */
.apply-step {
display: flex;
flex-direction: column;
gap: 2rem;
min-height: 40vh;
}
.apply-header h1 {
font-size: 1.7rem;
}
/* Ready state */
.apply-ready {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: var(--radius-lg);
padding: 2rem;
text-align: center;
animation: step-enter 0.4s 0.1s var(--ease-out) both;
}
.apply-ready-text {
color: var(--clr-text-mid);
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.btn-start {
min-width: 140px;
}
/* Progress state */
.apply-progress-container {
animation: step-enter 0.35s var(--ease-out) both;
}
.apply-stage-label {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--clr-accent);
letter-spacing: 0.04em;
margin-bottom: 0.6rem;
}
.apply-progress-track {
width: 100%;
height: 6px;
background: var(--clr-surface-2);
border-radius: 99px;
overflow: hidden;
border: 1px solid var(--clr-border);
}
.apply-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--clr-accent) 0%, var(--clr-success) 100%);
border-radius: 99px;
transition: width 0.4s var(--ease-out);
box-shadow: 0 0 10px var(--clr-accent-glow);
}
.apply-percent-label {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--clr-text-lo);
text-align: right;
margin-top: 0.3rem;
}
/* Complete state */
.apply-complete {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(0,229,160,0.08);
border: 1px solid var(--clr-success);
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
animation: step-enter 0.4s var(--ease-out) both;
}
.apply-complete-icon {
font-size: 1.4rem;
color: var(--clr-success);
line-height: 1;
}
.apply-complete p {
color: var(--clr-success);
font-family: var(--font-mono);
font-size: 0.85rem;
}
/* Error state */
.apply-error {
background: rgba(245,166,35,0.07);
border: 1px solid var(--clr-warn);
border-radius: var(--radius-md);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
animation: step-enter 0.35s var(--ease-out) both;
}
.apply-error-icon {
font-size: 1.5rem;
color: var(--clr-warn);
line-height: 1;
}
.apply-error-title {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 500;
color: var(--clr-warn);
}
.apply-error-detail {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--clr-text-mid);
margin-bottom: 0.5rem;
word-break: break-word;
}
.btn-retry {
align-self: flex-start;
}
/* ══════════════════════════════════════════════════════════════════════
DONE STEP
══════════════════════════════════════════════════════════════════════ */
.done-step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
justify-content: center;
min-height: 50vh;
gap: 1.5rem;
}
.done-step h1 {
background: linear-gradient(90deg, var(--clr-success) 0%, var(--clr-accent) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 2.2rem;
}
.done-step p {
color: var(--clr-text-mid);
max-width: 440px;
font-size: 0.92rem;
}
.btn-restart {
min-width: 160px;
background: linear-gradient(90deg, var(--clr-accent), var(--clr-success));
color: var(--clr-void);
font-weight: 500;
}
.btn-restart:hover:not(:disabled) {
box-shadow: 0 0 28px rgba(0,229,160,0.25), 0 0 18px var(--clr-accent-glow);
opacity: 0.92;
}
/* ── Accessibility / focus safety ───────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.step,
.step h1,
.step-subtitle,
.welcome-hero,
.welcome-hero h1,
.tagline,
.time-estimate,
.flavour-card,
.field-group,
.pref-item,
.apply-progress-container,
.apply-ready,
.apply-complete,
.apply-error {
animation: none !important;
transition: none !important;
}
}
/* ── MAUI layout safety ─────────────────────────────────────────────── */
.status-bar-safe-area {
display: none;
}
@@ -77,7 +770,7 @@ h1:focus {
position: sticky;
top: 0;
height: env(safe-area-inset-top);
background-color: #f7f7f7;
background-color: var(--clr-void);
width: 100%;
z-index: 1;
}

View File

@@ -0,0 +1,72 @@
using Bunit;
using Moq;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using SilverOS.Welcome.App.Components;
using SilverOS.Welcome.App.Components.Steps;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyStepTests : TestContext
{
[Fact]
public async Task Calls_apply_with_the_wizard_selections()
{
var apply = new Mock<IApplyService>();
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice",
Password = "pw",
AdminPassword = "apw",
BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
apply.Verify(a => a.RunAsync(
It.Is<ApplyRequest>(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"),
It.IsAny<IProgress<ApplyProgress>>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task OnComplete_invoked_when_apply_succeeds()
{
var apply = new Mock<IApplyService>();
apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
var completed = false;
var cut = RenderComponent<ApplyStep>(p => p.Add(s => s.OnComplete, EventCallback.Factory.Create(this, () => { completed = true; })));
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.True(completed);
}
[Fact]
public async Task Shows_error_and_retry_button_when_apply_fails()
{
var apply = new Mock<IApplyService>();
apply.Setup(a => a.RunAsync(It.IsAny<ApplyRequest>(), It.IsAny<IProgress<ApplyProgress>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Module 03 failed"));
var state = new WizardState
{
Flavour = new FlavourManifest { Id = "daily-driver", Hardening = new() { Modules = new[] { "00" } } },
Username = "alice", Password = "pw", AdminPassword = "apw", BitLockerPin = "123456"
};
Services.AddSingleton(state);
Services.AddSingleton(apply.Object);
var cut = RenderComponent<ApplyStep>();
await cut.InvokeAsync(() => cut.Instance.StartAsync());
Assert.Contains("Module 03 failed", cut.Markup);
Assert.NotNull(cut.Find(".btn-retry"));
}
}