Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3c7d945
docs(color): colour filters spec + plan (#288)
Maxaubert Sep 29, 2026
0f8f7f6
feat(color): pure colour matrices + spike findings (#288)
Maxaubert Sep 29, 2026
6a540ed
feat(config): colour filter keys (#288)
Maxaubert Sep 29, 2026
7d11c4e
feat(color): colour filter controller, RunTick wiring, render shader …
Maxaubert Sep 29, 2026
d53134f
feat(ui): Colour settings section; version 0.12.0 (#288)
Maxaubert Sep 29, 2026
8b73e57
fix(color): Night-light warm curve (6500-1200 K), brightness down to …
Maxaubert Sep 29, 2026
83d916e
feat(color): warmth and brightness sliders only, always applied (#288)
Maxaubert Sep 29, 2026
569ab62
fix(color): brightness down to 1% (#288)
Maxaubert Sep 29, 2026
cf286b4
fix(color): Night-light warmth (CIE blackbody, 6500-1200 K linear in …
Maxaubert Sep 29, 2026
fc4340e
feat(settings): recover from a WebView2 crash and keep unsaved edits
Maxaubert Sep 29, 2026
6134b65
fix(settings): a fresh page can no longer overwrite the draft it is a…
Maxaubert Sep 29, 2026
be13d3b
docs(color): HDR linear-light finding, Night light scale, RTSS crash …
Maxaubert Sep 29, 2026
daa9508
fix(color): brightness floor back to 1% (0 was completely black) (#288)
Maxaubert Sep 29, 2026
01db99a
feat(color): tint the system pointers at 1x while warmth/brightness i…
Maxaubert Sep 29, 2026
99ccb09
fix(color): idle restores reload the real pointer scheme; only zoom-i…
Maxaubert Sep 29, 2026
b7276fb
docs(color): the tinted pointer, verified (#288)
Maxaubert Sep 29, 2026
9ca59c1
docs(color): how f.lux, Iris and Night light apply their tint (research)
Maxaubert Sep 29, 2026
0ad0687
fix(color): review fixes: render pointer/outline filtered, colour fol…
Maxaubert Sep 30, 2026
2f5c196
fix(settings): recovery review fixes: closable after give-up, slow pa…
Maxaubert Sep 30, 2026
1f78673
Merge remote-tracking branch 'origin/main' into feat/288-colour-filters
Maxaubert Sep 30, 2026
88e5943
Merge remote-tracking branch 'origin/main' into feat/288-colour-filters
Maxaubert Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ restartWind), `dirty`, `openIni`, `exportDiagnostics`, `pickExe`, `mpoState`, `s
flips per 20s; measured 13-24 spike frames per 14 wheel-clicks, 0 with no context). Writing
level 1.0 does NOT leave the mode; only releasing the runtime does. Hence the transform model
creates its context on a session's first write and releases it ~1.2s after the zoom ends.
- COLOUR (WARMTH/BRIGHTNESS, #288) HOLDS THE MAGNIFICATION RUNTIME AT 1X while either is on, so the
gotcha above applies at 1x too (the #148 game-cursor tax). Accepted trade-off: the feature needs it,
and it is off by default. Measure a cursor-toggling game with colour on before blaming anything else.
The DWM effect scales LINEAR light under HDR (encoded in SDR), is one matrix for all monitors (mixed
HDR/SDR: right strength on the primary only), and never reaches the hardware pointer, so Wind swaps
the system pointers for tinted copies at 1x (src/cursor_tint.*). docs/COLOUR-FILTER-FINDINGS.md.
- THE CURSOR GROWS WITH THE ZOOM, IN EVERY ENGINE (owner decision 2026-09-18, issue #253; this
REPLACES the old "cursor size is constant, always" rule - do not restore it). The transform
engine cannot do otherwise (its sprite lives in desktop space, DWM magnifies it), and a render
Expand Down
136 changes: 136 additions & 0 deletions docs/COLOUR-FILTER-FINDINGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Colour filter findings (issue #288)

Spike 2026-09-29, this PC (3840x2160, 225%), Wind stopped, a standalone probe calling
`MagSetFullscreenColorEffect` with an invert matrix at level 1 (no UIAccess needed).

| Question | Result |
|---|---|
| Does the effect apply at level 1? | Yes (`set=1`, the screen inverted). |
| Does GDI capture (BitBlt) see it? | Yes: centre-block mean luma 19.8 before, 235.2 after. |
| Does Desktop Duplication (the render engine's capture) see it? | **Yes**: 34.2 before, 251.8 after. |
| Does the effect survive the process being killed? | **No**: after `TerminateProcess` with the effect on, the screen read 19.8 / 34.2 again. Windows clears it with the process. |

Consequences for the design:

- **Render engine:** its capture already contains the filtered desktop and DWM would filter its
overlay again (invert twice = no filter). So while a render session is live, the DWM effect is set
to identity and the matrix is applied in the render pixel shader instead. The transform engine and
1x use the DWM effect.
- **Crash safety comes free:** a killed or crashed Wind never leaves the screen filtered. The normal
exits still write identity so the change is immediate.
- Screenshots and recordings of the screen show the filter (both capture paths see it).

## Why there are only warmth and brightness (measured 2026-09-29)

Field report: with the filters on, coloured text in Windows Terminal became hard to read or
vanished. Measured on the Campbell palette (14 text colours on #0C0C0C) and a light web page
(black text, links, red/green/grey/orange on white): WCAG contrast of each text colour against
its filtered background, and the smallest RGB distance between any two filtered text colours.

| filter | terminal: min contrast | terminal: closest pair | page: min contrast |
|---|---|---|---|
| none | 2.38 | 0.146 | 3.49 |
| invert | 1.20 | 0.146 | 6.35 |
| greyscale | 1.52 | 0.002 | 4.48 |
| yellow on black (as first built) | 1.43 | 0.001 | 5.57 |
| same, balanced luma weights | 1.83 | 0.013 | 5.88 |
| same, 50% of the original colour kept | 1.51 | 0.057 | 5.81 |
| hue-keeping "smart invert" | 1.56 | 0.038 | 6.35 |
| brightness-keeping yellow tint | 1.56-1.74 | 0.001-0.094 | 3.8-3.9 |

Two limits, both inherent to one affine matrix applied to every pixel (all the DWM colour effect,
and Windows' own colour filters, can do):

1. Squashing three channels onto a grey or two-colour ramp makes colours of equal brightness
identical (closest pair 0.001). Greyscale has the same flaw.
2. Anything that inverts swaps light and dark for every window at once: it helps white pages and
wrecks dark apps (bright green in the terminal keeps 35% of its contrast under Invert).

Only a per-pixel, non-linear mapping could keep each text's contrast, and Wind has that only in
the render engine's shader (zoomed, render engine), not at 1x or on the transform engine. Owner
decision: keep only warmth and brightness, which scale channels and never merge or swap colours.
The numbers come from a small Python harness over the palette (not committed; rerun by computing
the matrix per colour and WCAG contrast in linear light).

## HDR, Night light and the warmth maths (measured 2026-09-29/30, this PC: LG OLED, Windows HDR on, SDR white 188 nits)

- **Under HDR the DWM colour effect scales LINEAR scRGB.** Desktop Duplication in R16G16B16A16_FLOAT
with a green gain of 0.339: green 2.198 -> 0.746 (exactly x0.339). A GDI capture (SDR, after
Windows' HDR->SDR step) had suggested sRGB-encoded maths; that was the conversion, not the effect.
In SDR the effect acts on encoded values. So the same number means a far weaker tint under HDR:
the first warmth build's 100% (green 0.339) was really about 2400 K on this screen, "yellow".
- **Night light's scale** (from its stored setting, CloudStore `bluelightreduction.settings`, the
value after `cf 28` is 2 x Kelvin as a varint): 0% = 6500 K, 100% = 1200 K, 50% = 3850 K, i.e.
LINEAR in Kelvin. Wind's Warmth now uses the same scale (`WarmKelvin`).
- **Night light is invisible to software capture under HDR** (no change in the FP16 duplication, gamma
ramp untouched): it runs in the display pipeline. Wind therefore models it: CIE 1931 blackbody
gains in linear light (`kKelvinGains`, Planck + Wyman-Sloan-Shirley CMF fit), encoded for SDR and
the render shader, linear for DWM under HDR (`BuildColorMatrix(..., linearLight)`). Brightness is
decoded to linear under HDR so the slider looks the same in both. Verified on the HDR desktop:
warm 100% green x0.074 / blue 0; warm 50% x0.67 / x0.34; brightness 50% x0.214; 0% black.
- **The pointer at 1x is out of the DWM effect's reach**: Windows draws it on a hardware cursor plane
after composition (Night light, in the display pipeline, does reach it). Since 2026-09-30 Wind
swaps the standard pointers for tinted copies while idle (spec 2026-09-30-cursor-tint-design.md).
Verified on this PC: Desktop Duplication's pointer shape reads 246,76,0 at warmth 100 (246,246,246
off), size 64x64 and hotspot kept; zoom-in swaps back in 1.1 ms (direct, no scheme reload) and the
engine draws + filters its own cursor; a fullscreen window in front and colour off restore the real
scheme (reload, ~6 ms, idle only); a force-killed Wind leaves the tint until its next start heals it.
In-memory COPIES of the pointers are not identical to the scheme's own (fixed size; the default
text beam came back as a different format), so idle restores always reload the real scheme.
- Wind's diagnostics snapshot logs `hdr=0` regardless (logging.cpp records it conservatively); the
engine's own `GetHdrEnabled` is the truth.

## Settings crash (RTSS), 2026-09-29

WebView2 154's browser process loads and unloads `dxgi.dll` at start-up; RTSS's global hook
(RTSSHooks64 of 2025-09-27) remembers it and, on the next window creation (Chromium's network-cost
watcher's hidden COM window), reads the unloaded DLL in `ValidateRuntimes`: an access violation that
kills the whole WebView2 engine (dump: faulting address inside "unloaded module dxgi.dll"). Chromium
creates that watcher unconditionally (`network_change_notifier_win.cc`), so there is no flag to avoid
it. Settings now recovers: `ProcessFailed` -> recreate the engine (page process: reload), at most 3 a
minute (`src/config_ui/webview_recover.h`), and the page's unapplied edits are mirrored to the host and
handed back. Verified by killing the engine with a staged change: back in ~0.3 s with the edit kept.

## How other night-light apps do it (research 2026-09-30, sources cited)

- f.lux, Iris, LightBulb, Night Ember tint with the GDI gamma ramp (`SetDeviceGammaRamp`, a 1D
256-entry table per channel; LightBulb source: https://github.com/Tyrrrz/LightBulb).
- They have THE SAME pointer problem: f.lux's FAQ says a bright white cursor "happens when your
videocard displays uses a 'hardware cursor'" and ships "Software mouse cursor when needed"
(https://justgetflux.com/faq.html); Iris has "use software mouse cursor"
(https://iristech.co/troubleshooting/). Wind's tinted-pointer swap is the same class of fix.
- They are WEAKER under HDR: Microsoft documents the gamma ramp as "undefined behavior in HDR modes"
(https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-setdevicegammaramp); f.lux
users report it stops working with HDR/Advanced Color
(https://forum.justgetflux.com/topic/6163/windows-10-with-hdr-and-advanced-color-working-with-f-lux).
Ramps are also reset by sleep/display changes (apps re-apply constantly) and deep warm shifts need
the admin `GdiIcmGammaRange` registry change.
- Windows Night light: Microsoft says it "might also use" the post-composition display pipeline
(3x3 linear matrix + 1D LUT), which apps can only reach through an ICC profile with the private
`MHC2` tag (https://learn.microsoft.com/en-us/windows/win32/wcs/display-calibration-mhc). That fits
the measurements above (invisible to capture, gamma ramp identity, hardware cursor tinted).
- Rejected for Wind: dwm_lut (injects into dwm.exe; https://github.com/ledoge/dwm_lut), NVAPI via
novideo_srgb (NVIDIA-only, fails under HDR, its README admits cursor issues;
https://github.com/ledoge/novideo_srgb), `IDXGIOutput::SetGammaControl` (exclusive fullscreen only),
`D3DKMTSetGammaRamp` (no public user-mode contract).
- The one untested lead: an MHC2 profile associated at runtime
(`ColorProfileAddDisplayAssociation`) would sit in Night light's own pipeline (HDR-capable,
likely reaching the pointer). Unknowns: pointer behaviour (https://github.com/dantmnf/MHC2 notes a
"buggy mouse cursor and MPO composition" with MHC active), change latency, stacking with Night
light, and it would displace a user's own calibration profile.

## Review fixes (2026-09-30)

- The render engine's drawn pointer, Inspect crosshair and zoom outline are filtered too (cursor
shader + CPU-filtered outline colour); an inverting text beam is drawn untinted (a matrix on an
inverting texture changes the inversion, it does not tint).
- Colour follows the VISIBLE engine (`RenderModel::visible()`), not the selected one: a pending
render reveal keeps the DWM-filtered desktop, the effect returns before the overlay hides at
zoom-out and the moment an outgoing render overlay rests in a hybrid switch. Worst case left: one
double-filtered frame while the capture catches up after the effect is cleared.
- HDR state is the PRIMARY monitor's, re-read once a second while a colour setting is on (toggling
HDR is not guaranteed to raise WM_DISPLAYCHANGE). Mixed HDR/SDR monitors: the single DWM matrix is
right on the primary only.
- Tinted pointer: a separate "pointers are ours" flag, so an idle restore after a render session
(which never reloads the scheme) still reloads the user's real scheme.
- Trade-off kept: colour on holds the Magnification runtime at 1x (CLAUDE.md gotcha).
92 changes: 92 additions & 0 deletions docs/superpowers/plans/2026-09-29-colour-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Colour Filters 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:** Invert / greyscale / warm / two-colour tint filters and a dim control, at 1x and zoomed, with an optional toggle hotkey.

**Architecture:** One DWM colour matrix (`MagSetFullscreenColorEffect`) applied through the Magnification runtime owner thread, built by a pure `color_matrix.h`, applied and lifetime-managed by `color_filter.*`, decided per tick in RunTick.

**Tech Stack:** C++17 / MSVC, Magnification API, doctest; Svelte settings UI, Playwright.

**Spec:** `docs/superpowers/specs/2026-09-29-colour-filters-design.md`

## Global Constraints

- No em-dashes anywhere (code, comments, docs, UI copy).
- Pure headers must not include `<windows.h>`.
- Every Magnification call runs on the owner thread via `wind::MagThreadInvoke`; the runtime is held only through `MagApiAcquire`/`MagApiRelease`.
- Nothing may cost anything when no filter is on and dim is 100% (no context, no per-tick syscalls).
- Identity must be restored on every exit path (disable, toggle, shutdown, crash filter, atexit).
- Branch `feat/288-colour-filters`, commits `type(scope): subject` with the attribution trailer.

## Review Focus

1. Wind killed (Task Manager) while a filter is on: the screen must not stay filtered (spike decides the mechanism).
2. Zoom in and out with a filter on: no flash of unfiltered or double-filtered frames at the transitions.
3. Render engine while zoomed: the magnified picture filtered exactly once.
4. HDR desktop: the filter still looks right (invert of HDR white).
5. Filter at 1x plus a game that toggles its cursor: the known context tax, only while a 1x filter is on.

---

### Task 1: Spike (throwaway probe, not committed)

**Files:** scratchpad only.

- [ ] Probe exe: `MagInitialize`, `MagSetFullscreenColorEffect(invert)` at level 1; read a known white pixel through Desktop Duplication before and after. Records whether DDA sees the effect (decides 4.1 single vs render-shader path).
- [ ] Same probe, then `TerminateProcess` itself with the effect on: does the screen return to normal? (decides 4.4 crash handling).
- [ ] Repeat the pixel read with HDR on, if the display supports it.
- [ ] Record results in the spec section 4.1 / 4.4 and in `docs/COLOUR-FILTER-FINDINGS.md`.

### Task 2: Pure matrices

**Files:** Create `src/color_matrix.h`, `tests/test_color_matrix.cpp`.

**Interfaces (produces):** `enum class ColorFilter { Off=0, Invert, Greyscale, Warm, YellowOnBlack, WhiteOnBlue, GreenOnBlack };` `struct ColorMatrix { float m[5][5]; };` `ColorMatrix BuildColorMatrix(ColorFilter f, double warm01, double dim01);` `bool IsIdentity(const ColorMatrix&);` `ColorMatrix Multiply(const ColorMatrix&, const ColorMatrix&);` `RGB ApplyToRgb(const ColorMatrix&, double r, double g, double b)` (test helper).

- [ ] Write the tests: identity for (Off, any, 1.0); invert maps white to black and black to white; greyscale maps pure red to 0.2126 grey; warm strength 1 keeps red, reduces blue to 0.4; yellow-on-black maps white page to black background and black text to yellow; dim 0.5 halves; Invert + dim composes.
- [ ] Run `build.bat test`, see them fail; implement; see them pass.
- [ ] Commit `feat(color): pure colour matrices (#288)`.

### Task 3: Config

**Files:** `src/config.h`, `src/config.cpp`, `tests/test_config.cpp`.

- [ ] Keys: `colorFilter` (0-6, default 0), `colorWarmPct` (10-100, default 50), `colorDimPct` (20-100, default 100), `colorAt1x` (default 1), `colorToggleVk`/`colorToggleMods` (default 0 = no hotkey). Parse, clamp, template lines.
- [ ] Tests: defaults, parse, clamps.
- [ ] Commit `feat(config): colour filter keys (#288)`.

### Task 4: Controller

**Files:** Create `src/color_filter.h`, `src/color_filter.cpp`.

**Interfaces:** `class ColorFilterController { void apply(const ColorMatrix& want, bool needOwnHold); void shutdown(); }` plus a static `RestoreColorIdentityForCrash()`.

- [ ] `apply`: if `want` equals the last applied matrix and the hold state matches, return (no syscalls). Else take or drop the own `MagApiAcquire` hold as `needOwnHold` says, then `MagThreadInvoke` a `MagSetFullscreenColorEffect`. Identity + no hold = fully released.
- [ ] `shutdown` and the crash restore write identity (while a runtime exists) and release.
- [ ] Log each change once (`color` tag): filter, dim, hold.
- [ ] Commit `feat(color): colour filter controller (#288)`.

### Task 5: RunTick, hotkey, exits

**Files:** `src/main.cpp`.

- [ ] Per tick: `want = BuildColorMatrix(cfg, toggleOn)`; identity if the model is `magnify`, or if not zoomed and `colorAt1x` is 0. `needOwnHold = !IsIdentity(want) && !zoomed`. Call the controller. When the spike says the render engine filters twice, clear the effect while a render session is active and pass the matrix to the render model instead (Task 5b).
- [ ] Hotkey: register `colorToggleVk/Mods` with `RegisterHotKey` like the hide-cursor hotkey (hot-reload on change); WM_HOTKEY flips `colorToggleOn`.
- [ ] Exit paths: controller `shutdown()` in the normal shutdown, the crash filter and `atexit` next to `RestoreInputState`; a model switch re-applies.
- [ ] (5b, only if the spike requires it) render shader: add a 5x5 matrix to the constant buffer and apply it after the brightness stage.
- [ ] Commit `feat(color): colour filters in RunTick + toggle hotkey (#288)`.

### Task 6: Settings UI

**Files:** `ui/src/settings-schema.js`, `ui/tests/settings.spec.js`.

- [ ] New section `{ id:'colour', label:'Colour' }`: select `colorFilter` (Off, Invert, Greyscale, Warm, Yellow on black, White on blue, Green on black), slider `colorWarmPct` (%), slider `colorDimPct` (%, "100% = normal"), toggle `colorAt1x`, keybind `__colorToggle` (vkKey `colorToggleVk`, modsKey `colorToggleMods`).
- [ ] Playwright: the section renders, choosing Invert writes `colorFilter=1`, dim slider writes `colorDimPct`.
- [ ] Commit `feat(ui): Colour settings section (#288)`.

### Task 7: Verify, docs, ship

- [ ] `build.bat test`, Playwright, deploy via `tools\uiaccess_setup.ps1`; owner field test per spec section 5.
- [ ] Docs: `docs/COLOUR-FILTER-FINDINGS.md` (spike results), a section in `docs/architecture/07-cursor.md` or a new chapter entry, README feature line, CLAUDE.md gotcha (effect lifetime / crash restore).
- [ ] Version bump in the PR; review workflow (sonnet reviewers + verifier), owner approves fixes, PR, owner says merge.
14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-09-30-cursor-tint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Tinted pointer at 1x: implementation plan

**Spec:** `docs/superpowers/specs/2026-09-30-cursor-tint-design.md`. Branch `feat/288-colour-filters`.

1. `src/cursor_tint_pixels.h` (pure) + `tests/test_cursor_tint.cpp`: `TintArgb(pixels, n, matrix)` and
`MonoToArgb(andBits, xorBits, w, h, out)` with the outline rule.
2. `src/cursor_tint.{h,cpp}`: `CursorTint` with `capture()`, `apply(matrix)`, `restore()`,
`invalidate()`, `applied()`; builds tinted cursors with GetIconInfo/GetDIBits/CreateIconIndirect;
logs each apply/restore with its duration.
3. `src/main.cpp`: capture after `RestoreInputState`; idle-tick `UpdateCursorTint` (colour, idle,
magnify, fullscreen game every 250 ms); `restore()` at the start of `enterActive`; `invalidate()` on
active -> idle; `WM_SETTINGCHANGE`/`SPI_SETCURSORS` recaptures; `restore()` at shutdown.
4. Build, unit + UI tests, deploy, field-verify per the spec, document in
`docs/COLOUR-FILTER-FINDINGS.md`.
Loading
Loading