Zum Inhalt

Windows v0.10.0 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: v0.10.0 des Windows-Hosts: UI-loser Kiosk-Saver, Settings-Dialog mit voller macOS-Parität, Performance-Paket (renderScale + adaptives Downscale), Multi-Monitor-Modi (Spanning / Je-Monitor) und Wallpaper-Modus /w mit Tray + PowerPolicy.

Architecture: Spec docs/specs/2026-07-16-windows-v0.10-saver-wallpaper-design.md. Web-Engine-Patches sind lokal testbar (vitest + typecheck + Vite-Dev); C++-Host ist MSVC-only → Verifikation ausschließlich über GitHub-CI (windows-host.yml, gh run watch). Alle Format-Erweiterungen sind ADDITIV: die 12 bestehenden Registry-Werte/Query-Keys bleiben byte-identisch als Präfix.

Tech Stack: TypeScript/Vite/vitest (web), C++17/Win32/WebView2/CMake (host).

Global Constraints

  • Die ersten 12 Query-Keys bleiben exakt: scene,preset,speed,audio,bloom,trails,scan,crt,matrix,terminal,radar,crosshair — Reihenfolge und on/off-Encoding unverändert.
  • Registry-Pfad HKCU\Software\KuroScreensaver unverändert; neue Werte additiv, PascalCase.
  • Numerische Werte laufen als ROHE validierte Strings durch Registry+Query (nie reformatieren → Byte-Parität trivial). Validierung: Regex ^[0-9]+(\.[0-9]+)?$ + Range-Check; Save-Reject ist all-or-nothing wie bisher.
  • npm run typecheck und npm test müssen nach jedem Web-Task grün sein.
  • C++-Tasks: lokal nur schreiben + committen; Kompilier-/Test-Verifikation gesammelt über CI (Task 12).
  • Conventional Commits mit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>.
  • macOS-Native-Code (native/macos/) wird NICHT angefasst.

Zentrale Kontrakte (Referenz für alle Tasks)

Query-/Registry-Erweiterung (Reihenfolge gepinnt)

Nach crosshair hängen exakt diese Keys in dieser Reihenfolge an (Query-Key → Registry-Name → Typ/Range → Default-String):

# Query Registry Typ Default
13 look Look Slug oder leer (clean\|heavy\|broken\|vapor\|matrix\|leer) `` (leer = Eigene)
14 altitude Altitude low\|mid\|high low
15 fog Fog auto\|clear\|dense auto
16 weather Weather light-fog\|heavy-fog\|storm\|dust\|clear light-fog
17 bank Bank num 0..2 1
18 reactive Reactive bool on
19 autocycle AutoCycle bool on
20 cyclemin CycleMin num 0.5..10 5
21 termlayout TermLayout strip\|window strip
22 boot Boot bool on
23 bootspeed BootSpeed fast\|normal\|cinematic normal
24 daynight DayNight bool on
25 crtintensity CrtIntensity num 0..1 0.35
26 curvature Curvature num 0..0.25 0.012
27 aperture Aperture num 0..0.5 0.22
28 bloomstrength BloomStrength num 0..3 1.4
29 trailsamount TrailsAmount num 0.5..0.95 (= trails.damp) 0.84
30 ntsc Ntsc num 0..1 0
31 halation Halation num 0..0.6 0.15
32 scale Scale num 0.25..1 1
33 perfadapt PerfAdapt bool on

Default-Query-Fixture v0.10 (beidseitig gepinnt):

?scene=random&preset=toxic-haze&speed=norm&audio=off&bloom=on&trails=off&scan=on&crt=on&matrix=off&terminal=on&radar=on&crosshair=on&look=&altitude=low&fog=auto&weather=light-fog&bank=1&reactive=on&autocycle=on&cyclemin=5&termlayout=strip&boot=on&bootspeed=normal&daynight=on&crtintensity=0.35&curvature=0.012&aperture=0.22&bloomstrength=1.4&trailsamount=0.84&ntsc=0&halation=0.15&scale=1&perfadapt=on

Voll-Flip-Fixture v0.10 (Roundtrip-Test, beidseitig):

scene=void&preset=phosphor&speed=fast&audio=on&bloom=off&trails=on&scan=off&crt=off&matrix=on&terminal=off&radar=off&crosshair=off&look=heavy&altitude=high&fog=dense&weather=storm&bank=2&reactive=off&autocycle=off&cyclemin=0.5&termlayout=window&boot=off&bootspeed=cinematic&daynight=off&crtintensity=0.85&curvature=0.022&aperture=0.45&bloomstrength=1.6&trailsamount=0.9&ntsc=0.6&halation=0.4&scale=0.66&perfadapt=off

Weitere Registry-Werte (nicht im Query an screensaver.html)

  • MonitorMode = per (Default) | span — Saver.
  • WallpaperMonitorMode = per (Default) | span — Wallpaper.
  • WallpaperScale = num 0.25..1, Default 0.66 (ersetzt scale in der Wallpaper-URL).
  • Subkey pro Monitor Software\KuroScreensaver\Monitors\<Id>: Mode (on|off|random|scene, Default on), Scene (Slug), Preset (Slug oder leer), WMode (on|off|random|scene, Default: Primär on, sonst off), WScene, WPreset.
  • Monitor-Id: sanitized aus DISPLAYCONFIG_TARGET_DEVICE_NAME.monitorDevicePath (nur [A-Za-z0-9_-], andere Zeichen → _, max 128); Fallback GetMonitorInfoW.szDevice gleich sanitized.

Save-Message v0.10

save:<voller 33-Key-Query ohne '?'> + optional angehängte Monitor-Keys (&m0id=…&m0mode=…&m0scene=…&m0preset=…&m1id=…, Index 0..7 lückenlos; mNscene/mNpreset dürfen leer sein). cancel unverändert. NEU Host→Seite: saveerror (Save abgelehnt, Dialog bleibt offen) via PostWebMessageAsString. Saver-URLs bekommen zusätzlich konstant &kiosk=on (KEIN Registry-Wert). Wallpaper-URLs: &kiosk=on&audio=off erzwungen + scale=<WallpaperScale> statt Scale.

Host→Page Power-Messages (nur Wallpaper)

power:hidden | power:frozen | power:animating:<fps> als WebMessage-String.

Engine-Settings-Erweiterungen (defaults.ts)

renderScale: number;   // 0.25..1, default 1 — multipliziert die devicePixelRatio-Cap
kioskMode: boolean;    // default false — UI-loser Betrieb im nativen Saver-Host
autoCycle: { on: boolean; intervalMin: number };  // Typweitung von 2|5|10 auf number

Neue/geänderte Engine-APIs

// core.ts (Engine)
setAdaptiveScale(f: number): void;   // 1|0.85|0.7|0.55 — re-applied pixelRatio+Größen
frameCap: number | null;             // rAF-Render-Throttle (Wallpaper-Power-State)
// controller.ts
applyPowerState(state: 'hidden' | 'frozen' | 'animating', fps?: number): void;

Task 1: Engine — Settings-Felder + params.ts v0.10 + main.ts-Konsolidierung

Files: - Modify: src/engine/data/defaults.ts (Interface + DEFAULT_SCREENSAVER) - Modify: src/screensaver/params.ts (alle neuen Params + kiosk) - Modify: src/screensaver/main.ts (Reihenfolge/Konsolidierung) - Test: tests/screensaver-params.test.ts

Interfaces: - Produces: settings.renderScale, settings.kioskMode, autoCycle.intervalMin: number; applyParamOverrides versteht die Keys 13–33 + kiosk. - Konsumiert von Task 2 (core/controller lesen renderScale/kioskMode) und Task 5 (Dialog sendet die Keys).

  • [ ] Step 1: Failing Tests schreiben — in tests/screensaver-params.test.ts neue describe-Blöcke:
describe('applyParamOverrides — v0.10 keys', () => {
  it('maps enums with whitelist validation', () => {
    expect(apply('altitude=high').cityAltitude).toBe('high');
    expect(apply('altitude=orbit').cityAltitude).toBe(DEFAULT_SCREENSAVER.cityAltitude);
    expect(apply('fog=dense').fogMode).toBe('dense');
    expect(apply('weather=storm').weather).toBe('storm');
    expect(apply('weather=sharknado').weather).toBe(DEFAULT_SCREENSAVER.weather);
    expect(apply('bootspeed=cinematic').bootSpeed).toBe('cinematic');
    expect(apply('termlayout=window').terminalLayout).toBe('center-window');
    expect(apply('termlayout=strip').terminalLayout).toBe('bottom-strip');
  });
  it('maps bools', () => {
    expect(apply('reactive=off').narrativeReactiveWorld).toBe(false);
    expect(apply('autocycle=off').autoCycle.on).toBe(false);
    expect(apply('boot=off').bootEnabled).toBe(false);
    expect(apply('daynight=off').dayNightCycle.on).toBe(false);
    expect(apply('perfadapt=off').perfAdapt).toBe(false);
  });
  it('maps clamped numerics from raw strings', () => {
    expect(apply('bank=2').bankStrength).toBe(2);
    expect(apply('bank=9').bankStrength).toBe(2);          // clamp
    expect(apply('cyclemin=0.5').autoCycle.intervalMin).toBe(0.5);
    expect(apply('crtintensity=0.85').crtSim.intensity).toBe(0.85);
    expect(apply('curvature=0.022').fx.curvature).toEqual({ on: true, amount: 0.022 });
    expect(apply('curvature=0').fx.curvature.on).toBe(false);
    expect(apply('aperture=0.45').fx.aperture).toEqual({ on: true, strength: 0.45 });
    expect(apply('trailsamount=0.9').fx.trails.damp).toBe(0.9);
    expect(apply('ntsc=0.6').fx.ntsc).toEqual({ on: true, amount: 0.6 });
    expect(apply('halation=0.4').fx.halation).toEqual({ on: true, amount: 0.4 });
    expect(apply('scale=0.66').renderScale).toBe(0.66);
    expect(apply('scale=0.1').renderScale).toBe(0.25);     // clamp low
    expect(apply('bank=abc').bankStrength).toBe(DEFAULT_SCREENSAVER.bankStrength);
  });
  it('bloomstrength disables theme inheritance so the value sticks', () => {
    const s = apply('bloomstrength=1.6');
    expect(s.fx.bloom.strength).toBe(1.6);
    expect(s.fxInheritFromTheme).toBe(false);
  });
  it('ignores look (dialog-only key)', () => {
    expect(apply('look=heavy')).toEqual(DEFAULT_SCREENSAVER);
  });
});

describe('applyParamOverrides — kiosk mode', () => {
  it('kiosk=on hides the control bar and survives applyHudPreset', () => {
    const s = apply('kiosk=on');
    expect(s.kioskMode).toBe(true);
    expect(s.hud.controlBar).toBe(false);
    expect(s.hudPreset).toBe('custom');
    applyHudPreset(s);
    expect(s.hud.controlBar).toBe(false);
  });
  it('defaults stay without the param', () => {
    expect(apply('').kioskMode).toBe(false);
    expect(apply('').hud.controlBar).toBe(true);
  });
});
  • [ ] Step 2: npm test → neue Tests FAILen (unbekannte Properties / unveränderte Defaults).
  • [ ] Step 3: defaults.ts erweitern — im Interface nach seedLock: renderScale: number; und kioskMode: boolean;; autoCycle: { on: boolean; intervalMin: number }; (Literal-Union ersetzen). In DEFAULT_SCREENSAVER: renderScale: 1, kioskMode: false, (autoCycle-Default bleibt { on: false, intervalMin: 5 }).
  • [ ] Step 4: params.ts erweitern — Helfer + Mappings anhängen (vollständig):
function readNum(params: URLSearchParams, key: string, lo: number, hi: number): number | undefined {
  const raw = params.get(key);
  if (raw === null || !/^[0-9]+(\.[0-9]+)?$/.test(raw)) return undefined;
  const v = parseFloat(raw);
  return Math.min(hi, Math.max(lo, v));
}
function readEnum<T extends string>(params: URLSearchParams, key: string, allowed: readonly T[]): T | undefined {
  const v = params.get(key);
  return v !== null && (allowed as readonly string[]).includes(v) ? (v as T) : undefined;
}

Dann in applyParamOverrides (nach dem bestehenden Block, VOR dem hudOverridden-Abschluss bzw. mit eigenem Guard): altitude→cityAltitude, fog→fogMode, weather→weather, bootspeed→bootSpeed (jeweils readEnum); termlayout: strip→bottom-strip, window→center-window; reactive→narrativeReactiveWorld, autocycle→autoCycle.on, boot→bootEnabled, daynight→dayNightCycle.on, perfadapt→perfAdapt (readBool); bank→bankStrength (readNum 0..2), cyclemin→autoCycle.intervalMin (0.5..10), crtintensity→crtSim.intensity (0..1), curvature→fx.curvature = { on: v > 0.001, amount: v } (0..0.25), aperture→fx.aperture = { on: v > 0.001, strength: v } (0..0.5), trailsamount→fx.trails.damp (0.5..0.95), ntsc→fx.ntsc = { on: v > 0.001, amount: v } (0..1), halation→fx.halation = { on: v > 0.001, amount: v } (0..0.6), scale→renderScale (0.25..1); bloomstrength (0..3): fx.bloom.strength = v; settings.fxInheritFromTheme = false; kiosk: readBool → kioskMode = true; hud.controlBar = false; hudOverridden = true; look wird bewusst NICHT gelesen (Dialog-only) — Kommentar an die Stelle.

  • [ ] Step 5: main.ts konsolidieren — (a) perfAdapt: false aus overrides entfernen (Kommentar dazu ersetzen: Steuerung jetzt via ?perfadapt=, Engine-Default on); (b) host.getSettings().autoCycle.on = true; VOR applyParamOverrides(...) ziehen, damit ?autocycle=off gewinnt; (c) die Zeilen 86–91 (ntsc/halation/weather-Direkt-Handling) ERSATZLOS streichen — params.ts deckt sie validiert ab.
  • [ ] Step 6: npm test + npm run typecheck → PASS (alle, inkl. Bestand).
  • [ ] Step 7: Commit feat(engine): v0.10 query params — kiosk, renderScale + full settings bridge

Task 2: Engine — renderScale/adaptScale/frameCap, kiosk in controller, AA off, Scanline-Drift

Files: - Modify: src/engine/engine/core.ts - Modify: src/engine/controller.ts - Modify: src/engine/hud/index.ts

Interfaces: - Consumes: settings.renderScale, settings.kioskMode (Task 1). - Produces: Engine.setAdaptiveScale(f: number), Engine.frameCap: number|null, ScreensaverController.applyPowerState(state, fps?) (von Task 3 verdrahtet).

  • [ ] Step 1: core.ts — (a) Konstruktor: antialias: true → antialias: false; PixelRatio-Zeile ersetzen durch this.applyPixelRatio(); mit:
private adaptScale = 1;
private applyPixelRatio() {
  const base = Math.min(window.devicePixelRatio, 2);
  const scale = Math.min(1, Math.max(0.25, this.settings.renderScale ?? 1));
  this.renderer.setPixelRatio(base * scale * this.adaptScale);
}
setAdaptiveScale(f: number) {
  this.adaptScale = f;
  this.applyPixelRatio();
  const W = window.innerWidth, H = window.innerHeight;
  this.renderer.setSize(W, H);
  this.composer.setSize(W, H);
}
frameCap: number | null = null;
private lastFrameT = 0;

(b) onResize: nach setSize zusätzlich this.applyPixelRatio();. (c) rAF-Loop (start()-Tick): am Anfang if (this.frameCap && now - this.lastFrameT < 1000 / this.frameCap - 1) { schedule next; return; } this.lastFrameT = now; (an die vorhandene Loop-Struktur anpassen — reines Render-Skip, Zeitbasis läuft weiter). - [ ] Step 2: controller.ts — (a) perfAdapt-Wächter (Z.366–380) erweitern: Stufenzähler perfTier 0..3; wenn nach Bloom/Trails-Drossel weiterhin 3× <40 fps → perfTier++ und engine.setAdaptiveScale([1, 0.85, 0.7, 0.55][perfTier]); Erholung: 13 Intervalle (~20 s) in Folge >55 fps → perfTier-- + re-apply. (b) kiosk in open(): bei s.kioskMode den mousemove-Cursor-Reveal überspringen (this.overlay.style.cursor bleibt none, kein showControlBar-Aufruf), initiales showControlBar (Z.232–235) und requestFullscreen (Z.243–245) überspringen. Parallax bleibt aktiv (harmlos ohne Maus). - [ ] Step 3: hud/index.ts Scanline-Drift — backgroundPositionY-Mutation (Z.731–733) ersetzen: beim Build des scanlines-Elements eine CSS-Keyframes-Animation injizieren (@keyframes ks-drift { from { background-position-y: 0 } to { background-position-y: 4px } }, Dauer aus 4 / (speed * 0.6 * 60) s, animation: ks-drift linear infinite), Loop-Körper behält nur den seltenen Sync-Tear (transform, 0.2 %). stopLoop/off-Pfad: Animation via animationPlayState pausieren, wenn scanlineDrift.on false ist. - [ ] Step 4: npm run typecheck + npm test → PASS. Visuelle Stichprobe: npm run dev, http://localhost:5173/screensaver.html?kiosk=on&scale=0.5 — keine Control-Bar (auch nicht initial), Szene läuft. - [ ] Step 5: Commit feat(engine): renderScale + adaptive downscale tier, kiosk-mode open path, AA off, compositor-friendly scanline drift

Task 3: Engine — Power-Bridge (Host→Page)

Files: - Modify: src/screensaver/main.ts - Modify: src/engine/controller.ts (applyPowerState)

Interfaces: - Consumes: Engine.frameCap, engine.stop()/start() (Task 2). - Produces: WebMessage-Verhalten power:hidden|frozen|animating:<fps> (Host Task 11 sendet).

  • [ ] Step 1: controller.applyPowerState —
applyPowerState(state: 'hidden' | 'frozen' | 'animating', fps?: number): void {
  if (!this.engine) return;
  if (state === 'hidden' || state === 'frozen') { this.engine.frameCap = null; this.engine.stop(); this.hud?.stopLoop(); }
  else { this.engine.frameCap = fps && fps > 0 ? fps : null; this.engine.start(); this.hud?.startLoop?.(); }
}

(stop()/start() idempotent verwenden — vorhandene Engine-API prüfen und ggf. Guard if (this.rafId) ergänzen. HUD-Loop-Neustart: exakten Methodennamen aus hud/index.ts übernehmen.) - [ ] Step 2: main.ts Listener — nach Controller-Erzeugung:

(window as any).chrome?.webview?.addEventListener?.('message', (ev: { data: unknown }) => {
  const m = typeof ev.data === 'string' ? ev.data : '';
  if (!m.startsWith('power:')) return;
  const [, state, fpsRaw] = m.split(':');
  if (state === 'hidden' || state === 'frozen' || state === 'animating') {
    controller.applyPowerState(state, fpsRaw ? parseInt(fpsRaw, 10) : undefined);
  }
});
  • [ ] Step 3: npm run typecheck + npm test → PASS.
  • [ ] Step 4: Commit feat(engine): host→page power bridge (pause/fps-cap) for the wallpaper mode

Task 4: Web — form-state v0.10 (Voll-Format + Monitore) + Fixtures

Files: - Modify: src/settings/form-state.ts - Test: tests/settings-form.test.ts

Interfaces: - Produces: SettingsFormState (33 Felder; Numerik als string), MonitorEntry { id, name, w, h, portrait, primary }, MonitorFormState { id, mode, scene, preset }, buildSaveMessage(s, monitors?), readInitial(params), readMonitors(params): MonitorEntry[] (aus monitors=-JSON), FORM_DEFAULTS. - Kontrakt: Default- und Flip-Fixture aus dem Kontrakt-Block oben, save:-Präfix; Monitor-Anhang &m0id=<id>&m0mode=<mode>&m0scene=<slug|leer>&m0preset=<slug|leer> je Eintrag.

  • [ ] Step 1: Failing Tests — die zwei bestehenden Fixture-Tests auf die v0.10-Strings aus dem Kontrakt-Block heben (Default-Serialisierung, Roundtrip mit Flip-String) + neu:
it('appends monitor entries in index order', () => {
  const mons = [
    { id: 'DELL_ABC1', mode: 'scene', scene: 'matrix', preset: 'phosphor' },
    { id: 'LAPTOP_0', mode: 'off', scene: '', preset: '' },
  ];
  expect(buildSaveMessage(FORM_DEFAULTS, mons)).toContain(
    '&m0id=DELL_ABC1&m0mode=scene&m0scene=matrix&m0preset=phosphor&m1id=LAPTOP_0&m1mode=off&m1scene=&m1preset=',
  );
});
it('readMonitors parses the host monitor list', () => {
  const json = encodeURIComponent(JSON.stringify([{ id: 'A', name: 'Dell U2720Q', w: 3840, h: 2160, portrait: false, primary: true }]));
  expect(readMonitors(new URLSearchParams('?monitors=' + json))[0].name).toBe('Dell U2720Q');
  expect(readMonitors(new URLSearchParams(''))).toEqual([]);
});
  • [ ] Step 2: npm test → FAIL.
  • [ ] Step 3: Implementieren — SettingsFormState um die 21 Felder erweitern (look: string; altitude: string; fog: string; weather: string; bank: string; reactive: boolean; autocycle: boolean; cyclemin: string; termlayout: string; boot: boolean; bootspeed: string; daynight: boolean; crtintensity: string; curvature: string; aperture: string; bloomstrength: string; trailsamount: string; ntsc: string; halation: string; scale: string; perfadapt: boolean; — Numerik als string!); FORM_DEFAULTS exakt mit den Default-Strings der Kontrakt-Tabelle; readInitial liest Strings roh (params.get(key) ?? default) und Bools wie bisher; buildSaveMessage(s, monitors: MonitorFormState[] = []) serialisiert in Kontrakt-Reihenfolge + Monitor-Anhang; readMonitors mit try/catch-JSON.parse.
  • [ ] Step 4: npm test + npm run typecheck → PASS.
  • [ ] Step 5: Commit feat(settings): v0.10 form state — full option set + per-monitor entries (pinned fixtures)

Task 5: Web — Settings-Dialog-Neubau (Sektionen, Looks, Slider, Monitore, saveerror)

Files: - Modify: src/settings/main.ts (Neubau auf Basis von form-state v0.10) - Modify: settings.html (CSS: 2-Spalten-Grid, Sektionen, Slider, Karten)

Interfaces: - Consumes: SettingsFormState/buildSaveMessage/readInitial/readMonitors (Task 4), LOOKS/applyLook-Wertetabellen (src/engine/data/looks.ts), SCENES, PRESETS. - Produces: UI-Verhalten, das Host-Task 10 konsumiert (monitors=-Param rein, save:/cancel raus, saveerror rein).

  • [ ] Step 1: UI bauen — Struktur (alles dynamisch in main.ts, Muster wie Bestand): Look-Zeile (Select ['','clean','heavy','broken','vapor','matrix'] mit Label „Eigene" für leer; onChange schreibt die Look-Werte aus LOOKS[key] in die Slider-Felder: crtintensity=String(L.intensity), curvature, aperture, ntsc, halation, bloomstrength=String(L.bloom), trailsamount=String(0.5 + L.trails * 0.45), preset=L.preset, matrix=L.matrix); jede manuelle Slider-Änderung setzt state.look = ''. Sektionen (h2 + Grid): Bild (Scene/Color/Speed-Selects wie bisher + Altitude low|mid|high, Fog auto|clear|dense, Weather 5er), CRT-Effekte (7 input[type=range] mit Live-Wert-Anzeige: crtintensity 0–1 step 0.01, curvature 0–0.25 step 0.001, aperture 0–0.5 step 0.01, bloomstrength 0–3 step 0.1, trailsamount 0.5–0.95 step 0.01, ntsc 0–1 step 0.01, halation 0–0.6 step 0.01 + Toggles scan/crt), Bewegung & Story (bank-Slider 0–2 step 0.1, reactive, autocycle + cyclemin 0.5–10 step 0.5, terminal-Toggle), Terminal (termlayout-Select strip/window — nur aktiv wenn terminal an), Anzeige & Automatik (radar, crosshair, matrix, boot + bootspeed, daynight, audio), Monitore (Radio MonitorMode per/span — NUR wenn readMonitors ≥2 Einträge liefert; Karte je Monitor: name — w×h, Badges „Hauptmonitor"/„Hochformat", Selects mode on|off|random|scene, scene (nur bei mode=scene), preset (leer=global)), Leistung (scale-Slider 0.25–1 step 0.01 mit %-Anzeige, perfadapt-Toggle). Hinweiszeile unter Monitore bei span: „Empfehlung: Render-Scale ≤ 66 % bei 3 Monitoren". Slider-Zahlwerte: Anzeige roh (String(v)), state speichert den String des Inputs.
  • [ ] Step 2: MonitorMode-Transport — MonitorMode kommt als eigener Query-Param monitormode=per|span vom Host (Task 10) und geht als &monitormode=<v> im Save-String zurück (Position: direkt nach perfadapt, VOR den mN-Keys; in Task 4-Fixtures ist er absichtlich nicht enthalten — buildSaveMessage bekommt ihn als optionales drittes Argument monitorMode?: string, nur angehängt wenn nicht-leer; Test ergänzen: buildSaveMessage(FORM_DEFAULTS, [], 'span') endet auf &monitormode=span).
  • [ ] Step 3: saveerror-Empfang — Listener chrome.webview.addEventListener('message', …): bei saveerror rote Statuszeile „Speichern abgelehnt — Werte prüfen" einblenden (Dialog bleibt offen; Host schließt bei Erfolg).
  • [ ] Step 4: settings.html-CSS — Grid minmax(0,1fr) minmax(0,1fr), Sektions-Rahmen im CRT-Look (Bestand fortschreiben), input[type=range]-Styling, .mon-card, .badge, .error-Zeile. Fenster-Inhalt für ~720×640 ausgelegt, aber flüssig (Overflow-y scroll als Fallback).
  • [ ] Step 5: Verifikation — npm run typecheck + npm test; npm run dev + Playwright-Screenshot:
node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 720, height: 640 } }); await p.goto('http://localhost:5173/settings.html?monitors=' + encodeURIComponent(JSON.stringify([{id:'A',name:'Laptop',w:1920,h:1200,portrait:false,primary:true},{id:'B',name:'Dell',w:2160,h:3840,portrait:true,primary:false}]))); await p.screenshot({ path: '/tmp/settings-v010.png', fullPage: true }); await b.close(); })()"

Screenshot ansehen: alle Sektionen da, Monitor-Karten mit Badges, keine Layout-Brüche. - [ ] Step 6: Commit feat(settings): full macOS-parity dialog — looks, CRT sliders, monitors, performance

Task 6: C++ — options v0.10 (Struct, Query, Registry, Parse + Monitor-Keys)

Files: - Modify: native/windows/host/src/options.h - Modify: native/windows/host/src/options.cpp - Test: native/windows/host/tests/options_test.cpp

Interfaces: - Produces (options.h):

struct SaverOptions {
    // …bestehende 12 Felder unverändert…
    std::wstring look;                       // leer = Eigene
    std::wstring altitude = L"low";
    std::wstring fog = L"auto";
    std::wstring weather = L"light-fog";
    std::wstring bank = L"1";                // Numerik als roher validierter String
    bool reactive = true;
    bool autocycle = true;
    std::wstring cyclemin = L"5";
    std::wstring termlayout = L"strip";
    bool boot = true;
    std::wstring bootspeed = L"normal";
    bool daynight = true;
    std::wstring crtintensity = L"0.35";
    std::wstring curvature = L"0.012";
    std::wstring aperture = L"0.22";
    std::wstring bloomstrength = L"1.4";
    std::wstring trailsamount = L"0.84";
    std::wstring ntsc = L"0";
    std::wstring halation = L"0.15";
    std::wstring scale = L"1";
    bool perfadapt = true;
};
struct MonitorSave { std::wstring id, mode, scene, preset; };
// msg OHNE "save:"; monitors nullable. All-or-nothing wie bisher.
bool ParseSaveMessage(const std::wstring& msg, SaverOptions& out,
                      std::vector<MonitorSave>* monitors = nullptr,
                      std::wstring* monitorMode = nullptr);
bool IsValidNumber(const std::wstring& v, double lo, double hi);  // ^[0-9]+(\.[0-9]+)?$ + Range
  • [ ] Step 1: Failing Tests — options_test.cpp: Default-Fixture und Flip-Roundtrip-Fixture auf die v0.10-Strings aus dem Kontrakt-Block umstellen; neue Fälle:
// Numerik-Validierung
SaverOptions n;
ExpectTrue(!ParseSaveMessage(L"crtintensity=1.5", n), "reject out-of-range");
ExpectTrue(!ParseSaveMessage(L"bank=1,5", n), "reject locale comma");
ExpectTrue(ParseSaveMessage(L"bank=1.25", n) && n.bank == L"1.25", "raw numeric passthrough");
// look darf leer sein
ExpectTrue(ParseSaveMessage(L"look=", n), "empty look ok");
ExpectTrue(!ParseSaveMessage(L"look=Heavy!", n), "reject bad look");
// Monitor-Keys
SaverOptions m; std::vector<MonitorSave> mons; std::wstring mode;
ExpectTrue(ParseSaveMessage(L"scene=city&m0id=DELL_ABC1&m0mode=scene&m0scene=matrix&m0preset=phosphor&m1id=LAPTOP_0&m1mode=off&m1scene=&m1preset=&monitormode=span", m, &mons, &mode), "parse monitors");
ExpectTrue(mons.size() == 2 && mons[0].scene == L"matrix" && mons[1].mode == L"off", "monitor entries");
ExpectTrue(mode == L"span", "monitor mode");
ExpectTrue(!ParseSaveMessage(L"m0id=A&m0mode=warp", m, &mons, &mode), "reject bad monitor mode");
// Registry-Roundtrip v0.10 (Test-Key): Flip-Werte schreiben, lesen, Query vergleichen.
  • [ ] Step 2: Implementieren — BuildQueryString hängt die 21 Keys in Kontrakt-Reihenfolge an (look mit EscapeDataString, Numerik roh); LoadOptions/SaveOptions um die 21 Registry-Werte (Kontrakt-Namen) erweitern; IsValidNumber (manueller Scan, kein swscanf — Locale!); ParseSaveMessage: neue Keys mit exakten Checks (Enums per Whitelist, Numerik per IsValidNumber mit Kontrakt-Ranges, look leer-oder-IsSlug), mN*-Keys (Regex-frei: Präfix m, Ziffer 0–7, dann id|mode|scene|preset) in eine lokale Map, am Ende lückenlos 0..k prüfen und in monitors schieben (id: IsSlug-ähnlich, zusätzlich A-Z_ erlaubt — eigene IsMonitorId; mode-Whitelist on|off|random|scene; scene/preset leer-oder-IsSlug); monitormode-Key (Whitelist per|span). Unbekannte Keys → weiterhin false.
  • [ ] Step 3: Kompiliert nicht lokal — Sichtprüfung + Commit; CI verifiziert in Task 12.
  • [ ] Step 4: Commit feat(windows): options v0.10 — additive query/registry format, numeric passthrough, monitor saves

Task 7: C++ — Monitor-Enumeration + Monitor-Registry-IO

Files: - Create: native/windows/host/src/monitors.h - Create: native/windows/host/src/monitors.cpp - Modify: native/windows/host/CMakeLists.txt (host_lib + dxgi-frei; nur user32 — QueryDisplayConfig lebt in user32) - Test: native/windows/host/tests/options_test.cpp (nur SanitizeMonitorId — API-freie Pure-Funktion)

Interfaces: - Produces:

struct MonitorInfo {
    std::wstring id;        // stabil (EDID-Pfad, sanitized) — Registry-Subkey-Name
    std::wstring name;      // friendly name, ggf. leer
    RECT rect;              // physische Pixel (per-monitor-aware)
    bool primary;
    bool portrait;          // rect höher als breit
};
std::vector<MonitorInfo> EnumMonitors();
std::wstring SanitizeMonitorId(const std::wstring& raw);  // [A-Za-z0-9_-], sonst '_', max 128, leer→L"UNKNOWN"
// Registry-IO für Monitors\<Id> (regPath-Override für Tests):
struct MonitorConfig { std::wstring mode = L"on", scene, preset, wmode = L"off", wscene, wpreset; };
MonitorConfig LoadMonitorConfig(const std::wstring& id, const wchar_t* regPath = kRegPath);
void SaveMonitorConfig(const std::wstring& id, const MonitorConfig& c, const wchar_t* regPath = kRegPath);
std::wstring LoadMonitorMode(bool wallpaper, const wchar_t* regPath = kRegPath);   // "per"|"span"
void SaveMonitorMode(bool wallpaper, const std::wstring& mode, const wchar_t* regPath = kRegPath);
  • [ ] Step 1: EnumMonitors: EnumDisplayMonitors (HMONITOR + Rect) → je Monitor GetMonitorInfoW (MONITORINFOEXW, primary-Flag, szDevice); Pfad-Auflösung: GetDisplayConfigBufferSizes/QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS) + DisplayConfigGetDeviceInfo mit DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME (Match auf szDevice) und DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME (monitorDevicePath + monitorFriendlyDeviceName). Id = SanitizeMonitorId(monitorDevicePath), Fallback SanitizeMonitorId(szDevice); Kollisions-Suffix _2, _3… portrait = (h > w).
  • [ ] Step 2: Registry-IO: Subkey <regPath>\Monitors\<id>; fehlender Subkey → Defaults (wmode: primär on / sonst off entscheidet der AUFRUFER, LoadMonitorConfig liefert neutral wmode=L"off" und der Wallpaper-Pfad wendet den Primär-Default an, wenn der Subkey fehlt — dafür zusätzlich bool MonitorConfigExists(id, regPath)).
  • [ ] Step 3: SanitizeMonitorId-Tests in options_test.cpp (Umlaute/\\?\DISPLAY#...-Pfade → _-ersetzt, Kappung, leer).
  • [ ] Step 4: Commit feat(windows): stable EDID-based monitor identity + per-monitor registry config

Task 8: C++ — webview_host: LOCALAPPDATA-Profil + WebView-Rückgabe + PostWebMessage

Files: - Modify: native/windows/host/src/webview_host.h - Modify: native/windows/host/src/webview_host.cpp

Interfaces: - Produces: onCreated-Signatur wird std::function<void(ICoreWebView2Controller*, ICoreWebView2*)> (Aufrufer: saver_window, settings_window, preview unberührt, wallpaper neu). UserDataDir: %LOCALAPPDATA%\KuroScreensaver\WebView2, Fallback %TEMP%\KuroScreensaverWV2.

  • [ ] Step 1: UserDataDir() auf GetEnvironmentVariableW(L"LOCALAPPDATA") + L"\\KuroScreensaver\\WebView2" umstellen (SHCreateDirectoryExW für die Kette; bei leerer Env-Var alter TEMP-Pfad). onCreated(controller, webview.Get()) durchreichen; alle bestehenden Aufrufer anpassen (Lambda-Signatur + ungenutzten Parameter benennen).
  • [ ] Step 2: Commit feat(windows): persistent WebView2 profile under LOCALAPPDATA, expose ICoreWebView2 to hosts

Task 9: C++ — render_policy (pure) + Tests

Files: - Create: native/windows/host/src/render_policy.h - Create: native/windows/host/src/render_policy.cpp - Modify: native/windows/host/CMakeLists.txt - Test: native/windows/host/tests/options_test.cpp (gleiches Test-Target)

Interfaces: - Produces (Port von native/macos/KuroNativeSaver/Core/RenderPolicy.swift, ohne Thermal):

enum class RenderState { Hidden, Frozen, Animating };
struct PolicyInputs {
    bool occluded = false;        // Monitor von Vollbild-/maximiertem Fenster verdeckt
    bool sessionLocked = false;
    bool displayOff = false;
    bool fullscreenApp = false;   // SHQueryUserNotificationState busy/D3D/presentation
    bool onBattery = false;
    bool powerSaver = false;      // Windows-Stromsparmodus
    bool animateOnBattery = false;
    int acFps = 30;
    int batteryFps = 10;
};
struct PolicyDecision { RenderState state; int fps; };
PolicyDecision DecideRenderPolicy(const PolicyInputs& in);

Prioritäten (wie macOS): Unsichtbarkeit (occluded|sessionLocked|displayOff) → Hidden(0); fullscreenApp → Frozen(0); (onBattery && !animateOnBattery) oder powerSaver → Frozen(0); sonst Animating(onBattery ? batteryFps : acFps).

  • [ ] Step 1: Tests zuerst (options_test.cpp, Tabellen-Fälle):
ExpectTrue(DecideRenderPolicy({}).state == RenderState::Animating, "default animates");
ExpectTrue(DecideRenderPolicy({}).fps == 30, "ac fps");
{ PolicyInputs i; i.occluded = true; i.fullscreenApp = true;
  ExpectTrue(DecideRenderPolicy(i).state == RenderState::Hidden, "invisibility beats fullscreen"); }
{ PolicyInputs i; i.sessionLocked = true; ExpectTrue(DecideRenderPolicy(i).state == RenderState::Hidden, "locked hides"); }
{ PolicyInputs i; i.fullscreenApp = true; ExpectTrue(DecideRenderPolicy(i).state == RenderState::Frozen, "fullscreen freezes"); }
{ PolicyInputs i; i.onBattery = true; ExpectTrue(DecideRenderPolicy(i).state == RenderState::Frozen, "battery w/o opt-in freezes"); }
{ PolicyInputs i; i.onBattery = true; i.animateOnBattery = true;
  auto d = DecideRenderPolicy(i); ExpectTrue(d.state == RenderState::Animating && d.fps == 10, "battery opt-in 10fps"); }
{ PolicyInputs i; i.powerSaver = true; ExpectTrue(DecideRenderPolicy(i).state == RenderState::Frozen, "power saver freezes"); }
  • [ ] Step 2: Implementation (~25 Zeilen, keine Win32-Includes im Header).
  • [ ] Step 3: Commit feat(windows): pure render-policy decision table (macOS RenderPolicy port, no thermal signal)

Task 10: C++ — settings_window v0.10 (DPI, monitors-Param, saveerror) + saver_window v0.10 (span/per/off/kiosk)

Files: - Modify: native/windows/host/src/settings_window.cpp - Modify: native/windows/host/src/saver_window.cpp

Interfaces: - Consumes: EnumMonitors/LoadMonitorConfig/MonitorConfigExists/LoadMonitorMode/SaveMonitorMode/SaveMonitorConfig (Task 7), ParseSaveMessage(msg, out, &mons, &mode) (Task 6), neue CreateWebView-Signatur (Task 8). - Produces: Saver-URL-Bau BuildSaverPage(const SaverOptions&, const MonitorConfig*) — Basis-Query + bei Monitor-Override scene/preset ersetzt + &kiosk=on.

  • [ ] Step 1: settings_window — (a) Fenstergröße: nach CreateWindowExW UINT dpi = GetDpiForWindow(hwnd); → Client 720×640 skaliert MulDiv(720, dpi, 96) etc., SetWindowPos re-zentriert auf Work-Area; (b) URL: settings.html + BuildQueryString(LoadOptions()) + L"&monitormode=" + LoadMonitorMode(false) + L"&monitors=" + EscapeDataString(MonitorListJson()) (kleines JSON per Hand: [{"id":"…","name":"…","w":1920,"h":1200,"portrait":false,"primary":true},…] — Name mit Escape für "/\); (c) WebMessage-Callback: save: → Parse mit &mons,&mode; Erfolg → SaveOptions + SaveMonitorMode(false, mode) falls nicht-leer + je MonitorSave in MonitorConfig{mode,scene,preset} (WMode/WScene/WPreset des bestehenden Configs erhalten!) SaveMonitorConfig + DestroyWindow; Fehler → webview->PostWebMessageAsString(L"saveerror"), Fenster bleibt offen (webview-Pointer aus dem neuen onCreated speichern, AddRef/Release symmetrisch zum Controller).
  • [ ] Step 2: saver_window — RunSaver():
SaverOptions opts = LoadOptions();
std::vector<MonitorInfo> mons = EnumMonitors();
if (mons.empty()) return 0;
bool span = LoadMonitorMode(false) == L"span";
ShowCursor(FALSE);
if (span) {
    RECT vs{ GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN), 0, 0 };
    vs.right = vs.left + GetSystemMetrics(SM_CXVIRTUALSCREEN);
    vs.bottom = vs.top + GetSystemMetrics(SM_CYVIRTUALSCREEN);
    CreateSaverWindowWithWebView(vs, BuildSaverPage(opts, nullptr));   // ein Fenster, ein Timer
} else {
    for (const MonitorInfo& m : mons) {
        MonitorConfig c = LoadMonitorConfig(m.id);
        if (c.mode == L"off") { CreateBlackCoverWindow(m.rect); continue; }  // Watchdog ja, WebView nein
        SaverOptions per = opts;
        if (c.mode == L"random") per.scene = L"random";
        else if (c.mode == L"scene" && !c.scene.empty()) per.scene = c.scene;
        if (!c.preset.empty()) per.preset = c.preset;
        CreateSaverWindowWithWebView(m.rect, BuildSaverPage(per, nullptr));
    }
}

BuildSaverPage(o) = L"screensaver.html" + BuildQueryString(o) + L"&kiosk=on". CreateBlackCoverWindow: gleiche Fensterklasse + SaverState + Timer, nur ohne CreateWebView (schwarzer Brush deckt). Bestehende Watchdog-/Exit-Logik unverändert; onCreated-Lambda auf die 2-Parameter-Signatur heben. - [ ] Step 3: Commit feat(windows): span/per-monitor saver modes, black cover for off-monitors, kiosk URL, DPI-scaled settings dialog with monitor cards

Task 11: C++ — Wallpaper /w + Tray + PowerSignals + TrySuspend

Files: - Create: native/windows/host/src/wallpaper_window.h (int RunWallpaper();) - Create: native/windows/host/src/wallpaper_window.cpp - Create: native/windows/host/src/tray.h / tray.cpp - Modify: native/windows/host/src/main.cpp (Dispatch /w) - Modify: native/windows/host/CMakeLists.txt (neue Quellen; wtsapi32 linken)

Interfaces: - Consumes: DecideRenderPolicy (Task 9), EnumMonitors/Monitor-Configs (Task 7), CreateWebView (Task 8), BuildQueryString (Task 6). - Produces: Tray-Menü-IDs IDM_TRAY_TOGGLE=1, IDM_TRAY_SETTINGS=2, IDM_TRAY_AUTOSTART=3, IDM_TRAY_EXIT=4; Run-Key HKCU\Software\Microsoft\Windows\CurrentVersion\Run\KuroWallpaper.

  • [ ] Step 1: WorkerW-Attach —
HWND FindWallpaperHost() {
    HWND progman = FindWindowW(L"Progman", nullptr);
    SendMessageTimeoutW(progman, 0x052C, 0xD, 0x1, SMTO_NORMAL, 1000, nullptr);
    // Strategie A (bis 23H2): WorkerW-Sibling hinter dem SHELLDLL_DefView suchen
    HWND workerw = nullptr;
    EnumWindows([](HWND top, LPARAM lp) -> BOOL {
        if (FindWindowExW(top, nullptr, L"SHELLDLL_DefView", nullptr))
            *reinterpret_cast<HWND*>(lp) = FindWindowExW(nullptr, top, L"WorkerW", nullptr);
        return TRUE;
    }, reinterpret_cast<LPARAM>(&workerw));
    if (workerw) return workerw;
    // Strategie B (24H2): WorkerW liegt direkt unter Progman
    HWND child = FindWindowExW(progman, nullptr, L"WorkerW", nullptr);
    return child ? child : progman;
}

Wallpaper-Fenster: WS_CHILD | WS_VISIBLE unter dem Host-HWND, Koordinaten relativ (MapWindowPoints vom virtuellen Screen); per/span analog Task 10 mit WMode-Werten (fehlender Subkey: primär on, sonst off — via MonitorConfigExists); URL screensaver.html + Query mit scale=<WallpaperScale> überschrieben + &kiosk=on&audio=off (audio-Feld vor BuildQueryString auf false zwingen). - [ ] Step 2: PowerSignals — Message-Only-Window (HWND_MESSAGE): WTSRegisterSessionNotification (WM_WTSSESSION_CHANGE → lock/unlock), RegisterPowerSettingNotification(GUID_CONSOLE_DISPLAY_STATE) (PBT_POWERSETTINGCHANGE → displayOff), GetSystemPowerStatus + SHQueryUserNotificationState + Occlusion (Foreground-Fenster-Rect deckt Monitor-Rect ≥95 %) im 2-s-Poll-Timer. Zustandsänderung → DecideRenderPolicy je Fenster → WebMessage power:hidden|frozen|animating:<fps> an die Page; bei Hidden zusätzlich ICoreWebView2_3::TrySuspend (Callback ignoriert Fehler), bei Verlassen Resume(). - [ ] Step 3: Tray — Shell_NotifyIconW(NIM_ADD) mit App-Icon, WM_APP+1-Callback; Kontextmenü (CreatePopupMenu): Wallpaper anhalten/fortsetzen (Fenster verstecken + TrySuspend vs. zurück), Einstellungen… (RunSettings() blockiert — stattdessen Settings-Fenster nicht-modal im selben Thread öffnen: OpenSettingsWindow()-Refactor: RunSettings behält seine Message-Loop für /c, Tray-Pfad erzeugt nur das Fenster), Autostart-Häkchen (Run-Key lesen/schreiben: "<exe-Pfad>" /w), Beenden (PostQuitMessage). Icon-Tooltip „Kuro Wallpaper". - [ ] Step 4: main.cpp — Dispatch: else if (flag == L"/w") { rc = RunWallpaper(); } vor dem Saver-Fallback. - [ ] Step 5: Commit feat(windows): /w animated wallpaper — WorkerW attach (both strategies), tray, power policy enforcement with TrySuspend

Task 12: CI-Loop — kompilieren, Tests, Paket

Files: keine neuen (Fix-Commits nach Bedarf)

  • [ ] Step 1: git push origin main && git push github main (bzw. der eingerichtete GitHub-Push-Weg), dann gh run watch auf windows-host.yml im GitHub-Mirror-Repo (johannes-kaindl/kuro-screensaver).
  • [ ] Step 2: Compile-/Testfehler lokal fixen, erneut pushen — iterieren bis grün (options_test inkl. render_policy-Fälle läuft im CI-Job).
  • [ ] Step 3: Paket-Artefakt herunterladen (gh run download), Zip-Inhalt prüfen: KuroScreensaver.scr + web/ mit settings.html/screensaver.html-Assets, Größe grob <6 MB.
  • [ ] Step 4: Commit (nur falls Fixes) — konventionell je Fix.

Task 13: bump-version.sh → app.rc + Doku + TaskNote

Files: - Modify: scripts/bump-version.sh - Modify: README.md (+ die 3 Linter-Pflichtsektionen Features/Usage/No build step) - Modify: docs/WINDOWS-INSTALL.md (Wallpaper-Kapitel, Monitor-Modi, Performance-Tipps, neue Settings) - Create (Vault): …/25_Coding/kuro-screensaver/_Tasks/v0.10.0 on-device-Abnahme Windows.md

  • [ ] Step 1: bump-version.sh — dritte Zieldatei native/windows/host/app.rc: FILEVERSION/PRODUCTVERSION → X, Y, Z, 0, String-Values FileVersion → X.Y.Z.0, ProductVersion → X.Y.Z (sed auf Temp-Kopie, gleiche Preflight-/Validierungs-/Atomic-Move-Systematik wie die zwei bestehenden Ziele).
  • [ ] Step 2: README — Windows-Abschnitt aktualisieren (Settings-Umfang, Multi-Monitor, Wallpaper); Pflichtsektionen ## Features, ## Usage, ## No build step ergänzen/umbenennen, danach python3 ../_docs/readme/readme_lint.py kuro-screensaver (vom Repo-Root; _docs = Schwester-Repo) → 0 Fehler.
  • [ ] Step 3: WINDOWS-INSTALL.md — neue Kapitel: „Animierter Hintergrund (/w)", „Mehrere Monitore", „Leistung" (Render-Scale, adaptive Qualität, Hybrid-GPU-Hinweis auf Windows-Grafikeinstellungen für msedgewebview2).
  • [ ] Step 4: TaskNote im Vault anlegen (Frontmatter-Muster der v0.9-Note): Checkliste je Block — Kiosk (keine Bar, 30 s beobachten), Settings-Roundtrip inkl. Slider + HiDPI 150 %, Multi-Monitor per/span/off/random + Hochformat, Wallpaper an/aus/Autostart/Lock/Batterie/Vollbild-App, Upgrade v0.9→v0.10 (Registry überlebt), Win10-MessageBox, SmartScreen.
  • [ ] Step 5: Commit docs+build: v0.10 docs, README lint sections, app.rc in bump-version

Task 14: Release-Vorbereitung (WARTET auf User-Go)

  • [ ] bash scripts/bump-version.sh 0.10.0 → Commit chore(release): v0.10.0.
  • [ ] NACH explizitem Go von Johannes: git tag v0.10.0 && git push origin main v0.10.0 (+ GitHub-Push für CI), Release-Assets von /releases/latest per API gegenprüfen (LESSON „README ist das Schaufenster"). macOS-dmg nur bei Bedarf neu anhängen (macOS unverändert → das v0.9.1-dmg bleibt gültig? NEIN — jedes Release braucht seine eigenen Assets: dmg neu bauen via scripts/package-native-app.sh, da die Engine-Web-Änderungen die Metal-App zwar nicht betreffen, das Release aber vollständig sein muss).

Self-Review (durchgeführt)

  • Spec-Coverage: §1→Task 1/2/10; §2→Task 1/4/5/6/10; §3→Task 1/2/8 (+GPU-Recherche in Task 12 falls CI-Zeit übrig — bewusst optional); §4→Task 6/7/10; §5→Task 3/9/11; §6→Task 6 (Fixtures), 12 (CI), 13 (Doku/bump), 14 (Release).
  • Konsistenz: ParseSaveMessage-Signatur (Task 6) == Verwendung (Task 10); CreateWebView-Signatur (Task 8) == Aufrufer (Task 10/11); Fixture-Strings identisch in Task 4 und 6; monitormode bewusst NICHT in den 33er-Fixtures (separater Test in Task 4/6).
  • Platzhalter: keine.