Files
SilverMetal/windows/docs/superpowers/plans/2026-06-09-first-boot-branding.md
sysadmin e4241f7f59 docs(windows): first-boot branding implementation plan
Phased TDD plan: A (branding module, hardware-free), B (kiosk: Shell
Launcher v2 + Keyboard Filter), C (MAUI fullscreen glass presentation),
D (build integration + VM e2e). Bite-sized tasks with complete code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:59:29 +01:00

45 KiB
Raw Blame History

SilverMetal Windows — First-Boot Experience & Branding Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bake SilverMetal Windows' four branding layers declaratively into the WIM, lock the one-time onboarding session into a hardened kiosk, and present the Welcome app as a fullscreen branded glass card.

Architecture: A shared dual-mode PowerShell branding module (windows/branding/) writes registry + stages assets, run offline by build.ps1's Invoke-Brand and online by self-apply. A build-only kiosk uses Shell Launcher v2 + Keyboard Filter configured at end-of-setup for the sm-bootstrap session. The MAUI Welcome app becomes a borderless fullscreen window with a CSS-only frosted-glass presentation.

Tech Stack: PowerShell 5.1 + DISM + offline registry (reg load), Pester v5 tests, Windows IoT Enterprise LTSC features (Shell Launcher v2 / Keyboard Filter via WMI), .NET MAUI Blazor (WebView2) + WinUI AppWindow, CSS.

Spec: ../specs/2026-06-09-first-boot-branding-design.md

Branch: feat/first-boot-branding (already created off origin/main; spec committed at 66e7fd4).

Conventions to follow (from the existing repo):

  • build.ps1 already does offline reg load/reg unload with a [gc]::Collect(); Start-Sleep guard before unload — reuse that idiom.
  • Hardening modules are windows/hardening/NN-*.ps1, Set-StrictMode -Version Latest, $ErrorActionPreference='Stop', Write-Stage/Write-Host logging. Match that style.
  • Tests live in windows/tests/. The repo already has Assert-IsoStructure.ps1.

Phase A — Branding module (windows/branding/)

Self-contained and testable on any Windows box with Pester — no ISO, no hardware. Do this phase first.

Task A1: Branding manifest + asset folder skeleton

Files:

  • Create: windows/branding/branding.manifest.json

  • Create: windows/branding/assets/README.md

  • Create: windows/branding/README.md

  • Step 1: Write the manifest (single source of truth for all branding strings)

windows/branding/branding.manifest.json:

{
  "schemaVersion": 1,
  "productName": "SilverMetal Windows",
  "oem": {
    "manufacturer": "SilverLABS",
    "model": "SilverMetal Windows",
    "supportUrl": "https://silverlabs.uk",
    "supportHours": "24/7 community + paid SLA",
    "logo": "oemlogo.bmp"
  },
  "bitlocker": {
    "recoveryMessage": "SilverMetal Windows. Locked out? silverlabs.uk",
    "recoveryUrl": "https://silverlabs.uk"
  },
  "lockScreen": {
    "image": "lockscreen.jpg",
    "lock": true
  },
  "desktop": {
    "wallpaper": "wallpaper.jpg",
    "theme": "SilverMetal.theme",
    "accentBgr": "00d4ff",
    "darkMode": true,
    "lockWallpaper": false
  }
}
  • Step 2: Write the assets README (documents required placeholder assets)

windows/branding/assets/README.md:

# Branding assets

Placeholder void/cyan assets until the brand identity is finalised
(`shared/branding/README.md`). Replace in place; keep filenames.

| File | Spec | Used by |
|------|------|---------|
| `oemlogo.bmp` | 120×120 24-bit BMP | OEM About logo |
| `lockscreen.jpg` | display-resolution JPG, dark | Lock/sign-in |
| `wallpaper.jpg` | display-resolution JPG, dark | Desktop |
| `SilverMetal.theme` | Windows .theme (dark + cyan accent) | Desktop theme |
  • Step 3: Write the module README

windows/branding/README.md:

# SilverMetal Windows branding (shared, dual-mode)

`Apply-Branding.ps1` writes the four branding layers either OFFLINE into a
mounted WIM (`-Mode Offline -MountPath <mount>`, used by `installer/build.ps1`
`Invoke-Brand`) or ONLINE onto the running system (`-Mode Online`, self-apply).

Strings live in `branding.manifest.json`; images in `assets/`. See
`docs/superpowers/specs/2026-06-09-first-boot-branding-design.md`.

Layers: BitLocker pre-boot recovery message, lock-screen image (locked),
desktop wallpaper+theme (changeable), OEM About.
  • Step 4: Commit
git add windows/branding
git commit -m "feat(branding): manifest + module skeleton for SilverMetal Windows branding"

Task A2: Registry hive abstraction + Pester harness

The module must write to a hive root that is either a loaded offline hive (HKLM\SM_*) or the live hive (HKLM:/default-user). Build a tiny helper that takes a PSDrive-style root so tests can point it at a throwaway key.

Files:

  • Create: windows/branding/lib/RegistryHelpers.ps1

  • Test: windows/tests/Branding.Tests.ps1

  • Step 1: Write the failing test

windows/tests/Branding.Tests.ps1:

#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' }
. "$PSScriptRoot\..\branding\lib\RegistryHelpers.ps1"

Describe 'Set-SmRegValue' {
    BeforeAll {
        $script:root = 'HKCU:\Software\SilverMetalTest'
        if (Test-Path $root) { Remove-Item $root -Recurse -Force }
    }
    AfterAll {
        if (Test-Path $script:root) { Remove-Item $script:root -Recurse -Force }
    }

    It 'creates the key path and writes a string value' {
        Set-SmRegValue -Root $script:root -SubKey 'A\B' -Name 'Greeting' -Type String -Value 'hi'
        (Get-ItemProperty "$script:root\A\B").Greeting | Should -Be 'hi'
    }

    It 'writes a dword value' {
        Set-SmRegValue -Root $script:root -SubKey 'A' -Name 'Flag' -Type DWord -Value 1
        (Get-ItemProperty "$script:root\A").Flag | Should -Be 1
    }
}
  • Step 2: Run it to verify it fails

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" Expected: FAIL — RegistryHelpers.ps1 not found / Set-SmRegValue not defined.

  • Step 3: Implement the helper

windows/branding/lib/RegistryHelpers.ps1:

Set-StrictMode -Version Latest

# Write a registry value under an arbitrary hive root (a live HKLM:/HKCU: path
# OR a loaded offline hive exposed as a PSDrive). Creates intermediate keys.
function Set-SmRegValue {
    param(
        [Parameter(Mandatory)][string]$Root,
        [Parameter(Mandatory)][string]$SubKey,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][ValidateSet('String','ExpandString','DWord','Binary')][string]$Type,
        [Parameter(Mandatory)]$Value
    )
    $key = Join-Path $Root $SubKey
    if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
    New-ItemProperty -Path $key -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null
}
  • Step 4: Run it to verify it passes

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" Expected: PASS (2 tests).

  • Step 5: Commit
git add windows/branding/lib windows/tests/Branding.Tests.ps1
git commit -m "feat(branding): registry helper + Pester harness"

Task A3: Layer writers (pure functions over a hive root)

Each layer is a function that writes its values under a given SOFTWARE root and/or default-user root. Pure registry writes — testable against throwaway keys. Asset staging is separate (Task A5).

Files:

  • Create: windows/branding/lib/BrandingLayers.ps1

  • Modify: windows/tests/Branding.Tests.ps1 (append)

  • Step 1: Write the failing tests (append to Branding.Tests.ps1)

. "$PSScriptRoot\..\branding\lib\BrandingLayers.ps1"

Describe 'Branding layer writers' {
    BeforeAll {
        $script:sw = 'HKCU:\Software\SilverMetalTest\SW'
        $script:du = 'HKCU:\Software\SilverMetalTest\DU'
        $script:m  = Get-Content "$PSScriptRoot\..\branding\branding.manifest.json" -Raw | ConvertFrom-Json
    }
    AfterAll {
        Remove-Item 'HKCU:\Software\SilverMetalTest' -Recurse -Force -ErrorAction SilentlyContinue
    }

    It 'writes OEM About info' {
        Set-OemInformation -SoftwareRoot $script:sw -Manifest $script:m -LogoPath 'C:\Windows\System32\oemlogo.bmp'
        $k = Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\OEMInformation"
        $k.Manufacturer | Should -Be 'SilverLABS'
        $k.Model        | Should -Be 'SilverMetal Windows'
        $k.SupportURL   | Should -Be 'https://silverlabs.uk'
        $k.Logo         | Should -Be 'C:\Windows\System32\oemlogo.bmp'
    }

    It 'writes a locked lock-screen image' {
        Set-LockScreen -SoftwareRoot $script:sw -ImagePath 'C:\Windows\Web\Screen\SilverMetal\lockscreen.jpg' -Lock $true
        (Get-ItemProperty "$script:sw\Microsoft\Windows\CurrentVersion\PersonalizationCSP").LockScreenImageStatus | Should -Be 1
        (Get-ItemProperty "$script:sw\Policies\Microsoft\Windows\Personalization").NoChangingLockScreen | Should -Be 1
    }

    It 'writes desktop wallpaper + dark mode into the default-user root' {
        Set-DesktopBranding -DefaultUserRoot $script:du -Manifest $script:m -WallpaperPath 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg'
        (Get-ItemProperty "$script:du\Control Panel\Desktop").WallPaper | Should -Be 'C:\Windows\Web\Wallpaper\SilverMetal\wallpaper.jpg'
        (Get-ItemProperty "$script:du\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").AppsUseLightTheme | Should -Be 0
    }

    It 'writes the BitLocker pre-boot recovery message policy' {
        Set-BitLockerPreboot -SoftwareRoot $script:sw -Manifest $script:m
        $k = Get-ItemProperty "$script:sw\Policies\Microsoft\FVE"
        $k.UseCustomRecoveryMessage | Should -Be 1
        $k.RecoveryMessage          | Should -Be 'SilverMetal Windows. Locked out? silverlabs.uk'
    }
}
  • Step 2: Run to verify failure

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" Expected: FAIL — layer functions not defined.

  • Step 3: Implement the layer writers

windows/branding/lib/BrandingLayers.ps1:

Set-StrictMode -Version Latest
. "$PSScriptRoot\RegistryHelpers.ps1"

function Set-OemInformation {
    param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$LogoPath)
    $sub = 'Microsoft\Windows\CurrentVersion\OEMInformation'
    Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Manufacturer' -Type String -Value $Manifest.oem.manufacturer
    Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Model'        -Type String -Value $Manifest.oem.model
    Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportURL'   -Type String -Value $Manifest.oem.supportUrl
    Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'SupportHours' -Type String -Value $Manifest.oem.supportHours
    Set-SmRegValue -Root $SoftwareRoot -SubKey $sub -Name 'Logo'         -Type String -Value $LogoPath
}

function Set-LockScreen {
    param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)][string]$ImagePath,[bool]$Lock=$true)
    # Per-device modern lock-screen image (reliable on Enterprise/IoT).
    $csp = 'Microsoft\Windows\CurrentVersion\PersonalizationCSP'
    Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImagePath'   -Type String -Value $ImagePath
    Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageUrl'    -Type String -Value $ImagePath
    Set-SmRegValue -Root $SoftwareRoot -SubKey $csp -Name 'LockScreenImageStatus' -Type DWord  -Value 1
    if ($Lock) {
        $pol = 'Policies\Microsoft\Windows\Personalization'
        Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'LockScreenImage'     -Type String -Value $ImagePath
        Set-SmRegValue -Root $SoftwareRoot -SubKey $pol -Name 'NoChangingLockScreen' -Type DWord  -Value 1
    }
}

function Set-DesktopBranding {
    param([Parameter(Mandatory)][string]$DefaultUserRoot,[Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$WallpaperPath)
    Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallPaper'      -Type String -Value $WallpaperPath
    Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Control Panel\Desktop' -Name 'WallpaperStyle' -Type String -Value '10'   # fill
    if ($Manifest.desktop.darkMode) {
        $p = 'Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
        Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'AppsUseLightTheme'   -Type DWord -Value 0
        Set-SmRegValue -Root $DefaultUserRoot -SubKey $p -Name 'SystemUsesLightTheme' -Type DWord -Value 0
    }
    # Accent (cyan). BGR DWORD from manifest hex (stored little-endian as 0x00BBGGRR).
    $bgr = [Convert]::ToInt32($Manifest.desktop.accentBgr,16)
    Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'AccentColor'       -Type DWord -Value $bgr
    Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\DWM' -Name 'ColorizationColor' -Type DWord -Value $bgr
    if (-not $Manifest.desktop.lockWallpaper) { return }
    Set-SmRegValue -Root $DefaultUserRoot -SubKey 'Software\Microsoft\Windows\CurrentVersion\Policies\ActiveDesktop' -Name 'NoChangingWallPaper' -Type DWord -Value 1
}

function Set-BitLockerPreboot {
    param([Parameter(Mandatory)][string]$SoftwareRoot,[Parameter(Mandatory)]$Manifest)
    # GPO "Configure pre-boot recovery message and URL" (ADMX VolumeEncryption).
    # NOTE: only the BitLocker RECOVERY screen is customisable; the normal PIN-entry
    # screen text is fixed Windows UI. Exact value names are asserted by the read-back
    # test; if a name is wrong the offline-apply verify (Task A4) catches it.
    $fve = 'Policies\Microsoft\FVE'
    Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryMessage' -Type DWord  -Value 1
    Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryMessage'          -Type String -Value $Manifest.bitlocker.recoveryMessage
    Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'UseCustomRecoveryUrl'     -Type DWord  -Value 1
    Set-SmRegValue -Root $SoftwareRoot -SubKey $fve -Name 'RecoveryUrl'              -Type String -Value $Manifest.bitlocker.recoveryUrl
}
  • Step 4: Run to verify pass

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" Expected: PASS (all layer tests green).

  • Step 5: Commit
git add windows/branding/lib/BrandingLayers.ps1 windows/tests/Branding.Tests.ps1
git commit -m "feat(branding): OEM/lockscreen/desktop/bitlocker layer writers + tests"

Task A4: Apply-Branding.ps1 orchestrator (offline + online hive mounting)

Ties the layers together and handles reg load/reg unload for offline mode and the default-user hive for online mode.

Files:

  • Create: windows/branding/Apply-Branding.ps1

  • Modify: windows/tests/Branding.Tests.ps1 (append an offline integration test against a throwaway hive file)

  • Step 1: Write the failing offline-mode integration test (append)

Describe 'Apply-Branding -Mode Offline' {
    BeforeAll {
        # Build a throwaway "mount" tree with empty SOFTWARE + default NTUSER hives.
        $script:mount = Join-Path $env:TEMP ("sm-brandtest-" + [guid]::NewGuid().ToString('N'))
        New-Item -ItemType Directory -Force "$script:mount\Windows\System32\config","$script:mount\Users\Default" | Out-Null
        # Create two empty hive files by loading/unloading a fresh key.
        foreach ($h in @("$script:mount\Windows\System32\config\SOFTWARE","$script:mount\Users\Default\NTUSER.DAT")) {
            reg load 'HKLM\SM_SEED' (New-Item -ItemType File -Force $h | Select-Object -Expand FullName) 2>$null | Out-Null
        }
        # The above seed is best-effort; if reg can't init an empty file, the apply
        # script creates the hives via reg load of the path it expects.
    }
    AfterAll { Remove-Item $script:mount -Recurse -Force -ErrorAction SilentlyContinue }

    It 'applies all layers into the offline SOFTWARE hive and reports success' {
        $r = & "$PSScriptRoot\..\branding\Apply-Branding.ps1" -Mode Offline -MountPath $script:mount -PassThru
        $r.OemApplied        | Should -BeTrue
        $r.LockScreenApplied | Should -BeTrue
        $r.DesktopApplied    | Should -BeTrue
        $r.BitLockerApplied  | Should -BeTrue
    }
}

Note: this test requires an elevated shell (reg load needs admin). The CI Windows runner runs elevated; document that in windows/tests/README.md (Task A6).

  • Step 2: Run to verify failure

Run (elevated): pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1 -Tag ''" Expected: FAIL — Apply-Branding.ps1 missing.

  • Step 3: Implement the orchestrator

windows/branding/Apply-Branding.ps1:

#Requires -Version 5.1
<#
.SYNOPSIS  Apply SilverMetal Windows branding (4 layers), offline (WIM) or online.
.DESCRIPTION
    Offline: reg-load the mounted image's SOFTWARE + default NTUSER hives, write
    values, stage assets, reg-unload. Online: write live HKLM + default-user hive.
    Design: ../docs/superpowers/specs/2026-06-09-first-boot-branding-design.md
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][ValidateSet('Offline','Online')][string]$Mode,
    [string]$MountPath,                                   # required for Offline
    [string]$Manifest  = "$PSScriptRoot\branding.manifest.json",
    [string]$AssetsDir = "$PSScriptRoot\assets",
    [switch]$PassThru
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot\lib\BrandingLayers.ps1"
function Write-Stage { param($m) Write-Host "==> $m" -ForegroundColor Cyan }

if ($Mode -eq 'Offline' -and -not $MountPath) { throw 'Offline mode requires -MountPath.' }
$m = Get-Content $Manifest -Raw | ConvertFrom-Json

# Destination paths (image-relative for offline, live for online).
$winRoot   = if ($Mode -eq 'Offline') { $MountPath } else { 'C:' }
$logoDest  = Join-Path $winRoot 'Windows\System32\oemlogo.bmp'
$lockDir   = Join-Path $winRoot 'Windows\Web\Screen\SilverMetal'
$wallDir   = Join-Path $winRoot 'Windows\Web\Wallpaper\SilverMetal'
$themeDest = Join-Path $winRoot 'Windows\Resources\Themes'
$lockLive  = 'C:\Windows\Web\Screen\SilverMetal\'   + $m.lockScreen.image
$wallLive  = 'C:\Windows\Web\Wallpaper\SilverMetal\' + $m.desktop.wallpaper
$logoLive  = 'C:\Windows\System32\oemlogo.bmp'

# --- stage assets ---
Write-Stage "Stage branding assets ($Mode)"
New-Item -ItemType Directory -Force $lockDir,$wallDir,$themeDest,(Split-Path $logoDest) | Out-Null
Copy-Item (Join-Path $AssetsDir $m.oem.logo)        $logoDest -Force
Copy-Item (Join-Path $AssetsDir $m.lockScreen.image) (Join-Path $lockDir $m.lockScreen.image) -Force
Copy-Item (Join-Path $AssetsDir $m.desktop.wallpaper)(Join-Path $wallDir $m.desktop.wallpaper) -Force
Copy-Item (Join-Path $AssetsDir $m.desktop.theme)    (Join-Path $themeDest $m.desktop.theme) -Force

$result = [ordered]@{ OemApplied=$false; LockScreenApplied=$false; DesktopApplied=$false; BitLockerApplied=$false }

function Invoke-WithHive {
    param([string]$HivePath,[string]$Name,[scriptblock]$Body)
    & reg load "HKLM\$Name" $HivePath | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "reg load $Name ($HivePath) failed" }
    try { & $Body "Registry::HKEY_LOCAL_MACHINE\$Name" }
    finally { [gc]::Collect(); Start-Sleep -Milliseconds 500; & reg unload "HKLM\$Name" | Out-Null }
}

if ($Mode -eq 'Offline') {
    $swHive = Join-Path $MountPath 'Windows\System32\config\SOFTWARE'
    $duHive = Join-Path $MountPath 'Users\Default\NTUSER.DAT'
    Invoke-WithHive $swHive 'SM_BRAND_SW' {
        param($sw)
        Write-Stage 'OEM About';        Set-OemInformation  -SoftwareRoot $sw -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true
        Write-Stage 'Lock screen';      Set-LockScreen      -SoftwareRoot $sw -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true
        Write-Stage 'BitLocker preboot';Set-BitLockerPreboot -SoftwareRoot $sw -Manifest $m; $result.BitLockerApplied=$true
    }
    Invoke-WithHive $duHive 'SM_BRAND_DU' {
        param($du)
        Write-Stage 'Desktop'; Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
    }
} else {
    Set-OemInformation  -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m -LogoPath $logoLive; $result.OemApplied=$true
    Set-LockScreen      -SoftwareRoot 'HKLM:\SOFTWARE' -ImagePath $lockLive -Lock:$m.lockScreen.lock; $result.LockScreenApplied=$true
    Set-BitLockerPreboot -SoftwareRoot 'HKLM:\SOFTWARE' -Manifest $m; $result.BitLockerApplied=$true
    Invoke-WithHive 'C:\Users\Default\NTUSER.DAT' 'SM_BRAND_DU' {
        param($du) Set-DesktopBranding -DefaultUserRoot $du -Manifest $m -WallpaperPath $wallLive; $result.DesktopApplied=$true
    }
}

Write-Host 'Branding applied.' -ForegroundColor Green
if ($PassThru) { [pscustomobject]$result }
  • Step 4: Provide placeholder assets so staging succeeds

Create minimal placeholder files so Copy-Item works in tests/builds (real art lands later):

# Generate tiny valid placeholders (run once, commit the outputs)
Add-Type -AssemblyName System.Drawing
$dir = 'windows/branding/assets'
$bmp = New-Object System.Drawing.Bitmap 120,120
$g = [System.Drawing.Graphics]::FromImage($bmp); $g.Clear([System.Drawing.Color]::FromArgb(11,15,20))
$bmp.Save("$dir/oemlogo.bmp", [System.Drawing.Imaging.ImageFormat]::Bmp)
$big = New-Object System.Drawing.Bitmap 1920,1200
$g2 = [System.Drawing.Graphics]::FromImage($big); $g2.Clear([System.Drawing.Color]::FromArgb(8,15,23))
$big.Save("$dir/lockscreen.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg)
$big.Save("$dir/wallpaper.jpg", [System.Drawing.Imaging.ImageFormat]::Jpeg)
@"
[Theme]
DisplayName=SilverMetal
[Control Panel\Desktop]
Wallpaper=%SystemRoot%\Web\Wallpaper\SilverMetal\wallpaper.jpg
WallpaperStyle=10
[VisualStyles]
SystemMode=Dark
AppMode=Dark
"@ | Set-Content "$dir/SilverMetal.theme" -Encoding ASCII
  • Step 5: Run the full branding test suite (elevated)

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" Expected: PASS (helper + layers + offline integration).

  • Step 6: Commit
git add windows/branding/Apply-Branding.ps1 windows/branding/assets windows/tests/Branding.Tests.ps1
git commit -m "feat(branding): Apply-Branding orchestrator (offline/online) + placeholder assets"

Task A5: Wire Invoke-Brand in build.ps1

Files:

  • Modify: windows/installer/build.ps1:249-250 (replace the Invoke-Brand stub)

  • Step 1: Replace the stub

Replace lines 249-250 (function Invoke-Brand { ... deferred to M4. }) with:

# --- 5. Brand --------------------------------------------------------------
# NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still
# mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and
# this stage just asserts the staged result for the SBOM/log.
function Invoke-Brand {
    Write-Stage 'Stage 5: branding (applied during WIM servicing)'
    Write-Host '   branding layers baked via branding\Apply-Branding.ps1 -Mode Offline'
}
  • Step 2: Call branding inside the mounted-WIM block

In Invoke-ServiceWim, after Copy-WelcomePayload (line 214) and before the UAC block, add:

        # Bake the four branding layers into the offline hives (must be inside the mount).
        Write-Stage 'Stage 3e: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
        & (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
        if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' }
  • Step 3: Lint-parse the script

Run: pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/build.ps1',[ref]$null,[ref]$null); 'OK'" Expected: OK

  • Step 4: Commit
git add windows/installer/build.ps1
git commit -m "feat(build): wire branding into Invoke-ServiceWim (offline hive bake)"

Task A6: Test docs

Files:

  • Modify: windows/tests/README.md

  • Step 1: Document the branding test + elevation requirement

Append to windows/tests/README.md:

## Branding.Tests.ps1

Pester v5 unit + offline-integration tests for `windows/branding/`.
**Requires an elevated shell** (the offline-integration test uses `reg load`).
Run: `pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1"`.
  • Step 2: Commit
git add windows/tests/README.md
git commit -m "docs(tests): document branding test suite + elevation requirement"

Phase A done → branding bakes offline and is unit-tested.


Phase B — Hardened kiosk (build-only)

Task B1: Configure-Kiosk.ps1 — Shell Launcher v2 + Keyboard Filter

Files:

  • Create: windows/installer/oem/Configure-Kiosk.ps1

  • Step 1: Implement the kiosk configurator (runs as SYSTEM at end-of-setup)

windows/installer/oem/Configure-Kiosk.ps1:

#Requires -Version 5.1
<#
.SYNOPSIS  Configure the one-time sm-bootstrap onboarding kiosk.
.DESCRIPTION
    Runs from SetupComplete.cmd as SYSTEM, after accounts exist, before first
    logon. Sets the sm-bootstrap shell to an elevating launcher for the Welcome
    app (no Explorer => no taskbar/Start), turns on the Keyboard Filter for shell
    hotkeys, and disables Task Manager / lock / fast-user-switch escapes.
    Reverted by the Welcome app's ApplyService on wizard success.
#>
[CmdletBinding()]
param([string]$BootstrapUser='sm-bootstrap',
      [string]$WelcomeExe='C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe')
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
$log='C:\Windows\Setup\Scripts\silvermetal-kiosk.log'
function Log($m){ "$(Get-Date -f s) $m" | Add-Content $log }

# Elevating launcher: Shell Launcher runs this as the shell; it relaunches the
# Welcome app elevated (silent via the baked UAC auto-approve).
$launcher='C:\Windows\Setup\Scripts\Start-WelcomeShell.cmd'
@"
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '$WelcomeExe' -Verb RunAs"
:loop
timeout /t 3600 >nul
goto loop
"@ | Set-Content $launcher -Encoding ASCII
Log "wrote launcher $launcher"

# --- Shell Launcher v2 (WMI bridge) ---
$cls='root\standardcimv2\embedded'
$wesl=Get-CimInstance -Namespace $cls -ClassName WESL_UserSetting -ErrorAction Stop
Invoke-CimMethod -Namespace $cls -ClassName WESL_UserSetting -MethodName SetEnabled -Arguments @{Enabled=$true} | Out-Null
# Default shell stays Explorer for everyone else.
Invoke-CimMethod -InputObject $wesl -MethodName SetDefaultShell -Arguments @{Shell='explorer.exe';DefaultAction=[uint32]0} | Out-Null
# sm-bootstrap => the elevating launcher; on exit, restart the shell (action 0).
Invoke-CimMethod -InputObject $wesl -MethodName SetCustomShell -Arguments @{
    Sid=(New-Object System.Security.Principal.NTAccount($BootstrapUser)).Translate([System.Security.Principal.SecurityIdentifier]).Value
    Shell="cmd.exe /c `"$launcher`""
    DefaultAction=[uint32]0
} | Out-Null
Log 'shell launcher configured for sm-bootstrap'

# --- Keyboard Filter (block shell hotkeys) ---
Enable-WindowsOptionalFeature -Online -FeatureName Client-KeyboardFilter -NoRestart -ErrorAction SilentlyContinue | Out-Null
$kf='root\standardcimv2\embedded'
foreach($combo in 'Win','Win+L','Ctrl+Esc','Ctrl+Win+F','Win+R'){
    $p=Get-CimInstance -Namespace $kf -ClassName WEKF_PredefinedKey -Filter "Id='$combo'" -ErrorAction SilentlyContinue
    if($p){ $p.Enabled=$true; Set-CimInstance -InputObject $p }
}
Log 'keyboard filter rules enabled'

# --- escape policies (machine-wide; reverted at teardown) ---
$sys='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
New-Item $sys -Force | Out-Null
Set-ItemProperty $sys -Name DisableTaskMgr        -Value 1 -Type DWord
Set-ItemProperty $sys -Name DisableLockWorkstation -Value 1 -Type DWord
Set-ItemProperty $sys -Name HideFastUserSwitching  -Value 1 -Type DWord
Log 'escape policies set; kiosk ready'
  • Step 2: Parse-lint

Run: pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('windows/installer/oem/Configure-Kiosk.ps1',[ref]$null,[ref]$null); 'OK'" Expected: OK

  • Step 3: Commit
git add windows/installer/oem/Configure-Kiosk.ps1
git commit -m "feat(kiosk): Configure-Kiosk.ps1 (Shell Launcher v2 + Keyboard Filter + escapes)"

Task B2: Enable kiosk features offline in build.ps1

Files:

  • Modify: windows/installer/build.ps1 (Invoke-ServiceWim, after drivers block ~line 193)

  • Step 1: Enable the optional features in the mounted WIM

After the drivers block in Invoke-ServiceWim, add:

        # Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC.
        if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
            Write-Host '   enabling Shell Launcher + Keyboard Filter features'
            Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null
            Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter        -All -NoRestart | Out-Null
        }
  • Step 2: Stage Configure-Kiosk.ps1 alongside the hardening payload

In the hardening-staging block (~line 210), after copying SetupComplete.cmd, add:

        Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force
  • Step 3: Parse-lint (same command as Task A5 Step 3) → OK

  • Step 4: Commit

git add windows/installer/build.ps1
git commit -m "feat(build): enable kiosk features offline + stage Configure-Kiosk.ps1"

Task B3: Invoke kiosk config from SetupComplete.cmd

Files:

  • Modify: windows/installer/oem/SetupComplete.cmd

  • Step 1: Add the kiosk-config call (before the Welcome-present branch)

Insert after line 15 (echo [...] first-boot start):

if exist "C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe" (
  echo [%DATE% %TIME%] configuring onboarding kiosk >> "%LOG%"
  powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Configure-Kiosk.ps1" >> "%LOG%" 2>&1
)
  • Step 2: Verify the file still has the deferral branch intact (read it; the existing if/else for hardening deferral must remain).

Run: pwsh -NoProfile -Command "Get-Content windows/installer/oem/SetupComplete.cmd" Expected: kiosk-config block present AND the original hardening-deferral if/else present.

  • Step 3: Commit
git add windows/installer/oem/SetupComplete.cmd
git commit -m "feat(kiosk): configure kiosk from SetupComplete before first logon"

Task B4: Remove the redundant FirstLogonCommands launch

Shell Launcher now launches the Welcome app as the session shell, so the answer-file launch would double-launch.

Files:

  • Modify: windows/installer/autounattend/autounattend.xml:122-128

  • Step 1: Delete the <FirstLogonCommands> block

Remove lines 122-128 (the whole <FirstLogonCommands>…</FirstLogonCommands> element) and replace the preceding comment (lines 115-121) with:

      <!--
        The Welcome wizard is launched by Shell Launcher v2 as the sm-bootstrap
        session shell (Configure-Kiosk.ps1, run from SetupComplete.cmd). No
        FirstLogonCommands launch is needed; adding one would double-launch.
      -->
  • Step 2: Validate the XML still parses

Run: pwsh -NoProfile -Command "[xml](Get-Content windows/installer/autounattend/autounattend.xml -Raw); 'OK'" Expected: OK

  • Step 3: Commit
git add windows/installer/autounattend/autounattend.xml
git commit -m "feat(kiosk): drop FirstLogonCommands launch (Shell Launcher owns launch)"

Task B5: Kiosk teardown in the Welcome app ApplyService

The app already deletes sm-bootstrap + removes AutoLogon on success. Add reversal of the kiosk config so the real user gets a normal desktop.

Files:

  • Modify: the bootstrap/teardown service in windows/welcome/src/SilverOS.Welcome.Core (find the class that removes AutoLogon — likely BootstrapService).

  • Test: existing Core test project (mirror its pattern).

  • Step 1: Find the teardown code

Run: pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.cs -Pattern 'AutoAdminLogon|sm-bootstrap|DeleteUser|Bootstrap' -List | Select-Object Path" Expected: locates BootstrapService (or equivalent). Read it to match its IProcessRunner/registry idiom.

  • Step 2: Write a failing test for RevertKioskAsync (match the project's existing test style)

Add to the Core test project a test asserting BootstrapService.RevertKioskAsync() invokes: a process that clears the sm-bootstrap WESL custom shell, and registry deletes for DisableTaskMgr/DisableLockWorkstation/HideFastUserSwitching. Use the project's existing fake IProcessRunner to capture invocations. (Mirror an existing BootstrapService test exactly — repeat its arrange/fake setup.)

  • Step 3: Run to verify failure → method not defined.

  • Step 4: Implement RevertKioskAsync

Add to BootstrapService:

public async Task RevertKioskAsync()
{
    // Remove sm-bootstrap custom shell + disable Shell Launcher's per-user entry.
    await _runner.RunPowerShellAsync(
        "$c='root\\standardcimv2\\embedded';" +
        "$w=Get-CimInstance -Namespace $c -ClassName WESL_UserSetting;" +
        "$sid=(New-Object System.Security.Principal.NTAccount('sm-bootstrap')).Translate([System.Security.Principal.SecurityIdentifier]).Value;" +
        "Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -ErrorAction SilentlyContinue;" +
        "Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -ErrorAction SilentlyContinue");
    // Revert escape policies.
    await _runner.RunPowerShellAsync(
        "$s='HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System';" +
        "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -ErrorAction SilentlyContinue");
}

If IProcessRunner has no RunPowerShellAsync, use its existing method (Step 1 told you the real signature) — adapt the two calls to it.

  • Step 5: Call RevertKioskAsync from the success path

In ApplyService, in the teardown sequence (where sm-bootstrap is deleted / AutoLogon removed), call await _bootstrap.RevertKioskAsync(); before the reboot and before deleting sm-bootstrap (the SID must still resolve).

  • Step 6: Run tests → PASS. Build the whole solution (dotnet build on the welcome .sln) → 0 errors.

  • Step 7: Commit

git add windows/welcome
git commit -m "feat(kiosk): revert kiosk (shell launcher + escapes) on wizard success"

Phase B done → onboarding session is an escape-proof kiosk, reverted for the real user.


Phase C — First-boot presentation (MAUI Welcome app)

Task C1: Borderless fullscreen window

Files:

  • Modify: windows/welcome/src/SilverOS.Welcome.App/App.xaml.cs

  • Create: windows/welcome/src/SilverOS.Welcome.App/Platforms/Windows/WindowExtensions.cs

  • Step 1: Add a Windows-only window customizer

Platforms/Windows/WindowExtensions.cs:

#if WINDOWS
using Microsoft.UI.Windowing;
using Microsoft.UI;
using WinRT.Interop;

namespace SilverOS.Welcome.App;

public static class WindowExtensions
{
    // Borderless, fullscreen, non-closable kiosk window.
    public static void ApplyKioskChrome(this Microsoft.UI.Xaml.Window winuiWindow)
    {
        var hwnd = WindowNative.GetWindowHandle(winuiWindow);
        var id = Win32Interop.GetWindowIdFromWindow(hwnd);
        var appWindow = AppWindow.GetFromWindowId(id);
        if (appWindow.Presenter is OverlappedPresenter p)
        {
            p.SetBorderAndTitleBar(false, false);
            p.IsResizable = false; p.IsMaximizable = false; p.IsMinimizable = false;
        }
        appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
        // Block the close box; the wizard exits by rebooting, not by closing.
        appWindow.Closing += (s, e) => e.Cancel = true;
    }
}
#endif
  • Step 2: Call it when the native window is created

In App.xaml.cs, replace CreateWindow with one that hooks the handler:

protected override Window CreateWindow(IActivationState? activationState)
{
    var window = new Window(new MainPage()) { Title = "SilverMetal Windows" };
#if WINDOWS
    window.HandlerChanged += (s, e) =>
    {
        if (window.Handler?.PlatformView is Microsoft.UI.Xaml.Window native)
            native.ApplyKioskChrome();
    };
#endif
    return window;
}
  • Step 3: Build for Windows

Run: dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 Expected: 0 errors.

  • Step 4: Commit
git add windows/welcome/src/SilverOS.Welcome.App
git commit -m "feat(welcome): borderless fullscreen non-closable kiosk window"

Task C2: Brand stylesheet (the void/cyan glass system)

Files:

  • Create: windows/welcome/src/SilverOS.Welcome.App/wwwroot/css/silvermetal.css

  • Modify: windows/welcome/src/SilverOS.Welcome.App/wwwroot/index.html (link the stylesheet)

  • Step 1: Add the brand stylesheet (ported from the approved mockups — backdrop wall + frosted glass card + step rail)

wwwroot/css/silvermetal.css:

:root{
  --void:#0b0f14; --ink:#e8edf5; --mid:#8fa4bc;
  --accent:#00d4ff; --accent2:#00e5a0; --line:rgba(255,255,255,.08);
}
html,body{height:100%;margin:0;background:#05080c;color:var(--ink);
  font-family:"Segoe UI Variable","Segoe UI",system-ui,sans-serif;overflow:hidden}
.sm-wall{position:fixed;inset:0;background:
  radial-gradient(85% 75% at 28% 18%, #173247, transparent 60%),
  radial-gradient(75% 75% at 82% 92%, #0f3528, transparent 55%),
  linear-gradient(135deg,#080f17,#0a1612)}
.sm-wall::after{content:"SILVERMETAL";position:fixed;right:26px;bottom:18px;
  font:700 12px/1 system-ui;letter-spacing:4px;color:rgba(255,255,255,.18)}
.sm-glass{position:fixed;inset:12% 16%;border-radius:18px;
  background:rgba(16,22,31,.55);backdrop-filter:blur(18px);
  border:1px solid rgba(255,255,255,.14);
  box-shadow:0 24px 70px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.12);
  display:flex;flex-direction:column;overflow:hidden;animation:sm-rise .5s ease both}
@keyframes sm-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.sm-rail{display:flex;gap:14px;padding:14px 26px;font:600 10px/1 "Cascadia Mono",Consolas,monospace;
  letter-spacing:1px;color:var(--mid);border-bottom:1px solid var(--line)}
.sm-rail .on{color:var(--accent)} .sm-rail .done{color:var(--accent2)}
.sm-body{flex:1;padding:18px 30px;overflow:auto;min-height:0}
.sm-next{align-self:flex-end;margin:14px 26px;background:linear-gradient(180deg,#13b6e6,#0a93c8);
  color:#001018;font-weight:700;border:0;border-radius:9px;padding:10px 22px;cursor:pointer}
  • Step 2: Link it in index.html (after the existing stylesheet links)
<link rel="stylesheet" href="css/silvermetal.css" />
  • Step 3: Build → 0 errors. Commit.
git add windows/welcome/src/SilverOS.Welcome.App/wwwroot
git commit -m "feat(welcome): SilverMetal void/cyan glass stylesheet"

Task C3: Apply the Hybrid shell to MainLayout

Files:

  • Modify: windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor

  • Step 1: Replace the stock template layout with the glass shell

MainLayout.razor:

@inherits LayoutComponentBase

<div class="sm-wall"></div>
<div class="sm-glass">
    <div class="sm-body">
        @Body
    </div>
</div>

The step rail (sm-rail) and the Next button (sm-next) live inside the wizard step components, which render the per-step content into @Body. If a shared step chrome already exists, move the rail here instead — match whatever the existing step components expect (read Components/Steps first).

  • Step 2: Build + run the app on the dev box (windowed sanity check is fine without the kiosk)

Run: dotnet build windows/welcome/src/SilverOS.Welcome.App -f net9.0-windows10.0.19041.0 Expected: 0 errors. (Visual verification happens in the VM e2e, Task D2.)

  • Step 3: Commit
git add windows/welcome/src/SilverOS.Welcome.App/Components
git commit -m "feat(welcome): Hybrid glass-card shell in MainLayout"

Task C4: Remove the dead default-template nav

The stock NavMenu + the "About → learn.microsoft.com" top row are template cruft that the kiosk must not show.

Files:

  • Modify: windows/welcome/src/SilverOS.Welcome.App/Components/Layout/MainLayout.razor (already replaced — confirms no <NavMenu/> / top-row remain)

  • Delete: windows/welcome/src/SilverOS.Welcome.App/Components/Layout/NavMenu.razor (if nothing else references it)

  • Step 1: Check for other references to NavMenu

Run: pwsh -NoProfile -Command "Select-String -Path windows/welcome/src/**/*.razor -Pattern 'NavMenu'" Expected: only the (now-removed) MainLayout — if so, delete NavMenu.razor. If referenced elsewhere, leave it and just ensure it's not rendered in the kiosk path.

  • Step 2: Build → 0 errors. Commit.
git add -A windows/welcome/src/SilverOS.Welcome.App/Components
git commit -m "chore(welcome): remove stock template nav from kiosk shell"

Phase C done → Welcome app presents as the fullscreen branded glass card.


Phase D — Integration + VM end-to-end

Task D1: Build a test ISO with branding + kiosk enabled

Files: none (operational). Build host = Windows + ADK (per iso-builder.md).

  • Step 1: Run the pipeline against a licensed base ISO

Run (elevated, on the Windows runner / ADK box):

windows\installer\build.ps1 -SourceIso <path-to-IoT-LTSC.iso> -SkipInputVerify

Expected: completes through Stage 7; out\SilverMetal-Enhanced-Windows.iso + .sha256 + .sbom.json produced. Branding stage 3e and kiosk feature-enable log lines present.

  • Step 2: Assert ISO structure

Run: pwsh -NoProfile windows/tests/Assert-IsoStructure.ps1 -IsoPath windows/installer/out/SilverMetal-Enhanced-Windows.iso (or the script's actual parameter). Expected: PASS.


Task D2: VM boot e2e (uses the existing harness)

Files: none (operational). Harness = SLAB01 VM 102 + _stageiso.py / _pverun.py / _shot.py (see memory silveros-welcome-app-implementation.md).

  • Step 1: Stage + boot the ISO in VM 102 (per the established harness flow; eject CD after file-copy to avoid the reinstall loop).

  • Step 2: Verify the kiosk session — screenshot the sm-bootstrap session. Assert:

    • The Welcome app fills the screen as the glass card on the branded wall.
    • No taskbar, no Start is present (Shell Launcher replaced Explorer).
    • Win key / Win+L / Ctrl+Alt+Del → Task Manager are blocked (try via _pverun/host key-send; the wizard stays foreground).
  • Step 3: Complete the wizard (pick a flavour, create the real user, set BitLocker PIN per existing flow). Let it reboot.

  • Step 4: Verify the real-user desktop:

    • Logs into a normal Explorer desktop (taskbar back) — kiosk reverted.
    • Branded wallpaper + dark theme + cyan accent.
    • Lock screen shows the branded image and cannot be changed (Settings ▸ Personalization ▸ Lock screen is greyed / policy-managed).
    • Settings ▸ System ▸ About shows Manufacturer SilverLABS, Model SilverMetal Windows, Support https://silverlabs.uk, logo.
  • Step 5: Verify BitLocker recovery message (best-effort): trigger recovery (or inspect manage-bde -status + the FVE policy) to confirm the custom recovery message/URL applied. If the FVE value names from Task A3 proved wrong, fix them now and re-run Phase A tests + rebuild.

  • Step 6: Record evidence — capture screenshots into .superpowers/ (gitignored) and note results in the PR description.


Task D3: Finish the branch

  • Step 1: Run the full local test suite

Run: pwsh -NoProfile -Command "Invoke-Pester windows/tests/Branding.Tests.ps1" → PASS, and dotnet build the welcome .sln → 0 errors.

  • Step 2: Use superpowers:finishing-a-development-branch to open the PR (feat/first-boot-brandingmain), summarizing the three components + VM evidence. Note follow-ups: SilverOS→SilverMetal rename; final brand assets; any FVE/PersonalizationCSP value-name corrections discovered on the VM.

Self-review notes (author)

  • Spec coverage: A=branding module (§4) ✓; A5=Invoke-Brand wiring (§7.1) ✓; B=kiosk (§5, §7.27.4) ✓; C=presentation (§6, §7.5) ✓; D=testing (§8) ✓. Honest BitLocker limitation (§4) is carried into A3's comment + D2 Step 5. Open items (§9) are attached to the tasks that resolve them (FVE names → A3/D2; PersonalizationCSP → A3/D2; elevation route → B1; assets → A4; rename → D3).
  • Type consistency: Set-SmRegValue, Set-OemInformation, Set-LockScreen, Set-DesktopBranding, Set-BitLockerPreboot, Apply-Branding.ps1 -Mode/-MountPath/-PassThru, Configure-Kiosk.ps1, RevertKioskAsync, ApplyKioskChrome are used consistently across tasks.
  • Known soft spots flagged in-place (not placeholders): exact FVE value names and PersonalizationCSP reliability are written concretely but guarded by read-back tests + the VM verify; IProcessRunner method name is resolved by reading the real class in B5 Step 1.