From ea5adacac330420bb16bb260dd6bf5c6765dadb6 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Tue, 9 Jun 2026 03:20:39 +0100 Subject: [PATCH] feat(welcome): apply step wiring + Mercury styling Wire ApplyStep with public StartAsync(), IProgress 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 --- .../Components/Routes.razor | 10 +- .../Components/Steps/ApplyStep.razor | 98 ++- .../Components/_Imports.razor | 1 + .../SilverOS.Welcome.App/wwwroot/css/app.css | 791 ++++++++++++++++-- .../SilverOS.Welcome.Tests/ApplyStepTests.cs | 72 ++ 5 files changed, 914 insertions(+), 58 deletions(-) create mode 100644 windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Routes.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Routes.razor index a466c0f..26104f1 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Routes.razor +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Routes.razor @@ -46,7 +46,7 @@ break; case 4: - + break; case 5: @@ -124,4 +124,12 @@ if (_currentStep > 0) _currentStep--; } + + void AdvanceToDone() + { + // Called by ApplyStep when configuration completes successfully. + // Step index 5 = Done. + _currentStep = 5; + StateHasChanged(); + } } diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/Steps/ApplyStep.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/Steps/ApplyStep.razor index 19ba43d..37d5ddf 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/Steps/ApplyStep.razor +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/Steps/ApplyStep.razor @@ -1,16 +1,98 @@ -@* Minimal placeholder — full wiring in Task 11 *@ +@inject IApplyService ApplyService +@inject WizardState State +
-

Applying Configuration

-

Your settings will be applied now.

- - @if (_started) +
+

Applying Configuration

+

Your SilverOS is being configured. This may take a few minutes.

+
+ + @if (_errorMessage is not null) { -

Working… please wait.

+
+
+

Configuration failed

+

@_errorMessage

+ +
+ } + else if (!_running && !_complete) + { +
+

Ready to apply your selections. Click Start to begin.

+ +
+ } + else + { +
+
@_stageLabel
+
+
+
+
@(_percent)%
+
+ + @if (_complete) + { +
+
+

Configuration complete.

+
+ } }
@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(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(); + } + } } diff --git a/windows/welcome/src/SilverOS.Welcome.App/Components/_Imports.razor b/windows/welcome/src/SilverOS.Welcome.App/Components/_Imports.razor index 8c75f01..078f017 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/Components/_Imports.razor +++ b/windows/welcome/src/SilverOS.Welcome.App/Components/_Imports.razor @@ -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 diff --git a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css index cc59fb5..796debe 100644 --- a/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css +++ b/windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/app.css @@ -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; } diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs new file mode 100644 index 0000000..e40a9d8 --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/ApplyStepTests.cs @@ -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(); + 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(); + await cut.InvokeAsync(() => cut.Instance.StartAsync()); + apply.Verify(a => a.RunAsync( + It.Is(r => r.Username == "alice" && r.Flavour.Id == "daily-driver"), + It.IsAny>(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task OnComplete_invoked_when_apply_succeeds() + { + var apply = new Mock(); + apply.Setup(a => a.RunAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .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(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(); + apply.Setup(a => a.RunAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .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(); + await cut.InvokeAsync(() => cut.Instance.StartAsync()); + Assert.Contains("Module 03 failed", cut.Markup); + Assert.NotNull(cut.Find(".btn-retry")); + } +}