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>
45 KiB
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.ps1already does offlinereg load/reg unloadwith a[gc]::Collect(); Start-Sleepguard before unload — reuse that idiom.- Hardening modules are
windows/hardening/NN-*.ps1,Set-StrictMode -Version Latest,$ErrorActionPreference='Stop',Write-Stage/Write-Hostlogging. Match that style. - Tests live in
windows/tests/. The repo already hasAssert-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 loadneeds admin). The CI Windows runner runs elevated; document that inwindows/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 theInvoke-Brandstub) -
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.ps1alongside 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 — likelyBootstrapService). -
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
IProcessRunnerhas noRunPowerShellAsync, use its existing method (Step 1 told you the real signature) — adapt the two calls to it.
- Step 5: Call
RevertKioskAsyncfrom 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 buildon 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 (readComponents/Stepsfirst).
- 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, ModelSilverMetal Windows, Supporthttps://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 theFVEvalue 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-branchto open the PR (feat/first-boot-branding→main), summarizing the three components + VM evidence. Note follow-ups: SilverOS→SilverMetal rename; final brand assets; anyFVE/PersonalizationCSPvalue-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.2–7.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,ApplyKioskChromeare used consistently across tasks. - Known soft spots flagged in-place (not placeholders): exact
FVEvalue names andPersonalizationCSPreliability are written concretely but guarded by read-back tests + the VM verify;IProcessRunnermethod name is resolved by reading the real class in B5 Step 1.