` composites don't expose disabled state (#170), the GAL canvas is invisible to screen readers (#171), and there's no high-contrast support (#172). The data to fix the top items already exists in the JSON (`t.tooltip`/`t.label`). *File:* `wx-dom.js`, `src/wasm/tooltip.cpp`.
+
+**RC-10 — Bitmap mask/alpha metadata loss + lying success returns.** `ConvertToImage` drops the mask (#23), `GetSubBitmap` loses mask+alpha (#24/#215), blits always bake the mask ignoring `useMask` (#18/#95), `InitFromMonoBitmap` is a stub (#65/#98), and `SaveFile` returns `true` while writing nothing (#96/#216) while `LoadFile` is dead (#97/#217). Mostly latent because KiCad uses alpha PNGs via the `wxImage` path, but `SaveFile`'s false-success is a real footgun. *File:* `src/wasm/bitmap.cpp`.
+
+**Config honesty (RC-11)** and **inherent-platform gaps (RC-12)** are covered in §7. The recurring meta-pattern: the port faithfully maintains C++ state but **drops the C++→DOM push** for a specific mutator family (text deletes, menu UpdateUI, check-list checks, per-item radio enable/show). Several "stubs" return `true`/no-error, turning silent gaps into false-success traps (#48,#90,#96,#158,#213,#216).
+
+---
+
+## 7. Whole missing subsystems & config honesty
+
+### Coverage map (from `inv_compiled-sources.md`)
+The WASM build is a genuine `TOOLKIT=WASM` library; the full `GUI_CMN_SRC` (grid, treectrl, dataview, splitter, vscroll, propgrid) is compiled and **verified against 293 archived members** of `libwx_wasmu_core`, not just the bakefile. All 62 `src/wasm/*.cpp` are present. Of the 35 `src/gtk/*.cpp` subsystems with no `src/wasm` counterpart:
+
+- **Class a (generic fallback compiled):** aboutdlg, animate, bmpcbox, calctrl, clrpicker, collpane, colordlg, **dataview** (KiCad-critical), dirdlg, filectrl, **filedlg** (KiCad open/save), filepicker, fontdlg, fontpicker, hyperlink, infobar, mdi, **msgdlg**, notifmsg, **print** (dialogs only), renderer, **scrolwin**, spinctrl, srchctrl, textmeasure — *real generic widgets, no gap.*
+- **Class b (base/common/other-lib):** filehistory, mimetype (degraded — empty mime DB), overlay, sockgtk (base wxSocket compiled, never pumped).
+- **Class c (GENUINE GAPS, flag ON but nothing compiled):** **taskbar** (`wxUSE_TASKBARICON 1` but no impl; `wx/taskbar.h:83-95` has no WASM class → incomplete type if any TU includes it) and **webview** (`wxUSE_WEBVIEW 1` but `libwx_wasmu_webview` is an 88-byte dummy). Both are browser-N/A and KiCad-unused — harmless today but **misrepresent capability** and are latent link hazards.
+- **Class d (intentionally N/A):** nativewin (HWND/GdkWindow handles meaningless in browser).
+
+**`wxFileSystemWatcher` (#180)** is genuinely compiled out (`wxUSE_FSWATCHER 0` in the generated header; no inotify/kqueue) — the concrete type does not exist; any `#if wxUSE_FSWATCHER` code is gone. Moot in a single-tab MEMFS world but the contract capability is absent. **`wxGraphicsContext`/`wxGCDC` (`wxUSE_GRAPHICS_CONTEXT 0`)** is the *one* true wasm-vs-desktop disablement that matters: GAL is unaffected (own GL), only wx-level anti-aliased `wxDC` drawing in some dialogs falls back to plain `wxDC`.
+
+### Disabled / misrepresented setup.h features (from `inv_disabled-features.md`)
+The headline is a **config-honesty defect**: `include/wx/wasm/setup.h` is the **stock `setup_inc.h` template copied verbatim** with only two WASM edits (`wxUSE_OPENGL_EMULATION 1`, `wxUSE_THEME_WASM 0`). `include/wx/wasm/chkconf.h` is **empty** (8-line comment header), so the port force-corrects nothing. The actual build is **configure-driven** (`build-wx-wasm.sh` runs `emconfigure … --disable-richtext --without-libtiff --disable-xlocale --with-opengl`), regenerating a `setup.h` into the build tree that the compiler actually sees. Consequences:
+
+- **The checked-in header LIES about the shipped value:** `wxUSE_RICHTEXT`, `wxUSE_XLOCALE`, `wxUSE_LIBTIFF` all say `1` but ship as `0` (#200; also `wxUSE_POSTSCRIPT` 0 in checked-in vs 1 generated, the print-link footgun #166). `wxUSE_XLOCALE 0` degrades locale-aware numeric parsing — relevant to KiCad coordinate parsing.
+- **Knobs left `1` that can't work in a browser** (no `src/wasm` backend): `wxUSE_SOCKETS`/`PROTOCOL_*`/`URL`/`FS_INET` (#175,#176), `wxUSE_FSWATCHER` (overridden to 0 in build), `wxUSE_IPC`/`SNGLINST_CHECKER` (#174,#176), `wxUSE_DYNLIB_CLASS`/`DYNAMIC_LOADER`/`DIALUP_MANAGER`, `wxUSE_SOUND`/`MEDIACTRL`/`JOYSTICK`, `wxUSE_STACKWALKER`/`DEBUGREPORT`, `wxUSE_TASKBARICON`, `wxUSE_PRINTING_ARCHITECTURE` (#48). Mostly graceful dead capability + binary bloat (the binary is size-critical, ~65 MB gz).
+- **The only unambiguous numeric wasm-0/gtk-1 diff** is `wxUSE_IPV6` (cosmetic). `wxUSE_GRAPHICS_CONTEXT 0` is the only meaningful conscious disablement.
+
+**Recommendation:** populate `wx/wasm/chkconf.h` to force-off the browser-impossible knobs (sockets/IPV6/FS_INET/PROTOCOL_*/URL/FSWATCHER/IPC/DIALUP/DYNLIB/JOYSTICK/SOUND/MEDIACTRL/STACKWALKER/DEBUGREPORT/TASKBARICON/PRINTING_ARCHITECTURE), and audit the *configure-generated* header as the source of truth rather than the checked-in one.
+
+---
+
+## 8. Recommended priorities (value-to-effort)
+
+1. **Fix the selection client-data crash (#1).** *Tiny effort, eliminates a guaranteed module trap.* Route `wxEVT_CHOICE/COMBOBOX/LISTBOX` through `SendSelectionChangedEvent`/`InitCommandEventWithItems`. → `src/wasm/choice.cpp`, `combobox.cpp`, `listbox.cpp`.
+2. **Create the `wxWindowDisabler` in `ShowModal` (#2/#11).** *One line; lights up existing `IsEnabled()` guards; closes a UAF/re-entrancy class.* → `src/wasm/dialog.cpp:270`.
+3. **Wire menu UpdateUI/`wxEVT_MENU_OPEN` to the DOM (#8/#9/#12/#37/#38/#142).** *Medium effort, broad UX win* — fixes stale Undo/Redo/check/enable across the whole app and context menus. → `src/wasm/menu.cpp`, `window.cpp`, `wx-dom.js`.
+4. **Fix `SetClippingRegion(x,y,w,h)` (#3) and config UTF-8 truncation (#10).** *Both are small, localized correctness bugs with data/visual impact.* → `src/wasm/dc.cpp:292`, `config.cpp` + `wx.js` (`lengthBytesUTF8`).
+5. **Re-apply check-list checks after rebuild (#6) + Plot/Print correctness.** *Small; the Plot/Print dialogs are core and visibly wrong.* → `src/wasm/checklst.cpp`/`listbox.cpp:100`.
+6. **Add the text caret/selection DOM bridge (RC-1: #4,#5,#30,#31,#32).** *Medium; fixes a whole cluster (caret, clipboard, Clear, insert-at-cursor, validator highlight).* → `dom.h` + `textentry.cpp`/`textctrl.cpp`.
+7. **Dispatch horizontal wheel + slider scroll family (#71/#149, #7/#220).** *Small; restores trackpad canvas pan and color-picker brightness/alpha.* → `app.cpp:703`, `slider.cpp:130`.
+8. **Strip the `&` mnemonic in `wxStaticText` (#36).** *One line, matches the established `GetLabelText()` pattern used by every other control; removes ~17 visible stray ampersands.* → `src/wasm/stattext.cpp:90`.
+9. **HiDPI: de-quantize DPR + invalidate JS cache on zoom + emit `wxDPIChangedEvent` (#49/#50/#168).** *Medium; fixes blur at 125%/3× and the zoom-mis-scale-until-reload.* → `display.cpp`, `wx.js`.
+10. **Accessibility quick wins (#51/#53/#161):** set `aria-label = tooltip||label` on toolbar buttons and switch tooltips from `aria-label` to `aria-description`/`title`; bind the tooltip to its window. *Low effort, the data already exists.* → `wx-dom.js:1167`, `tooltip.cpp`.
+11. **Config honesty:** populate `wx/wasm/chkconf.h`, reconcile the checked-in `setup.h` with the generated header, and stop misrepresenting RICHTEXT/XLOCALE/LIBTIFF/sockets/taskbar/webview. *Low effort, reduces bloat + latent link hazards.* → `include/wx/wasm/chkconf.h`, `setup.h`.
+12. **Stop lying-success returns (#48,#90,#96,#158,#213,#216):** make stubs return `false`, so callers can detect failure. *Trivial, removes silent-data-loss traps.*
+
+---
+
+## 9. What is solid
+
+- **The DOM-bridge architecture is well-designed and correct in its hard parts.** Three cleanly separated handle namespaces (`m_cssId` for TLW/GL canvases in `windowMap`; `m_domId` for native controls in `controls`/`gs_domWindows`; owner-drawn windows with neither, painting into the TLW's shared Canvas2D). Lifecycle teardown in `~wxWindowWasm` unregisters from all three registries and drops dangling focus/capture/tooltip pointers (`window.cpp:204`).
+- **The Asyncify event-loop integration is genuinely hard and done well.** Top loop via `emscripten_set_main_loop` with the `"unwind"` sentinel; nested/modal loops via `EM_ASYNC_JS` + `setTimeout` pump with a **LIFO resolver stack** so inner modals exit before outer; every error path resolves loudly so a parked stack never freezes silently. The `handlesleep.js` `Asyncify.currData` save/restore shim is what makes nested modal pumps + KiCad coroutine tools survive — a non-obvious, correct fix.
+- **Fresh-entry event re-entry works under suspension.** `wx_dom_event`/`wx_dom_mouse` re-enter wx through the exact same `HandleMouseEvent`/`OnDomEvent` paths as native callbacks, keeping capture/dclick/hover synthesis in one place, with a disciplined canvas-path-vs-control-path double-dispatch partition.
+- **The real generic widgets are all present and compiled** (verified in the archives, not just the bakefile): wxGrid, generic wxDataViewCtrl (choosers/inspectors), wxPropertyGrid, wxAUI docking, Scintilla (STC), wxScrolledWindow, all generic dialogs (file/dir/color/font/message/print). KiCad's panels depend on these and they are not gaps.
+- **`wxScrollBar` is fully implemented** (drag, track-click paging, auto-hide, correct thumb events) — the old "no-op stub" claim is stale (refuted, §10). **Clipboard text copy/paste** (the dominant KiCad path) works correctly via the async browser Clipboard API with a thoughtful capability-probe to avoid asyncify-suspension crashes. **Threads work** (`-pthread -matomics`, real POSIX `wxThread`). **File dialogs** are functional generic widgets bridged to MEMFS with a real `
` import + download export.
+- **Bitmaps:** masks are first-class on the image→bitmap direction and in clone; alpha PNGs (KiCad's actual icon format) round-trip correctly. The mask losses are on rarer sub-bitmap/ConvertToImage paths.
+
+---
+
+## 10. Verification appendix
+
+**Process:** 244 findings were extracted from a 23-subsystem deep comparison; every medium-and-above item was independently re-checked by an adjudicator that read both the WASM port and the native/contract (wxGTK/common) source, with explicit `verifier_note`/`verifier_correction` fields. Low items were carried through and tagged. **11 candidate findings were refuted/dismissed** after reading the actual code (they would otherwise have inflated the count or asserted false native contracts).
+
+**Notable dismissed candidates:**
+
+| Dismissed claim | Why refuted |
+|---|---|
+| `wxWindow::DoPopupMenu` is a broken `wxFAIL` stub | The asserted string doesn't exist; `window.cpp:727-768` is a complete implementation (the *real* gap is the UpdateUI/menu-open omission, captured as #9). |
+| `wxScrollBar` is a no-op (no draggable thumb) | `src/wasm/scrolbar.cpp` read in full: complete (drag, track-click, auto-hide, thumb events via `wx-dom.js:528-653`). The "no-op" DOCUMENTED-LIMITATION entry is stale. |
+| `wxThread`/threading stubbed | Build compiles `-pthread -matomics`, `wxUSE_THREADS 1`, real `unix/threadpsx.cpp` linked; no wasm thread override. |
+| `wxTextEntry::Undo()/Redo()` broken vs GTK | wxGTK's `Undo/Redo` are **identical** TODO no-ops (`gtk/textentry.cpp:742-760`); not a parity regression. |
+| `wxTextEntry::CanUndo()/CanRedo()` hardwired false vs GTK | wxGTK is identical (`return false`); base declares pure virtual with no default. |
+| `wxDC::SetPalette()` / `wxBitmap::GetPalette()/SetPalette()` worse than GTK | wxGTK's are themselves `wxFAIL`/`return NULL`/no-op stubs under `wxUSE_PALETTE`; no native contract being violated. |
+| `wxCheckListBox` renders plain `
`, no checkbox UI | Stale TODO comment; `checklst.h:53` returns the `"checklistbox"` node type and `wx-dom.js` builds real checkbox rows (the real bug is check-state wipe-on-rebuild, #6). |
+| `wxToolBar::FindToolForPosition()` returns NULL vs GTK | wxGTK does the *same* (`wxFAIL_MSG`, returns NULL); GTK delivers tool-enter via a separate signal. |
+| `wxWindow::DoFreeze()` no-op breaks Freeze/Thaw | Identical to the base-class default `wxWindowBase::DoFreeze(){}`; not a port-specific defect. |
+| web-init app: load-only, no save/S3/auth/collab | The spec explicitly lists these as non-goals for the iteration (documented scope, not a defect). |
+
+**Net:** 1 critical + 9 distinct high + ~50 distinct medium + 174 carried low survive verification; the prior report's "0 confirmed findings" headline is corrected by this list.
diff --git a/docs/features/wx-parity-fixes/redgreen.md b/docs/features/wx-parity-fixes/redgreen.md
new file mode 100644
index 000000000..afb64adca
--- /dev/null
+++ b/docs/features/wx-parity-fixes/redgreen.md
@@ -0,0 +1,419 @@
+# Parity fixes red-green ledger
+
+Every fix was driven test-first: the test was run against the **unpatched** DOM
+port to confirm it fails for the right reason (RED), then against the **patched**
+port to confirm it passes (GREEN). "Right reason" = the captured `[REPRO] …: FAIL`
+line or the observed stale DOM value, not an unrelated harness error.
+
+## Standalone wxWidgets e2e — `tests/e2e/parity-audit.spec.ts`
+
+Apps under `tests/apps/standalone/`. Run: `npx playwright test e2e/parity-audit.spec.ts`.
+
+| # | Test / app | RED (unpatched) | GREEN (patched) |
+|---|---|---|---|
+| C-1 | `selevent-clientdata` — choice/listbox/combobox selection carries client data | `choice_clientdata: FAIL (GetClientObject()==null)` | `choice/listbox/combobox_clientdata: PASS (DATA_B)` |
+| H-4 | `textctrl-clear` — `Remove(1,5)` then `Clear()` update the ` ` | ` ` still shows `"hello world"` (Playwright: `"h[ello] world"`) | `"h world"` then `""` |
+| H-5 | `checklist-checks` — `Append`-then-`Check` evens (0,2,4) of 5 rows | DOM checkboxes = `[✗,✗,✗,✗,✗]` (all wiped) | `[✓,✗,✓,✗,✓]` |
+| H-6 | `slider-scroll` — slider bound only to `wxEVT_SCROLL_*`, dragged to 70 | only `slider_command: 70` (no scroll family) | `slider_thumbtrack: 70`, `slider_changed: 70`, `slider_command: 70` |
+| H-9 | `config-utf8` — write/read `"café-résumé-naïve-Žluťoučký-日本語-Ω"` + non-ASCII key | `config_value: FAIL (read back 'café-résumé-naïve-Žluťoučk')`; `config_keyname: FAIL` | `config_value: PASS`, `config_keyname: PASS` |
+| #36 | `stattext-mnemonic` — label `"&Layer && Net"` | `` shows `"&Layer && Net"` | `"Layer & Net"` |
+
+Final run: **6 parity tests + 4 existing dom-port-bug tests green**; a widget
+regression sweep (specialized/pickers/validators/clipboard/dataview/listctrl/
+collapsible/wizard) = **49 green, 0 regressions**.
+
+### Two harness corrections made during the first green run (not product-fix bugs)
+- **selevent**: `choice` went green immediately; the `listbox` step then hit
+ `stringToNewUTF8 is not a function` — a missing runtime export in the standalone
+ build (added to `Makefile.wasm` `BASE_LDFLAGS`; production gets it via embind).
+- **config**: the value round-trip passed; my *key-name* sub-assertion accidentally
+ exercised a different, still-unfixed bug (#44, a missing path separator) rather
+ than the truncation bug — rewritten to use an absolute key so it isolates H-9.
+
+## KiCad e2e (product proof) — `tests/kicad/plot-checklist.spec.ts`
+
+pcbnew's Plot dialog fills its layer list with the exact interleaved
+`Append`-then-`Check` loop the H-5 bug breaks (`dialog_plot.cpp:351-354`); a
+fresh board's default plot selection is
+`LSET{F_SilkS,B_SilkS,F_Mask,B_Mask,F_Paste,B_Paste,Edge_Cuts}` (+Cu).
+
+Built pcbnew at `-O1` twice:
+
+| # | KiCad surface | RED (pcbnew vs unpatched wx) | GREEN (pcbnew vs patched wx) |
+|---|---|---|---|
+| **H-5** | pcbnew Plot dialog layer checklist | **`0 of 48` layers checked** | **`9 of 48` checked** |
+
+Method: `git -C wxwidgets stash` (revert fixes) → `BINARYEN_OPT_LEVEL=-O1
+docker/build.sh pcbnew` → `npm run setup:kicad` → run test (RED) → `git -C
+wxwidgets stash pop` (restore fixes) → rebuild → run test (GREEN). The Plot dialog
+does open in WASM pcbnew (the colour picker, notably, does not).
+
+## KiCad e2e (product proof) — `tests/kicad/via-clientdata.spec.ts` (C-1)
+
+A crafted board seeds two predefined via sizes via `(setup (user_via 0.9 0.45)
+(user_via 1.1 0.55))` plus one via of size `0.7 / 0.35`. The test selects the via
+(Ctrl+A on a board that contains only the via), opens the Track & Via Properties
+dialog (E → `PCB_ACTIONS::properties`), and picks the predefined `0.9 / 0.45`
+entry in the `m_predefinedViaSizesCtrl` wxChoice. That `change` fires
+`wxChoice::OnDomEvent` → `onViaSelect`
+(`dialog_track_via_properties.cpp:1674-1680`), which does, with **no null guard**:
+`viaDimension = event.GetClientData(); m_viaDiameter.ChangeValue(
+viaDimension->m_Diameter )`.
+
+Built pcbnew at `-O1` twice (both measured end-to-end in WASM pcbnew):
+
+| # | KiCad surface | RED (pcbnew vs unpatched wx) | GREEN (pcbnew vs patched wx) |
+|---|---|---|---|
+| **C-1** | Track & Via Properties → predefined via size → Via-diameter field | Via-diameter → **`0`** — *measured* | Via-diameter → **`0.9`** — *measured* |
+
+- **GREEN — measured**: the diameter field transitions `0.7 → 0.9`
+ (`[VIA] diameter field before: "0.7"` → `after: "0.9"`); test passes.
+- **RED — measured** on a pcbnew build with `choice.cpp`'s
+ `InitCommandEventWithItems` reverted: the diameter field transitions `0.7 → 0`
+ (`[VIA] diameter field after: "0"`) and the test fails (`expected 0.9,
+ received 0`). `onViaSelect` reads the choice event's `GetClientData()`, which is
+ NULL without the fix, so `->m_Diameter` reads 0 (WASM null-deref-reads-zero).
+ This matches the standalone `selevent-clientdata` RED above
+ (`choice_clientdata: FAIL (GetClientObject()==null)`) on the same
+ `wxChoice::OnDomEvent` path. (The spec also has an `Aborted(` guard for builds
+ that trap instead of reading zero.)
+
+Method: revert the `InitCommandEventWithItems` line in
+`wxwidgets/src/wasm/choice.cpp` (the via-size control is a `wxChoice`) →
+`BINARYEN_OPT_LEVEL=-O1 docker/build.sh pcbnew` → `npm run setup:kicad` → run the
+spec (RED, diameter `0`) → restore the fix + rebuild → run (GREEN, diameter `0.9`).
+
+> **Build caveat for a pruned worktree:** OpenCASCADE is a *mandatory* KiCad link
+> dep (`CMakeLists.txt find_package(OCC)` is `FATAL_ERROR` if absent). A worktree
+> whose `kicad-wasm-` docker volumes were pruned must re-provision the
+> sysroot from scratch (`docker/build.sh pcbnew --build-deps`, ~30 min incl. OCC)
+> — its OCC must be built with this fork's legacy `-fexceptions` model. Reusing
+> another worktree's warm sysroot fails to link if its OCC used the newer
+> `-fwasm-exceptions` model (`undefined symbol: __cpp_exception`).
+
+## KiCad e2e (product proof) — `tests/kicad/modal-input-lock.spec.ts` (H-1)
+
+Unlike C-1 and H-5 (already fixed among the six), **H-1 was unimplemented** — this
+is the first time it was fixed. H-1: real modal dialogs (`wxDialog::ShowModal`)
+never created a `wxWindowDisabler`, so the parent editor frame stayed input-live
+behind the "modal". Fix (`wxwidgets/src/wasm/dialog.cpp`, `wxDialog::ShowModal`):
+`m_windowDisabler = new wxWindowDisabler(this)` — the upstream-univ mechanism the
+DOM port had dropped (teardown via the pre-existing `wxDELETE(m_windowDisabler)` in
+`Show(false)`).
+
+**Dialog choice is load-bearing.** The test drives pcbnew's **Page Settings**
+dialog, shown via a *real* `wxDialog::ShowModal()` (`board_editor_control.cpp:534`;
+`DIALOG_SHIM::ShowModal`, `dialog_shim.cpp:1386`, forwards straight to
+`wxDialog::ShowModal` with no disabler of its own). The obvious candidates are the
+**wrong** target: both **Plot** and **Track & Via Properties** are shown via
+`ShowQuasiModal()` (`board_editor_control.cpp:565`, `edit_tool.cpp:2306`), which
+runs KiCad's own nested loop and creates a `WINDOW_DISABLER(parent)`
+(`dialog_shim.cpp:1431`) — so the parent is disabled *with or without* the wx fix.
+(Measured: opening Plot on the *unpatched* binary already reported the frame
+disabled — a false green — which is what flushed out this distinction.) So H-1's
+real user impact in KiCad is limited to real-`ShowModal` dialogs.
+
+**Signal:** the port re-emits a window's `enabled` flag into
+`window.wxElementRegistry` on every `DoEnable()` (`window.cpp:1438`), so the pcbnew
+main frame's registry entry flips `enabled: true → false` the instant the disabler
+runs — and the *same* `wxWindow::IsEnabled()` parent-walk that flips it is what
+every input gate consults (`app.cpp` mouse/keyboard/wheel, `domevents.cpp`
+control/toolbar/menu-item). The test reads `registry.getElement().enabled`.
+
+Built pcbnew at `-O1` once (RED = the pre-existing binary, which never had H-1;
+GREEN = the post-fix build):
+
+| # | KiCad surface | RED (pcbnew vs unpatched wx) | GREEN (pcbnew vs patched wx) |
+|---|---|---|---|
+| **H-1** | Page Settings modal open → main-frame `enabled` | **`true`** — frame stays live — *measured* | **`false`** — frame input-disabled — *measured* |
+
+- **RED — measured**: `[H1] PcbFrame.enabled while the Page Settings modal is open: true`
+ → `expect(false)` fails (`received true`); test fails.
+- **GREEN — measured**: the same line reads `false`, the frame re-enables to `true`
+ after the dialog closes (round-trip verifies the `wxDELETE(m_windowDisabler)`
+ teardown), and no `Aborted(`; test passes (15.8s).
+
+Method: measure RED against the current binary (no build needed — H-1 was never
+built in) → add the `wxWindowDisabler` line → `BINARYEN_OPT_LEVEL=-O1
+docker/build.sh pcbnew` → `npm run setup:kicad` → run the spec (GREEN).
+
+**Residual (documented, not fixed):** the C++ disabler blocks all *consequential*
+input (canvas tools, toolbar/menu-item commands, keyboard) but a menubar dropdown
+still *visually* opens over the modal (its popup is built in pure JS,
+`wx-dom.js:1127`, bypassing the C++ gate) though its items do nothing. Closing that
+cosmetic leak would need a DOM-level backdrop/`inert` shield — deliberately out of
+scope to stay close to upstream.
+
+## KiCad e2e (product proof) — `tests/kicad/grid-clip-bleed.spec.ts` (H-2)
+
+H-2: `wxWasmDCImpl::DoSetClippingRegion(x,y,w,h)` fed `m_clipX1..m_clipY2` to the
+JS `clipRect`, but the modern `wxDCImpl` base stores the clip box only in the
+*private* `m_devClipX1..` members — `m_clipX1..` stay ctor-zero — so JS always
+received `clipRect(id, 0,0,0,0)`, which `wx.js` treats as "uninitialized" and
+resets to a whole-context clip. Every rectangular DC clip (`wxDCClipper`,
+`SetClippingRegion(wxRect)`) was a silent no-op; the `wxRegion` overload worked.
+Fix (`wxwidgets/src/wasm/dc.cpp`): after the base call, read the accumulated box
+back with `DoGetClippingRect()` and mirror it into `m_clipX1..m_clipY2` (the
+`wx/dc.h:766` contract every other port fulfills itself); `DestroyClippingRegion`
+now chains to the base (resetting the clip-state members) instead of only
+flipping `m_clipping`.
+
+**Surface choice is load-bearing.** wxGrid clips every cell's text to its cell via
+exactly the broken path (`wxGrid::DrawTextRectangle` → `wxDCClipper`,
+`grid.cpp:7336`), and KiCad's `WX_GRID` disables cell overflow
+(`wx_grid.cpp:214`), so a long cell text must truncate at its own cell edge even
+next to an *empty* neighbor. But the obvious grid — Board Setup → **Text
+Variables** — is the **wrong** target: `WX_GRID::SetupColumnAutosizer` re-runs
+`AutoSizeColumn()` on `wxEVT_GRID_CELL_CHANGED`/`wxEVT_UPDATE_UI`, so committing a
+70-char name simply *widened the Name column to fit* (measured: column grew to
+~980px, pushed the Value column off-view behind an h-scrollbar) — nothing left to
+clip, bug invisible. Board Setup → **Net Classes** has fixed column widths (no
+autosizer; `min_best_width` from `"555,555555 mils"` ≈ 96px) and a newly added
+netclass row leaves its Clearance cell empty.
+
+**Why the bleed survives a full repaint:** `DrawGridCellArea` draws cells in
+*descending* order (`grid.cpp:6497` reverse loop), so the Name cell (1,0) paints
+*after* its neighbor (1,1) and the unclipped overflow lands on top of the
+already-painted empty cell; only gridlines/highlight (which don't cover the cell
+interior) paint later.
+
+**Driving the grid (three port gotchas baked into the spec):**
+- The "+" button is a `STD_BITMAP_BUTTON` — a custom-painted `wxPanel`, which the
+ element registry *skips* — so it can't be found by type; the spec clicks it
+ blind at the grid rect's bottom-left + (13, 16) (offsets verified on-screen).
+- Keys typed into a DOM editable never reach wx (audit #43/#55), so Enter cannot
+ commit the cell editor. The spec clicks "+" a *second* time:
+ `WX_GRID::OnAddRow` calls `CommitPendingChanges()` first (mouse events do reach
+ wx), committing the payload row.
+- The netclass grid inserts new rows at the *top*, so the second "+" click also
+ shifts the payload row to row 1 and moves the cursor/row-selection highlight
+ (dark navy — would fake ink) to the fresh row 0, leaving row 1 clean to
+ measure. A mean-luminance guard (>128) fails loudly if a highlight ever lands
+ on the measured cell anyway.
+
+**Signal:** type a 70×`W` name (~700px vs the 96px Name column), commit, then
+count dark ("ink", luminance < 128) pixels inside the *empty* Clearance cell
+(1,1) interior (4px inset; device-scale screenshot → offscreen-canvas
+`getImageData`). Precondition: the Name cell (1,0) must itself contain painted
+text (ink > 50) so a failed commit can't fake a pass.
+
+| # | KiCad surface | RED (pcbnew vs unpatched wx) | GREEN (pcbnew vs patched wx) |
+|---|---|---|---|
+| **H-2** | Board Setup → Net Classes, empty Clearance cell next to 70-char name | **ink=325**, mean=191.5 — name bleeds across the whole grid — *measured* | **ink=0**, mean=255.0 — name clipped at its cell edge — *measured* |
+
+- **RED — measured**: `[H2] name cell: ink=413 mean=181.7; clearance cell: ink=325
+ mean=191.5` → `expect(325).toBeLessThan(5)` fails; identical on retry
+ (deterministic). The failure screenshot shows the name crossing Clearance …
+ DP Width to the grid edge.
+- **GREEN — measured**: `[H2] name cell: ink=413 mean=181.7; clearance cell: ink=0
+ mean=255.0` — the name-cell ink count is *identical* to RED (413), proving the
+ same text painted and only the clipping changed; test passes (20.2s), no
+ `Aborted(`.
+
+Method: measure RED against the current binary (fix not yet built) → apply the
+`dc.cpp` fix → `BINARYEN_OPT_LEVEL=-O1 docker/build.sh pcbnew` → rerun the spec
+(GREEN) → regression-run `plot-checklist` / `via-clientdata` / `modal-input-lock`.
+
+**Residual (documented, not fixed):** a genuinely *empty* clip intersection still
+degenerates to a whole-context clip (the `wx.js` `width<=0 → full` guard, kept as
+defense-in-depth, can't express an empty clip); no KiCad surface hits this.
+Finding #19 (the `wxRegion` overload never sets the C++ clip-box members) is a
+separate audit item, untouched here.
+
+## KiCad e2e (product proof) — `tests/kicad/dialog-select-on-open.spec.ts` (H-3/#30/#31)
+
+H-3: the wasm port's `wxTextEntry` caret/selection was a pure C++ cache —
+`GetInsertionPoint`/`GetSelection` never read the DOM ` `'s
+`selectionStart`/`selectionEnd`, and every DOM `input` event reset the cached
+caret to 0 (`textctrl.cpp` INPUT → `wxTextEntry::DoSetValue`). #30: `SetSelection`/
+`SelectAll`/`SetInsertionPoint` wrote only the cache, never `setSelectionRange`.
+#31: `WriteText` inserted at the stale caret. Fix (wx port):
+`include/wx/wasm/private/dom.h` + `build/wasm/wx-dom.js` add
+`wxDomGetSelectionStart/End` + `wxDomSetSelection`; `src/wasm/textentry.cpp`
+live-reads/writes the DOM when the element exists (cache fallback pre-Create) and
+`WriteText` computes its insert position from the live selection.
+
+**KiCad surface: select-all-on-dialog-open.** `DIALOG_SHIM::OnPaint` first-paint
+runs `SelectAllInTextCtrls()` then focuses the initial-focus control
+(`dialog_shim.cpp:1343-1357`) so typing replaces a pre-filled field. Two things
+were broken in wasm and both had to be fixed:
+1. The `SelectAll()` inside `SelectAllInTextCtrls` was gated
+ `#if defined(__WXMAC__) || defined(__WXMSW__)` (`dialog_shim.cpp:901`) — the
+ wasm build defines `__WXWASM__`/`__WXUNIVERSAL__`, so it was **compiled out
+ entirely**. Widened the gate with `|| defined(__WXWASM__)` (the actual product
+ fix — dialogs regain native type-to-replace).
+2. Even when called, `SetSelection` never reached the DOM (#30).
+
+**The load-bearing discovery (drove a bridge-level design choice):** a selection
+set via `setSelectionRange` on a **blurred** ` ` is dropped when the element
+is later focused (the browser restores its own caret on focus). But that is
+exactly KiCad's order — `SelectAllInTextCtrls` runs while the field is still
+unfocused, then `ForceFocus` focuses it. So `wxDomSetSelection` (wx-dom.js), when
+it sets a selection on a non-active element, registers a **one-shot `focus`
+listener that re-applies the range** — making "select then focus" behave like
+native. Verified at the wx layer first: the standalone `textsel` app's write-half
+(`SetSelection(2,7)` on a button-blurred field → focus → read) was RED without
+this and GREEN with it.
+
+**Surface: Track & Via Properties on a single-track board.** With a track
+selected the dialog sets initial focus on `m_TrackWidthCtrl`
+(`dialog_track_via_properties.cpp:845-846`), a plain wxTextCtrl pre-filled with
+the width. (A via board is wrong — focus would land on the net selector.) The
+test injects a `(segment … (width 0.25) …)` board, Ctrl+A selects the track, `e`
+opens the dialog, then — **without clicking** — types `5` into the focused field.
+
+| # | KiCad surface | RED (pcbnew vs unpatched) | GREEN (pcbnew vs patched wx + kicad gate) |
+|---|---|---|---|
+| **H-3** | Track Properties open, focused width field pre-filled `0.25`, type `5` | **`"0.255"`** — inserted — *measured* | **`"5"`** — selected pre-fill replaced — *measured* |
+
+- **RED — measured**: `[H3] width field value after typing "5": "0.255"` →
+ `expect("0.255").toBe("5")` fails; identical on retry.
+- **GREEN — measured**: `[H3] … after typing "5": "5"` (pre-fill `"0.25"` at open,
+ precondition confirms focus landed on the width field); passes (20.9s), no
+ `Aborted(`.
+
+Standalone (read-path proof, no KiCad-observable consumer): `textsel` app +
+`tests/e2e/parity-audit.spec.ts`. READ half — put a real DOM selection (4,9) /
+type at the end, ask C++: RED `insertion=0 sel=0,0` → GREEN `insertion=4 sel=4,9`
+/ `insertion=12`. WRITE half — `SetSelection(2,7)`/`SelectAll()` → assert the DOM
+selection: RED unchanged → GREEN `[2,7]`/`[0,12]`. Full parity-audit suite 7/7.
+
+Method: KiCad spec RED vs the current binary → wx fix → standalone RED→GREEN
+(fast, no docker) → kicad gate line → `BINARYEN_OPT_LEVEL=-O1 docker/build.sh
+pcbnew` → KiCad spec GREEN → regressions (`via-clientdata`, `grid-clip-bleed`,
+`plot-checklist`, `modal-input-lock`, `load-pcb`).
+
+**Note on `wxDomSetValue`:** it now skips a no-op `el.value = value` assignment
+(`el.value !== value` guard) — reassigning the same string collapses the DOM
+caret to the end, which would undo a selection just placed by `wxDomSetSelection`.
+
+## KiCad e2e (product proof) — `tests/kicad/calc-slider-scroll.spec.ts` (H-6)
+
+H-6: `wxSlider` in the DOM port fired only `wxEVT_SLIDER`, never the
+`wxEVT_SCROLL_*` family, so handlers bound *exclusively* to the scroll family
+never ran. Fix (`wxwidgets/src/wasm/slider.cpp`, already in the tree): on the DOM
+`input` event fire `wxEVT_SCROLL_THUMBTRACK` + `wxEVT_SCROLL_CHANGED`, and on
+`change` fire `wxEVT_SCROLL_THUMBRELEASE`, before the `wxEVT_SLIDER` command
+event (mirrors `src/gtk/slider.cpp`).
+
+**Surface the reachability doc missed.** `kicad-e2e-reachability.md` marked H-6
+"blocked" because it only considered the colour picker (doesn't open in wasm)
+and the Appearance opacity sliders (canvas-pixel effect). But the **PCB
+Calculator → Cable Size** panel has the exact pattern with a *readable text
+field* effect: `m_slCurrentDensity` is wired **only** to `wxEVT_SCROLL_*`
+(`panel_cable_size_base.cpp:283-`) → `onUpdateCurrentDensity`, whose body
+recomputes the **Ampacity** output (`panel_cable_size.cpp:199`). The calculator
+runs in wasm (`calculator.spec.ts`); the panel is `pcb_calculator_frame.cpp:173`.
+(The calculator app is a separate `docker/build.sh calculator` target — it must
+be built + staged alongside pcbnew for this spec.)
+
+**The e2e:** open the calculator, dismiss the first-run wizard, `clickTreeItem
+('Cable Size')`, snapshot every editable text ` `, drive the range slider
+to `12` (`el.value=…; dispatch input+change`), re-snapshot, assert ≥1 field
+changed. Only the slider is touched between snapshots, so any change is
+attributable to it.
+
+| # | KiCad surface | RED (calculator vs unfixed slider) | GREEN (vs fixed slider) |
+|---|---|---|---|
+| **H-6** | Cable Size current-density slider → output fields | **0 fields changed** — the scroll event never fires, `onUpdateCurrentDensity` never runs — *measured* | **1 field changed** — Ampacity recomputes — *measured* |
+
+- **RED — measured**: `[H6] output fields changed by the slider move: 0` (the
+ Ampacity field stays `2.35619`) → `expect(0).toBeGreaterThan(0)` fails.
+- **GREEN — measured**: `[H6] output fields changed by the slider move: 1`
+ (Ampacity `2.35619 → 9.42478`); test passes.
+
+Method: measured GREEN against the current binary (fix already staged) →
+`git checkout src/wasm/slider.cpp` to revert to the unfixed HEAD → `docker/
+build.sh calculator` → RED → restore the fix.
+
+## KiCad e2e (product proof) — `tests/kicad/menu-undo-stale.spec.ts` (H-7)
+
+H-7: `wxEVT_MENU_OPEN` was never fired — the DOM menubar opens its popup from a
+cached JS snapshot and never calls into C++, and the menu is only re-serialized
+to the DOM on structural mutations. KiCad refreshes menu enable/check **only** on
+`wxEVT_MENU_OPEN` (`ACTION_MENU::OnMenuEvent` → `ACTIONS::updateMenu`,
+`action_menu.cpp:424`), so every item kept its construction-time state.
+
+**Fix (wx port):** a new `wx_dom_menu_open(domId, menuIndex)` export
+(`src/wasm/domevents.cpp`) → `wxMenuBar::WasmOnMenuOpen` (`src/wasm/menu.cpp`)
+fires `wxEVT_MENU_OPEN` via `wxMenu::ProcessMenuEvent` (reaching KiCad's
+ACTION_MENU handler + the frame), runs `menu->UpdateUI()`, then serializes the
+menu. The JS title-click handler (`build/wasm/wx-dom.js`) calls it before opening
+the popup and uses the fresh items.
+
+**Two non-obvious mechanics (each cost an iteration):**
+1. **The refresh Asyncify-suspends** (`updateMenu` runs through the tool
+ framework). A string returned across that suspension is **lost** — the sync
+ ccall resolves to `null` (measured: `[MENUOPEN] fresh=NULL`). Fix: C++ pushes
+ the fresh JSON to a JS store via `EM_ASM` (`wxDomSetOpenMenuItems`), which
+ survives suspend/resume, and the JS handler calls the ccall with
+ `{async:true}` + `await` so the popup opens only after the refresh completes.
+2. **`m_dirty` gates `updateMenu`** (`action_menu.cpp:424`), but menubar
+ ACTION_MENUs never `ClearDirty` (only context menus do, `tool_menu.cpp:61`),
+ so `m_dirty` stays true and **every** open refreshes — which is exactly what
+ the test needs (both reads refresh).
+
+**The e2e:** load a one-footprint board (empty undo stack), open **Edit**, read
+the rendered `Undo` menuitem's `enabled` (E0); `kicadCollabTestMoveFirst` (a real
+`BOARD_COMMIT::Push` → one undo entry — pump the canvas so the deferred commit
+drains); reopen Edit, read `enabled` (E1).
+
+| # | KiCad surface | RED (pcbnew vs unpatched) | GREEN (vs patched) |
+|---|---|---|---|
+| **H-7** | Edit▸Undo `enabled`, empty stack → after an undoable move | **E0=`true`, E1=`true`** — frozen at the construction default, never tracks the stack — *measured* | **E0=`false`, E1=`true`** — disabled on the empty stack, enabled after the move — *measured* |
+
+- **RED — measured**: `[H7] Undo.enabled with an empty undo stack: true` →
+ `expect(E0).toBe(false)` fails (the frozen default is `true`).
+- **GREEN — measured**: the C++ push logs `UndoEnabled=false` on the first open
+ and `UndoEnabled=true` on the second; E0=`false`, E1=`true`; test passes
+ (15.8s). Menu-opening regressions (`plot-checklist`, `modal-input-lock`,
+ `grid-clip-bleed`) stay green under the new async menu-open path.
+
+Method: RED = the current binary (H-7 was never implemented) → implement the fix
+→ `BINARYEN_OPT_LEVEL=-O1 docker/build.sh pcbnew` → GREEN. The JS side
+(`wx-dom.js`) is a separate script (not compiled into the wasm), so its
+iterations were validated without rebuilding.
+
+## KiCad e2e (product proof) — `tests/kicad/contextmenu-fresh.spec.ts` (H-8)
+
+H-8 is the right-click sibling of H-7. `wxWindowWasm::DoPopupMenu`
+(`src/wasm/window.cpp`) serialized the popup once via `WasmItemsToJson()` and
+never fired `wxEVT_MENU_OPEN` or ran a `menu->UpdateUI()` pass. Fix: fire both
+before serializing — mirrors `wxMenuBar::WasmOnMenuOpen`.
+
+**Why the pcbnew *selection* menu can't repro it (the surface trap):** KiCad
+pre-refreshes most context menus in C++ before the popup —
+`TOOL_MENU::ShowContextMenu(SELECTION&)` (`tool_menu.cpp:57`) runs `Evaluate()`
+\+ `UpdateAll()` + `ClearDirty()`, so the selection menu is fully fresh even in
+RED. The gap survives only in the **no-arg** `TOOL_MENU::ShowContextMenu()`
+overload (`tool_menu.cpp:66`), which just `SetDirty()`s and shows — relying
+entirely on `wxEVT_MENU_OPEN` → `updateMenu` (gated on `m_dirty`, true here) to
+`Evaluate` the `CONDITIONAL_MENU`. The **pcbnew Measure tool** uses it
+(`pcb_viewer_tools.cpp:441`). Because a `CONDITIONAL_MENU`'s items don't
+materialize until `Evaluate()` runs, the RED trap isn't a stale bit but an
+**empty menu**: the cloned popup (`tool_manager.cpp:971`) serializes 0 items.
+The clone keeps `m_dirty=true` (constructor default; `copyFrom` doesn't copy
+it), so the fix's `wxEVT_MENU_OPEN` does trigger `updateMenu` on it.
+
+**The e2e:** boot pcbnew.html; right-click the canvas after a left-click
+(selection menu — the CONTROL) → must be populated in both builds; then activate
+Measure (Ctrl+Shift+M) and right-click → the SIGNAL.
+
+| # | KiCad surface | RED (unpatched) | GREEN (patched) |
+|---|---|---|---|
+| **H-8** | Measure-tool canvas right-click menu | **0 items — empty popup** (CONDITIONAL_MENU never Evaluated) — *measured* | **4 items `["Cancel","Copy","Zoom","Grid"]`** (Evaluated on open) — *measured* |
+
+- **RED — measured**: `[H8] selection-tool menu (5): [...,"Zoom","Grid"]` (control
+ OK) but `[H8] measure-tool menu (0): []` →
+ `expect(measLabels.length).toBeGreaterThan(0)` fails.
+- **GREEN — measured**: `[H8] measure-tool menu (4): ["Cancel","Copy","Zoom","Grid"]`;
+ test passes (15.2s). The measure menu's items differ from the selection menu's
+ (`Cancel`/`Copy` vs `Get and Move Footprint`/`Paste`), confirming the Measure
+ tool activated and its *own* no-arg menu is the one that was empty in RED.
+
+The selection-menu control proves the failure is specific to the
+`wxEVT_MENU_OPEN` / no-arg path, not a general popup break. Same root as H-7;
+`window.cpp` change → full pcbnew rebuild. Build gotcha on a memory-tight Mac:
+the host-side binaryen `-O1` asyncify-shrink pass wants ~10-15 GB — run the build
+**detached** (`nohup … & disown`, not a reap-prone tracked background task) with
+`BINARYEN_CORES=2` to keep peak RAM under the ceiling.
diff --git a/kicad b/kicad
index ca8877324..f3a2e27e8 160000
--- a/kicad
+++ b/kicad
@@ -1 +1 @@
-Subproject commit ca8877324ce78d1bc9cb32bf634f091e0e76fb29
+Subproject commit f3a2e27e8a488ee29a760f690e5a917167facbd1
diff --git a/tests/apps/Makefile.wasm b/tests/apps/Makefile.wasm
index 324d2e4d3..9aec49888 100644
--- a/tests/apps/Makefile.wasm
+++ b/tests/apps/Makefile.wasm
@@ -65,7 +65,7 @@ CXXFLAGS += $(EH_FLAGS)
# - js_writeTextToClipboard, js_readTextFromClipboard, js_clipboardHasText, js_clearClipboard: for clipboard
# - js_enumerateFonts: for font enumeration via Local Font Access API
BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
- -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
+ -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stringToNewUTF8']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['startModal','js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
@@ -213,7 +213,14 @@ all: minimal_test.html \
$(S)/stattext-ellipsize/stattext-ellipsize_test.html \
$(S)/tooltip-lifetime/tooltip-lifetime_test.html \
$(S)/tooltip-toolbar/tooltip-toolbar_test.html \
- $(S)/warp-pointer/warp-pointer_test.html
+ $(S)/warp-pointer/warp-pointer_test.html \
+ $(S)/selevent-clientdata/selevent-clientdata_test.html \
+ $(S)/textctrl-clear/textctrl-clear_test.html \
+ $(S)/checklist-checks/checklist-checks_test.html \
+ $(S)/slider-scroll/slider-scroll_test.html \
+ $(S)/config-utf8/config-utf8_test.html \
+ $(S)/stattext-mnemonic/stattext-mnemonic_test.html \
+ $(S)/textsel/textsel_test.html
# Main test app
minimal_test.o: minimal_test.cpp
@@ -270,6 +277,76 @@ $(S)/warp-pointer/warp-pointer_test.html: $(S)/warp-pointer/warp-pointer_test.o
warp-pointer: $(S)/warp-pointer/warp-pointer_test.html
+# --- wxWidgets DOM-port vs native parity reproductions (parity-audit) ---------
+
+# Selection command events must carry per-item client data (choice/listbox/
+# combobox) — else KiCad's Track & Via Properties dialog null-derefs.
+$(S)/selevent-clientdata/selevent-clientdata_test.o: $(S)/selevent-clientdata/selevent-clientdata_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/selevent-clientdata/selevent-clientdata_test.html: $(S)/selevent-clientdata/selevent-clientdata_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+selevent-clientdata: $(S)/selevent-clientdata/selevent-clientdata_test.html
+
+# wxTextCtrl::Clear()/Remove() must update the visible element.
+$(S)/textctrl-clear/textctrl-clear_test.o: $(S)/textctrl-clear/textctrl-clear_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/textctrl-clear/textctrl-clear_test.html: $(S)/textctrl-clear/textctrl-clear_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+textctrl-clear: $(S)/textctrl-clear/textctrl-clear_test.html
+
+# wxCheckListBox check marks must survive item-list rebuilds.
+$(S)/checklist-checks/checklist-checks_test.o: $(S)/checklist-checks/checklist-checks_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/checklist-checks/checklist-checks_test.html: $(S)/checklist-checks/checklist-checks_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+checklist-checks: $(S)/checklist-checks/checklist-checks_test.html
+
+# wxSlider must fire the wxEVT_SCROLL_* family, not only wxEVT_SLIDER.
+$(S)/slider-scroll/slider-scroll_test.o: $(S)/slider-scroll/slider-scroll_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/slider-scroll/slider-scroll_test.html: $(S)/slider-scroll/slider-scroll_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+slider-scroll: $(S)/slider-scroll/slider-scroll_test.html
+
+# wxConfig must round-trip non-ASCII strings without truncation.
+$(S)/config-utf8/config-utf8_test.o: $(S)/config-utf8/config-utf8_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/config-utf8/config-utf8_test.html: $(S)/config-utf8/config-utf8_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+config-utf8: $(S)/config-utf8/config-utf8_test.html
+
+# wxStaticText must consume the mnemonic '&' (KiCad dialog labels show a stray &).
+$(S)/stattext-mnemonic/stattext-mnemonic_test.o: $(S)/stattext-mnemonic/stattext-mnemonic_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/stattext-mnemonic/stattext-mnemonic_test.html: $(S)/stattext-mnemonic/stattext-mnemonic_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+stattext-mnemonic: $(S)/stattext-mnemonic/stattext-mnemonic_test.html
+
+# wxTextEntry caret/selection must live-read/write the DOM input (H-3/#30).
+$(S)/textsel/textsel_test.o: $(S)/textsel/textsel_test.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+$(S)/textsel/textsel_test.html: $(S)/textsel/textsel_test.o $(WX_CORE_LIB) $(JS_FILES)
+ $(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
+
+textsel: $(S)/textsel/textsel_test.html
+
+# Aggregate: build every parity-audit reproduction in one go.
+parity-repros: selevent-clientdata textctrl-clear checklist-checks slider-scroll config-utf8 stattext-mnemonic textsel
+.PHONY: parity-repros selevent-clientdata textctrl-clear checklist-checks slider-scroll config-utf8 stattext-mnemonic textsel
+
# Menu test (no GL)
$(S)/menu/menu_test.o: $(S)/menu/menu_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
diff --git a/tests/apps/standalone/checklist-checks/checklist-checks_test.cpp b/tests/apps/standalone/checklist-checks/checklist-checks_test.cpp
new file mode 100644
index 000000000..1541cc6f2
--- /dev/null
+++ b/tests/apps/standalone/checklist-checks/checklist-checks_test.cpp
@@ -0,0 +1,77 @@
+// wxCheckListBox check marks must survive item-list rebuilds (DOM port).
+//
+// Bug (src/wasm/listbox.cpp + checklst.cpp):
+// Any Append/Insert/Delete/SetString on the (check)listbox calls
+// wxListBox::WasmSyncItems(), which rebuilds every DOM row UNCHECKED
+// (wxDomSetItems) and then re-applies only m_itemsSelected via
+// WasmSyncSelection(). wxCheckListBox keeps its check state in a SEPARATE
+// array m_itemsChecked that is never re-pushed — and the DOM checklist has a
+// single boolean per row (wxDomSetItemSelected drives the row checkbox). So
+// every Check() done before a later Append() is wiped from the DOM.
+//
+// The canonical KiCad pattern is Append-then-Check in a loop:
+// int i = clb->Append(name);
+// if (enabled) clb->Check(i);
+// (dialog_plot.cpp, dialog_print_*.cpp, ...). Each Append rebuilds the rows
+// unchecked, so all but the final Append's check vanish — the Plot/Print
+// layer checklists show the wrong checkboxes even though the C++ cache is fine.
+//
+// This app appends 5 rows and checks the even indices (0, 2, 4) with that exact
+// Append-then-Check pattern. The spec reads the live DOM checkboxes:
+// RED (bug present): only the last-checked row (4) is checked in the DOM.
+// GREEN (fixed): rows 0, 2, 4 are checked; rows 1, 3 are not.
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#include "wx/checklst.h"
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame();
+};
+
+ReproFrame::ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxCheckListBox checks repro",
+ wxDefaultPosition, wxSize(320, 280))
+{
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+
+ wxCheckListBox *clb = new wxCheckListBox(this, wxID_ANY);
+ sizer->Add(clb, 1, wxALL | wxEXPAND, 10);
+
+ // Append-then-Check loop, exactly like KiCad's layer checklists.
+ for (int i = 0; i < 5; i++)
+ {
+ const int n = clb->Append(wxString::Format("Layer %d", i));
+ if (i % 2 == 0) // check the even rows: 0, 2, 4
+ clb->Check(n, true);
+ }
+
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] checklist ready'); }); });
+#endif
+}
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/config-utf8/config-utf8_test.cpp b/tests/apps/standalone/config-utf8/config-utf8_test.cpp
new file mode 100644
index 000000000..a0ede3a84
--- /dev/null
+++ b/tests/apps/standalone/config-utf8/config-utf8_test.cpp
@@ -0,0 +1,120 @@
+// wxConfig must round-trip non-ASCII strings without truncation (DOM port).
+//
+// Bug (src/wasm/config.cpp + build/wasm/wx.js):
+// DoReadString() sizes its C++ buffer from getConfigEntryLength(), and the
+// enumeration paths (GetNextEntry/GetNextGroup) from getConfigKeyLength().
+// Both JS helpers return the JavaScript string's `.length` — the number of
+// UTF-16 code units — but the C++ side feeds that to stringToUTF8() as a max
+// BYTE budget. For any non-ASCII text the UTF-8 byte length exceeds the
+// UTF-16 unit count, so the buffer is undersized and the value/name is
+// truncated at the first multi-byte character (writes are fine; only the
+// read-back is corrupted). A "café" written and read back returns "caf".
+//
+// This silently corrupts recent-file paths and any wxConfig-backed string for
+// i18n users (e.g. /home/José/board.kicad_pcb).
+//
+// This app writes a non-ASCII VALUE and a non-ASCII entry NAME, reads them back
+// in the same session, and self-reports:
+// RED (bug present): the read-back is truncated -> [REPRO] ...: FAIL
+// GREEN (fixed): the read-back equals what was written -> PASS
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#include "wx/config.h"
+#include "wx/wasm/config.h"
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+static void Repro(const wxString& line)
+{
+#ifdef __EMSCRIPTEN__
+ EM_ASM({ console.log('[REPRO] ' + UTF8ToString($0)); },
+ (const char *)line.utf8_str());
+#endif
+}
+
+static void RunConfigTest()
+{
+ wxLocalStorageConfig cfg("wxUtf8ReproTest");
+
+ // start from a clean group so a previous run can't pollute enumeration
+ cfg.DeleteGroup("/reprotest");
+
+ // ---- 1) non-ASCII VALUE round-trip -------------------------------------
+ const wxString value =
+ wxString::FromUTF8("café-résumé-naïve-Žluťoučký-日本語-Ω");
+
+ cfg.Write("/reprotest/path", value);
+ cfg.Flush();
+
+ wxString readValue;
+ const bool readOk = cfg.Read("/reprotest/path", &readValue);
+ if (readOk && readValue == value)
+ Repro("config_value: PASS");
+ else
+ Repro("config_value: FAIL (read back '" + readValue + "')");
+
+ // ---- 2) non-ASCII entry NAME round-trip (enumeration) ------------------
+ // Write with an ABSOLUTE key so the stored localStorage key is well-formed
+ // (a relative write under a non-root path is a separate, unrelated bug), and
+ // the only thing under test is getConfigKeyLength's byte budget.
+ const wxString keyName = wxString::FromUTF8("náme_日_key");
+
+ cfg.Write("/reprotest/" + keyName, "x");
+ cfg.Flush();
+
+ bool found = false;
+ cfg.SetPath("/reprotest");
+ wxString entry;
+ long idx = 0;
+ if (cfg.GetFirstEntry(entry, idx))
+ {
+ do
+ {
+ if (entry == keyName)
+ found = true;
+ } while (cfg.GetNextEntry(entry, idx));
+ }
+ Repro(found ? "config_keyname: PASS"
+ : "config_keyname: FAIL (non-ASCII entry name truncated)");
+
+ cfg.DeleteGroup("/reprotest");
+}
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxConfig UTF-8 repro",
+ wxDefaultPosition, wxSize(320, 120))
+ {
+ new wxStaticText(this, wxID_ANY, "wxConfig UTF-8 round-trip test",
+ wxPoint(10, 10));
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] {
+ RunConfigTest();
+ EM_ASM({ console.log('[REPRO] config ready'); });
+ });
+#endif
+ }
+};
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/selevent-clientdata/selevent-clientdata_test.cpp b/tests/apps/standalone/selevent-clientdata/selevent-clientdata_test.cpp
new file mode 100644
index 000000000..1f0956052
--- /dev/null
+++ b/tests/apps/standalone/selevent-clientdata/selevent-clientdata_test.cpp
@@ -0,0 +1,137 @@
+// Selection command events must carry per-item client data (DOM port).
+//
+// Bug (src/wasm/{choice,listbox,combobox}.cpp, OnDomEvent):
+//
+// wxCommandEvent event(wxEVT_CHOICE, GetId());
+// event.SetInt(m_selection);
+// event.SetString(GetString(m_selection));
+// event.SetEventObject(this);
+// HandleWindowEvent(event); // <-- no client data EVER attached
+//
+// The hand-rolled wxEVT_CHOICE / wxEVT_LISTBOX / wxEVT_COMBOBOX events never call
+// InitCommandEventWithItems(), so event.GetClientData()/GetClientObject() always
+// return NULL even when the picked item was appended WITH client data. Native
+// ports route through wxControlWithItemsBase::SendSelectionChangedEvent(), which
+// copies the selected item's client object/data into the event
+// (src/common/ctrlsub.cpp).
+//
+// This is a real crash in KiCad: pcbnew's Track & Via Properties dialog does
+// static_cast(aEvent.GetClientData())->m_Diameter
+// with no null guard (dialog_track_via_properties.cpp onViaSelect) — a normal
+// "pick a predefined via size" action then traps the WASM module.
+//
+// The repro appends items WITH typed client data (wxStringClientData), then the
+// spec fires a real DOM 'change' on each control's element. The bound handler
+// reads the event's client object and self-reports:
+//
+// RED (bug present): GetClientObject() == NULL -> [REPRO] : FAIL
+// GREEN (fixed): GetClientObject() == the picked item's data -> PASS
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#include "wx/combobox.h"
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+static void Repro(const wxString& line)
+{
+#ifdef __EMSCRIPTEN__
+ EM_ASM({ console.log('[REPRO] ' + UTF8ToString($0)); },
+ (const char *)line.utf8_str());
+#else
+ wxPrintf("[REPRO] %s\n", line);
+#endif
+}
+
+// Expected client-data payload for item index n: "DATA_A", "DATA_B", ...
+static wxString ExpectedData(int n)
+{
+ return wxString::Format("DATA_%c", (char)('A' + n));
+}
+
+// Read the selection event's client OBJECT and report PASS/FAIL for `name`.
+static void CheckEvent(const wxString& name, wxCommandEvent& evt)
+{
+ const int sel = evt.GetInt();
+ wxClientData *obj = evt.GetClientObject();
+ if (!obj)
+ {
+ Repro(name + ": FAIL (GetClientObject()==null, sel=" +
+ wxString::Format("%d", sel) + ")");
+ return;
+ }
+
+ wxStringClientData *sd = static_cast(obj);
+ const wxString got = sd->GetData();
+ const wxString want = ExpectedData(sel);
+ if (got == want)
+ Repro(name + ": PASS (" + got + ")");
+ else
+ Repro(name + ": FAIL (got '" + got + "' want '" + want + "')");
+}
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame();
+
+private:
+ void OnChoice(wxCommandEvent &e) { CheckEvent("choice_clientdata", e); }
+ void OnListBox(wxCommandEvent &e) { CheckEvent("listbox_clientdata", e); }
+ void OnComboBox(wxCommandEvent &e) { CheckEvent("combobox_clientdata", e); }
+};
+
+static void Fill(wxControlWithItems *ctrl)
+{
+ ctrl->Append("Alpha", new wxStringClientData("DATA_A"));
+ ctrl->Append("Beta", new wxStringClientData("DATA_B"));
+ ctrl->Append("Gamma", new wxStringClientData("DATA_C"));
+}
+
+ReproFrame::ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "selection client-data repro",
+ wxDefaultPosition, wxSize(360, 320))
+{
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+
+ wxChoice *choice = new wxChoice(this, wxID_ANY);
+ Fill(choice);
+ choice->Bind(wxEVT_CHOICE, &ReproFrame::OnChoice, this);
+ sizer->Add(choice, 0, wxALL | wxEXPAND, 8);
+
+ wxListBox *listbox = new wxListBox(this, wxID_ANY);
+ Fill(listbox);
+ listbox->Bind(wxEVT_LISTBOX, &ReproFrame::OnListBox, this);
+ sizer->Add(listbox, 1, wxALL | wxEXPAND, 8);
+
+ // editable combobox -> in the DOM port
+ wxComboBox *combo = new wxComboBox(this, wxID_ANY, "");
+ Fill(combo);
+ combo->Bind(wxEVT_COMBOBOX, &ReproFrame::OnComboBox, this);
+ sizer->Add(combo, 0, wxALL | wxEXPAND, 8);
+
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] selevent ready'); }); });
+#endif
+}
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/slider-scroll/slider-scroll_test.cpp b/tests/apps/standalone/slider-scroll/slider-scroll_test.cpp
new file mode 100644
index 000000000..624d9a4e6
--- /dev/null
+++ b/tests/apps/standalone/slider-scroll/slider-scroll_test.cpp
@@ -0,0 +1,88 @@
+// wxSlider must fire the wxEVT_SCROLL_* family, not only wxEVT_SLIDER (DOM port).
+//
+// Bug (src/wasm/slider.cpp, OnDomEvent):
+// On a DOM 'input' the slider builds ONLY a wxEVT_SLIDER command event:
+// wxCommandEvent event(wxEVT_SLIDER, GetId());
+// ...
+// // TODO(dom-phase-3): also fire the wxScrollEvent family
+// // (wxEVT_SCROLL_THUMBTRACK/THUMBRELEASE/CHANGED).
+// Native ports fire the wxEVT_SCROLL_* scroll-event family first, then
+// wxEVT_SLIDER (src/gtk/slider.cpp).
+//
+// This breaks KiCad's colour picker: dialog_color_picker_base.cpp connects the
+// brightness AND transparency sliders EXCLUSIVELY to wxEVT_SCROLL_* (->
+// OnChangeBrightness / OnChangeAlpha). In WASM, dragging them did nothing.
+//
+// This app binds a slider to wxEVT_SCROLL_THUMBTRACK and wxEVT_SCROLL_CHANGED
+// (NOT wxEVT_SLIDER), exactly like the colour picker. The spec drives the real
+// :
+// RED (bug present): the scroll handlers never run -> no [REPRO] scroll lines.
+// GREEN (fixed): thumbtrack + changed fire with the dragged value.
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#include "wx/slider.h"
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+static void Repro(const wxString& line)
+{
+#ifdef __EMSCRIPTEN__
+ EM_ASM({ console.log('[REPRO] ' + UTF8ToString($0)); },
+ (const char *)line.utf8_str());
+#endif
+}
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame();
+
+private:
+ void OnThumbtrack(wxScrollEvent &e)
+ { Repro(wxString::Format("slider_thumbtrack: %d", e.GetPosition())); }
+ void OnChanged(wxScrollEvent &e)
+ { Repro(wxString::Format("slider_changed: %d", e.GetPosition())); }
+ // regression guard: the command event must keep working too
+ void OnCommand(wxCommandEvent &e)
+ { Repro(wxString::Format("slider_command: %d", e.GetInt())); }
+};
+
+ReproFrame::ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxSlider scroll-event repro",
+ wxDefaultPosition, wxSize(360, 140))
+{
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+
+ wxSlider *slider = new wxSlider(this, wxID_ANY, 0, 0, 100);
+ // The colour picker binds ONLY the scroll family — no wxEVT_SLIDER.
+ slider->Bind(wxEVT_SCROLL_THUMBTRACK, &ReproFrame::OnThumbtrack, this);
+ slider->Bind(wxEVT_SCROLL_CHANGED, &ReproFrame::OnChanged, this);
+ slider->Bind(wxEVT_SLIDER, &ReproFrame::OnCommand, this);
+ sizer->Add(slider, 0, wxALL | wxEXPAND, 16);
+
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] slider ready'); }); });
+#endif
+}
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/stattext-mnemonic/stattext-mnemonic_test.cpp b/tests/apps/standalone/stattext-mnemonic/stattext-mnemonic_test.cpp
new file mode 100644
index 000000000..affc53146
--- /dev/null
+++ b/tests/apps/standalone/stattext-mnemonic/stattext-mnemonic_test.cpp
@@ -0,0 +1,57 @@
+// wxStaticText must consume the mnemonic '&' like the native ports (DOM port).
+//
+// Bug (src/wasm/stattext.cpp, WXSetVisibleLabel):
+// The visible label is pushed to the DOM verbatim (wxDomSetText), so a wx
+// mnemonic marker '&' shows up literally. Native ports strip/interpret it:
+// wxGTK runs GTKConvertMnemonics() + gtk_label_set_text_with_mnemonic(), so
+// "&File" displays as "File" and "&&" collapses to a single "&".
+//
+// KiCad has ~17 dialog labels that carry a '&' (pin properties, find/replace,
+// defaults dialogs, ...) which render with a stray '&' in WASM.
+//
+// The app creates one wxStaticText whose label exercises both rules; the spec
+// reads the rendered :
+// RED (bug present): the shows "&Layer && Net".
+// GREEN (fixed): the shows "Layer & Net".
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxStaticText mnemonic repro",
+ wxDefaultPosition, wxSize(320, 120))
+ {
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+ // "&Layer" -> mnemonic 'L' removed -> "Layer"; "&&" -> "&".
+ sizer->Add(new wxStaticText(this, wxID_ANY, "&Layer && Net"),
+ 0, wxALL, 16);
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] stattext-mnemonic ready'); }); });
+#endif
+ }
+};
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/textctrl-clear/textctrl-clear_test.cpp b/tests/apps/standalone/textctrl-clear/textctrl-clear_test.cpp
new file mode 100644
index 000000000..824d48fc5
--- /dev/null
+++ b/tests/apps/standalone/textctrl-clear/textctrl-clear_test.cpp
@@ -0,0 +1,81 @@
+// wxTextCtrl::Clear()/Remove() must update the visible (DOM port).
+//
+// Bug (src/wasm/textentry.cpp + textctrl.cpp):
+// wxTextCtrl overrides WriteText() and DoSetValue() to push the new value into
+// the DOM element, but it does NOT override Remove(). wxTextEntryBase::Clear()
+// is { Remove(0, GetLastPosition()); }, so both Clear() and Remove() fall
+// through to wxTextEntry::Remove(), which only mutates the C++ m_value cache:
+//
+// m_value.erase(from, to - from);
+// // TODO(dom-phase-2): mirror the new value into the DOM element.
+//
+// So after textCtrl->Clear() the cache (GetValue()) is empty but the
+// still shows the old text — and the next keystroke reads the stale DOM value
+// back, silently resurrecting the "deleted" text. Native ports delete the text
+// in the live widget (gtk_editable_delete_text).
+//
+// The spec sets a known value, clicks Clear (then Remove), and checks the real
+// element value:
+// RED (bug present): the keeps the old text after Clear()/Remove().
+// GREEN (fixed): the reflects the cache.
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+enum { ID_CLEAR = wxID_HIGHEST + 1, ID_REMOVE };
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame();
+
+private:
+ void OnClear(wxCommandEvent &) { m_text->Clear(); }
+ // Remove the middle: "hello world" -> "ho world" (drops chars [1,5)).
+ void OnRemove(wxCommandEvent &) { m_text->Remove(1, 5); }
+
+ wxTextCtrl *m_text = nullptr;
+};
+
+ReproFrame::ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxTextCtrl Clear/Remove repro")
+{
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+
+ m_text = new wxTextCtrl(this, wxID_ANY, "hello world");
+ sizer->Add(m_text, 0, wxALL | wxEXPAND, 10);
+
+ wxButton *clearBtn = new wxButton(this, ID_CLEAR, "Clear");
+ clearBtn->Bind(wxEVT_BUTTON, &ReproFrame::OnClear, this);
+ sizer->Add(clearBtn, 0, wxALL, 10);
+
+ wxButton *removeBtn = new wxButton(this, ID_REMOVE, "Remove");
+ removeBtn->Bind(wxEVT_BUTTON, &ReproFrame::OnRemove, this);
+ sizer->Add(removeBtn, 0, wxALL, 10);
+
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] textctrl-clear ready'); }); });
+#endif
+}
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/apps/standalone/textsel/textsel_test.cpp b/tests/apps/standalone/textsel/textsel_test.cpp
new file mode 100644
index 000000000..37d6eb85d
--- /dev/null
+++ b/tests/apps/standalone/textsel/textsel_test.cpp
@@ -0,0 +1,96 @@
+// Reproduction for parity-audit H-3/#30: wxTextEntry caret/selection is a pure
+// C++-side cache in the WASM DOM port.
+//
+// - READ (H-3): GetInsertionPoint()/GetSelection() never consult the DOM
+// 's selectionStart/selectionEnd. Worse, every DOM "input" event
+// routes through wxTextEntry::DoSetValue, which resets the cached caret to
+// 0 and clears the cached selection — so after any typing (or a user
+// drag-select) the accessors report insertion=0, sel=(0,0) regardless of
+// the real caret.
+// - WRITE (#30): SetInsertionPoint()/SetSelection()/SelectAll() update only
+// the cache; nothing calls setSelectionRange() on the DOM element, so
+// programmatic selection (e.g. KiCad's select-all-on-dialog-open) is
+// invisible and typing inserts instead of replacing.
+//
+// The app exposes one wxTextCtrl seeded "hello world" plus three buttons:
+// Report -> logs "[REPRO] textsel state: insertion= sel=,"
+// from the C++ accessors (read-path probe).
+// Select Middle -> SetSelection(2, 7) (write-path probe)
+// Select All -> SelectAll() (write-path probe)
+// The spec (tests/e2e/parity-audit.spec.ts) manipulates/reads the real DOM
+// selection and cross-checks both directions.
+
+#include "wx/wxprec.h"
+#ifndef WX_PRECOMP
+ #include "wx/wx.h"
+#endif
+
+#ifdef __EMSCRIPTEN__
+#include
+#endif
+
+enum { ID_REPORT = wxID_HIGHEST + 1, ID_SEL_MIDDLE, ID_SEL_ALL };
+
+class ReproFrame : public wxFrame
+{
+public:
+ ReproFrame();
+
+private:
+ void OnReport(wxCommandEvent &)
+ {
+ long from = 0, to = 0;
+ m_text->GetSelection(&from, &to);
+ const long insertion = m_text->GetInsertionPoint();
+#ifdef __EMSCRIPTEN__
+ EM_ASM({
+ console.log('[REPRO] textsel state: insertion=' + $0 + ' sel=' + $1 + ',' + $2);
+ }, (int)insertion, (int)from, (int)to);
+#endif
+ }
+ void OnSelMiddle(wxCommandEvent &) { m_text->SetSelection(2, 7); }
+ void OnSelAll(wxCommandEvent &) { m_text->SelectAll(); }
+
+ wxTextCtrl *m_text = nullptr;
+};
+
+ReproFrame::ReproFrame()
+ : wxFrame(nullptr, wxID_ANY, "wxTextEntry caret/selection repro")
+{
+ wxBoxSizer *sizer = new wxBoxSizer(wxVERTICAL);
+
+ m_text = new wxTextCtrl(this, wxID_ANY, "hello world");
+ sizer->Add(m_text, 0, wxALL | wxEXPAND, 10);
+
+ wxButton *reportBtn = new wxButton(this, ID_REPORT, "Report");
+ reportBtn->Bind(wxEVT_BUTTON, &ReproFrame::OnReport, this);
+ sizer->Add(reportBtn, 0, wxALL, 10);
+
+ wxButton *middleBtn = new wxButton(this, ID_SEL_MIDDLE, "Select Middle");
+ middleBtn->Bind(wxEVT_BUTTON, &ReproFrame::OnSelMiddle, this);
+ sizer->Add(middleBtn, 0, wxALL, 10);
+
+ wxButton *allBtn = new wxButton(this, ID_SEL_ALL, "Select All");
+ allBtn->Bind(wxEVT_BUTTON, &ReproFrame::OnSelAll, this);
+ sizer->Add(allBtn, 0, wxALL, 10);
+
+ SetSizer(sizer);
+
+#ifdef __EMSCRIPTEN__
+ CallAfter([] { EM_ASM({ console.log('[REPRO] textsel ready'); }); });
+#endif
+}
+
+class ReproApp : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ if (!wxApp::OnInit())
+ return false;
+ (new ReproFrame())->Show(true);
+ return true;
+ }
+};
+
+wxIMPLEMENT_APP(ReproApp);
diff --git a/tests/e2e/parity-audit.spec.ts b/tests/e2e/parity-audit.spec.ts
new file mode 100644
index 000000000..d9827de27
--- /dev/null
+++ b/tests/e2e/parity-audit.spec.ts
@@ -0,0 +1,269 @@
+import { test, expect, tryLoadApp } from './utils/fixtures';
+
+// Red-green reproductions for wxWidgets DOM-port vs native (wxGTK) parity gaps
+// found in the parity audit. Each standalone app
+// (tests/apps/standalone//) drives the exact buggy path and self-reports a
+// "[REPRO] : PASS/FAIL" line (or exposes live DOM state); the test is RED
+// while the bug is present and GREEN after the wasm-layer fix.
+
+// Find the "[REPRO] : ..." line emitted by a repro app.
+function reproLine(logs: string[], name: string): string | undefined {
+ return logs.find((l) => l.includes(`[REPRO] ${name}:`));
+}
+
+async function waitReady(testLogger: { consoleLogs: string[] }, marker: string) {
+ await expect
+ .poll(() => testLogger.consoleLogs.some((l) => l.includes(marker)), {
+ timeout: 30000,
+ message: `repro app should emit "${marker}"`,
+ })
+ .toBe(true);
+}
+
+test.describe('wxWidgets DOM-port parity reproductions', () => {
+ // C-1 (Critical): wxEVT_CHOICE/LISTBOX/COMBOBOX are hand-rolled and never call
+ // InitCommandEventWithItems(), so event.GetClientObject() is always NULL even
+ // when the item was appended WITH client data. KiCad's Track & Via Properties
+ // dialog static_cast(aEvent.GetClientData())->... then traps
+ // the WASM module on a normal selection. The app appends typed client data and
+ // the handler checks the event carries it.
+ test('selection events carry per-item client data (choice/listbox/combobox)', async ({
+ page,
+ testLogger,
+ }) => {
+ await page.goto('/standalone/selevent-clientdata/selevent-clientdata_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] selevent ready');
+
+ // wxChoice -> (single): pick "Beta" (index 1, client data DATA_B).
+ await page.locator('select:not([multiple])').first().selectOption({ index: 1 });
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'choice_clientdata') ?? '', {
+ timeout: 10000,
+ message: 'wxChoice selection event must carry the item client object',
+ })
+ .toContain('PASS');
+
+ // wxListBox -> : pick index 1.
+ await page.locator('select[multiple]').first().selectOption({ index: 1 });
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'listbox_clientdata') ?? '', {
+ timeout: 10000,
+ message: 'wxListBox selection event must carry the item client object',
+ })
+ .toContain('PASS');
+
+ // wxComboBox -> : commit "Beta" (index 1).
+ const combo = page.locator('input[list]').first();
+ await combo.fill('Beta');
+ await combo.dispatchEvent('change');
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'combobox_clientdata') ?? '', {
+ timeout: 10000,
+ message: 'wxComboBox selection event must carry the item client object',
+ })
+ .toContain('PASS');
+ });
+
+ // H-4 (High): wxTextCtrl does not override Remove(), so Clear() (=Remove(0,-1))
+ // and Remove() update only the C++ cache and leave the showing the old
+ // text. The app sets "hello world"; buttons drive Remove(1,5) then Clear().
+ test('wxTextCtrl Clear()/Remove() update the visible ', async ({ page, testLogger }) => {
+ await page.goto('/standalone/textctrl-clear/textctrl-clear_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] textctrl-clear ready');
+
+ const input = page.locator('input').first();
+ await expect
+ .poll(async () => await input.inputValue(), { timeout: 10000 })
+ .toBe('hello world');
+
+ // Remove(1,5): "hello world" -> "h world".
+ await page.getByRole('button', { name: 'Remove' }).click();
+ await expect
+ .poll(async () => await input.inputValue(), {
+ timeout: 10000,
+ message: 'Remove() must update the ',
+ })
+ .toBe('h world');
+
+ // Clear(): -> "".
+ await page.getByRole('button', { name: 'Clear' }).click();
+ await expect
+ .poll(async () => await input.inputValue(), {
+ timeout: 10000,
+ message: 'Clear() must empty the ',
+ })
+ .toBe('');
+ });
+
+ // H-5 (High): wxCheckListBox check marks are wiped on every item-list rebuild
+ // (Append re-pushes only m_itemsSelected, never m_itemsChecked). The app
+ // appends 5 rows and checks the even ones (0,2,4) with the Append-then-Check
+ // pattern; before the fix only the last-checked row survives in the DOM.
+ test('wxCheckListBox check marks survive item-list rebuilds', async ({ page, testLogger }) => {
+ await page.goto('/standalone/checklist-checks/checklist-checks_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] checklist ready');
+
+ const boxes = page.locator('[data-wx-check-list] input[type=checkbox]');
+ await expect.poll(async () => await boxes.count(), { timeout: 10000 }).toBe(5);
+
+ const expected = [true, false, true, false, true]; // checked evens
+ await expect
+ .poll(
+ async () => {
+ const states: boolean[] = [];
+ for (let i = 0; i < 5; i++) states.push(await boxes.nth(i).isChecked());
+ return JSON.stringify(states);
+ },
+ {
+ timeout: 10000,
+ message: 'rows 0,2,4 must be checked; 1,3 unchecked',
+ },
+ )
+ .toBe(JSON.stringify(expected));
+ });
+
+ // H-6 (High): wxSlider fires only wxEVT_SLIDER, never the wxEVT_SCROLL_* family
+ // — so KiCad's colour-picker brightness/alpha sliders (bound exclusively to
+ // wxEVT_SCROLL_*) do nothing. The app binds only the scroll family; the spec
+ // drives the real .
+ test('wxSlider fires the wxEVT_SCROLL_* family on drag', async ({ page, testLogger }) => {
+ await page.goto('/standalone/slider-scroll/slider-scroll_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] slider ready');
+
+ const slider = page.locator('input[type=range]').first();
+ await slider.evaluate((el: HTMLInputElement) => {
+ el.value = '70';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+
+ // The scroll family must fire with the dragged value (RED before the fix).
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'slider_thumbtrack') ?? '', {
+ timeout: 10000,
+ message: 'wxEVT_SCROLL_THUMBTRACK must fire with the slider value',
+ })
+ .toContain('slider_thumbtrack: 70');
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'slider_changed') ?? '', {
+ timeout: 10000,
+ message: 'wxEVT_SCROLL_CHANGED must fire',
+ })
+ .toContain('slider_changed: 70');
+ });
+
+ // H-9 (High): wxConfig read-back truncates non-ASCII at the first multi-byte
+ // char (getConfigEntryLength/getConfigKeyLength return UTF-16 code-unit count,
+ // used as a UTF-8 byte budget). The app writes a non-ASCII value and entry
+ // name and reads them back in-session.
+ test('wxConfig round-trips non-ASCII strings without truncation', async ({ page, testLogger }) => {
+ await page.goto('/standalone/config-utf8/config-utf8_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] config ready');
+
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'config_value') ?? '', {
+ timeout: 10000,
+ message: 'non-ASCII config value must round-trip intact',
+ })
+ .toContain('PASS');
+ await expect
+ .poll(() => reproLine(testLogger.consoleLogs, 'config_keyname') ?? '', {
+ timeout: 10000,
+ message: 'non-ASCII config entry name must round-trip intact',
+ })
+ .toContain('PASS');
+ });
+
+ // #36 (Medium): wxStaticText pushes its label to the DOM verbatim, so a wx
+ // mnemonic '&' renders literally. Native ports consume it ("&File" -> "File",
+ // "&&" -> "&"). The app renders "&Layer && Net".
+ test('wxStaticText consumes the mnemonic ampersand', async ({ page, testLogger }) => {
+ await page.goto('/standalone/stattext-mnemonic/stattext-mnemonic_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] stattext-mnemonic ready');
+
+ // wxStaticText -> ; it is the only span in this app.
+ await expect
+ .poll(async () => (await page.locator('span').first().textContent()) ?? '', {
+ timeout: 10000,
+ message: 'the rendered label must have the mnemonic & removed and && collapsed',
+ })
+ .toBe('Layer & Net');
+ });
+
+ // H-3/#30 (High): wxTextEntry caret/selection is a pure C++ cache. READ: the
+ // accessors never consult the DOM input's selectionStart/End, and every DOM
+ // "input" event resets the cached caret to 0 — so GetInsertionPoint()/
+ // GetSelection() report 0/(0,0) regardless of the real caret. WRITE:
+ // SetSelection()/SelectAll() never call setSelectionRange(), so programmatic
+ // selection (KiCad's select-all-on-dialog-open) is invisible in the DOM.
+ // The app reports the C++ view on demand and offers SetSelection buttons.
+ test('wxTextEntry caret/selection is live against the DOM input', async ({ page, testLogger }) => {
+ await page.goto('/standalone/textsel/textsel_test.html');
+ expect(await tryLoadApp(page, 30000), 'repro app should load').toBe(true);
+ await waitReady(testLogger, '[REPRO] textsel ready');
+
+ const input = page.locator('input').first();
+ await expect.poll(async () => input.inputValue(), { timeout: 10000 }).toBe('hello world');
+
+ const stateLines = () =>
+ testLogger.consoleLogs.filter((l) => l.includes('[REPRO] textsel state:'));
+
+ // READ half (H-3), user selection: put a real selection on the DOM input,
+ // then ask C++ what it sees.
+ await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(4, 9));
+ await page.getByRole('button', { name: 'Report', exact: true }).click();
+ await expect
+ .poll(() => stateLines()[0] ?? '', {
+ timeout: 10000,
+ message: 'GetInsertionPoint/GetSelection must reflect the live DOM selection (4,9)',
+ })
+ .toContain('insertion=4 sel=4,9');
+
+ // READ half (H-3), typed caret: type at the end — the DOM caret is at 12,
+ // while the buggy cache is reset to 0 by the input event.
+ await input.click();
+ await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(11, 11));
+ await input.pressSequentially('!');
+ await expect.poll(async () => input.inputValue(), { timeout: 10000 }).toBe('hello world!');
+ await page.getByRole('button', { name: 'Report', exact: true }).click();
+ await expect
+ .poll(() => stateLines()[1] ?? '', {
+ timeout: 10000,
+ message: 'GetInsertionPoint must track the DOM caret after typing (12)',
+ })
+ .toContain('insertion=12 sel=12,12');
+
+ // WRITE half (#30): programmatic SetSelection/SelectAll must reach the DOM.
+ // Mirror KiCad's real order (dialog_shim select-all runs while the field is
+ // unfocused, then the dialog focuses it): set selection via the button
+ // (which blurs the input to itself), then focus the input and read — the
+ // selection must survive the focus, which is what makes type-to-replace work.
+ const focusAndReadSelection = () =>
+ input.evaluate((el: HTMLInputElement) => {
+ el.focus();
+ return [el.selectionStart, el.selectionEnd];
+ });
+
+ await page.getByRole('button', { name: 'Select Middle', exact: true }).click();
+ await expect
+ .poll(focusAndReadSelection, {
+ timeout: 10000,
+ message: 'SetSelection(2,7) must set a DOM selection that survives focus',
+ })
+ .toEqual([2, 7]);
+
+ await page.getByRole('button', { name: 'Select All', exact: true }).click();
+ await expect
+ .poll(focusAndReadSelection, {
+ timeout: 10000,
+ message: 'SelectAll() must select the whole DOM value',
+ })
+ .toEqual([0, 12]);
+ });
+});
diff --git a/tests/kicad/calc-slider-scroll.spec.ts b/tests/kicad/calc-slider-scroll.spec.ts
new file mode 100644
index 000000000..819934194
--- /dev/null
+++ b/tests/kicad/calc-slider-scroll.spec.ts
@@ -0,0 +1,130 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from './fixtures';
+import { clickByLabel, clickTreeItem } from '../e2e/utils/element-tracker';
+
+// KiCad-level reproduction of the wxWidgets DOM-port "wxSlider fires only
+// wxEVT_SLIDER, never the wxEVT_SCROLL_* family" bug (parity audit H-6), fixed
+// in wxwidgets/src/wasm/slider.cpp.
+//
+// Native wxSlider fires the wxEVT_SCROLL_* family (THUMBTRACK/CHANGED/…) in
+// addition to the wxEVT_SLIDER command event. The unfixed DOM port fired only
+// wxEVT_SLIDER, so handlers bound EXCLUSIVELY to the scroll family never run.
+//
+// Surface: the PCB Calculator's "Cable Size" panel. Its current-density slider
+// m_slCurrentDensity is connected ONLY to wxEVT_SCROLL_* → onUpdateCurrentDensity
+// (panel_cable_size_base.cpp:283-286), whose body recomputes the output text
+// fields (Ampacity etc.): `m_amp_by_mm2 = m_slCurrentDensity->GetValue(); updateAll(...)`
+// (panel_cable_size.cpp:199-204). The effect is a readable value change,
+// not a canvas repaint — so it is deterministically assertable (unlike the
+// colour picker, which does not open in WASM, and the Appearance opacity
+// sliders, whose effect is canvas-pixel only).
+//
+// RED (bug present): moving the current-density slider fires no scroll event,
+// so onUpdateCurrentDensity never runs and NO output field
+// changes.
+// GREEN (fixed): moving the slider recomputes Ampacity (and derived
+// fields) → at least one output value changes.
+
+async function waitForRegistry(page: Page): Promise {
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
+ await page.waitForTimeout(2000);
+}
+
+async function waitForLabel(page: Page, label: string, timeoutMs: number): Promise {
+ try {
+ await page.waitForFunction(
+ (l) => {
+ const r = window.wxElementRegistry;
+ return !!(r && r.findByLabel && r.findByLabel(l, {}).length > 0);
+ },
+ label,
+ { timeout: timeoutMs },
+ );
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// Same first-run wizard dismissal as calculator.spec.ts.
+async function completeFirstRunWizard(page: Page): Promise {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
+ await waitForRegistry(page);
+ for (let i = 1; i <= 12; i++) {
+ if (await waitForLabel(page, 'Next >', 15000)) {
+ if (await clickByLabel(page, 'Next >')) {
+ await page.waitForTimeout(400);
+ continue;
+ }
+ }
+ if (await waitForLabel(page, 'Finish', 5000)) {
+ await clickByLabel(page, 'Finish');
+ await page.waitForTimeout(400);
+ }
+ break;
+ }
+ await page.waitForTimeout(2500);
+}
+
+// Values of every editable text currently in the document (excludes the
+// range slider itself and checkboxes/radios). Between two snapshots only the
+// slider is moved, so any difference is attributable to it.
+async function textInputValues(page: Page): Promise {
+ return page.evaluate(() =>
+ Array.from(
+ document.querySelectorAll('input:not([type=range]):not([type=checkbox]):not([type=radio])'),
+ ).map((el) => (el as HTMLInputElement).value),
+ );
+}
+
+test.describe('PCB Calculator Cable Size — wxSlider scroll-event family (H-6)', () => {
+ test.describe.configure({ timeout: 180000 });
+
+ test('moving the current-density slider recomputes an output field', async ({ page, testLogger }) => {
+ await page.goto('/kicad/calculator.html');
+ await completeFirstRunWizard(page);
+
+ expect(await clickTreeItem(page, 'Cable Size'), 'Cable Size panel should be selectable').toBe(true);
+ await page.waitForTimeout(1200);
+ await page.screenshot({ path: 'test-results/calc-slider-00-panel.png', scale: 'device' });
+
+ // The current-density slider is the only wxSlider ( ) on
+ // the panel; default value 3, range 3..12.
+ const slider = page.locator('input[type=range]').first();
+ await expect(slider).toBeVisible({ timeout: 15000 });
+
+ const before = await textInputValues(page);
+ console.log(`[H6] output values before: ${JSON.stringify(before)}`);
+
+ // Drive it to the far end and fire the DOM input/change events the port
+ // translates into the wxSlider event(s).
+ await slider.evaluate((el: HTMLInputElement) => {
+ el.value = '12';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ await page.waitForTimeout(1200);
+
+ const after = await textInputValues(page);
+ console.log(`[H6] output values after: ${JSON.stringify(after)}`);
+ await page.screenshot({ path: 'test-results/calc-slider-01-moved.png', scale: 'device' });
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'WASM module should not abort').toBe(false);
+
+ // Precondition: the slider is wired and the field set is stable.
+ expect(before.length, 'the panel should expose output text fields').toBeGreaterThan(0);
+ expect(after.length, 'the field set should be stable across the move').toBe(before.length);
+
+ // RED: no output changed (scroll event never fired). GREEN: Ampacity et al.
+ // recomputed.
+ const changedCount = before.filter((v, i) => v !== after[i]).length;
+ console.log(`[H6] output fields changed by the slider move: ${changedCount}`);
+ expect(
+ changedCount,
+ 'moving the current-density slider must recompute at least one output field',
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/tests/kicad/contextmenu-fresh.spec.ts b/tests/kicad/contextmenu-fresh.spec.ts
new file mode 100644
index 000000000..b782b7ad1
--- /dev/null
+++ b/tests/kicad/contextmenu-fresh.spec.ts
@@ -0,0 +1,154 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from './fixtures';
+import { clickByLabel, findRenderedByType } from '../e2e/utils/element-tracker';
+
+// KiCad-level reproduction of the wxWidgets DOM-port "context menu goes stale"
+// bug (parity audit H-8) — the right-click sibling of H-7. Fixed in
+// wxwidgets/src/wasm/window.cpp (DoPopupMenu).
+//
+// Native behavior: right-click menus fire wxEVT_MENU_OPEN just before showing,
+// so KiCad refreshes them just-in-time. For the pcbnew *selection* menu this is
+// masked — TOOL_MENU::ShowContextMenu(SELECTION&) already runs Evaluate() +
+// UpdateAll() in C++ before the popup (tool_menu.cpp:57). But the no-arg
+// TOOL_MENU::ShowContextMenu() overload (tool_menu.cpp:66) only marks the menu
+// dirty and shows it — it relies ENTIRELY on wxEVT_MENU_OPEN → updateMenu to
+// Evaluate the CONDITIONAL_MENU. The pcbnew Measure tool uses that overload
+// (pcb_viewer_tools.cpp:441).
+//
+// In the WASM/DOM port DoPopupMenu never fired wxEVT_MENU_OPEN, so the cloned
+// CONDITIONAL_MENU was never Evaluated → ZERO items materialized → the measure
+// tool's right-click menu came up EMPTY. The selection menu (pre-Evaluated in
+// C++) stayed populated, which is why this survived undetected.
+//
+// RED (bug present): measure-tool right-click menu has 0 items (empty popup).
+// GREEN (fixed): it is populated (contains the Zoom + Grid submenus).
+//
+// The selection-tool menu is used as an in-test CONTROL: it is populated in
+// BOTH builds (its C++ pre-Evaluate path is unaffected), proving the popup
+// pipeline works and isolating the failure to the no-arg / wxEVT_MENU_OPEN path.
+
+async function waitForEditor(page: Page): Promise {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 120000 });
+ await page.waitForTimeout(2000);
+ // Dismiss the first-run setup wizard (pcbnew shows one).
+ for (let i = 0; i < 12; i++) {
+ const next = await clickByLabel(page, 'Next >');
+ if (!next) {
+ await clickByLabel(page, 'Finish');
+ break;
+ }
+ await page.waitForTimeout(400);
+ }
+ await page.waitForTimeout(2000);
+}
+
+async function getGlBox(
+ page: Page,
+): Promise<{ x: number; y: number; width: number; height: number }> {
+ const id = await page.evaluate(() => {
+ const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
+ .map((c) => c as HTMLCanvasElement)
+ .find((c) => {
+ const rect = c.getBoundingClientRect();
+ return window.getComputedStyle(c).display !== 'none' && rect.width > 0 && rect.height > 0;
+ });
+ return (visible ?? (document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null))?.id ?? null;
+ });
+ if (!id) throw new Error('No visible GL canvas found');
+ const box = await page.locator(`#${id}`).boundingBox();
+ if (!box) throw new Error('GL canvas bounding box unavailable');
+ return box;
+}
+
+// Labels of the items in the currently-open DOM context-menu popup.
+async function popupLabels(page: Page): Promise {
+ const items = await findRenderedByType(page, 'menuitem', { parentId: 'popupmenu' });
+ return items.map((i) => i.label);
+}
+
+async function dismissPopup(page: Page): Promise {
+ await page.keyboard.press('Escape');
+ await expect
+ .poll(async () => (await popupLabels(page)).length, {
+ timeout: 5000,
+ message: 'popup should dismiss on Escape',
+ })
+ .toBe(0);
+}
+
+test.describe('pcbnew: context menus refresh on open (H-8)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('the measure-tool right-click menu is populated (wxEVT_MENU_OPEN fires)', async ({
+ page,
+ testLogger,
+ }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForEditor(page);
+
+ const box = await getGlBox(page);
+ const x = Math.round(box.x + box.width * 0.5);
+ const y = Math.round(box.y + box.height * 0.5);
+
+ // --- CONTROL: selection-tool menu (pre-Evaluated in C++ before popup). It
+ // is populated in RED and GREEN alike — proves the popup pipeline works. ---
+ await page.mouse.click(x, y); // activate the selection tool
+ await page.waitForTimeout(400);
+ await page.mouse.click(x, y, { button: 'right' });
+ await expect
+ .poll(async () => (await popupLabels(page)).length, {
+ timeout: 15000,
+ message: 'control: the selection-tool context menu should be populated',
+ })
+ .toBeGreaterThan(0);
+ const selLabels = await popupLabels(page);
+ console.log(`[H8] selection-tool menu (${selLabels.length}): ${JSON.stringify(selLabels)}`);
+ expect(selLabels, `selection menu items: ${JSON.stringify(selLabels)}`).toEqual(
+ expect.arrayContaining(['Zoom', 'Grid']),
+ );
+ await dismissPopup(page);
+ await page.screenshot({ path: 'test-results/pcbnew-h8-00-selection-menu.png', fullPage: true });
+
+ // --- SIGNAL: Measure tool menu — the no-arg ShowContextMenu() path that
+ // depends on wxEVT_MENU_OPEN. Activate Measure (Ctrl+Shift+M), right-click. ---
+ await page.mouse.move(x, y);
+ await page.mouse.click(x, y); // ensure the GAL canvas has keyboard focus
+ await page.waitForTimeout(300);
+ await page.keyboard.press('Control+Shift+KeyM'); // ACTIONS::measureTool
+ await page.waitForTimeout(900);
+ await page.mouse.move(x, y);
+ await page.mouse.click(x, y, { button: 'right' });
+
+ // Read the popup: GREEN populates quickly; RED stays empty. Poll so a slow
+ // GREEN render is not mistaken for RED, but bail out after the deadline so
+ // an actually-empty (RED) popup reports fast.
+ let measLabels: string[] = [];
+ const deadline = Date.now() + 12000;
+ do {
+ measLabels = await popupLabels(page);
+ if (measLabels.length > 0) break;
+ await page.waitForTimeout(500);
+ } while (Date.now() < deadline);
+ console.log(`[H8] measure-tool menu (${measLabels.length}): ${JSON.stringify(measLabels)}`);
+ await page.screenshot({ path: 'test-results/pcbnew-h8-01-measure-menu.png', fullPage: true });
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'WASM module should not abort').toBe(false);
+
+ // RED trap: without wxEVT_MENU_OPEN the CONDITIONAL_MENU clone is never
+ // Evaluated, so the measure-tool popup materializes 0 items.
+ expect(
+ measLabels.length,
+ `measure-tool right-click menu must be populated — an empty menu means ` +
+ `wxEVT_MENU_OPEN was not fired on open. items=${JSON.stringify(measLabels)}`,
+ ).toBeGreaterThan(0);
+ expect(measLabels, `measure menu items: ${JSON.stringify(measLabels)}`).toEqual(
+ expect.arrayContaining(['Zoom', 'Grid']),
+ );
+
+ await dismissPopup(page);
+ });
+});
diff --git a/tests/kicad/dialog-select-on-open.spec.ts b/tests/kicad/dialog-select-on-open.spec.ts
new file mode 100644
index 000000000..ac208d9d0
--- /dev/null
+++ b/tests/kicad/dialog-select-on-open.spec.ts
@@ -0,0 +1,189 @@
+import { test, expect } from './fixtures';
+import { waitForPcbnew } from './utils/pcbnew-ready';
+
+// KiCad-level reproduction of the wxWidgets DOM-port "caret/selection never
+// reaches the DOM input" bug family (parity audit H-3/#30/#31), fixed in
+// wxwidgets/src/wasm/textentry.cpp + include/wx/wasm/private/dom.h +
+// build/wasm/wx-dom.js, plus a one-line KiCad gate widening.
+//
+// Native behavior: when a DIALOG_SHIM opens, its first paint runs
+// SelectAllInTextCtrls() and then focuses the initial-focus control
+// (common/dialog_shim.cpp:1343-1357) — so the pre-filled text of the focused
+// field is selected and typing REPLACES the value ("type-to-replace").
+//
+// In the WASM build this is double-broken:
+// 1. The SelectAll() inside SelectAllInTextCtrls is gated
+// `#if defined(__WXMAC__) || defined(__WXMSW__)` (dialog_shim.cpp:901) and
+// the wasm build defines __WXWASM__/__WXUNIVERSAL__ — compiled out.
+// 2. Even where SelectAll()/SetSelection() IS called, the wasm port only
+// writes a C++-side cache (src/wasm/textentry.cpp:150-178) — nothing calls
+// setSelectionRange on the real DOM (#30), and the accessors never
+// read selectionStart/End back (H-3).
+// So the DOM input has no selection, and typing INSERTS at the browser caret
+// instead of replacing the field.
+//
+// Surface: pcbnew Track & Via Properties on a board with a single TRACK. With
+// tracks in the selection the dialog sets initial focus on m_TrackWidthCtrl
+// (dialog_track_via_properties.cpp:845-846), a plain wxTextCtrl pre-filled
+// with the track width — and wxWindowWasm::SetFocus() does reach the DOM
+// (window.cpp:967 wxDomFocus), so keystrokes land in the real without
+// any click.
+//
+// RED (bug present): typing "5" into the just-opened dialog INSERTS into
+// the pre-filled width (e.g. "50.25" / "0.255").
+// GREEN (fixed): typing "5" REPLACES the selected width -> value "5".
+
+const DOC_DIR = `/home/kicad/documents`;
+const PCB_PATH = `${DOC_DIR}/selectonopen.kicad_pcb`;
+const TRACK_WIDTH_MM = 0.25;
+
+// A minimal board with a single track segment of a distinctive width. Ctrl+A
+// selects exactly the track, satisfying the tracks/vias-only condition of
+// PCB_ACTIONS::properties and making m_tracks true (initial focus -> width).
+const BOARD = `(kicad_pcb
+ (version 20241229)
+ (generator "pcbnew")
+ (generator_version "9.0")
+ (general (thickness 1.6))
+ (paper "A4")
+ (layers
+ (0 "F.Cu" signal)
+ (2 "B.Cu" signal)
+ )
+ (net 0 "")
+ (segment (start 100 100) (end 120 100) (width ${TRACK_WIDTH_MM}) (layer "F.Cu") (net 0)
+ (uuid "88888888-0000-0000-0000-000000000001"))
+)
+`;
+
+// Open the board directly through the embind bridge (present in pcbnew.wasm).
+async function openBoard(page: import('@playwright/test').Page): Promise {
+ await page.evaluate(
+ ({ dir, path, content }) => {
+ const w = window as unknown as {
+ FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
+ Module: { kicadOpenFile(p: string): unknown };
+ };
+ try {
+ w.FS.mkdirTree(dir);
+ } catch {
+ /* exists */
+ }
+ w.FS.writeFile(path, content);
+ w.Module.kicadOpenFile(path);
+ },
+ { dir: DOC_DIR, path: PCB_PATH, content: BOARD },
+ );
+}
+
+// The bounding box of the visible GAL WebGL canvas (some glcanvas-* are hidden).
+async function visibleGlCanvasBox(page: import('@playwright/test').Page) {
+ const id = await page.evaluate(() => {
+ const c = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
+ .map((el) => el as HTMLCanvasElement)
+ .find((el) => {
+ const rect = el.getBoundingClientRect();
+ return window.getComputedStyle(el).display !== 'none' && rect.width > 0 && rect.height > 0;
+ });
+ return c?.id ?? null;
+ });
+ expect(id, 'a visible GL canvas').not.toBeNull();
+ const box = await page.locator(`#${id}`).boundingBox();
+ expect(box, 'GL canvas bounding box').not.toBeNull();
+ return box!;
+}
+
+test.describe('pcbnew dialog select-all-on-open — caret/selection DOM bridge (H-3)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('typing into the just-opened Track Properties replaces the width value', async ({
+ page,
+ testLogger,
+ }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+
+ await openBoard(page);
+ await page.waitForTimeout(3000);
+ await page.screenshot({ path: 'test-results/select-on-open-00-loaded.png', scale: 'device' });
+
+ // Focus the GAL canvas with a real click on an empty spot, Select All grabs
+ // the only track, E opens Track & Via Properties (proven C-1 harness).
+ const glBox = await visibleGlCanvasBox(page);
+ await page.mouse.click(
+ Math.round(glBox.x + glBox.width * 0.5),
+ Math.round(glBox.y + glBox.height * 0.2),
+ );
+ await page.waitForTimeout(400);
+ await page.keyboard.press('Control+a');
+ await page.waitForTimeout(700);
+ await page.keyboard.press('e');
+
+ // Dialog open signal: a visible wxDialog in the registry.
+ await expect
+ .poll(
+ async () =>
+ page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ return (r?.findAll({ visible: true }) ?? []).some((e) => /Dialog/i.test(e.typeName));
+ }),
+ { timeout: 30000, message: 'Track & Via Properties dialog should open' },
+ )
+ .toBe(true);
+
+ // Precondition (separates harness failure from RED): initial focus really
+ // landed on the pre-filled track-width . Match by parseFloat, not
+ // string equality (unit-binder formatting may vary).
+ await expect
+ .poll(
+ async () =>
+ page.evaluate((expected: number) => {
+ const a = document.activeElement as HTMLInputElement | null;
+ if (!a || a.tagName !== 'INPUT') return null;
+ if (Math.abs(parseFloat(a.value) - expected) > 0.02) return null;
+ if (!a.id) a.id = '__h3_width_input';
+ return a.value;
+ }, TRACK_WIDTH_MM),
+ {
+ timeout: 20000,
+ message: `initial focus should land on the pre-filled width (≈${TRACK_WIDTH_MM})`,
+ },
+ )
+ .not.toBeNull();
+
+ const before = await page.evaluate(
+ () => (document.getElementById('__h3_width_input') as HTMLInputElement).value,
+ );
+ console.log(`[H3] width field value at dialog open: "${before}"`);
+ await page.screenshot({ path: 'test-results/select-on-open-01-dialog.png', scale: 'device' });
+
+ // Type WITHOUT clicking — the dialog's open-time select-all must make this
+ // replace the whole value, exactly as on native.
+ await page.keyboard.type('5');
+ await page.waitForTimeout(500);
+
+ const after = await page.evaluate(
+ () => (document.getElementById('__h3_width_input') as HTMLInputElement).value,
+ );
+ console.log(`[H3] width field value after typing "5": "${after}"`);
+ await page.screenshot({ path: 'test-results/select-on-open-02-typed.png', scale: 'device' });
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'WASM module should not abort').toBe(false);
+
+ // RED: "5" is inserted into the pre-filled value (e.g. "50.25" / "0.255").
+ // GREEN: the selected pre-fill is replaced -> exactly "5".
+ expect(
+ after,
+ `typing must replace the selected width value (got "${after}" from pre-fill "${before}")`,
+ ).toBe('5');
+
+ // Teardown: Escape twice (first may revert the field edit, second cancels).
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(300);
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(500);
+ });
+});
diff --git a/tests/kicad/grid-clip-bleed.spec.ts b/tests/kicad/grid-clip-bleed.spec.ts
new file mode 100644
index 000000000..b313851e9
--- /dev/null
+++ b/tests/kicad/grid-clip-bleed.spec.ts
@@ -0,0 +1,283 @@
+import { test, expect } from './fixtures';
+import { clickMenuBarItem, clickTreeItem, findTreeItem, findGridCell } from '../e2e/utils/element-tracker';
+import { waitForPcbnew } from './utils/pcbnew-ready';
+import type { Page } from '@playwright/test';
+
+// KiCad-level reproduction of the "rectangular DC clip is a silent no-op" bug
+// (parity audit H-2), fixed in wxwidgets/src/wasm/dc.cpp.
+//
+// wxWasmDCImpl::DoSetClippingRegion(x,y,w,h) (src/wasm/dc.cpp:292-304) calls
+// the base wxDCImpl::DoSetClippingRegion and then feeds m_clipX1..m_clipY2 to
+// the JS clipRect. But the modern base class stores the clip box only in the
+// *private* m_devClipX1.. members — m_clipX1.. stay at their ctor value of 0 —
+// so JS always receives clipRect(id, 0,0,0,0), which wx.js treats as
+// "uninitialized" and resets the clip to the whole context. Every wxDC-level
+// rectangular clip (wxDCClipper, SetClippingRegion(wxRect)) is silently
+// ignored. (The wxRegion overload works; only the coordinate overload is
+// broken.)
+//
+// KiCad surface: wxGrid cell text is clipped to its cell via exactly this path
+// (wxGrid::DrawTextRectangle -> wxDCClipper, src/generic/grid.cpp:7336), and
+// KiCad's WX_GRID disables cell overflow (common/widgets/wx_grid.cpp:214
+// SetDefaultCellOverflow(false)), so text longer than its column must be
+// truncated at the cell edge — even when the neighboring cell is empty.
+// wxGrid paints cells in descending order (grid.cpp:6497), so with the bug the
+// long text is painted *after* its right-hand neighbor and the unclipped
+// overflow survives the paint pass, visibly bleeding across the next column.
+//
+// The surface is Board Setup > Net Classes: its grid has fixed column widths
+// (no WX_GRID::SetupColumnAutosizer, which on e.g. the Text Variables grid
+// auto-fits the column to its content and would mask the bug), and a newly
+// added netclass row has an EMPTY Clearance cell next to the Name cell.
+//
+// RED (bug present): dark text pixels ("ink") inside the empty Clearance
+// cell — the Name text bleeds across the column edge.
+// GREEN (fixed): the Clearance cell interior stays background-only;
+// the Name text is clipped at its own cell edge.
+
+const PAYLOAD = 'W'.repeat(70); // ~700px of text vs a ~130px Name column
+
+interface CssRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+function insetRect(r: { screenX: number; screenY: number; width: number; height: number },
+ by: number): CssRect {
+ return {
+ x: r.screenX + by,
+ y: r.screenY + by,
+ width: Math.max(1, r.width - 2 * by),
+ height: Math.max(1, r.height - 2 * by),
+ };
+}
+
+// Measure "ink" (dark, text-colored) pixels and mean luminance of a
+// viewport-CSS-coordinate rect in a device-scale screenshot. Grid cell
+// backgrounds are white/near-white; text is black — luminance < 128 cleanly
+// separates them. The mean guards against measuring a row that is still
+// selected (dark navy row highlight), which would fake ink.
+async function measureCell(
+ page: Page,
+ png: Buffer,
+ rect: CssRect,
+): Promise<{ ink: number; mean: number }> {
+ return page.evaluate(
+ async ({ b64, rect }) => {
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error('screenshot decode failed'));
+ img.src = 'data:image/png;base64,' + b64;
+ });
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d')!;
+ ctx.drawImage(img, 0, 0);
+ // The screenshot is viewport-sized at device scale; derive the ratio
+ // instead of trusting devicePixelRatio so both scales work.
+ const scale = img.width / window.innerWidth;
+ const data = ctx.getImageData(
+ Math.round(rect.x * scale),
+ Math.round(rect.y * scale),
+ Math.max(1, Math.round(rect.width * scale)),
+ Math.max(1, Math.round(rect.height * scale)),
+ ).data;
+ let ink = 0;
+ let lumSum = 0;
+ const pixels = data.length / 4;
+ for (let i = 0; i < data.length; i += 4) {
+ const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
+ lumSum += lum;
+ if (lum < 128) ink++;
+ }
+ return { ink, mean: lumSum / pixels };
+ },
+ { b64: png.toString('base64'), rect },
+ );
+}
+
+// Open the File menu and click the menu item whose label matches `re`.
+async function clickFileMenuItem(page: Page, re: RegExp): Promise {
+ expect(await clickMenuBarItem(page, 'File'), 'File menu should open').toBe(true);
+ await page.waitForTimeout(600);
+ const items = await page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ const all = r?.findAllRendered?.({}) ?? [];
+ return all
+ .filter((e) => e.elementType === 'menuitem')
+ .map((e) => ({ label: e.label, x: e.centerX, y: e.centerY }));
+ });
+ console.log('[H2] File menu items: ' + JSON.stringify(items.map((i) => i.label)));
+ const target = items.find((i) => re.test(i.label));
+ if (!target) return false;
+ await page.mouse.click(target.x, target.y);
+ return true;
+}
+
+async function pollCellLabel(
+ page: Page,
+ row: number,
+ col: number,
+ expected: string,
+ timeoutMs: number,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const cell = await findGridCell(page, row, col);
+ if (cell && cell.label === expected) return true;
+ await page.waitForTimeout(500);
+ }
+ return false;
+}
+
+test.describe('pcbnew Board Setup Net Classes grid — DC rect clip (H-2)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('long netclass name does not bleed into the empty Clearance cell', async ({ page, testLogger }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+
+ // --- File > Board Setup... ---
+ expect(
+ await clickFileMenuItem(page, /board setup/i),
+ 'File menu should contain Board Setup',
+ ).toBe(true);
+
+ // Dialog is open once its treebook renders; all nodes are expanded on open
+ // (dialog_board_setup.cpp:265), so the Net Classes item is clickable.
+ await expect
+ .poll(async () => (await findTreeItem(page, 'Net Classes')) !== null, {
+ timeout: 30000,
+ message: 'Board Setup dialog tree should render the Net Classes item',
+ })
+ .toBe(true);
+ await page.screenshot({ path: 'test-results/grid-clip-bleed-01-dialog.png', scale: 'device' });
+
+ expect(await clickTreeItem(page, 'Net Classes')).toBe(true);
+
+ // The lazily-built PANEL_SETUP_NETCLASSES page holds two grids: the
+ // netclass grid on top, the assignment grid below — take the topmost.
+ await expect
+ .poll(
+ async () =>
+ page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ return (r?.findAll({ visible: true }) ?? []).filter((e) => e.typeName === 'wxGrid').length;
+ }),
+ { timeout: 30000, message: 'Net Classes grids should appear' },
+ )
+ .toBeGreaterThan(0);
+ await page.waitForTimeout(800);
+ await page.screenshot({ path: 'test-results/grid-clip-bleed-02-page.png', scale: 'device' });
+
+ const grid = await page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ const grids = (r?.findAll({ visible: true }) ?? [])
+ .filter((e) => e.typeName === 'wxGrid')
+ .sort((a, b) => a.screenY - b.screenY);
+ const g = grids[0];
+ return g
+ ? { screenX: g.screenX, screenY: g.screenY, width: g.width, height: g.height }
+ : null;
+ });
+ expect(grid, 'netclass wxGrid should be in the registry').not.toBeNull();
+ console.log(`[H2] netclass grid rect: (${grid!.screenX},${grid!.screenY}) ${grid!.width}x${grid!.height}`);
+
+ // --- add a row ---
+ // The panel's "+" STD_BITMAP_BUTTON is a custom-painted wxPanel and never
+ // enters the element registry, so click it blind: it is the leftmost
+ // button in the strip directly below the grid's bottom-left corner
+ // (offsets verified against the registry grid rect on the Text Variables
+ // page, which uses the identical sizer layout). OnAddNetclassClick ->
+ // WX_GRID::OnAddRow inserts a row at the TOP (row 0) and auto-opens the
+ // cell editor on (0, Name); the new row's Clearance cell stays empty.
+ const addBtn = { x: grid!.screenX + 13, y: grid!.screenY + grid!.height + 16 };
+ const editorFocused = () =>
+ page
+ .waitForFunction(
+ () => {
+ const a = document.activeElement;
+ return !!a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA');
+ },
+ null,
+ { timeout: 10000 },
+ )
+ .then(() => true)
+ .catch(() => false);
+
+ await page.mouse.click(addBtn.x, addBtn.y);
+ const editorOpen = await editorFocused();
+ if (!editorOpen) {
+ await page.screenshot({ path: 'test-results/grid-clip-bleed-03-no-editor.png', scale: 'device' });
+ }
+ expect(editorOpen, 'add-row should open the (0, Name) cell editor').toBe(true);
+
+ await page.keyboard.type(PAYLOAD, { delay: 15 });
+ await page.screenshot({ path: 'test-results/grid-clip-bleed-03-typed.png', scale: 'device' });
+
+ // Commit the editor. Keys typed into a DOM editable are consumed by the
+ // browser and never reach wx (audit #43/#55), so Enter cannot commit.
+ // Click the "+" button again instead: WX_GRID::OnAddRow first calls
+ // CommitPendingChanges() (mouse events do reach wx), committing the
+ // payload row — which the new top insertion shifts down to ROW 1 — and
+ // moving the cursor/row-selection highlight to the fresh row 0. Row 1 is
+ // then unhighlighted and stable for pixel measurement.
+ await page.mouse.click(addBtn.x, addBtn.y);
+ const committed = await pollCellLabel(page, 1, 0, PAYLOAD, 15000);
+ expect(committed, 'Name cell (1,0) should hold the committed payload').toBe(true);
+
+ // --- measure ---
+ await page.waitForTimeout(1000); // let the post-commit repaint settle
+ const cellName = await findGridCell(page, 1, 0);
+ const cellClearance = await findGridCell(page, 1, 1);
+ expect(cellName, 'gridcell (1,0) should be registered').not.toBeNull();
+ expect(cellClearance, 'gridcell (1,1) should be registered').not.toBeNull();
+ console.log(
+ `[H2] cell rects: name=(${cellName!.screenX},${cellName!.screenY},${cellName!.width}x${cellName!.height}) ` +
+ `clearance=(${cellClearance!.screenX},${cellClearance!.screenY},${cellClearance!.width}x${cellClearance!.height})`,
+ );
+
+ const png = await page.screenshot({
+ path: 'test-results/grid-clip-bleed-04-committed.png',
+ scale: 'device',
+ });
+
+ // 4px inset keeps gridlines, the cell-cursor border, and clip-edge
+ // antialiasing out of both samples.
+ const nameStats = await measureCell(page, png, insetRect(cellName!, 4));
+ const clearanceStats = await measureCell(page, png, insetRect(cellClearance!, 4));
+ console.log(
+ `[H2] name cell: ink=${nameStats.ink} mean=${nameStats.mean.toFixed(1)}; ` +
+ `clearance cell: ink=${clearanceStats.ink} mean=${clearanceStats.mean.toFixed(1)}`,
+ );
+
+ // Harness guards: row 1 must not carry the dark row-selection highlight
+ // (would fake ink), and the payload really was painted in the Name cell
+ // (guards against a vacuous pass from a failed commit).
+ expect(
+ clearanceStats.mean,
+ 'Clearance cell background should be light — row still selected?',
+ ).toBeGreaterThan(128);
+ expect(nameStats.ink, 'Name cell should contain painted text').toBeGreaterThan(50);
+
+ // RED: hundreds of ink pixels — the Name text bleeds across the empty
+ // Clearance cell. GREEN: background only.
+ expect(
+ clearanceStats.ink,
+ `empty Clearance cell should contain no text ink (got ${clearanceStats.ink} dark pixels — Name-cell bleed)`,
+ ).toBeLessThan(5);
+
+ // --- teardown & stability ---
+ await page.keyboard.press('Escape'); // close Board Setup (Cancel)
+ await page.waitForTimeout(500);
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'WASM module should not abort').toBe(false);
+ });
+});
diff --git a/tests/kicad/menu-undo-stale.spec.ts b/tests/kicad/menu-undo-stale.spec.ts
new file mode 100644
index 000000000..00c3df02f
--- /dev/null
+++ b/tests/kicad/menu-undo-stale.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from './fixtures';
+import { clickMenuBarItem } from '../e2e/utils/element-tracker';
+import type { Page } from '@playwright/test';
+
+// KiCad-level check of the wxWidgets DOM-port menu fix (parity audit H-7), in
+// wxwidgets/src/wasm/menu.cpp + domevents.cpp + include/wx/wasm/{window,menu}.h
+// + build/wasm/wx-dom.js.
+//
+// Native behavior: opening a menu fires wxEVT_MENU_OPEN, and KiCad refreshes
+// each item's enable/check state just-in-time (ACTION_MENU::OnMenuEvent runs
+// ACTIONS::updateMenu, gated on wxEVT_MENU_OPEN — action_menu.cpp:421-426).
+// Before H-7 the DOM port NEVER fired that event: clicking a menubar title
+// opened a popup from a cached JS snapshot and never called back into C++.
+// H-7 fires wxEVT_MENU_OPEN and renders the dropdown from the cached structure
+// snapshot (build/wasm/wx-dom.js).
+//
+// SCOPE (merged kicad_editor module): the just-in-time enable/check REFRESH does
+// NOT complete on the merged module, so this test asserts only that the Edit
+// menu OPENS and RENDERS its items (the H-7 menubar-render path). KiCad's WASM
+// coroutines are Emscripten fibers (kicad/thirdparty/libcontext/libcontext.cpp →
+// emscripten_fiber_swap, an ASYNCIFY import); RunAction(updateMenu) fiber-swaps,
+// and driving that from a DOM-click ccall({async:true}) nests two asyncify
+// contexts so the refresh never resolves — the same fiber/asyncify family as the
+// 3D-viewer on-demand-boot deadlocks. The H-7 fix itself is correct; its
+// fresh-state refresh is validated on STANDALONE pcbnew, and on the merged module
+// the menu shows its last-serialized enable/check state (a minor UX staleness —
+// a stale-enabled item is a no-op when clicked — not a correctness bug).
+
+const DOC_DIR = `/home/kicad/documents`;
+const PCB_PATH = `${DOC_DIR}/menu_undo.kicad_pcb`;
+
+// A minimal board with one footprint (the item kicadCollabTestMoveFirst moves,
+// proven in save-hook.spec.ts / pcbnew-collab.spec.ts). The move runs through a
+// BOARD_COMMIT::Push → one undo entry. Loading a board does not push undo
+// entries, so the undo stack is empty until the move.
+const BOARD = `(kicad_pcb
+ (version 20241229)
+ (generator "pcbnew")
+ (generator_version "9.0")
+ (general (thickness 1.6))
+ (paper "A4")
+ (layers
+ (0 "F.Cu" signal)
+ (2 "B.Cu" signal)
+ (37 "F.SilkS" user)
+ (25 "Edge.Cuts" user)
+ )
+ (setup)
+ (net 0 "")
+ (footprint "TestLib:R"
+ (layer "F.Cu")
+ (uuid "99999999-0000-0000-0000-000000000001")
+ (at 100 100)
+ (attr smd)
+ (property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
+ )
+)
+`;
+
+// Boot the seeded pcbnew-collab harness (skips the first-run wizard, and is the
+// proven context for kicadCollabTestMoveFirst — save-hook/pcbnew-collab specs).
+async function bootPcbnew(page: Page): Promise {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
+ await page.waitForFunction(
+ () => {
+ const m = (window as unknown as { Module?: Record }).Module;
+ return (
+ typeof m?.kicadOpenFile === 'function' &&
+ typeof m?.kicadCollabTestMoveFirst === 'function' &&
+ typeof m?.kicadCollabGetPos === 'function'
+ );
+ },
+ null,
+ { timeout: 90000 },
+ );
+ await page.waitForFunction(
+ () =>
+ !!window.wxElementRegistry &&
+ window.wxElementRegistry
+ .findAll({ visible: true })
+ .some((e) => /Frame$/.test(e.typeName) || (e.name || '').endsWith('Frame')),
+ null,
+ { timeout: 90000 },
+ );
+}
+
+async function openBoard(page: Page): Promise {
+ await page.evaluate(
+ ({ dir, path, content }) => {
+ const w = window as unknown as {
+ FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
+ Module: { kicadOpenFile(p: string): unknown };
+ };
+ try {
+ w.FS.mkdirTree(dir);
+ } catch {
+ /* exists */
+ }
+ w.FS.writeFile(path, content);
+ w.Module.kicadOpenFile(path);
+ },
+ { dir: DOC_DIR, path: PCB_PATH, content: BOARD },
+ );
+}
+
+// Open the Edit menu and return the labels of its rendered items (from the
+// registry), then close the menu again. The popup renders synchronously from
+// the cached structure snapshot, so its rows enter the registry on the next
+// frame — wait for the Undo row rather than a fixed dwell.
+async function readEditMenuItems(page: Page): Promise {
+ expect(await clickMenuBarItem(page, 'Edit'), 'Edit menu should open').toBe(true);
+ await page.waitForFunction(
+ () =>
+ (window.wxElementRegistry?.findAllRendered?.({}) ?? []).some(
+ (e) => e.elementType === 'menuitem' && /^Undo\b/.test(e.label || ''),
+ ),
+ null,
+ { timeout: 15000 },
+ );
+ const labels = await page.evaluate(() =>
+ (window.wxElementRegistry?.findAllRendered?.({}) ?? [])
+ .filter((e) => e.elementType === 'menuitem')
+ .map((e) => e.label || ''),
+ );
+ await page.keyboard.press('Escape');
+ return labels;
+}
+
+test.describe('pcbnew Edit menu opens and renders its items (H-7)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('Edit menu opens and renders its items', async ({ page, testLogger }) => {
+ await page.goto('/kicad/pcbnew-collab.html');
+ await bootPcbnew(page);
+
+ await openBoard(page);
+ await page.waitForTimeout(3000);
+ await page.screenshot({ path: 'test-results/menu-undo-00-loaded.png', scale: 'device' });
+
+ const labels = await readEditMenuItems(page);
+ console.log(`[H7] Edit menu items: ${JSON.stringify(labels)}`);
+ await page.screenshot({ path: 'test-results/menu-undo-01-edit-open.png', scale: 'device' });
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'WASM module should not abort').toBe(false);
+
+ // The Edit menu must open and render its items from the cached structure —
+ // the H-7 menubar-render path (before H-7 the popup was blank on the merged
+ // module). Undo is one of its standard entries. The just-in-time enable/check
+ // refresh is a documented merged-module limitation (see the file header).
+ expect(labels.some((l) => /^Undo\b/.test(l)), 'Edit menu should render an Undo item').toBe(true);
+ expect(labels.length, 'Edit menu should render multiple items').toBeGreaterThan(1);
+ });
+});
diff --git a/tests/kicad/modal-input-lock.spec.ts b/tests/kicad/modal-input-lock.spec.ts
new file mode 100644
index 000000000..a7074778f
--- /dev/null
+++ b/tests/kicad/modal-input-lock.spec.ts
@@ -0,0 +1,167 @@
+import { test, expect } from './fixtures';
+import { waitForPcbnew } from './utils/pcbnew-ready';
+import { clickMenuBarItem } from '../e2e/utils/element-tracker';
+import type { Page } from '@playwright/test';
+
+// KiCad-level reproduction of the wxWidgets DOM-port "modal dialogs are not
+// input-modal" bug (parity audit H-1), fixed in src/wasm/dialog.cpp by creating
+// a wxWindowDisabler in wxDialog::ShowModal().
+//
+// The audit claimed this could not be surfaced as a product test ("e2e tests
+// drive only the dialog, so they don't surface it"). It can:
+//
+// A real modal dialog (wxDialog::ShowModal) is supposed to make the rest of the
+// application input-inert until it closes. Native wxWidgets does this by creating
+// a wxWindowDisabler, which calls Disable() on every OTHER top-level window. The
+// DOM port dropped that one line (dialog.cpp: m_windowDisabler is NULL-inited and
+// only ever wxDELETE'd, never new'd), so the parent pcbnew frame stays fully live
+// behind a "modal" — its menubar/toolbar/canvas keep accepting input.
+//
+// The fix (restore `m_windowDisabler = new wxWindowDisabler(this)` in ShowModal)
+// disables the main frame. That is directly observable: the WASM port re-emits a
+// window's `enabled` flag into window.wxElementRegistry whenever DoEnable() runs
+// (window.cpp UpdateElementRegistry), so the pcbnew main frame's registry entry
+// flips enabled:true -> false the instant a real modal opens, and back to true on
+// close. This is not a proxy for input-modality: the SAME wxWindow::IsEnabled()
+// parent-walk that flips this flag is exactly what every input gate consults
+// (app.cpp mouse/keyboard/wheel, domevents.cpp control/toolbar/menu-item events).
+// enabled===false while a modal is open <=> the frame is input-modal.
+//
+// RED (bug present): PcbFrame.enabled stays `true` while the dialog is open
+// (no wxWindowDisabler) -> assertion fails.
+// GREEN (fixed): PcbFrame.enabled is `false` while open, `true` after close.
+//
+// IMPORTANT — dialog choice: this MUST drive a dialog shown via *real*
+// wxDialog::ShowModal(). pcbnew's **Page Settings** dialog qualifies —
+// board_editor_control.cpp:534 does `DIALOG_PAGES_SETTINGS dlg( ... );
+// dlg.ShowModal()`, and DIALOG_SHIM::ShowModal (dialog_shim.cpp:1386) forwards
+// straight to wxDialog::ShowModal without adding a disabler of its own.
+//
+// Most other pcbnew dialogs are the WRONG target because KiCad already disables
+// the parent for them, so they read "green" even unpatched:
+// * Track & Via Properties AND the Plot dialog are both shown via
+// ShowQuasiModal() (edit_tool.cpp:2306, board_editor_control.cpp:565), which
+// runs KiCad's own nested loop and creates a WINDOW_DISABLER(parent)
+// (dialog_shim.cpp:1431) — the parent frame is disabled with or without the
+// wx fix. (Measured: opening Plot on the unpatched binary already reports
+// PcbFrame.enabled === false. So quasi-modal dialogs do NOT exercise H-1.)
+// H-1's real user impact in KiCad is therefore limited to the real-ShowModal
+// dialogs, which is exactly what this test drives.
+
+// Open the File menu and click the menu item whose label matches `re`
+// (menu labels carry a trailing ellipsis, so match by regex). Mirrors the proven
+// pattern in plot-checklist.spec.ts.
+async function clickFileMenuItem(page: Page, re: RegExp): Promise {
+ expect(await clickMenuBarItem(page, 'File'), 'File menu should open').toBe(true);
+ await page.waitForTimeout(600);
+ const items = await page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ const all = (r?.findAllRendered?.({}) ?? []) as Array<{
+ elementType: string;
+ label: string;
+ centerX: number;
+ centerY: number;
+ }>;
+ return all
+ .filter((e) => e.elementType === 'menuitem')
+ .map((e) => ({ label: e.label, x: e.centerX, y: e.centerY }));
+ });
+ const target = items.find((i) => re.test(i.label));
+ if (target) await page.mouse.click(target.x, target.y);
+ return items.map((i) => i.label);
+}
+
+// The visible pcbnew main frame's registry entry (id + live enabled flag).
+async function pcbFrame(page: Page): Promise<{ id: string; enabled: boolean } | null> {
+ return page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ if (!r) return null;
+ const f = r.findAll({ visible: true }).find((e) => e.name === 'PcbFrame');
+ return f ? { id: f.id, enabled: f.enabled } : null;
+ });
+}
+
+// The live `enabled` flag for a specific registry element id (the disable only
+// re-emits the frame's own entry; children entries go stale — so read the frame).
+async function enabledById(page: Page, id: string): Promise {
+ return page.evaluate((fid) => {
+ const el = window.wxElementRegistry?.getElement(fid);
+ return el ? el.enabled : null;
+ }, id);
+}
+
+// Count currently-visible modal dialogs (KiCad dialog typeNames match /Dialog/i:
+// e.g. "wxDialog", "wxFileDialog"). Used as an independent "a modal is open"
+// signal that holds in both the RED and GREEN builds.
+async function visibleDialogCount(page: Page): Promise {
+ return page.evaluate(
+ () =>
+ window.wxElementRegistry
+ ?.findAll({ visible: true })
+ .filter((e) => /Dialog/i.test(e.typeName)).length ?? 0,
+ );
+}
+
+test.describe('pcbnew modal dialogs are input-modal (H-1)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('opening a modal dialog disables the main pcbnew frame', async ({ page, testLogger }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+
+ // 1. Baseline: the main frame is registered and enabled, no modal open.
+ const before = await pcbFrame(page);
+ expect(before, 'PcbFrame should be registered').toBeTruthy();
+ expect(before!.enabled, 'main frame is enabled before any modal opens').toBe(true);
+ const frameId = before!.id;
+ expect(await visibleDialogCount(page), 'no modal dialog open at baseline').toBe(0);
+
+ // 2. Open the Page Settings dialog — a real wxDialog::ShowModal
+ // (board_editor_control.cpp:534). NOT Plot/Track&Via, which are quasi-modal.
+ const fileLabels = await clickFileMenuItem(page, /page settings/i);
+ console.log('[H1] File menu items: ' + JSON.stringify(fileLabels));
+
+ await expect
+ .poll(() => visibleDialogCount(page), {
+ timeout: 30000,
+ message: 'the Page Settings dialog (a real modal) should open',
+ })
+ .toBeGreaterThan(0);
+ // The disabler is created synchronously inside ShowModal (after Show(true),
+ // before startModal() suspends), so the frame is already disabled by the time
+ // the modal is parked; a short settle keeps this robust.
+ await page.waitForTimeout(1000);
+
+ await page.screenshot({ path: 'test-results/modal-input-lock-01-dialog.png', scale: 'device' });
+
+ // 3. PRIMARY: while the modal is open, the main pcbnew frame must be
+ // input-disabled. RED (no wxWindowDisabler) -> stays true -> FAILS here.
+ const duringModal = await enabledById(page, frameId);
+ console.log(`[H1] PcbFrame.enabled while the Page Settings modal is open: ${duringModal}`);
+ expect(
+ duringModal,
+ 'the main pcbnew frame must be disabled (input-modal) while a modal dialog is open — H-1',
+ ).toBe(false);
+
+ // 4. Closing the modal re-enables the frame (proves the wxDELETE(m_windowDisabler)
+ // teardown path). Esc is KiCad's dialog-cancel.
+ await page.keyboard.press('Escape');
+ await expect
+ .poll(
+ async () => {
+ if ((await visibleDialogCount(page)) > 0) await page.keyboard.press('Escape');
+ return enabledById(page, frameId);
+ },
+ { timeout: 20000, message: 'closing the modal should re-enable the main frame' },
+ )
+ .toBe(true);
+
+ await page.screenshot({ path: 'test-results/modal-input-lock-02-closed.png', scale: 'device' });
+
+ // 5. The whole sequence must not trap the WASM module.
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'the WASM module must not abort while toggling modal state').toBe(false);
+ });
+});
diff --git a/tests/kicad/plot-checklist.spec.ts b/tests/kicad/plot-checklist.spec.ts
new file mode 100644
index 000000000..b1b598a10
--- /dev/null
+++ b/tests/kicad/plot-checklist.spec.ts
@@ -0,0 +1,109 @@
+import { test, expect } from './fixtures';
+import { clickMenuBarItem, clickByLabel } from '../e2e/utils/element-tracker';
+import type { Page } from '@playwright/test';
+
+// KiCad-level reproduction of the wxCheckListBox "check marks wiped on rebuild"
+// bug (parity audit H-5), fixed in src/wasm/{listbox.h,checklst.{h,cpp}}.
+//
+// pcbnew's Plot dialog fills its layer list with the exact Append-then-Check
+// pattern the bug breaks (pcbnew/dialogs/dialog_plot.cpp:351-354):
+//
+// int checkIndex = m_layerCheckListBox->Append( board->GetLayerName( layer ) );
+// if( m_plotOpts.GetLayerSelection()[layer] )
+// m_layerCheckListBox->Check( checkIndex );
+//
+// A fresh board's default plot selection is
+// LSET{ F_SilkS, B_SilkS, F_Mask, B_Mask, F_Paste, B_Paste, Edge_Cuts } (+Cu),
+// i.e. 7+ layers should be checked. With the DOM-port bug, every Append rebuilds
+// the rows unchecked and only re-applies the listbox *selection*, never the
+// *check* state — so all but the final Append's check are wiped and the dialog
+// opens showing 0-1 checked layers.
+//
+// RED (bug present): <= 1 layer checkbox is checked in the Plot dialog.
+// GREEN (fixed): the default plot layers (>= 2) are checked.
+
+async function waitForPcbnew(page: Page): Promise {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
+ await page.waitForFunction(
+ () => {
+ const r = window.wxElementRegistry;
+ return !!r && r.findAll({}).length > 0;
+ },
+ null,
+ { timeout: 150000 },
+ );
+ await page.waitForTimeout(2500);
+ // dismiss the first-run setup wizard (Next > … Finish); no-op if absent
+ for (let i = 0; i < 12; i++) {
+ const advanced = await clickByLabel(page, 'Next >');
+ if (!advanced) break;
+ await page.waitForTimeout(400);
+ }
+ await clickByLabel(page, 'Finish');
+ await page.waitForTimeout(800);
+ await page.waitForFunction(
+ () => {
+ const r = window.wxElementRegistry;
+ if (!r) return false;
+ return r.findAll({ visible: true }).some((el) => el.name === 'PcbFrame');
+ },
+ null,
+ { timeout: 90000 },
+ );
+ await page.waitForTimeout(1500);
+}
+
+// Open the File menu and click the menu item whose label matches `re`.
+async function clickFileMenuItem(page: Page, re: RegExp): Promise {
+ expect(await clickMenuBarItem(page, 'File'), 'File menu should open').toBe(true);
+ await page.waitForTimeout(600);
+ const items = await page.evaluate(() => {
+ const r = window.wxElementRegistry;
+ const all = (r?.findAllRendered?.({}) ?? []) as any[];
+ return all
+ .filter((e) => e.elementType === 'menuitem')
+ .map((e) => ({ label: e.label, x: e.centerX, y: e.centerY }));
+ });
+ const labels = items.map((i) => i.label);
+ const target = items.find((i) => re.test(i.label));
+ if (target) {
+ await page.mouse.click(target.x, target.y);
+ }
+ return labels;
+}
+
+test.describe('pcbnew Plot dialog — wxCheckListBox check persistence (H-5)', () => {
+ test('Plot dialog shows the default plot layers checked', async ({ page }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+
+ const fileLabels = await clickFileMenuItem(page, /plot/i);
+ console.log('[PLOT] File menu items: ' + JSON.stringify(fileLabels));
+
+ // The Plot dialog's layer list is a wxCheckListBox -> a [data-wx-check-list]
+ // div of checkbox rows in the DOM port.
+ const boxes = page.locator('[data-wx-check-list] input[type=checkbox]');
+ await expect
+ .poll(() => boxes.count(), {
+ timeout: 30000,
+ message: 'Plot dialog layer checklist should render its rows',
+ })
+ .toBeGreaterThan(2);
+
+ await page.screenshot({ path: 'test-results/plot-checklist.png', scale: 'device' });
+
+ const checked = await page
+ .locator('[data-wx-check-list] input[type=checkbox]:checked')
+ .count();
+ const total = await boxes.count();
+ console.log(`[PLOT] checked ${checked} of ${total} layer rows`);
+
+ // RED: <= 1 checked (only the last Append survived). GREEN: the default
+ // plot layers (>= 2) are checked.
+ expect(
+ checked,
+ `expected the default plot layers to be checked (got ${checked}/${total})`,
+ ).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/tests/kicad/via-clientdata.spec.ts b/tests/kicad/via-clientdata.spec.ts
new file mode 100644
index 000000000..9c44d1cfc
--- /dev/null
+++ b/tests/kicad/via-clientdata.spec.ts
@@ -0,0 +1,235 @@
+import { test, expect } from './fixtures';
+import { clickByLabel } from '../e2e/utils/element-tracker';
+import type { Page } from '@playwright/test';
+
+// KiCad-level reproduction of the wxWidgets DOM-port "selection events carry no
+// per-item client data" bug (parity audit C-1, the audit's single Critical),
+// fixed in src/wasm/{choice,combobox,listbox}.cpp by calling
+// InitCommandEventWithItems() before dispatch.
+//
+// pcbnew's Track & Via Properties dialog is the ONLY KiCad surface that reads
+// per-item client data off a wxChoice *selection event* (an exhaustive sweep of
+// the tree found exactly one such consumer). It populates the "predefined via
+// sizes" choice with a VIA_DIMENSION* as client data
+// (dialog_track_via_properties.cpp:767-781) and, on selection, does with NO null
+// guard (dialog_track_via_properties.cpp:1674-1680):
+//
+// VIA_DIMENSION* v = static_cast( aEvent.GetClientData() );
+// m_viaDiameter.ChangeValue( v->m_Diameter ); // v is NULL when the bug is present
+// m_viaDrill.ChangeValue( v->m_Drill );
+//
+// With the DOM-port bug, event.GetClientData() is always NULL; in WASM a null
+// deref at a small offset reads 0 (no trap — see memory wasm-null-deref-reads-zero),
+// so picking a predefined via size sets the Via diameter/drill fields to 0
+// instead of the predefined value.
+//
+// RED (bug present): after picking "0.9 / 0.45", the Via diameter field -> 0
+// (or the module aborts).
+// GREEN (fixed): the Via diameter field -> 0.9 (the predefined value).
+//
+// The predefined via sizes are seeded from the board file's
+// (setup (user_via ) ...) so no Board Setup interaction is
+// needed. The board contains ONLY the via, so Edit -> Select All selects exactly
+// the via (satisfying the tracks/vias-only condition the Properties action needs)
+// with no fragile canvas-pixel clicking.
+
+const DOC_DIR = `/home/kicad/documents`;
+const PCB_PATH = `${DOC_DIR}/via_clientdata.kicad_pcb`;
+
+// A minimal board: two predefined via sizes (0.9/0.45 and 1.1/0.55 mm) plus a
+// single via whose own size (0.7/0.35) is distinct from both predefined sizes
+// and from 0 — so the diameter field visibly changes on selection.
+const BOARD = `(kicad_pcb
+ (version 20241229)
+ (generator "pcbnew")
+ (generator_version "9.0")
+ (general (thickness 1.6))
+ (paper "A4")
+ (layers
+ (0 "F.Cu" signal)
+ (2 "B.Cu" signal)
+ )
+ (setup
+ (user_via 0.9 0.45)
+ (user_via 1.1 0.55)
+ )
+ (net 0 "")
+ (via (at 100 100) (size 0.7) (drill 0.35) (layers "F.Cu" "B.Cu") (net 0)
+ (uuid "77777777-0000-0000-0000-000000000001"))
+)
+`;
+
+async function waitForPcbnew(page: Page): Promise {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
+ await page.waitForFunction(
+ () => {
+ const r = window.wxElementRegistry;
+ return !!r && r.findAll({}).length > 0;
+ },
+ null,
+ { timeout: 150000 },
+ );
+ await page.waitForTimeout(2500);
+ // dismiss the first-run setup wizard (Next > … Finish); no-op if absent
+ for (let i = 0; i < 12; i++) {
+ const advanced = await clickByLabel(page, 'Next >');
+ if (!advanced) break;
+ await page.waitForTimeout(400);
+ }
+ await clickByLabel(page, 'Finish');
+ await page.waitForTimeout(800);
+ await page.waitForFunction(
+ () => {
+ const r = window.wxElementRegistry;
+ if (!r) return false;
+ return r.findAll({ visible: true }).some((el) => el.name === 'PcbFrame');
+ },
+ null,
+ { timeout: 90000 },
+ );
+ await page.waitForTimeout(1500);
+}
+
+// Open the board directly through the embind bridge (present in pcbnew.wasm).
+async function openBoard(page: Page): Promise {
+ await page.evaluate(
+ ({ dir, path, content }) => {
+ const w = window as unknown as {
+ FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
+ Module: { kicadOpenFile(p: string): unknown };
+ };
+ try {
+ w.FS.mkdirTree(dir);
+ } catch {
+ /* exists */
+ }
+ w.FS.writeFile(path, content);
+ w.Module.kicadOpenFile(path);
+ },
+ { dir: DOC_DIR, path: PCB_PATH, content: BOARD },
+ );
+}
+
+// Snapshot the id + option texts of every single-select on the page.
+// Stamps a stable unique id on each so before/after sets can be diffed (the
+// dialog's predefined-via-size choice is a NEW vs the toolbar ones).
+async function dumpSelects(page: Page) {
+ return page.evaluate(() => {
+ const w = window as unknown as { __vsc?: number };
+ w.__vsc = w.__vsc ?? 0;
+ return Array.from(document.querySelectorAll('select:not([multiple])')).map((sel) => {
+ const s = sel as HTMLSelectElement;
+ if (!s.id) s.id = `__vsel_${w.__vsc!++}`;
+ return { id: s.id, options: Array.from(s.options).map((o) => o.text) };
+ });
+ });
+}
+
+// The bounding box of the visible GAL WebGL canvas (some glcanvas-* are hidden).
+async function visibleGlCanvasBox(page: Page) {
+ const id = await page.evaluate(() => {
+ const c = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
+ .map((el) => el as HTMLCanvasElement)
+ .find((el) => {
+ const rect = el.getBoundingClientRect();
+ return window.getComputedStyle(el).display !== 'none' && rect.width > 0 && rect.height > 0;
+ });
+ return c?.id ?? null;
+ });
+ expect(id, 'a visible GL canvas').not.toBeNull();
+ const box = await page.locator(`#${id}`).boundingBox();
+ expect(box, 'GL canvas bounding box').not.toBeNull();
+ return box!;
+}
+
+test.describe('pcbnew Track & Via Properties — selection-event client data (C-1)', () => {
+ test.describe.configure({ timeout: 240000 });
+
+ test('predefined via size sets the diameter field (not 0)', async ({ page, testLogger }) => {
+ await page.goto('/kicad/pcbnew.html');
+ await waitForPcbnew(page);
+
+ // Load the crafted board and let it settle.
+ await openBoard(page);
+ await page.waitForTimeout(3000);
+ await page.screenshot({ path: 'test-results/via-clientdata-00-loaded.png', scale: 'device' });
+
+ // Record the toolbar s (they also list via sizes with "N / N mm")
+ // so the dialog's predefined-via-size choice can be told apart as a NEW one.
+ const beforeSelIds = new Set((await dumpSelects(page)).map((s) => s.id));
+
+ // Give the GAL canvas keyboard focus with a real mouse click on an empty
+ // spot (keyboard events only reach pcbnew after a canvas interaction — see
+ // pcbnew-move.spec.ts). Then Select All (Ctrl+A) grabs the only via and E
+ // (PCB_ACTIONS::properties) opens its dialog.
+ const glBox = await visibleGlCanvasBox(page);
+ await page.mouse.click(Math.round(glBox.x + glBox.width * 0.5), Math.round(glBox.y + glBox.height * 0.2));
+ await page.waitForTimeout(400);
+ await page.keyboard.press('Control+a');
+ await page.waitForTimeout(700);
+ await page.keyboard.press('e');
+
+ // Wait for the dialog: a NEW (not a toolbar one) whose options look
+ // like via sizes ("0.9 / 0.45 …") can only appear when the dialog opens.
+ await expect
+ .poll(
+ async () => {
+ return (await dumpSelects(page)).some(
+ (s) => !beforeSelIds.has(s.id) && s.options.some((o) => /\d\s*\/\s*\d/.test(o)),
+ );
+ },
+ {
+ timeout: 30000,
+ message: 'Track & Via Properties dialog with the predefined-via-size choice should open',
+ },
+ )
+ .toBe(true);
+
+ await page.screenshot({ path: 'test-results/via-clientdata-01-dialog.png', scale: 'device' });
+
+ const selects = await dumpSelects(page);
+ const viaSelect = selects.find(
+ (s) => !beforeSelIds.has(s.id) && s.options.some((o) => /\d\s*\/\s*\d/.test(o)),
+ );
+ expect(viaSelect, 'dialog predefined-via-size present').toBeTruthy();
+ const targetOption = viaSelect!.options.find((o) => o.includes('0.9'))!;
+ console.log(`[VIA] predefined options: ${JSON.stringify(viaSelect!.options)}; picking "${targetOption}"`);
+
+ // Record the diameter field: the dialog whose value ≈ 0.7 (the via's
+ // current diameter, distinct from every other field and from 0).
+ const diameterBefore = await page.evaluate(() => {
+ const inputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[];
+ const hit = inputs.find((el) => Math.abs(parseFloat(el.value) - 0.7) < 0.02);
+ if (hit && !hit.id) hit.id = '__via_diameter_input';
+ return hit ? { id: hit.id, value: hit.value } : null;
+ });
+ console.log('[VIA] diameter field before: ' + JSON.stringify(diameterBefore));
+ expect(diameterBefore, 'via diameter field (≈0.7) found before selection').toBeTruthy();
+
+ // Pick the predefined via size -> fires DOM change -> wxChoice::OnDomEvent ->
+ // onViaSelect reads event.GetClientData().
+ await page.locator(`#${viaSelect!.id}`).selectOption({ label: targetOption });
+ await page.waitForTimeout(1000);
+
+ await page.screenshot({ path: 'test-results/via-clientdata-02-selected.png', scale: 'device' });
+
+ const diameterAfter = await page.evaluate((id: string) => {
+ const el = document.getElementById(id) as HTMLInputElement | null;
+ return el ? el.value : null;
+ }, diameterBefore!.id);
+ const numAfter = parseFloat(diameterAfter ?? 'NaN');
+ console.log(`[VIA] diameter field after: "${diameterAfter}" (${numAfter})`);
+
+ const aborted = [...testLogger.consoleLogs, ...testLogger.errors].some((l) =>
+ l.includes('Aborted('),
+ );
+ expect(aborted, 'the WASM module must not abort on the via-size selection').toBe(false);
+
+ // RED (bug): diameter -> 0. GREEN (fixed): diameter -> 0.9 (the predefined value).
+ expect(
+ numAfter,
+ `predefined via size 0.9 mm must land in the diameter field (got "${diameterAfter}")`,
+ ).toBeCloseTo(0.9, 2);
+ });
+});
diff --git a/tests/playwright-kicad.config.ts b/tests/playwright-kicad.config.ts
index 220311101..f910a2336 100644
--- a/tests/playwright-kicad.config.ts
+++ b/tests/playwright-kicad.config.ts
@@ -90,6 +90,16 @@ const BIG_MODULE_SPECS = [
// boots pcbnew.html — must run on V8 (chromium-ci); on Firefox/x86 CI the
// ~190M module OOMs at instantiation and #canvas never appears (run 27626037849).
"**/pcbnew-move.spec.ts",
+ // boots pcbnew.html and opens the Page Settings dialog (H-1 modal input-lock repro).
+ "**/modal-input-lock.spec.ts",
+ // boots pcbnew.html and opens Board Setup > Net Classes (H-2 DC-clip repro).
+ "**/grid-clip-bleed.spec.ts",
+ // boots pcbnew.html and opens Track & Via Properties (H-3 select-on-open repro).
+ "**/dialog-select-on-open.spec.ts",
+ // boots pcbnew.html and opens the Edit menu (H-7 menu-staleness repro).
+ "**/menu-undo-stale.spec.ts",
+ // boots pcbnew.html and right-clicks the canvas (H-8 context-menu-staleness repro).
+ "**/contextmenu-fresh.spec.ts",
// 3D viewer specs boot pcbnew.html (3D-enabled build) — same V8 routing.
"**/3d-viewer.spec.ts",
// Isolated (own file → own worker) so its heavy single load isn't degraded by the
diff --git a/wxwidgets b/wxwidgets
index d9c3feecd..ac1427d15 160000
--- a/wxwidgets
+++ b/wxwidgets
@@ -1 +1 @@
-Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823
+Subproject commit ac1427d156666556d7e23d4fd113efaf90da4fb2