# Platform Adaptation Notes

Field-tested pitfalls and conclusions per platform; each entry records only the pitfall and the fix. Protocol-interop conclusions all come from real buses and real panels. Linux is organized by distribution → desktop environment, Windows / macOS by version; add new conclusions to the matching section and sync the Chinese version ([docs/zh/adaptation.md](zh/adaptation.md)) in the same batch.

## Performance Baseline

Overhead of the full MoonBit stack (shim + MoonBit runtime) relative to native C++: examples/hello (release build) compared against a functionally identical pure-C++ libyue hello, both linked against the same vendored static library — no shim on the C++ side, so the delta is the entire wrapper cost.

| Metric | Native C++ | Full MoonBit stack |
|---|---|---|
| Startup (median of 20 warm starts, exec → window map) | 73ms | 70ms |
| Steady-state memory (Rss, 3s after the window appears) | 62.1MB | 62.9MB |
| Binary size | 6.52MB | 7.39MB |

Methodology: Ubuntu 24.04 XFCE (X11), same machine and session; the startup delta is below detection granularity — a tie. C++ side built with `-std=c++20 -O2 -DNDEBUG`; headers from the same-version fork tree (nativeui) plus the prebuilt companion tree (base/build, with `base/allocator/partition_allocator/src` added as an include root). Without `-DNDEBUG` the link fails on the missing `RefCountedBase::CalledOnValidSequence` symbol.

## Cross-Platform (build chain / FFI)

### Build and Linking

- moon must run at the repository root: vendor/build artifacts and WebView2Loader.dll are located via the working directory at runtime.
- Library packages must not carry a `link` section (moon would generate a main-less exe and the build fails); all link flags come from `scripts/prebuild.py` via the `--moonbit-unstable-prebuild` hook, which emits link_configs auto-propagated to every main package depending on yue. Never hand-write `cc-link-flags` in any package.
- prebuild script constraints: stdout must carry only the final JSON (progress goes to stderr); all flags go in the single link_flags string with hand-controlled ordering (`-lyue_mbt` must precede `-lstdc++`, GNU ld is single-pass); the script's cwd is the consumer's project root, so library paths must be absolute.
- moon does not relink when a static library changes (prebuild only rebuilds a missing library): after shim or static-library changes run `prepare.py` to rebuild plus `moon clean` (or delete the produced exe) to force relinking; `nm build/libyue_mbt.a | grep <symbol>` with zero hits means a stale library. prebuild rebuilds automatically when shim sources are newer than the vendored library.
- shim patches land as commits in the fork (lb091188/yue, main = upstream v0.15.6); fork CI emits three-platform source packages and prebuilt libraries on `v*-mbt*` tags. A shim change must backfill the local platform's vendored library in the same batch and promptly align all three platforms via the `vendor-*` CI, otherwise the zero-compile mooncakes path is broken for users.
- The static library and the shim must come from the same compiler family: on Linux a clang-built library + gcc-built shim segfaults reliably (same ABI, same libstdc++); prebuilt libraries are built with gcc. Ubuntu 22.04 toolchain artifacts need `-latomic` at final link.
- libyue version is pinned in prepare.py (`LIBYUE_VERSION` + six-asset sha256); update checksums when upgrading.
- Vendored libraries ship with mooncakes (`lib/<platform>/`); prebuild resolves "vendored → build/" in cascade. Three-platform artifacts are pinned by `vendor-native.yml` (tag `vendor-*`); maintainers align with `vendor_native.py --fetch <tag>`.
- Recent moon deprecates `moon.mod.json` / `moon.pkg.json` (`moon fmt` migrates in one shot); `moon doc` only accepts the new format, and older moon does not understand it.
- MSVC misreads BOM-less UTF-8 source as cp936; Chinese comments produce fake preprocessing errors (the reported line has no such directive): CMake adds `/utf-8` for MSVC.
- Adding a shim function: definitions use `extern "C"`, declarations go into the extern "C" block of `yue_mbt.h` in the same batch, and `nm` must show no `_Z`-prefixed symbol; GLib (`g_*`) is Linux-only — cross-platform functions must not call it.
- Recent moon deprecates implicit trait-method promotion: call sites use the explicit static form `ViewLike::method(obj)`; `impl Trait for X` declaration sites need explicit `pub extend X with Trait::{...}` (generate the method list from `moon check --no-render` output, don't hand-copy); black-box tests must qualify in-package symbols as `@yue.xxx`. These warnings are missed by incremental builds — a clean full build surfaces them all.

### On-demand Browser dependency (0.5.0)

- Mechanism: the MoonBit package boundary is the link-dependency boundary. All `Browser` bindings moved into the standalone package `yue/browser` (`@yue.Browser` → `@browser.Browser`, API unchanged; `examples/showcase/moon.pkg` is the import example), and prebuild's link_configs now has three entries — the Linux webkit2gtk pkg-config output goes only into the `yue/browser` entry, while `yue`/`yue/traybus` keep only the common libraries. **The main package must never import `yue/browser`**, otherwise the dependency closure hands webkit flags back to every downstream.
- The shim was split accordingly: all 27 `yue_mbt_browser_*` functions moved into `shim/yue_mbt_browser.cpp`, and `CastTo`/`Store`/`BytesFromString` moved to `shim/include/yue_mbt_internal.h` (function-local statics inside templates/inline functions are unique program-wide by the standard, so multiple TUs share one handle registry — a Browser handle must stay visible to the generic View functions). Acceptance: `nm libyue_mbt.a` shows browser symbols only in the browser member, and `nm -C` finds no `U nu::Browser` in the main member.
- Major pitfall (fully reproduced; two approaches falsified): the libyue distribution jumbo-bakes `browser.cc`/`browser_gtk.cc` into the same object as PainterGtk/Font/Image, so any program linking that member must resolve webkit_* symbols. A static stub bailout does not work — moon adds `--as-needed` to link commands by default and concatenates per-package flags in dependency-topology order (yue→traybus→browser); in a browser program the main-package stub binds every webkit reference first (ELF static binding is irreversible), the real library then loses its DT_NEEDED to --as-needed ("not referenced up to this point"), and at runtime the browser page calls an empty stub and segfaults. "A weak definition is overridden by a strong dynamic one" is also false — a minimal sample binds the weak one at runtime (prints -1, not 42). A symbol-renaming surgery (objcopy --redefine-syms producing a browser-reference-free library + y4b* stubs, see `scripts/make_webkit_stubs.py`) serves non-browser programs exactly, but a single yue entry cannot branch on whether the main imports browser, leaving the same segfault hole for browser programs. **Conclusion: member-level library isolation is the only sound fix; any static stub is a trap.**
- The fix (landed; full source-mode coverage): `prepare.py`'s source fallback extracts segments right after unpacking — `browser.cc`/`browser_gtk.cc` are pulled out of the jumbo into a standalone translation unit `nativeui_browser.cc` (located by `// ../../nativeui/...` section comment headers, idempotent); the 2-symbol webkit coupling in `menu_item_gtk` (role items running Cut/Paste on the focused WebView) became runtime probing (`g_type_from_name("WebKitWebView")` for the type, `dlsym(RTLD_DEFAULT,...)` for the command; skipped when unresolvable — behavior unchanged). Result: all 58 webkit/soup/JS-family references in `libyue_mbt.a` converge into the browser member, and static-library pull-on-demand isolates it naturally. Use `LIBYUE_FORCE_SOURCE=1` (or a platform without prebuilt assets) to take this path.
- Platform status: Linux source mode as above; Linux prebuilt mode and Windows unchanged (WebView2 has no link-time symbols; identical flags across the three entries is the status quo) — **prebuilt mode gains the same on-demand behavior only after the fork's packaging script splits the jumbo (4a) and a `vendor-*` re-release lands**, after which the stub logic retires naturally at the next prepare version bump; macOS stays unsplit (no local Mach-O archive toolchain to verify the WebKit reference surface of `libyue_prebuilt(macos)`; llvm-nm reads universal-archive member symbol tables incompletely — a wrong split breaks the whole mac line with no real machine as backstop), so the Darwin branch keeps pre-refactor flags on all three entries.
- Verification (Ubuntu 24.04 XFCE X11, source mode): sysmonitor (no browser import) has no webkit/javascriptcore in `ldd`, 0 webkit entries in the dynamic symbol table via `objdump -T` (symbol-level zero, not just missing DT_NEEDED), and survives startup; showcase links `libwebkit2gtk-4.1`/`libjavascriptcoregtk-4.1` and survives a Browser on the first screen (the WEBKIT_DISABLE_DMABUF_RENDERER guard still applies); `moon check` zero warnings, `moon test` 131/131. In-page interaction (page loading / JS round-trip / bindings) still needs on-device confirmation.

### MoonBit cfg(platform=)

- moonc implements `#cfg(platform="windows"/"linux"/"macos")`, evaluated from the `-target` triple; but current released moon passes moonc an OS-less `native` target, so every condition is false. The condition lights up once `moon build -v` shows the full triple in the moonc command line.
- Current substitute: runtime `platform()` checks — declarative trees assemble nodes conditionally and simply skip creation when unmet.

### Declarative Layer and Self-Drawn Components

- Never call `set_background_color` inside mouse callbacks: runtime CSS rewrites swallow the immediately following press ("works on the second click"). Interactive states (hover / pressed) are expressed in on_draw; set_background_color is allowed only at mount time and in theme-subscription callbacks.
- A flex container's attach order is also its layout order and z-order: interleave widgets (handles / dividers) must attach in exact layout position and stay permanently visible — users find them by looking, not by hovering.

### Self-drawn canvas and chart rendering

- 8-digit hex colors have the alpha **first** (`#AARRGGBB` — a libyue `ParseHexColor` source-level convention, the opposite of CSS `#RRGGBBAA`; `yue/color.mbt`'s `parse_hex` agrees). Appending a tail neither errors nor changes opacity — the two hex digits land in the **blue channel**, showing up as color drift: instance one, the floating scrollbar fade changed the alpha string from "d9" to "73", which actually moved blue 0xd9→0x73 with red/green untouched, so the fading thumb turned yellow; instance two, chart area fill `color + "26"` prepends nothing — accent `#009688` + "26" → `#00968826` = alpha 0x00 (fully transparent) + RGB(96,88,38), so **the area fill had never rendered at all**. Fixed by routing everything through `with_alpha(hex, aa)` (prepends the alpha); any set_fill_color/set_stroke_color needing opacity must use it — no hand-appended suffixes.
- An offscreen `Canvas::new + get_painter` supports all geometry drawing (fill / stroke / arc / clip) without `initialize()`, so drawing benchmarks run on headless CI too; but text paths (`draw_text` / `AttributedText::get_bounds_for`) segfault outright — the GTK text stack needs initialize first. Benchmarks therefore come in two tiers: "pure pipeline + geometry-call mirror" (no display needed) and a real full-frame run (text included) that only executes when DISPLAY is present; both tiers print from `charts_wbtest.mbt`.
- `stroke()` under the painter's default stroke color is a no-op (draws nothing): a benchmark that forgets `set_stroke_color` shows stroke cost as ~0 and passes bogusly. Always set an explicit color before timing strokes.
- Under software rasterization (GTK/X11, no GPU) a path stroke costs ~1.8µs per segment, ~13µs for near-vertical segments (slope > 30); `fill_rect` ~1µs each (axis-aligned fast path), `draw_text` ~0.11ms per call, `line_to` itself ~12ns (path building only). A 1000-point × 4-series line chart drawn entirely with path strokes costs 45–60ms per frame — far over the 5ms acceptance line.
- Fix (shared by the whole chart family, see `yue/charts.mbt`): in dense mode (more points than pixel columns) decimate to columns (min/max preserving extremes) and switch to rect paths — area mode fills one rect per column up to the anchor (the fill's top edge *is* the line), line mode draws a min..max vertical bar per column; only sparse mode (points <= columns) uses a true polyline plus polygon area fill. Per-frame cost is decoupled from window size and grows only with plot width.
- libyue's Arc cannot express a counterclockwise arc: on GTK `PainterGtk::Arc` is a plain `cairo_arc`, and cairo normalizes `ea < sa` into a "plus 2π clockwise long arc"; the Win public API is hardwired clockwise too (the underlying ArcPixel has an `anticlockwise` parameter that is never exposed). The shim's "ccw as negative span" conversion therefore never takes effect on either backend — using ccw for a ring's inner arc wraps the hole into the long arc and fills a solid pie (verified by real-machine screenshot: donut and gauge centers were not hollow, with the total/percentage painted on the solid face). Fix: approximate the inner return arc with a polyline (32 segments per full circle, chord error < 0.4px, identical on all platforms); for pure strokes (icon arcs) simply swap the start/end angles, which is geometrically equivalent. Changing `PainterGtk::Arc` to `cairo_arc_negative` in the fork would be the root fix, but that needs a vendor-* release, so it is deferred.
- Chart acceptance benchmarks (release, Ubuntu 24.04 XFCE X11, offscreen canvas + initialize, real full frame incl. text): line 1000 points × 4 series (adversarial sawtooth) 3.08ms / smooth data 1.81ms; bar 200 categories 0.38ms; donut 50 sectors 0.70ms; gauge 0.15ms; scatter 10000 points 3.03ms. Pure-function pipeline (range / ticks / decimation / mapping) for the line chart: 0.028ms. Long-run push: 4800 pushes (10 min at 2Hz × 4 series) total 2.89ms with window length pinned at 1000 — live data is bounded at 4 × 1000 × 8B = 32KB; the increment is GC-reclaimed window garbage, which does not accumulate across continuous pushes.
- Line area anchor: the zero line when 0 is inside the range, the plot bottom for all-positive data, the plot top for all-negative (straddling columns emit two rects, above and below).
- `Painter::DrawText` (canvas `draw_text`) defaults to `wrap=true`: long text in a fixed-height row or narrow box gets wrapped by the platform layout engine and overflows the row bounds. Three symptoms verified on the real machine: process-table command lines (often 100+ chars) wrapped and bled into the next row; large chart y-axis values ("21414.7") stacked vertically as multi-line garbage; end-value labels of multiple line series overlapped when their last values were close. Fix: the shim gained `yue_mbt_painter_draw_text_ex` exposing TextAttributes' wrap/ellipsis (pure ABI translation), MoonBit's `draw_text` gained optional parameters, and table cells now draw with `wrap=false + ellipsis=true` (truncation is done by the platform layout, so no per-cell measurement); `fmt_axis` switches to k/M/G units from |v| >= 1e4 (one decimal, trailing zeros trimmed) so labels stay within 5 characters and never trigger wrapping; end labels are now collected, spread apart by y (14px minimum gap, clamped back into the canvas), then drawn.
- Chart / virtual-table responsiveness (`fill` switch): no fixed width — the container stretches along the cross axis of the column parent and follows window resizes. In the table's fill mode, column geometry is re-laid-out on every paint from the actual width: fixed columns keep their (possibly drag-resized) widths while elastic columns share the remainder (dragging swaps width between the two adjacent columns conservatively, so it never conflicts with the elastic re-layout). Self-drawn row containers that anchor to `w - offset` already followed their parent's width.
- Table header decorations (sort arrows, column-boundary lines, hover highlights) must be painted in the header container's `on_draw`, not in a per-cell container: on GTK, libyue Painter path fills (`begin_path` + `fill`) do not render at all inside a single header cell (about one column wide × 32px, with a Label child widget) — the code runs, nothing errors, but no pixels appear; the identical code renders fine on the whole-row header container or the table's big canvas container. `fill_rect` and path `stroke` render at every container size, so the symptom is easily misread as "wrong coordinates". Reproduce by painting `fill_rect` / path fill / path stroke side by side in one window. Fix: both table_t and table_v_t keep all header decoration in a single `on_draw` on the header container, leaving cells with interaction only. Root cause pending fork-level investigation (suspected to relate to the GTK draw region / clipping of small containers).
- Mouse events at header column boundaries: the pixels just right of a boundary line belong to the next cell, so pressing exactly on a visible boundary lands on the next cell's left edge (triggering a sort instead of a drag). Fix: the drag hot zone recognizes both edges (right 4px → boundary (j, j+1); left 4px → boundary (j-1, j)), with no handle on the last column's right edge.

### Layout Geometry (Yoga flexbox)

- Built-in layout assertions pass 16/16 (±1px); composition rules: content area = container − 2×padding; gap does not stack with margin; percentages resolve against the parent content width.
- Composite widgets (Tab / Scroll / Group) are measure-less leaf nodes in the yoga tree: their outer size must be given explicitly (e.g. `flex:1`) or they collapse (Tab freezes its minimum size at construction, driving the page area to zero).
- tabs_t once hard-coded the outer width to 360px and gave content pages no flex: any Scroll / Table placed inside collapsed to zero (measured: the sysmonitor overview page rendered completely blank except the tab strip). Fix: outer drops the fixed width in favor of `flex:1` (stretch gets the width in a column parent; fills when the parent has a definite height) and content pages get `flex:1` too. When the parent has no definite height flex does not grow, so embedded usage (the showcase section) is unchanged.
- After changing styles at runtime (`set_style`), calling `update_layout` on the root container does not refresh the subtree on GTK at all (yoga state has changed, bounds stay put), and calling it on a single node only re-lays-out that node and its siblings under the same parent — other subtrees (e.g. table body rows) are unaffected. A probe with four controlled variants (immediately after mount / 500ms later, update root / leaf, width / flexbasis) pinned the only reliable form: call it on every container whose style changed. table_t column dragging used to move the handle (bounds follow col_w) while column widths never changed (the style took no effect) because it updated the root; the splitter happened to have a single wrapper container to restyle, so the same pit existed but never surfaced. Fix: apply_col_widths calls update_layout on both header cells and both cells of every row individually.
- GUI automation: prefer keyboard (Tab focus + Space activate); `xdotool key --window` sends XSendEvent events that GTK drops — use XTEST (no --window); coordinate clicks are unreliable due to WM decoration offsets.

### MoonBit ↔ C ABI

- Trampolines must match the C function pointer prototype bit-for-bit, including arity: C calls `callback(closure, args...)` and the trampoline's first parameter receives the closure. Misalignment hides itself — row counts work, some callbacks fire; a callback is verified only once it has actually fired.
- `extern "c"` returning nullable types: older toolchains segfault outright (report success/failure via a `Ref[Int]` out-parameter); on moon 0.1.20260904 + moonc v0.10.12, `-> Bytes?` works — a C-side NULL maps to None correctly, verified in both debug and release against real missing-file and directory (EISDIR) paths (sysmonitor's read_text_file). Other nullable types (handles etc.) are untested; the out-parameter pattern remains the fallback.
- FFI pointer parameters need `#borrow` (compiler-enforced); widget parameters take the handle type `View`, never the MoonBit wrapper struct (otherwise handles are invalid at runtime and silently dropped).
- Closures across the ABI: capture-free top-level function literals compile to real C function pointers; capturing closures use the "function pointer + closure pointer" two-parameter form.
- Callback closures are kept alive process-wide by a registry and not reclaimed per window (negligible leak for single-window tools — settled).
- Toolbar / Vibrant have no Linux static-library symbols (link would fail) and are not exposed; the Browser empty-cookie-list crash is fixed since fork mbt.7.
- Changing a shim signature requires the same batch in `.cpp` / `yue_mbt.h` / ffi.mbt; both a missed signature sync and an entirely missed declaration manifest as a mangle split (`_Z`-prefixed symbol vs plain-C reference) — locate with nm, and run a ".cpp definitions × header declarations" cross-scan before packaging.
- **A standalone translation unit (e.g. `yue_accent_mac.mm`) that does not include `yue_mbt.h` must mark its function definitions `extern "C"` explicitly**: without it they export C++-mangled (`__Z25yue_mbt_system_accent_macv`) while `yue_mbt.cpp` references the plain-C name from the header — link-time undefined. Only macOS exposes this (Linux/Windows never compile the .mm), and neither does the vendor/CI prepare stage (a static archive never checks undefined) — only a real macOS link (moon test/build) hits it; with no local mac, CI is the only defense line. Actually fixed ahead of the 0.5.0 release (symbol re-verified via strings on the re-vendored archive as `_yue_mbt_system_accent_mac`).
- The XFCE panel prioritizes IconPixmap over IconName: set_icon_name and set_pixmap are mutually exclusive — setting one must clear the other.

## Linux

### Distributions

#### Ubuntu 24.04 ✅ mainline

- System dependencies: `build-essential cmake pkg-config libgtk-3-dev libpango1.0-dev libfontconfig1-dev libx11-dev libwebkit2gtk-4.1-dev`.
- AppIndicator runtime is gone, so libyue's built-in tray is unusable → replaced by `yue/traybus/` (pure-MoonBit SNI direct to the panel).
- The webkit2gtk package name varies by distribution (4.0 / 4.1); prepare.py probes with pkg-config and accepts either.

#### Other distributions ❓ untested

- First porting step: check each dependency's pkg-config name, then run prepare.py.

### Desktop environments (tray / menu behavior)

#### XFCE ✅

- Autostart .desktop findings: the Exec path comes from readlink on /proc/self/exe (during development it points into the _build output, so the entry breaks as soon as the artifact moves — only a fixed install path is reliable; AppImage likewise points at its mount point); the full file passes desktop-file-validate with zero warnings; the enabled check honors both system-side disable switches (Hidden=true and X-GNOME-Autostart-enabled=false), and disable is idempotent (a missing entry counts as success).

- Context menus are rendered by the panel mirroring DBusMenu; the app is not asked to draw via SNI ContextMenu.
- xfce4-panel 4.18 sends only the batch `EventGroup` / `AboutToShowGroup`, not the singular versions: implementing only the singular forms gets silently rejected by UnknownMethod — the menu opens but clicks do nothing.
- Desktop notifications must go through `Notification::Show()`; `NotificationCenter::AddNotification` never issues the DBus Notify call on Linux and fails silently.
- Global shortcuts are exclusive XGrabKey registrations; a taken key combination makes Register return -1 (no crash, silent failure) — callers must check and offer another key.
- With desktop focus, xfwm4 captures the keyboard and global shortcuts do not fire (they work when an application window has focus).

#### GNOME ✅ (X11 and Wayland sessions)

- The AppIndicator extension reads the Menu property at registration time to build its proxy: an empty menu returning `/` permanently kills the menu client (clicking the icon does nothing). traybus always returns the real `/MenuBar`, exporting it even when empty, filled via LayoutUpdated.
- With `ItemIsMenu=false`, both left and right clicks emit Activate.
- Global shortcuts segfault on Wayland sessions (upstream casts the root window through a GDK X11 macro): a `GDK_IS_X11_DISPLAY` guard is in place; non-X11 sessions get Register = -1.
- Mouse button semantics are yue-unified 1=left 2=right 3=middle (GDK's 2/3 are swapped); components judge by yue semantics.
- Environment variables for launching GUI apps over SSH: X11 session `XAUTHORITY=/run/user/1000/gdm/Xauthority`; Wayland session `XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.*` and `WAYLAND_DISPLAY=wayland-0`; both need `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus`.

#### KDE ✅ (Plasma 5.27)

- The panel consumes SNI directly; new icons go straight into the visible tray area; tray / menu / quit all pass.
- Protocol behavior is the opposite of XFCE: only singular `Event` / `AboutToShow`, no batch versions; traybus implements both.

#### Deepin ✅ (23 / 25, DDE)

- dde-dock implements StatusNotifierWatcher; SNI direct connection works; new icons land in the collapsed tray area by default and can be dragged out.
- libyue's Popover (transparent window + pointer grab) does not render on DDE and slows the pointer: the shim checks `XDG_CURRENT_DESKTOP` (23=DDE, 25=Deepin) and falls back to a borderless plain window.
- Binaries built on the host (Ubuntu 24.04) run directly: the glibc symbol ceiling is 2.38 and deepin ships the webkit2gtk-4.1 runtime; for cross-distribution shipping, check the glibc symbol requirement first (`objdump -T | grep GLIBC_`).
- `TextEdit::Delete()` deletes the selection, it does not clear; use `set_text("")` to clear.
- DDE clipboard-manager interactions produce occasional CHECK / CRITICAL log noise; functionality is unaffected.

#### KDE / MATE / Cinnamon / Budgie / LXQt ❓ untested

- All support SNI at the protocol level and traybus implements the protocol; awaiting per-environment verification.

### DBus wire protocol (traybus)

- The DBus array length prefix excludes alignment padding before the first element; counting it makes dbus-daemon disconnect as a protocol violation.
- The variant signature in the header SIGNATURE field is "g" (u8 length-encoded); encoding it as "s" passes unit tests but real buses reject it.
- The SNI Menu property must always return the real menu object path — never `/`, even when empty.
- Unit-test self-consistency ≠ interop: protocol issues are located with dbus-monitor on the real bus; GNOME panel-side exceptions show up in journalctl.
- Single-instance RequestName must carry flags=4 (DO_NOT_QUEUE): the default 0 queues the request, so the second instance's already-running check hangs until the first instance exits — semantics fully broken. Real-bus findings (Ubuntu 24.04 XFCE): reply 3 = held by another connection (instance exists → wake it and exit); after SIGKILL of the first instance the bus reclaims the name automatically and a new connection gets reply 1 (becomes the primary); reply 4 = this connection already owns it (idempotent). The full chain (RequestName flags=4 → EXISTS → Wake('as') → RETURN) was verified by dbus-monitor capture on the real bus: Wake to RETURN in 39µs; after SIGKILL of the first instance a third instance claims normally.

- The second-instance wake dispatch must hook Conn::handle's kind==1 branch in bus.mbt before sni.mbt's handle_call: all inbound calls funnel into handle_call, and without the interception Wake lands in the UnknownMethod fallback so the first instance never sees it. Wake naming convention: interface org.moonbitlibyue.Instance, object path /org/moonbitlibyue/Instance, member Wake ('as' = the second instance's command line, passed through moonbitlang/core/env args(), including argv[0]; callers decide what to keep).
- FileManager1 (reveal-in-file-manager) findings on XFCE: the NameHasOwner probe works; ShowItems wire format is "ass" (array of file URIs + an empty startup id); file URI percent-hex uses uppercase (RFC 3986 allows either case, uppercase is the convention), unreserved characters (A-Za-z0-9-._~) and '/' stay raw, everything else is %XX-encoded per UTF-8 byte — locked by unit tests. When the service is offline or the call fails, the fallback is xdg-open on the parent directory — selection is lost, a semantic downgrade that the usage doc must state.
- External-open spawns go through g_spawn_async (G_SPAWN_SEARCH_PATH + stdout/stderr to DEV_NULL); glib reaps the child automatically, no zombies; no shell involved, argv passed directly. xdg-open's behavior for nonexistent paths or unopenable URLs varies by desktop, so the library's Ok only means "handed to the system" — the platform-side outcome does not travel back.
- Screen-saver inhibition (Inhibit/UnInhibit) picks the service by real NameHasOwner presence, not by desktop-name guessing: the candidate list is [org.freedesktop.ScreenSaver, org.xfce.ScreenSaver]; measured on Ubuntu 24.04 XFCE only org.xfce.ScreenSaver is online (held by xfce4-screensaver), org.freedesktop.ScreenSaver is unheld. The two are interface-isomorphic: object path and interface name derive from the service name by dot→slash, Inhibit("ss" = app name + reason) -> u cookie, UnInhibit("u" = the original cookie) must match bit for bit (real-bus capture: Inhibit returned cookie 1516211641, UnInhibit 2 seconds later carried the same value, empty successful reply). GNOME / KDE service holdings pending real-machine notes.
- System bus (measured Ubuntu 24.04): socket is /run/dbus/system_bus_socket — connecting by this default with DBUS_SYSTEM_BUS_ADDRESS unset works; when the variable is explicitly set, take the first unix:path= among the comma-separated entries, and if none matches (e.g. a tcp: address) return an explicit Err carrying the original text — silently falling back to the default socket would reconnect to a bus that really has UPower, turning the degradation test into a false pass. BecomeMonitor is rejected on the system bus (both dbus-monitor and busctl monitor), so capture is unavailable; verification goes through the probe's own round trips plus busctl call to cross-check reply shapes.
- Multi-bus connections coexisting (B5 infrastructure): the shim's fd watch went from a process-wide single slot (each watch overwriting the previous) to an fd→callback dispatch table plus unwatch; the MoonBit side routes fd→Conn through a registry. Dual connections (session SNI + system UPower) verified to coexist without clobbering. When a glib source callback returns 0, glib destroys the source itself — the shim table entry must be erased in step (otherwise a later unwatch calls g_source_remove on a dead id and warns).
- Wire-level 'd' (DOUBLE) and 't' (UINT64) must land as a set: in a real UPower GetAll reply UpdateTime is 't' and Percentage/Energy are 'd'; missing 't' sent the variant's unknown-signature old defensive branch ("return empty string but leave the read position alone"), misaligning every subsequent element and aborting inside the read_string slice (unit tests stayed green — self-crafted shapes never hit it). The defense is now two layers: unknown variant signatures push pos to the message end (parsing degrades to garbage values instead of misalignment), and read_string/read_sig gained bounds checks. Adding a type is an eight-site whole-chain change: DVal/Sig/sig_align/parse_one_sig/sig_of/sig_char/encode/decode_in.
- Double↔IEEE 754 bit conversion lives in the shim (moonbitlang/core has no Double::to_bits/from_bits — confirmed): yue_mbt_sys_f64_to_bits/from_bits are pure bit reinterpretations (static_assert sizeof(double)==8), and the wire layer's 'd' piggybacks on the Int64 8-byte little-endian read/write. Note: once traybus's whitebox test target references such externs, its link needs -lyue_mbt — but link_configs propagate to "targets depending on the package", and traybus does not depend on yue (it's the reverse), so prebuild.py must carry a separate identical entry for NoahLiu/moonbit-libyue/yue/traybus (the single static-lib member references the whole gtk set, so trimmed flags won't link).
- UPower reading path (measured Ubuntu 24.04, desktop machine): DisplayDevice (/org/freedesktop/UPower/devices/DisplayDevice, interface org.freedesktop.UPower.Device) aggregates the main battery — IsPresent=false → Ok(None); mind the interface-name split: devices are …UPower.Device, the toplevel is …UPower, and PropertiesChanged's arg0 filter naturally keeps device-level signals out of toplevel subscriptions. OnBattery lives on the toplevel object; power-source events subscribe to toplevel PropertiesChanged and parse the changed dict directly in the callback (signal dispatch sits on the drain stack — call_sync inside a callback would re-enter the receive path; forbidden).
- Disconnect self-healing (B5, not yet exercised by a real disconnection): mark_dead is idempotent (keyed on the fd registry), reconnects via the shim's post_delayed_task (extern'd directly, bypassing the traybus→yue reverse dependency) after 500ms, at most 3 attempts; on success it replays AddMatch rules, migrates the subscription table and tray items, and re-registers them (during the disconnect the watcher already dropped the old registrations when our unique name vanished). In a pure-CLI context (main loop not running) the reconnect callback never fires and gives up naturally.
- logind event subscriptions (B6): PrepareForSleep(b) lives on the Manager interface; Lock/Unlock live on each session object's Session interface (empty-body signals, one AddMatch per member, path unbounded — all sessions match, so with multiple logged-in users someone else's lock fires too; ownership is the caller's to check via sender). Degradation measured (private bus without logind): suspend_resume_supported()==false and session_lock_watch returns Err(SystemError::Unsupported), never silent. Rapid suspend/resume cycles may deliver two Resuming events in a row (no library-side debouncing); network/DBus may not be ready right after wake — retry logic in callbacks should defer.
- Lock events need a dual signal source (real-machine bugfix): XFCE's xfce4-screensaver does **not** call logind when locking (no Lock/Unlock signals; calling Session.Lock via busctl directly fires them fine, proving the subscription chain is healthy — the locker just never notifies). It emits ActiveChanged(b) on its own org.xfce.ScreenSaver name instead. Fix: session_lock_watch subscribes to both sources — logind Lock/Unlock (system bus, GNOME/KDE path) plus the screensaver service's ActiveChanged (session bus, candidates [org.xfce.ScreenSaver, org.freedesktop.ScreenSaver] picked by real presence) — with a state machine deduping multi-source reports (same-state repeats don't dispatch; only transitions do). The ActiveChanged subscription key carries the service-name prefix, so it cannot clash with the logind subscriptions.
- XFCE "blank-after-1-minute setting has no effect" and the keep-awake verification path (real-machine diagnosis): the power manager GUI's "blank after 1 minute" relies on DPMS or the screensaver's blanking, and on the measured host all three dimming chains were off (xfce4-screensaver /saver/enabled=false, X DPMS Disabled, xfce4-power-manager carrying no dpms/blank keys at all) — the GUI shows a value but nothing executes it, so the keep-awake switch is unobservable. Before verifying keep-awake, enable one executor (the screensaver blank is recommended: same component as the locker, so inhibition and lock events pair up); org.xfce.PowerManagement's inhibit interface is offline here (NameHasOwner=false), so DPMS blanking under XFCE cannot be inhibited via DBus — avoid that path.
- Windows power/session event window (B6, code landed, real-machine verification pending): one message-only window receives both WM_POWERBROADCAST (PBT_APMSUSPEND; both PBT_APMRESUME and PBT_APMRESUMEAUTOMATIC count as resumed) and WM_WTSSESSION_CHANGE (WTS_SESSION_LOCK/UNLOCK, NOTIFY_FOR_THIS_SESSION); the WNDPROC PostTasks back to the main loop (B1 message-window pattern reused). The C side is single-window single-callback shared by sleep and lock — the MoonBit side registers one global dispatcher keyed on the event code, so later registrations must not clobber earlier ones. A WTSRegisterSessionNotification failure only disables lock events and is not treated as overall failure. wtsapi32.lib is the plan's only link-list change; prebuild.py's WINDOWS_LINK_LIBS and shim/CMakeLists.txt must land in the same commit (the two lists carry no consistency-comment backing).
- NetworkManager online status (B7): State and Connectivity are both toplevel properties; a Properties.Get reply body is a single "v" (containing a u32) — a different shape from GetAll's a{sv}. Both change signals are subscribed: the legacy StateChanged(u) carries the new value directly, while PropertiesChanged(a{sv})'s changed dict may carry either property; subscription callbacks sit on the drain stack, so they only update the cache and notify with merged values — never call_sync to re-query (the first cache build happens inside the registration action, legal off the drain stack, worst-case 1.5s block). Normalization: State 70/50 with Connectivity 4/3 is Online; captive portal (Connectivity=2) counts as Offline ("online" = internet-reachable). Measured Ubuntu 24.04: State=70/Connectivity=4 → Online, matching busctl bit for bit; private-bus degradation gives Err(NetworkError::Unsupported). Windows NLM GetConnectivity bitmask: IPV4_INTERNET (0x40) or IPV6_INTERNET (0x400) present means Online, polled every 5s (listener-based later); netlistmgr.h needs no extra link libraries.
- NM StateChanged signal parameter is Int32 (i), not u32 (real-machine pitfall, corrected): the D-Bus spec makes the State *property* u and the StateChanged *signal* parameter i — different types for the same concept; the wire layer decodes i as VI32, and the old implementation matched only VU32, so the signal was silently dropped — signals arrived, callbacks never fired, and after the panel went offline (State 70→20/10) the UI kept showing the initial Online (unit tests alone can't catch this: the hand-crafted signal body happened to use u). Fix: statechanged_of matches both VI32/VU32; bool and other types are still dropped. State inside Properties.Get and PropertiesChanged remains u. The dispatch chain was verified alive on a real machine via a harmless property write (NM 1.46 WwanEnabled toggle, zero network impact on machines without WWAN hardware, PropertiesChanged arriving in real time); the UI flip after a signal was left to real-machine offline/online verification.
- Network callback registration must sit outside the platform branches (located via two-level live observation): on_network_status_change dispatches uniformly through net_dispatch iterating g_net_cbs, but the registration g_net_cbs.push(cb) used to live only in the Windows branch — on Linux every signal arrived and the cache updated value by value (State 70→10→20→40→60→70 matching nmcli monitor line for line) while callbacks never ran, leaving the UI stuck on the initial value; a two-level probe (traybus nm_on_change layer vs the online dispatch layer) pinpointed it. Fix: push moved outside the platform branches so both platforms register uniformly. Other event registrations (on_suspend_resume/on_power_source_change) embed their callback closures directly and don't go through a global array, so they were never affected; net_dispatch dedupes against the network_status() baseline backfilled at registration, so the first same-state signal after registration not dispatching is expected.
- Windows NLM GetConnectivity called on the UI thread can freeze for seconds and drag the native layout assert into a crash (measured on a Win10 19045 host; reproduced deterministically with a poor network environment): showcase aborts steadily 3.7–4.7s after launch (0xC0000409 = fast-fail), with yoga's toplevel assert "availableHeight is indefinite so heightMeasureMode must be YGMeasureModeUndefined" on stderr right before; the crash rate drifts 0%–100% with network health (when the network is healthy the query is millisecond-fast, matching the "smoke survived" notes at commit time — easy to misread as a code regression). Locating path: page bisection on the same exe (removing only the System Integration page → 0 crashes) → disabling only the initial network query + callback registration → 10/10 stable → prewarming and delayed queries both still crashed (any in-process query freezes, timing irrelevant). Root cause: the GetConnectivity slow path can take seconds per call; the synchronous initial-value query at registration froze the UI thread for seconds, and the moment it returned yoga processed the backed-up invalidations through ScrollView's content-measurement GetPreferred* path (which calls YGNodeCalculateLayout with a NaN height — fatal for containers with a defined height style). Fix: shim adds yue_mbt_netwin_start/netwin_cached — a background MTA thread owns COM and the polling loop (interval 5s default) and writes a process-level cache; the UI thread only reads the cache (network_status returns QueryFailed until the cache is ready; on_network_status_change no longer backfills the initial value synchronously; g_net_last becomes None = unknown, first value always dispatches). The cross-thread COM apartment problem dissolves because the interface pointer is created and used on the same thread. Verification: moon check zero warnings, 128/128 tests, showcase smoke 12/12 alive (same environment was 7/10 crashes before the fix).

- Measured sysmonitor figures (Ubuntu 24.04 XFCE X11, same methodology as the performance baseline at the top of this file: startup median, steady-state RSS, release binary): startup (exec → window map) over 5 runs 77/78/81/82/88ms, median 81ms (the hello baseline of 70ms is an idle system; this run carried 1042 processes); steady state with the process page foregrounded at 1Hz uses 2-3% CPU (sampling + derived data + 1000-row table rebuild + repaint total ≈ 25ms/second), RSS 84.9MB flattening at 85.8MB after 100s; binary 7.72MB (hello counterpart 7.03MB). The 1000-row process page meets acceptance: full sampling of 1053 processes takes 14.94ms/pass (release, ≈14µs per process — two /proc reads each), a 1.5% duty cycle at 1Hz.
- The 1000-row table uses table_v_t virtual scrolling (only visible rows draw): refresh goes "full data sampling → filter/sort derived data → rows Store set → table load + schedule_paint", never rebuilding the view tree; selection is remapped by pid so it doesn't drift as the sort order changes every second. There is no C++ counterpart binary, so the combined "wrapper + data layer" overhead is attributed directly from the figures above; the UI drawing portion is on par with the hello baseline under the same methodology.
### System monitor data layer (/proc, /sys — sysmonitor example)

- /proc and /sys pseudo-files always report stat size 0 (fseek/ftell cannot learn the length): whole-file reads must loop with incremental `fread` and a doubling buffer (16MB cap); opening a directory succeeds but `fread` fails with EISDIR — detect via `ferror`. Implemented in the app's own native stub `examples/sysmonitor/stub/sysmon.c`; the MoonBit side goes through `read_text_file`.
- An app-owned native stub may live in a subdirectory: `"native-stub": ["stub/sysmon.c"]` resolves relative to the moon.pkg directory; all symbols are within libc's default link set — zero shim / fork / vendored / link-flag changes. The test target links the stub automatically, so wbtests can read real /proc files.
- /proc/stat column order is `user nice system idle iowait irq softirq steal guest guest_nice`: the 9th column (guest) is already folded into user/nice by the kernel — adding it double-counts. Usage = (Δtotal − Δidle − Δiowait) / Δtotal; iowait is not CPU-busy. When the sampling interval is shorter than one tick (USER_HZ, usually 10ms), Δtotal ≤ 0 and the result is 0; a zero first sample makes the first screen show the since-boot average.
- /proc/cpuinfo model field differs by platform: x86 uses `model name`, ARM boards only have `Processor` / `Hardware` — three-level fallback; core count = number of `processor` lines (logical CPUs incl. hyperthreading, matches nproc).
- /proc/meminfo units are always kB; `MemAvailable` only exists on kernels ≥ 3.14 — fall back to `MemFree`; used = total − available (includes reclaimable cache).
- The `moon run` wrapper process does not forward signals to its child: smoke-testing exit behavior requires killing the built exe child process — killing only the wrapper PID leaves an orphan window.
- Measured full-process sampling (release, Ubuntu 24.04): 580 processes × 2 file reads (stat + cmdline) = 9.57ms per pass, ~1% CPU at 1Hz refresh; RSS comes from stat's page count × page size (equal to status's VmRSS), saving a third read per process.
- /proc/[pid]/stat comm can contain spaces and nested parentheses (process name "(foo (bar))") — split at the LAST ')' in the line; comm is truncated to 15 chars, so the full command line is read separately from cmdline (NUL-separated; empty falls back to [comm] for kernel threads).
- getpriority's nice = -1 is a legal value and is ambiguous with the error return: report success/failure via a `Ref[Int]` out-parameter (kill / setpriority still use the errno return).
- Windows has no /proc and no nice semantics: the stub keeps the same ABI at compile time and returns an "unsupported" sentinel (-1000) at runtime, which the MoonBit layer turns into a Chinese notice — the whole process page degrades cleanly; three-platform CI builds are unaffected (macOS takes the POSIX branch naturally).
- diskstats lists both whole disks and partitions (nvme0n1 alongside nvme0n1p1/p2/p3); an LVM mount's device name (/dev/mapper/ubuntu--vg-ubuntu--lv) does not match its diskstats name (dm-N) — resolve via `/sys/block/dm-*/dm/name` to find dm-N, then take the first slaves entry (measured: dm-0 → nvme0n1p3) to attribute IO rates to the mount row.
- hwmon temperature indices skip numbers (coretemp exposes only some cores' tempN_input); labels may be absent (acpitz has none — fall back to the chip name); millidegrees can be negative (battery sensors); NVIDIA dGPUs commonly expose no hwmon temperature (measured: 0x2488 has none), so GPU temperature shows "—" under the hwmon convention.
- statvfs capacity uses f_bavail (available, minus reserved blocks) rather than f_bfree, matching df's Use% convention; the struct is flattened across the ABI into three int64 out-params (total/free/avail).
- /proc/mounts pseudo-filesystems (~20 kinds: proc/sysfs/cgroup2/devtmpfs/efivarfs …) have no meaningful statvfs capacity — the capacity table skips them via an fstype blacklist, keeping only real /dev/ device lines; multiple mounts of one device (btrfs subvolumes / LVM snapshots) are deduplicated by device, first occurrence wins.
- Directory enumeration (/sys/class/hwmon, /sys/class/net, /sys/bus/pci/devices, /sys/block/*/slaves) goes through a generic opendir/readdir stub (newline-separated entry names); together with read_text_file these are the data layer's only two IO primitives.
- The /dev/fuse control mount (appears once the file manager brings up gvfsd-fuse; mount point /tmp/fuse) returns a valid statvfs with f_blocks=0: filtering by the fstype blacklist alone is not enough — also skip total<=0, or the disk page shows a "0 MB / 0 MB" noise row (verified: the S5 whitebox assertion total>0 failed because of it).
- sysmonitor UI copy discipline (from the full UI-polish pass): interface text states only "what it is / how to use it" — never data conventions or implementation paths (e.g. /proc paths, "two-sample difference", "milli-degree conversion", or a "nvidia-smi planned later" note); rates, capacities and axis values always switch units dynamically (B→K→M→G) so numbers stay short; the large 24px statistic cards especially must never wrap and overflow.
- sysmonitor overview card pattern, modeled on Mission Center: icon + title, a spec subtitle (hardware specs such as CPU model / total capacity / mount point live in the card subtitle — not in a window-level subtitle row), a current-value line (usage % · temperature, used / total · swap), and an in-card sparkline (series window + end dot; fixed 0-100 range or peak-adaptive, dual series overlaid for network rx/tx). Cards share width via flex and equal height per row via stretch, following window resizes; each card is self-contained and readable without the rest of the window.

### Display protocols

- X11 ✅ mainline; Wayland unsupported — verify GUI behavior in an X11 session (tray / shortcuts are session-guarded).
- User idle seconds come from the X Screen Saver Extension (XSS) XScreenSaverQueryInfo, dlopen'd at runtime ("libXss.so.1"/"libXss.so") instead of build-time linking: keeps libxss-dev out of the distribution chain (prebuilt static libs ship via mooncakes; an extra dynamic dependency may be absent on consumer machines). XScreenSaverInfo is minimal — the shim hand-mirrors the struct (idle-milliseconds field at offset 24); a failed load takes the same path as "no X" and returns Unsupported.
- In an XWayland session input events never reach the X server, so the XSS idle reads absurdly high (hours right after user activity): an explicit WAYLAND_DISPLAY probe returns Unsupported — better no number than a wrong one; env -u DISPLAY (no X session) is likewise Unsupported; neither crashes. Measured cross-check (Ubuntu 24.04 XFCE X11): idle_seconds vs xprintidle same-instant double read differs by 17ms (criterion ±2s); after an xdotool-simulated mouse move, 0.317s vs xprintidle 320ms.
- Screen locking does not count as input: the XSS idle keeps growing while the screen is locked (lockers report no input). "User idle" and "screen locked" are orthogonal dimensions — do not approximate lock detection with an idle threshold (lock events come from logind, landing in a later batch).
- Idle-reading demo interaction: a click is itself an input event and resets the XSS idle counter — a "click the button to read the current idle" demo contradicts itself (it always reads 0.x seconds; command-line probes and xdotool-based external reads never exposed this). The correct shape is a switch that starts a periodic refresh (one read per second), with the timer stopping itself on the next tick once the switch turns off (libyue timers have no cancel id — the callback checks the switch state and suicides).

### GTK specifics

- A Table inside a Notebook tab segfaults during size measurement (negative allocation): keep it in a plain container or a standalone window.
- Content widgets (Group / Scroll) extend View, not Container: attach content via `SetContentView`; `AddChild` is rejected by the type check.
- Upstream NUContainer defects (patch `patch_linux_container_events`): (1) the event window raises on map, stealing hits from native child widgets (tabs unclickable, wheel dead) → `gdk_window_show_unraised`; (2) container preferred sizes hardcode 0 and Scroll's size_request is 0×0 → width follows the viewport, height resolves to the content's natural yoga height. Do not report yoga's dynamic natural size to GTK: allocation pollutes yoga state and requisition negotiation oscillates without converging.
- The visibility guard at the top of `UpdateChildBounds` misses GTK's first size-allocate (it happens before map): layout must run unconditionally.
- `Slider::SetValue` sets the ignore flag even for identical values, swallowing the user's first callback: set it only when the value actually changes.
- `ProgressBar::SetValue` means 0..100 on both Linux and Windows; the yue layer is unified on 0..1, so the conversion branch must cover both platforms.
- `View::GetBoundsInScreen` stacks coordinates wrongly under Scroll / nested containers (patch `patch_linux_view_bounds_in_screen`): GTK screen coordinates must be "client-area origin (`gdk_window_get_origin`) + offset within the client area (`gtk_widget_translate_coordinates`)"; `gtk_window_get_position` includes the title-bar decoration — mixing it with translate is off by exactly one decoration size.
- Table checkbox-column indicators scale with row height (XFCE theme): set `indicator-size=16` explicitly on checkbox columns and cap the renderer height at 20.
- Drag-out data must use the `Data(std::vector<base::FilePath>)` constructor (the string constructor silently degrades to Text); relativize paths first (`g_filename_to_uri` rejects relative paths).
- The drag preview hotspot is hardcoded to (0,0) upstream; the patch centers the image on the cursor (`patch_linux_drag_icon_hotspot`).
- Whether a drop is accepted is decided by drag-motion (`handle_drag_update`); `handle_drag_enter` is only an entry notice. Register the Image data type too — dragging in from image viewers / browsers yields image content, not file paths.
- libyue's `Entry::SetText` swallows `on_text_change`: the GTK side guards with an `is-editing` object-data flag that filters the `changed` signal during programmatic sets (loop prevention), so the visible text changes but consumers get no callback — `input_t`'s clear ✕ therefore "cleared the text but never refreshed the filtered list". Fix: the clear handler explicitly invokes `on_input("")` once. Any path that programmatically changes Entry / TextEdit text and then relies on the callback must invoke it manually.
- Drag-out initiation: calling `gtk_drag_begin` synchronously desyncs GTK's drag state machine (nested gtk_main never exits, drag works once), so it must be deferred until the event queue drains, initiated with the press event, and the drag_context backfilled; drag-failed needs a defensive cleanup (fork mbt.12).
- Creating a Browser page (WebKitGTK) aborts the process immediately: `Could not create GBM EGL display: EGL_NOT_INITIALIZED. Aborting...` — WebKitGTK 2.5x's DRMDeviceManager initializes the main DRM device when a WebView is created and RELEASE_ASSERTs to death when the GBM EGL display can't be obtained. On the NVIDIA proprietary driver without `libnvidia-egl-gbm`, GLVND only has the X11 backend (`10_nvidia.json` has no GBM), so neither card1 nor renderD128 yields a display (measured: `eglGetPlatformDisplay(GBM)` returns NULL; note that in-process probe results don't match WebKit's actual path — an in-process probe on renderD128 initialized successfully while WebKit still aborted, so don't gate on probe results). showcase mounts a Browser page on its first screen, so it always crashed. Library-side fix: the shim's app_init (Linux) sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` unconditionally (overwrite=0, an explicit user setting wins), pushing WebKit onto the legacy rendering path — no more crash, full Browser functionality (only web content loses one layer of GPU acceleration; the cairo-based UI is untouched). Verified: bare `moon run examples/showcase` opens its window, stays alive, zero aborts. Root fix at the system level: install `libnvidia-egl-gbm` (NVIDIA's GBM EGL backend), after which `WEBKIT_DISABLE_DMABUF_RENDERER=0` restores hardware rendering. Engineering note: after changing the shim, moon does not necessarily relink (the exe isn't in its dependency graph) — run `moon clean` or delete the exe to force a relink and confirm with `nm exe | grep <new symbol>`.
- The overlay-scrollbar default needs a process-environment guard: the `GTK_OVERLAY_SCROLLING` environment variable outranks gsettings inside GtkSettings (distro scripts / users exporting `=0` to force classic scrollbars is a common practice), and GTK re-reads the process environment every time a ScrolledWindow is created — writing the `gtk-overlay-scrolling` GtkSettings property during theme init alone cannot beat the env-var path. app_init (Linux) calls `setenv("GTK_OVERLAY_SCROLLING","1",1)` before gtk_init (overwrite=1: the library default and `set_overlay_scrollbar(true)` win over the global preference; a caller turning a single scroll area off explicitly still goes through the per-widget API), immune to machines running such scripts; the later GtkSettings write stays as a fallback for paths that bypass the env var.
- **Cross-axis scroll-range bug (the root cause of unscrollable pages, verified bit-for-bit with probes)**: the fork's Linux ScrollImpl::GetMaximumScrollPosition uses the **viewport width** as the vertical page_size — measured `max_y = content natural height − viewport width` (window 900×600: pure drawn-rows content 1064 → maxy 164, charts+rows 1088 → 188, pure labels 1160 → 260 — all exact hits), with the horizontal axis wrong symmetrically. Consequences: programmatic scroll range collapses to a fraction of the real one, and the floating thumb never appears (the library hides it at maxy ≤ 0.5). **Initially-visible and shown-after-hidden scrolls are equally wrong** (briefly misdiagnosed as a hidden-mount measurement issue, disproved by an always-visible control group reading the same value; queue_resize/update_layout remeasure attempts had no effect — GTK's measurement was correct all along, nativeui's reading was what's wrong). Fix: on Linux the shim's `yue_mbt_scroll_get_max_position_x/y` now reads `upper − page_size` straight from `gtk_scrolled_window_get_h/vadjustment` (other platforms still pass through to nativeui); after the fix 1160−600=560 exactly, and sysmonitor's right pane scrolls by wheel (5871 differing pixels before/after screenshots).
- A hover group's (hover_group) detection cannot rely on container enter/leave: GTK pointer events don't bubble — delivered to the deepest hit GdkWindow and finished there — and child containers' (NUContainer) / native widgets' (Entry etc.) event windows **monopolize** pointer events, so ancestor containers never see enter (sysmonitor cards do receive enter only because their children are window-less labels; once a child is a container or native widget the stream breaks). A true "event pass-through" would need either upstream fork changes (every view broadcasting events up the ancestor chain) or platform-wide hooks (XI2/WH_MOUSE) — both invasive and heavy to maintain, so not taken. The workable mechanism is pointer-position polling: **all groups share one global 100ms timer (a set_timeout chain that stops entirely when no groups exist), one pointer query per tick plus one screen-rectangle compare per group, all microsecond-level**; cursor_screen_x/y and get_bounds_in_screen share screen-root coordinates, consistent under multi-monitor tiling including negatives (verified bit-for-bit with a probe). enter serves only as a hit accelerator; leaving is entirely the poll's job. Cursor pass-through is generalized the same way: `cursor_group` shares the same global poll tick and, while the pointer is inside the group bounds, recursively SetCursor on the group root plus every descendant through the shim's `yue_mbt_view_set_cursor_deep` (setting only the container does not cover children with their own windows); leaving restores Default — the original per-child cursor, not the pre-group one (the library offers no read-back, so don't wrap a group whose children must keep their own cursors). A visible background must be self-drawn in on_draw (set_background_color and the backgroundColor style are both unreliable for draw-less containers — theme CSS and child windows suppress them; paint the hover/idle backgrounds directly in the group container's draw, transparent child regions show through naturally). Measured: hovering a child widget (input) turns the whole card to fill_hover (239,241,243) exactly, and leaving restores it.
- The self-drawn floating scrollbar (overlay_scroll) is demoted to an explicitly opted-in component (user decision): it once was the declarative `scroll()` default form (to unify the look across platforms against Windows' classic bars), but the thumb depends on the maxy/on_size_changed chain and turned unstable once the cross-axis bug stacked on top. `scroll()` now always uses the platform's native scrollbars (overlay=true requests the overlay style — guaranteed by the environment variable on Linux; Windows has no overlay equivalent), and the overlay_scroll component stays for scenes that explicitly want the unified look, with docs marking it not recommended for new code.
- Reading the system accent color (GTK3 has no accent API; `yue_mbt_system_accent` falls through three steps): 1) the GNOME 47+ GSettings key `org.gnome.desktop.interface`/`accent-color` — when the schema exists but the key does not (e.g. Ubuntu 24.04's gsettings-desktop-schemas), `g_settings_get_string` aborts instead of returning empty, so probe with `g_settings_schema_has_key` first (abort reproduced with a probe on this machine); 2) `@define-color theme_selected_bg_color` in the current theme's CSS: GTK themes express their primary color as the selection background, searched in `~/.themes` → `$XDG_DATA_HOME/themes` → `$XDG_DATA_DIRS/themes` → `/usr/share/themes` under `gtk-3.0/{gtk,gtk-contained,gtk-dark}.css`, with the dark preference deciding which file is parsed first (Orchis themes define the light accent in gtk.css and the dark variant in gtk-dark.css); 3) empty when neither hits. Verified with a probe (Ubuntu 24.04 + XFCE + Orchis-Teal-Light-Compact): returns `#009688`, matching the theme CSS bit for bit; XFCE has no accent setting, so step 2 is what captures the theme's primary color.

## Windows 10 / 11 ✅

First full local-chain verification environment: Windows 10 19045 + VS BuildTools 2022 (v17.14) + SDK 10.0.26100 — prepare.py build → moon check / test / build → hello launch smoke all pass (previously Windows had CI-only verification; some branches had never been compiled against a real Windows SDK).

### Toolchain

- Requires VS Build Tools (VCTools workload + the ATL component for `base/win/atl_throw.h`); run moon / cmake from an x64 Native Tools Command Prompt or after vcvars64. Installer quiet / passive mode requires elevation, else Exit 5007.
- Release asset names are `libyue_{v}_win.zip` / `_mac.zip` (not windows / darwin).
- On case-sensitive volumes compilation fails with C1083 on `webview2.h`: the SDK ships only `WebView2.h` (capital W); prepare.py adds a lowercase alias after extraction. On the same volume `shutil.copyfile`'s samefile check is unreliable — delete the destination before copying.
- Platform-specific code must guard its includes and implementations in the same batch: a bare `gtk/gtk.h`, or a function guarded at the call site but not at the definition, explodes on the other platform's compiler with C1083 / C2065.
- The power/session message code (B6) hit three SDK facts on its first real-machine compile: the `PBT_APMRESUME` macro does not exist (resume only ever sends the unconditional `PBT_APMRESUMEAUTOMATIC`, plus `PBT_APMRESUMESUSPEND` afterwards only for user-input resumes — the latter is a subset of the former, and matching both fires the callback twice per wake); SDK 10.0.26100 has folded the `PBT_*` constants into winuser.h and there is no standalone `pbt.h` at all — explicitly including it fails with C1083; `WTSRegisterSessionNotification` / `NOTIFY_FOR_THIS_SESSION` are declared in `wtsapi32.h` while the `WTS_SESSION_LOCK` message codes live in winuser.h — a missing include therefore reports only the functions as undeclared while the message codes compile fine, which misleads you into thinking the headers are okay.
- shim platform differences: guard `dlfcn.h` with `__linux__`; MSVC needs `_USE_MATH_DEFINES` for `M_PI`; `base::FilePath` is `std::wstring` under UNICODE builds — go through `FromUTF8Unsafe / AsUTF8Unsafe`; Windows has no Popover and no `SetOverlayScrollbar` / `Clipboard::Selection` / `Tray::SetTitle` etc., all degraded to no-ops in the shim; `operator new/delete` redirect to `malloc/free` (moon builds its runtime with MOONBIT_ALLOCATOR=SYSTEM); a widget's HWND comes from `dynamic_cast<nu::SubwinView*>(GetNative())->hwnd()` — `GetNative()` itself is not an HWND.
- Native child HWNDs do not follow the container after scrolling (they float over scrolled content): force `View::Layout()`, and the scroll wrapper registers on_scroll with the callback deferred via a 0ms timer until layout completes; an input's inner shadow is `WS_EX_CLIENTEDGE`, so borderless must clear STATICEDGE / CLIENTEDGE / WS_BORDER; DatePicker shows only the year unless given an explicit width; glyph-based small icons are inconsistent across platforms — draw them with Painter vectors inside components.

### Link flags (moon → cl / link)

- `cc-link-flags` is spliced verbatim into the cl command line; GNU-style `-L/-l` triggers D9002. The correct form is link inputs (`build/yue_mbt.lib setupapi.lib …`): cl forwards .lib position arguments to link, and system libraries resolve via the LIB environment variable.
- Path separators must be forward slashes: backslashes are eaten by moon's argument parsing and link fails with LNK1104.
- The official CMakeLists system-library list is incomplete; copying it verbatim yields 144+ LNK2019. The prepare.py list is complete.
- The CRT must match moon's static /MT: CMake multi-config generators ignore `CMAKE_BUILD_TYPE`, so `cmake --build` must pass `--config Release` (prepare.py automates this), else LNK4098 plus unresolved `__imp__*`.
- The console window on exes is fixed by the `win_gui.c` link pragma built into the yue package: the pragma lives in the .obj's drectve section and an archived member is only read if it is referenced — `initialize()` references the stub symbol `yue_mbt_win_gui_marker` to guarantee extraction, so consumers need zero configuration; the PE-header rewrite in release-bin.yml (Subsystem 3→2) remains as a fallback. User link_flags are spliced before `/link`, so cl discards `/SUBSYSTEM`-class link options outright (D9002) — appending flags cannot work. Under the GUI subsystem stdout is visible only through pipes / redirection.
- After swapping `yue_mbt.lib`, `moon build` reports "no work to do": delete the produced exe under `_build` to force relinking.
- prepare.py mode-switch pitfall (prebuilt↔source): `cmake -D` only overrides the CMakeCache when explicitly passed — a stale ON from a previous prebuilt configure survives into source mode, so none of the globbed sources get compiled, `yue_mbt.lib` ends up with just the single shim obj, and the final link fails with 360 library-wide unresolved symbols. Fix: always pass the flag explicitly as ON/OFF for both modes. Quick check: `lib /list build\yue_mbt.lib` and count the objs — a full Windows source build has 27.
- prebuild's link_configs Windows branch once omitted the separate entry for `yue/traybus`: traybus does not depend on yue (it is the reverse), so propagation "to targets that depend on the package" never reaches it, and `moon test` linking the traybus test exe failed with 12 LNK2019 `yue_mbt_sys_*` symbols; the Linux/macOS branches already carried the separate entry, and Windows was brought in line.

### Manifest

- moon's link-flag splicing behavior (measured on Windows): for each main package it splices the flags of every package carrying link_configs in the dependency closure once, and for blackbox test targets it splices the tested package's own flags one extra time (tested package's entry ×2). Listing a `.lib` twice is harmless; listing `manifest.res` twice puts two same-name MANIFEST resources into the exe → CVT1100 link failure. The earlier conclusion that "newer moon embeds its own MANIFEST that conflicts with our res" was wrong: mt verified that moon-linked exes contain no manifest resource at all — the "other copy" of the conflict was always our own duplicated res (it started firing for any target that splices both flag sets once the Windows link config for traybus was added).
- Channel exploration conclusions: link options like `/MANIFEST:EMBED` `/MANIFESTINPUT:` placed into link_flags are discarded by cl as compile options (D9002 — options before `/link` do not reach the linker); `#pragma comment(linker,"/manifestdependency")` requires the linker to run with /MANIFEST, which moon's link does not (no external .manifest file appears next to moon-linked exes either); prebuild's stdin carries only an environment snapshot and module_root, with no target/package info, so per-target differentiated configs are impossible.
- Final scheme: manifest.res stays out of the default link_flags — dev / test / moon run need zero configuration. The cost of running without a manifest is more than visual: on a real Win10 19045 machine, clicking a button in a message box killed the process — `TaskDialogIndirect` is only exported by ordinal 345 of comctl32 v6; without a manifest the v5.82 comctl32 is loaded, whose export table also has an ordinal 345 pointing to an unrelated function, so libyue's by-ordinal lookup resolves a non-null garbage pointer and calling it is plain UB (a python ctypes probe confirmed the resolved non-null address; a MoonBit probe replicating the exact same usage ran clean three times in a row — UB is nondeterministic, a single non-repro does not disprove it). Fork fix (mbt.13): MessageBox checks comctl32's DllGetVersion before resolving the ordinal and only calls it on major version ≥ 6; below v6 it falls back to the classic `MessageBoxW` (icons mapped from the TD_*_ICON values to MB_ICON*, MB_OKCANCEL with OK returning the first custom button's response when two or more buttons are registered, MB_OK otherwise with cancel semantics), so a dialog always shows regardless of the manifest; OnClose is uniformly posted back to the UI thread (the old empty-resolution path calling it straight from the background thread was also a cross-thread hazard). The first degradation build closed silently on a null resolution, which real-machine feedback reported as "clicking the message box does nothing", hence the MessageBoxW fallback; a second round of feedback reported "icons and buttons but no text" — TaskDialog keeps the main text in `pszMainInstruction` and only the informative text in `pszContent`, while `SetText` writes only the former, so the fallback must concatenate both fields into MessageBoxW's single text line. Distribution-style builds set `YUE_MBT_KEEP_MANIFEST=1`: the res rides the yue entry, and moon build's main packages splice each entry exactly once, embedding exactly one manifest (Common-Controls v6 + supportedOS, verified by reading it back with mt) — with v6 a real TaskDialog shows; do NOT set this switch for a full `moon test` (the blackbox tested-package duplication guarantees CVT1100). release-bin.yml is configured accordingly.
- After an environment variable changes link_flags, moon occasionally keeps relinking with the stale config: if setting/unsetting the variable seems to change nothing, fall back to `moon clean` (or delete the produced exes under `_build`).

### Runtime differences

- **2026-09-25: the whole tree rolled back to the 09-23 evening state f3ba8bf (the user's call)**: the entire fix chain made from 09-24 onward for "form-page native children following the scroll / page-switch streaks / tooltips never showing" (first-generation 0ms resync → fork recursion into UpdateChildBounds → dropping WS_CLIPCHILDREN → allocation translation → pixel blit → band repaint ordering → page-switch relayout coalescing, 30+ commits, including fork ba479418/e373e60a/f4528cb8/cf308737, the browser package split, and three tooltip generations) proved a **net burden** across rounds of real-machine review — every round introduced new diseases (jank / blue ghosts / streak recurrence / ghosting / floating inputs / tearing) without curing the old ones, so the tree was rolled back wholesale per the user's decision; native pinned back to v0.15.6-mbt.12. Core lesson: **sync-layer fixes for mixing native HWNDs into a self-drawn world failed on the real machine repeatedly — each such fix must pass a single-point real-machine verification before the next one is layered on; bundling several fixes per round is forbidden**. Full root-cause narratives of the rolled-back approaches live in the git history of 09-24~09-25 (both repos). Accepted known state after the rollback: native tooltips never show on Windows (the native path needs a comctl32 v6 manifest), native inputs follow scrolling via the update_layout fallback, and page-switch/scroll streaks remain (inherent to WS_CLIPCHILDREN). **After the rollback the user hand-picked items to restore** (same day): the browser package split (with the lib-form-differentiated linking fix), vector self-drawn result symbols, the cursor_group component, the network-status label() fix plus the Linux initial-dispatch fix, the Windows live-resize bitmap placeholder (pure shim subclass), and the single-line Entry wheel forwarding (fork 7f57e87f, delivered via the v0.15.6-mbt.18 prebuilt; the unselected fork ba479418 was removed in 3f8e8935 before cutting that release). Not restored: the self-drawn tooltip bubble, the prepare backport machinery, and every scroll/page-switch family fix. **The Windows overlay-scrollbar form was restored later** (its own batch after real-machine acceptance): only the form itself was ported (host wrapper + self-drawn thumb + flex/basis 0 inside the host branch); the 09-23 baseline's update_layout follow fallback stays untouched and the non-Windows branch is unchanged. The original commit's bundled "remove the fallback + unconditional basis 0" (the collapse root that aab1a74 had to cure) did not come along.
- MoonBit concrete-type misuse in FFI handle parameters: `MessageBox::run_for_window` once passed the `Window` struct straight into an extern expecting a `View` handle (the C side received a MoonBit heap address instead of the registry id), so `CastTo<nu::Window>` failed silently — the message box ended up parentless and the synchronous path deadlocked. Detection: the MoonBit-generated C prototype shows `struct ...Window*` for the parameter instead of `void*`. Rule: any extern declaration taking a handle must use `View` (the external type) and let MoonBit call sites unwrap the concrete struct via `.view()`; the async `show_for_window` and the sync `run_for_window` must follow the same convention. Also note: moon's incremental check is unreliable — after changing the shim/library, `moon build` may report "up to date" and not relink, so delete the produced exe under `_build` to force it (otherwise you probe stale-library behavior).
- Visibility-toggled pages disappear after switching (set_visible-driven tabs like tabs_t, verified on a real machine): libyue win's `View::Layout()` propagates only to an `IsContainer()` parent (`if (GetParent() && GetParent()->IsContainer())`), and Scroll is not a Container — after a subtree mounted under a Scroll's content toggles visibility (set_visible → yoga display switch), the propagation chain breaks at the Scroll, the yoga root never recalculates, and the page being shown gets a 0-height allocation; when the page content has no explicit height it is squeezed to the sum of its paddings (measured: page height 50→16, the whole content block vanishes). GTK hides this behind full size-allocate relayout; it is Windows-only. Fix: the shim's `yue_mbt_view_layout` (update_layout) now walks the parent chain to the root container and calls `Layout()` there, and tabs_t's `sel.subscribe` adds one `update_layout(outer)` after the visibility flips. A probe (page-content on_draw self-report + bounds dump) confirms every switch paints correctly with a stable page height. Note: only trees routed through a Scroll's content break the chain — a direct flex child of the root always recalculates.
- Visibility-toggle vanishing, residual three-layer case (the showcase structure: page-container set_visible switching + scroll + section + tabs_t, still reproduces on a real machine): on tab switch, the dirty self-heal branch of `Container::Layout` (the one carrying a TODO comment in view.cc) allocates outer containers from the yoga mid-state of the display switch — sibling flex:1 page containers get a compressed height from the scroll content's mid-state measurement (measured: 210→56), and it cannot self-correct afterwards: the self-heal propagation breaks at Scroll (not a Container), and the root-level recalc's SetBounds chain breaks at intermediate containers whose size did not change (identical size → ViewImpl::SizeAllocate early-returns → children's UpdateChildBounds never fires). Consequence: the WM_PAINT dirty rect is clipped by the compressed page container into a 24-high sliver that does not intersect the page area (74,278,512,50), so `DrawChild` skips the whole block on `child_dirty.IsEmpty()` — on_draw never fires while bounds look fine. Tried and measured ineffective: root recalc ×N, reversed visibility order (true before false), root recalc inlined into set_visible (shim side), leaf-level visibility (only page content toggles), page_c flexbasis 0 (CSS flex:1 1 0 semantics). Conclusion: the root cause lives in libyue win's yoga integration itself (dirty self-heal using stale layout + the Scroll chain break), which external shim/yue-layer patches cannot penetrate; a fork-side fix is needed (candidate directions: force YGNodeCalculateLayout before allocating in UpdateChildBounds, or keep Scroll content measurement out of the mid-state). The 20babb5 fix fully covers the case where tabs_t sits directly in a scroll's content (no page-container layer); the showcase case awaits the fork-side approach.
- Scrollbar form: libyue's Windows scrollbar is a self-drawn classic style (the Scrollbar class: track + arrow buttons + permanent layout space), and `Scroll::SetOverlayScrollbar` is a no-op on Windows (the API is excluded by `#if !defined(OS_WIN)` in the header) — GTK's floating form has no counterpart here. The unifying answer is the yue-level `attach_overlay_thumb`: set policy to Never to hide the platform bar, and a self-drawn thumb positioned via yoga absolute (static right/top/bottom) spanning the right edge's full height, with the visible segment painted in on_draw from Ref state — scroll updates only schedule_paint with zero yoga relayout; appear/fade uses two clear_timeout-cancellable timer steps (d9→73→hidden), dragging relies on the implicit mouse capture acquired on press (moves keep arriving outside the thumb — see the WM_CAPTURECHANGED note under the splitter entry), and wheel events on the slim thumb are forwarded to the Scroll manually via on_wheel. Integration point: the declarative `scroll()` default form (overlay=true with no explicit policy) wraps a host container and routes through it (the style parameters move to the host with the Scroll filling it — the thumb's absolute positioning is anchored to the host; attaching the thumb to the page container directly would be skewed by padding/margins); the overlay_scroll component and every page-level scroll share this path. The Windows on_scroll→0ms-timer forced relayout of native HWNDs is kept.
- System accent color: `DwmGetColorizationColor` returns the "window colorization color" — a blend of the accent color with the system base color, visibly off from the Settings accent color under default configuration (reference machine Win10 19045 returned yellow-green 0xFFB7AC00 while the Settings accent color is teal 0xFF00B7C3); read the registry `HKCU\Software\Microsoft\Windows\DWM\AccentColor` first (0xAABBGGRR, written since Win10 1803+), falling back to the colorization color only when missing / 0 / 0xFFFFFFFF — after the fix the probe returned 0xFF00B7C3, bit-for-bit equal to the Settings page. The fallback value's alpha bits encode "intensity", not opacity; take RGB only.
- GetSystemPowerStatus semantic losses (battery query): ACLineStatus 255 (unknown) counts as not-online; BatteryFlag 128 (no battery) / 255 (unknown) both count as no battery; BatteryLifeTime drifts with AC/battery mode and is often -1, so remaining time is never reported (the Linux UPower side folds State 1/4/5 all into "on power", aligning the two platforms). Also: at full charge on AC the BatteryFlag is High without the Charging bit (measured: percent=100 charging=false), so "full but still on power" shows as non-charging on Windows; there is no change event for the charge level itself (only plug/unplug), so slow drain/charge needs polling to stay fresh (the showcase system page uses a 30s set_timer plus instant refresh on plug events, both through the same query path).
- `AttributedText` ranged font / color: upstream Windows supports only whole-text (ranged calls CHECK-crash; GDI+ has no rich text). Fork mbt.9 builds a segmented layout engine (run storage / flowing line breaking / measure-draw from one source) and the MoonBit-side degradation guards are removed — three platforms now agree. Gotcha: `Gdiplus::Font::GetHeight`'s overload takes `(const Graphics*)`; passing a reference does not compile.
- `Color::Get(Border)` hits NOTREACHED and returns a garbage color: the shim maps Border to `GetSysColor(COLOR_WINDOWFRAME)`.
- Blurry self-drawn text: libyue's GDI+ brush hardcodes grayscale antialiasing; an idempotent prepare.py patch switches it to `TextRenderingHintClearTypeGridFit`.
- System notifications: WinRT toast looks up the notifier by AUMID and silently fails without an AppUserModelID; the shim sets the AUMID automatically before the first notification and writes the registry DisplayName.
- Browser prefers WebView2 (falls back to IE when the loader / runtime is missing); WebView2 follows the system proxy — on machines with a broken proxy set `LIBYUE_WEBVIEW2_ARGS=--no-proxy-server` (a prepare.py patch injects AdditionalBrowserArguments from the variable); the demo:// custom protocol does nothing under WebView2 (the IE path works).
- win32 Group / Scroll do not grow with content: give them explicit heights; ScrollImpl's scroll range only honors SetContentSize, so a prepare.py patch queries the content yoga tree for natural size when none was set explicitly.
- Key codes and modifiers: the Windows KeyboardCode uses Win32 VK values, normalized to the constant table at the events.mbt entry; Windows' native modifier bits are Shift=2 / Ctrl=4 / Alt=8, and the shim's `NormalizeModifiers` gained an OS_WIN branch mapping to the unified 1/2/4/8.
- Ghost tray icons: abnormal exits skip the CRT static-destruction chain and leave the icon behind; the shim installs four process-level hooks (atexit / SetConsoleCtrlHandler / SetUnhandledExceptionFilter / SIGABRT) that re-issue NIM_DELETE by "owner window + icon ID range"; a taskkill /F hard kill cannot be rooted out in-process. A blank tray icon means check the asset first (it was once a 1×1 placeholder).
- The Popover substitute (borderless / non-activating / topmost window): the popup needs `WS_EX_NOACTIVATE` (else clicking the popup steals focus); under nested scrolling `GetBoundsInScreen` yields garbage offsets, so anchor coordinates come from the native child HWND's `GetWindowRect`; close is destructive on Windows — a reusable popup switches to `SetVisible(false)`; outside-click dismissal installs a `WH_MOUSE_LL` hook that PostTasks a close when the press lands outside the popup rectangle; the popup background ignores themes, so `Popover::set_background_color` was added end to end. libyue's SetVisible / IsVisible are unreliable on a non-activating topmost window — use Win32 `SetWindowPos` + `SW_SHOWNOACTIVATE` directly.
- autocomplete keyboard navigation: the Windows branch attaches on_key_down on the Entry (↑↓ highlight / Enter select / Esc dismiss); single-line EDIT has no vertical-centering style, so `entry_vcenter` narrows the control by font line height and splits the leftover into margins; RichEdit text stays black regardless of theme — `Entry::set_colors` (EM_SETBKCOLOR + CHARFORMAT2) hooks it into the theme chain.
- Dark mode for native controls: real Win32 common controls (RICHEDIT50W / SysListView32 / SysDateTimePick32 etc.) all ignore the system dark theme; RichEdit can be darkened via the message channel; Table cannot (its custom draw repaints a white base over external messages); the official answer is the self-drawn component library, with native dark mode as a known boundary.
- Hit testing runs opposite to painting: upstream `FindChildFromPoint` iterates children in insertion order, so a late-attached full-screen mask paints on top but its events fall through to the earlier container (dialog uncloseable, clicks pass through) — fork mbt.6 reversed the traversal to match paint order. Any "visually-topmost view receives no events" — check the hit-test direction first.
- The wheel is consumed outright by the outermost Scroll and never dispatched, so nested scroll areas and self-drawn canvases never scroll: a prepare.py patch (`patch_win_wheel_dispatch`) dispatches to the child under the cursor via FindChildFromPoint, and shim `yue_mbt_view_on_wheel` gained a Windows branch converting WM_MOUSEWHEEL deltas.
- Text measuring and drawing disagree: `GetBoundsFor` uses GenericDefault (with overhang) while `DrawString` uses GenericTypographic, so manual `x=(width−measured)/2` centering always sits left; positioning must use `align=Center/End` and leave it to the platform — measuring is only for computing container widths.
- Two splitter traps: an explicit `SetCapture` on Windows triggers `WM_CAPTURECHANGED`, which tears down the implicit capture (skip if capture is already held); the `flexbasis:"50%"` percentage string parses only on GTK — use pixel values everywhere.
- The Entry infinite-recursion case: two mutually recursive functions in the non-Linux branch overflowed the stack, and MSVC C4717 had already warned — treat "logically doomed" warnings as errors. For "no window" GUIs use `Get-Process <name> | Select MainWindowHandle`; MoonBit println is fully buffered through pipes and lost if the process dies — instrument via stderr.
- mount_window activates the window automatically after the handle callback; consumers no longer call activate manually.
- Platform info / locale / scaling / clipboard / timers / global shortcuts / global mouse polling / canvas (GDI+) all verified working.
- An offscreen Canvas cannot be created before initialize on Windows: a single `Canvas::new` crashes with 0xc0000005 (minimal repro involves no drawing at all). Root cause: Canvas / DoubleBuffer / Painter construction depends on `nu::State` — GDI+ (`GdiplusHolder`), the default font, and NativeTheme are all established with State, which initialize() creates; a bare `GdiplusStartup` fallback inside canvas_new still crashed (State has more null dereferences), and the correct fix is running the same `yue_mbt_app_init()` as initialize when `g_state` is null. Contrast with Linux: the cairo image surface has no State dependency, so offscreen geometry runs without initialize (only the text path needs the GTK stack — see "Self-drawn canvas and chart rendering"); Windows could not even start geometry, and after the shim fallback the "offscreen Canvas usable anywhere" semantics now agree across all three platforms. charts_wbtest's offscreen geometry benchmark passes on Windows accordingly (17/17).
- A platform-independent function mistakenly stubbed in a platform branch: wire's `'d'` codec goes through `yue_mbt_sys_f64_to_bits/from_bits` (a pure bit reinterpretation implemented with memcpy), but the implementation sat inside the OS_LINUX branch with a non-Linux stub returning 0 — on Windows three pure in-memory wire tests failed with everything decoding to 0 (wire codec tests need no bus and run on non-Linux too). Fixed by moving them outside the platform branches. The rule: go through the stub list asking "is this really platform-specific" — pure computation / pure in-memory logic never gets stubbed (same family as the macOS note on the `#else` fallback swallowing macOS).
- The single-instance message window is the first self-owned WNDPROC/window-class code in this repo (grep found no prior usage): the class name derives from the app_id (moonbit_libyue_instance_<app_id with dots replaced>), and the window must be created on the main thread that runs the libyue loop. WM_COPYDATA is dispatched synchronously by SendMessage into the WNDPROC (it does not traverse the message queue); the receiver posts back to the main loop via MessageLoop::PostTask before invoking the MoonBit callback, so no application code runs inside the peer process's SendMessage stack. The mutex uses the Local\ session namespace (no elevation needed); a second same-name CreateMutexW within one process hits ERROR_ALREADY_EXISTS, guarded idempotent via a static handle. [Pending real-machine verification] actual dispatch of the message window under the libyue loop, SetForegroundWindow success rate under foreground-lock, and the title-based fallback when mixed with older builds.
- The UI thread's COM apartment must be STA or file dialogs hang/crash: the common file dialog `IFileDialog::Show` requires STA — with COM never initialized on the UI thread, `CoCreateInstance(CLSID_FileOpenDialog)` fails outright (null dereference inside the lib), and when an application call path initializes the thread as MTA first (typically an old in-UI-thread `CoInitializeEx(MTA)` network query), `Show` hangs forever — observed as "app freezes the moment you open/save a file". Fix: shim `yue_mbt_app_init` calls `State::InitializeCOM()` right after creating State (ScopedCOMInitializer STA + OleInitialize, idempotent); afterwards `CoInitializeEx(MTA)` on the UI thread only yields `RPC_E_CHANGED_MODE` and local COM objects keep working. Complementary to "move NLM queries to a background MTA thread": the background thread's COM init is thread-local and does not replace the UI thread's own STA. Verify: file dialogs open and return paths on a real machine.
- No global repaint on theme switch on Windows leaves stale pixels ("ghosts"): the GTK side of `theme_apply` rebuilds CSS and repaints every window synchronously as a fallback; the Windows side used to only notify theme-subscribed custom views, so unsubscribed regions (and the area behind hidden/moved native child windows) kept old pixels until a mouse-over invalidated them locally. Fix: shim `yue_mbt_repaint_all` gains a Windows branch — `EnumWindows` filtered to this process's visible ownerless top-level windows, `RedrawWindow(RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN)` to invalidate everything including native children, invalidation only (WM_PAINT goes back to the message loop); `theme_apply`'s windows branch calls it. Verify: toggling dark/light repeatedly leaves no residue on headers/pages.
- Zero-height custom-drawn views silently render nothing (table_t text column invisible): `ViewImpl::Invalidate` returns early on empty sizes, and a childless on_draw container with no height style measures to 0 in yoga and never paints — table_t's CellText cell is exactly that (text drawn via on_draw, no children), so the whole text column vanished while header/zebra/checkboxes stayed fine; GTK's clipping behaves differently which is why it never showed there (the mirror-image of the earlier "GTK small-cell path fills don't render" trap). Fix: `cell_view` sets `minHeight=row_height` on the CellText container. Lesson: an on_draw container needs a non-zero size source (explicit height or children); "no children + auto height" equals "not painted" on Windows; an offscreen canvas pixel probe rules out the paint pipeline first (colored AttributedText is bit-exact fine on a Windows canvas).
- GDI+ blend modes: only Normal/Copy take effect: `PainterWin::SetBlendMode` maps only Copy to `CompositingModeSourceCopy`, everything else falls to `SourceOver` — GDI+ `Graphics` has exactly these two compositing modes, so Multiply/Screen/Difference/Xor are silent no-ops. Offscreen pixel probe: the Multiply overlap (128,128,255) is bit-identical to SourceOver (mathematically expected #8028FF). This is a platform capability gap; the lib stays untouched (a full blend set needs D2D); the showcase canvas demo reports "only Normal takes effect on this platform" on Windows, and the platform wording of `Image::write_to_file` in docs is corrected accordingly (Windows GDI+ encoder handles png/jpeg; the mac release package never compiled it and always fails).
- The native Tab emits `on_selected_page_change` when the first page is added (internal initial selection, not a user switch): registering the callback before adding pages makes it fire spuriously during mount (declarative `tab()` used to let switch-counter demos start at nonzero); registering after the add loop avoids it.

## macOS (CI build chain verified, GUI pending real-machine)

- The libyue v0.15.6 release ships ARC / no-ARC dual libraries: Darwin link flags = main library + `-lyue_mbt_noarc` (no-ARC symbols are referenced by the main library, so it goes after) + frameworks AppKit / Carbon / IOKit / Security / WebKit / OpenDirectory + `-lobjc -lc++ -lpthread -lbsm -Wl,-dead_strip`; the prebuild Darwin branch is pre-configured accordingly.
- CI (macos runner) covers build + tests; headless runners have no WindowServer, so no GUI smoke.
- `#else` fallbacks in shim platform branches swallow macOS: borderless needs `#elif defined(OS_WIN)`; CurrentDirForDrag splits into three branches (mac uses `getcwd`); `Window::SetSkipTaskbar` / `SetIcon` / `App::SetID` have no macOS declarations — guard the calls as no-ops.
- **AppleClang 17 (after the macos-15 image update) parses the return of `getRed:green:blue:alpha:` as void**, making `![...]` a compile error; the value is guaranteed after the nil check and sRGB conversion, so discarding the return compiles under both BOOL/void parses (69136b7 — once lost to a full-tree rollback and picked back up; when a rollback baseline contains a sick file, later fixes vanish with it, so cross-check that file's history before re-pushing vendor).
- **Three root causes of the CI red streak before the 0.5.0 release (since 9-22; Linux/macOS red, Windows green)**: ① the AppleClang compile error above dead-locking prepare; ② the missing extern "C" (see ABI section) dead-locking the link; ③ sysmonitor's S4 hardware-sampling test asserting "coretemp always has a Package sensor" — virtual-machine runners lack the hardware, so environmental gaps (no sensors / no DISPLAY) must skip, not assert. Also: the CI native-layer cache key must include the shim source hash (with only prepare.py in the key, a shim change keeps the old key and the restored build/ cache holds a stale shim archive); and with no Actions log access, slicing the failing output into `::error` annotations (the check-runs annotations API is anonymously readable) is the only forensics channel.

## Maintenance

1. Add new conclusions to the matching section, recording only the pitfall and the fix; protocol-interop conclusions must come from the real bus and real panels — unit-test self-consistency does not count.
2. Keep both language versions (this file and docs/zh/adaptation.md) in sync in the same batch.
