Skip to content

sparkles:wsi — Delivery plan

Companion to SPEC.md. Each milestone ends green, records evidence in comparison.md, and leaves the repository buildable. Feature IDs refer to feature-requirements.md.

Last reviewed: August 21, 2026

Rules of execution

  1. Land small capability slices, not one platform-sized dump. A feature-bearing commit updates the comparison matrix in that same commit.
  2. Preserve the SDL triangle until the native Wayland path is measured and green; then retain SDL as the explicit compatibility backend.
  3. Renderer work must not fossilize an incomplete WSI seam. Graphite work that does not touch the seam (the Nix Skia build, extern(C++) bindings, offscreen render/readback goldens, the sparkles:ui canvas adapter) may proceed in parallel at any time; the presentation host may begin once the renderer-facing subset — INT1/INT2 handles and Vulkan bridge, F01 first frame, F02 resize, F04 frame pacing, F08 scale — is verified on the platform in question, which it now is on all four at the partial level the evidence log records. M7's full acceptance gate (goldens, interactive resize, DPI, device loss) is unchanged, and ui-app integration (M8) stays sequential because it consumes the still-moving input vocabulary.
  4. Platform code may reuse OS client libraries but never a cross-platform WSI library.
  5. Prefer pure model/translation tests, then automated native harnesses, then the named HITL pass. Record which kind proved each claim.
  6. Behavior assertions are written once, in sparkles.wsi.conformance, against the value-level contract, and every implementation — the four native backends and the recording fake — runs the same suite. A platform smoke is a thin driver supplying only what that platform alone can (its wait step, out-of-band triggers, key injection); platform-only channels (IMM32) are explicit addenda, and a backend that cannot trigger a behavior skips that property rather than weakening it.

Milestone overview

MilestoneDeliverableRequirementsStatus
M0normative docs, prior-art matrix, cross-spec ownershipWSI1WSI8, INT6complete
M1shared geometry/input/text primitivesWSI8, F06, F07, F11, F12, INT5in progress
M2Event Horizon/native-loop spikes on all four backendsWSI3, WSI4, F03F05, F17in progress
M3package core and lifecycle (F01F05, F17)WSI1WSI6, F01F05, F17, INT1in progress
M4input, DPI, outputs, capture and cursorsF06F12in progress
M5decorations, state, popup, clipboard and DnDF13F16planned
M6SDL compatibility and Vulkan bridgeWSI7, INT2in progress
M7Skia Graphite native and SDL hostsINT3planned
M8ui-app configurations and migrationINT4, INT5planned
M9Win32 TSFPOST1deferred

Milestone M0 — specification and evidence baseline

Deliver:

  • this normative tree (SPEC, plan, requirements, comparison, open issues);
  • cross-links from Event Horizon, UI input/backends, UI App, the SDL resize handoff, and the research synthesis/recommendations;
  • pinned source revisions for GLFW, raylib, SDL3, SFML, Qt QPA, Gio, winit, raw-window-handle and OpenTK 5;
  • explicit ownership/reuse findings for existing sparkles:* packages.

Gate: docs sidebar/link/build checks pass. No implementation cell says partial or verified merely because a dependency or research example already implements it.

Milestone M1 — shared foundations

Make the common types genuinely common before WSI copies them:

  1. alias sparkles.input.events.PointF to ScreenPosition!float and prove source and ABI expectations in tests;
  2. expose canonical PointerShape through sparkles:input while retaining its leaf definition in sparkles:base (moving the definition upward would create the forbidden base↔input dependency cycle);
  3. move CellMetrics and pixel-to-cell conversion from the SDL/raylib producers into one pure input helper;
  4. add allocation-conscious UTF-8/UTF-16 conversion to sparkles:base, with malformed sequence, embedded-NUL, surrogate and bounded-buffer tests (complete);
  5. add lossless physical/logical key, composition, scroll source/unit/phase, and target capability values without breaking the existing simplified toolkit events.

Gate: dub test :base, :input, :ui-raylib, and :ui-sdl3 pass in their supported configurations; no downstream package owns a duplicate of the moved primitive.

Milestone M2 — native-loop spikes

Prove the only uncertain integration seam before building full backends. Each spike opens a minimal native window, queues a native event, runs a concurrent Event Horizon timer and wake, and shuts down without a second blocking loop.

SpikeRequired proof
Waylandprepare-read/flush/read/dispatch over OpPollAdd; cancellation repairs prepare-read state
X11non-blocking XCB drain over the connection fd; XIM bridge does not introduce its own wait
Win32PeekMessage drain progresses beside IOCP; move/size modal loop still wakes and queues
AppKitCFRunLoopSource/observer plus kqueue waker coexist on the main thread

The OS-owned-loop half of M2 is delivered on both platforms: Win32 combines User32 with an IOCP completion-mirror event; AppKit wraps Event Horizon's native kqueue descriptor in a CFFileDescriptor source in the main CFRunLoop. Their platform smokes each run a real window lifecycle, timer, and foreign-thread waker without a polling timeout or second scheduler. Both Linux foreign-fd lifecycles are delivered: X11 drains one XCB connection around OpPollAdd; Wayland asynchronously discovers globals, pairs every successful prepare_read with read_events or cancel_read, handles flush backpressure, and acks configure inside the listener. Their Xvfb and headless-Weston smokes run resize/configure beside the same timer and waker. XIM still has to prove that it preserves X11's single connection/event queue.

If an Event Horizon generic native-host primitive is missing, add the smallest backend-neutral operation there and update its spec. Do not put a second IOCP, kqueue, timer wheel, channel, or worker scheduler in WSI.

Gate: each spike has a deterministic core test and a platform-labelled executable result. Any unresolved integration issue moves to open-issues.md before M3.

Milestone M3 — package core and window lifecycle

Add libs/wsi to the monorepo with types, events, handles, loop, a fake/recording backend for tests, and the four platform modules. Implement in vertical feature slices:

  1. F17 ownership, generation ids, command queue, non-reentrant drain and shutdown;
  2. F01 create/ready/first metrics/native handle/first frame;
  3. F02 continuous resize and zero-size suspension;
  4. F03 modal-loop survival;
  5. F04 native frame opportunity plus Ticker fallback;
  6. F05 readiness, wake and external Event Horizon work.

The native Wayland surface probe is delivered as M3's first renderer consumer. It exposed and fixed the shared-display rule: Mesa Vulkan WSI calls run while the backend's prepared read is synchronously cancelled, then Event Horizon is re-armed. The full triangle stays gated on immediate configure acknowledgement and a live-resize trace matching the measured target in the SDL handoff.

Gate: all four platform columns for F01F05 and F17 are verified; typed handles are exercised by a minimal native surface probe; resize and wake traces contain no unbounded native wait.

Milestone M4 — input, scale and outputs

Implement F06F12 one cross-platform semantic at a time. Windows IME is IMM32 in this milestone. The common event contract must be rich enough for TSF later without a breaking change.

The first Win32 input slice is delivered: scan-code/VK key transitions, UTF-16 WM_CHAR commits, and allocation-bounded IMM32 pre-edit attributes/cursor/result translation. Wine/Xvfb exercises a real SCS_SETSTRGCS_COMPSTRCPS_COMPLETE/GCS_RESULTSTR round trip. The matching X11 slice queues evdev-keycode identity, left/right/numpad location, core-state-mask modifiers and press/repeat/release, with XKB detectable auto-repeat requested so a held key repeats without synthesized releases; an XTEST-injected shift-chorded key runs through the real server under Xvfb. The Wayland slice binds wl_seat, tracks capability arrival and departure, consumes the keymap fd, derives keyboard focus from enter/leave, and queues press/release with the same shared evdev-location and xkb-real-modifier policy; its evidence lane chords through Xvfb → Weston's X11 backend → wl_keyboard, with the smoke mapping a real shm buffer under the native-I/O borrow because compositors only focus mapped surfaces. The AppKit slice completes the four-platform physical-keyboard contract: the content view accepts first-responder status, keyDown/keyUp carry Carbon virtual keycodes with repeat identity, and modifier keys arrive through flagsChanged with press/release derived from the event's own flag state; a synthetic shift-chorded key posted through the real responder chain verifies it on macOS. Logical keys are delivered on all four backends at the layout's unshifted base level, matching sparkles:input's unshifted convention: xkbcommon compiles the Wayland keymap fd (previously just closed) and the X11 core device's map, Win32 asks the layout via MAPVK_VK_TO_CHAR, and AppKit reads charactersIgnoringModifiers with function keys kept as named private-range scalars; the conformance chord now asserts the layout-derived spelling on every platform. The pointer slice delivers button/motion/enter/leave and scroll on all four backends under one sign convention (positive scrolls down/right): Wayland accumulates axis events per frame with axis_source/axis_discrete and acquires/releases the seat pointer with the keyboard; X11 translates core buttons with the legacy wheel mapping; Win32 pairs WM_MOUSEMOVE entry with TrackMouseEvent leave and flips wheel deltas; AppKit hit-tests through the real responder path (with acceptsFirstMouse so an inactive window's activating click is still delivered) and reports precise deltas with the natural-direction flag. The conformance click and scroll properties inject through XTEST, SendMessageW, posted NSEvents, and the external Weston lane — which moved to the kiosk shell, because this Weston's desktop-shell click-to-activate binding crashes on a null pointer focus in the headless environment. The key-repeat slice closes Wayland's repeat gap: repeats never travel on the wire, so the backend synthesizes KeyAction.repeat from the compositor's repeat_info schedule with a single Event Horizon timer op in flight (retarget and teardown reap it deterministically), and a shared hold-a-repeating-key conformance property verifies the platform repeat channel on X11 (detectable auto-repeat under Xvfb) and the synthesized one through the kiosk-Weston lane, where Weston filters the X server's own repeat so every observed repeat is the client's. The Wayland text-input slice starts F07's third column: zwp_text_input_manager_v3 binds whenever the compositor offers it (the manager and the seat arrive in either registry order), the per-seat text input auto-enables on the compositor's enter — the always-on role IMM32 already plays — and the double-buffered protocol state is applied as one pure batch on done: commit string before the new pre-edit, byte-offset cursor/selection mapped to the shared composition vocabulary with the conventional underline segment, and an active composition ended by exactly one empty event. The batch and mapping semantics are model-tested; live compositor IME evidence is owed to a text-input-v3 compositor, because this Weston implements only text-input-v1 and never advertises the v3 manager. The XIM slice closes M2's last open integration question the right way round: XIM is an Xlib API, but xcb-imdkit's client speaks the XIM wire protocol over the backend's one XCB connection — xcb_xim_filter_event consumes its ClientMessages inside the ordinary pump, so the hosted single wait stays the only wait. With a live per-window input context every key forwards to the input method first and either commits (queued as TextCommittedEvent, UTF-8 negotiated and validated) or bounces back through the unchanged delivery path; without a server on $XMODIFIERS nothing changes. The evidence is end-to-end: the lane runs an in-tree test XIM server (xcb-imdkit's server API) that commits for one keycode and bounces everything else, so the chord, hold, and repeat properties incidentally prove the bounce path while the new IME property proves the commit path. The NSTextInputClient slice completes the F07 column set: the content view adopts the protocol (registered through the Objective-C runtime, since extern (Objective-C) has no adoption syntax and NSView's input context is nil without conformance), keyDown routes every event through the input context after the physical key, and text arrives only as protocol calls — insertText commits validated UTF-8 and ends marked text, setMarkedText maps AppKit's UTF-16-unit selection to byte offsets with underline and selected segments. The shared IME-commit property runs on AppKit (a posted key comes back as committed text through the real input context) and an addendum drives the marked-text contract exactly as the input context does. Keymap-change events, callback-style XIM pre-edit, candidate-window placement, and raw input remain. The scale slice makes SurfaceMetrics real on Wayland: outputs are bound with their scales, each window's scale is the maximum of its entered outputs, a change requests the matching buffer scale and reports one atomic metrics transition — verified at scale 2 through the kiosk-Weston lane, where the chord, click, and scroll properties also still pass. Win32's existing GetDpiForWindow path keeps its Wine caveat: a per-monitor-V2-aware window under Wine always sees a 96-DPI monitor whatever LogPixels says, so that column's scale evidence is owed to native Windows CI. The cursor slice puts the shared PointerShape vocabulary — CSS names, so Wayland's cursor-shape-v1 maps one-to-one — behind setCursor and setCursorVisible on all four backends, with a conformance property that requires a standard shape to apply or fail with typed unsupported, plus platform-observable confirmation where one exists (GetCursor after WM_SETCURSOR under Wine, NSCursor.currentCursor on macOS). The cursor-shape protocol is vendored beside xdg-shell with its single unused tablet request stubbed rather than pulling in the tablet-v2 tables. The drag slice delivers F10's release-preservation half: Win32 gains implicit capture — User32 stops delivering client messages at the border without it — spanning the first press to the last release and unwound on WM_CAPTURECHANGED, while X11's automatic core grab and AppKit's window drag routing are verified rather than assumed; the conformance drag property requires the release to arrive with outside coordinates. X11 scale detection, custom cursor images, full output enumeration/hotplug, explicit capture/confinement, raw relative motion, and native-Windows evidence remain.

Required platform paths:

  • Wayland: xkbcommon, text-input-v3, fractional-scale/viewporter, seat capability changes, relative-pointer and pointer-constraints;
  • X11: xkbcommon-x11, XIM, RandR, grabs/XI2 raw motion where available;
  • Win32: scan code/VK separation, WM_CHAR/WM_UNICHAR, IMM32 composition, Per-Monitor-V2, raw input and capture;
  • AppKit: key codes plus NSTextInputClient, backing conversion, display changes, tracking/raw delta behavior.

Gate: recorded cross-backend event fixtures agree on the common semantics while retaining platform-specific codes; native layout/IME/mixed-DPI/capture/cursor passes are recorded in the matrix.

Milestone M5 — shell integration

Implement F13F16 in four vertical slices: decorations, state/close, grabbed popup, then clipboard/DnD. Large transfer paths must stream through Event Horizon rather than blocking the UI thread.

Wayland CSD is renderer-owned and protocol-driven: WSI supplies frame regions/actions, never a libdecor dependency. X11 selection tests include INCR; Win32 OLE runs on the STA UI thread; AppKit pasteboard promises do not call application code re-entrantly.

Gate: the full F01F17 matrix is verified across four native backends. This is the entry condition for renderer integration.

Milestone M6 — compatibility and Vulkan

  1. Factor a small host concept from WSI without weakening native semantics.
  2. Implement it in sparkles:wsi-sdl3; unsupported SDL public semantics remain explicitly degraded.
  3. Add sparkles:vulkan-wsi: reuse sparkles:vulkan commands/types and extract FrameSync, Swapchain, context selection, tracing, and resource retirement from sparkles:ui-sdl3.
  4. Drive the same Vulkan triangle on native WSI and SDL compatibility, with backend selection explicit at build/configuration time.

Gate: Vulkan validation clean on Wayland, X11, Win32/Wine and native Windows CI; native macOS uses the handle probe here but Metal in M7.

Delivered slices: target-native surface ABI/dispatch in sparkles:vulkan, typed handle-pair validation, instance/surface/device ownership in sparkles:vulkan-wsi, and a headless-Weston Wayland-to-present-device smoke; then the proven CommandPool, FrameSync, Swapchain, resize decisions, and deferred-retirement policy were moved from SDL into that bridge. The SDL module names are compatibility re-exports and its existing triangle builds unchanged against the shared implementation. The renderer half of that triangle is promoted to sparkles.vulkan_wsi.triangle, and the native Wayland triangle drives it through sparkles:wsi and Event Horizon alone — no SDL or libdecor in the process — presenting under sync validation with compositor resize, with the native-I/O borrow widened to cover acquire and present. The X11 and Win32 runtime surface probes drive the live ICD through the same typed handles: XCB under Xvfb and winevulkan under Wine both create a real surface, select a present-capable device (falling through to lavapipe where hardware ICDs cannot present), and prove the integrated wait still dispatches after the driver ran. The Wayland presentation gate is scripted: Weston's X11 backend inside Xvfb with the client pinned to lavapipe presents the full stress run under sync validation without a live desktop session (headless Weston loses the surface, and a hardware ICD's dmabufs crash a software Weston — the windowed backend avoids both), and the triangle now reports the SDL handoff's comparison counters (framesOver50ms/framesOver100ms, reaps, maxDispatchMs) so the HITL run can be read against the measured target in one glance. The HITL live-resize trace itself and native Windows CI remain.

Milestone M7 — Skia Graphite

Add sparkles:ui-skia over the existing Nix Skia build:

  • Graphite Vulkan on Linux and Windows;
  • Graphite Metal on macOS;
  • shared canvas/text/resource policy independent of the WSI implementation;
  • SDL host only in a separate compatibility configuration.

Gate: render/readback goldens plus interactive resize, DPI and device-loss/recreation passes. No Graphite type crosses into sparkles:wsi.

Milestone M8 — application host

Replace ui-app's generic gui configuration with the explicit set:

ConfigurationBackend closure
tuiui-tui only
gui-skianative WSI + Graphite; default GUI
gui-skia-sdlSDL compatibility WSI + Graphite
gui-raylibexisting raylib host
fullTUI plus all GUI choices used by development/gallery builds

The application continues to call one runApp; backend selection stays in the host. The GPU arm uses Event Horizon readiness/frame opportunities rather than a private poll or sleep loop.

Gate: UI Gallery and representative hue/terminal flows run through recording, TUI, native Graphite, SDL Graphite, and raylib as applicable; application modules do not import WSI, SDL, Vulkan, Metal, or Skia.

Milestone M9 — TSF (deferred)

Implement TSF as the second Win32 text-service backend after the desktop/renderer/host stack is stable. Preserve the M4 composition values and application contract; compare IMM32 and TSF under native Windows automation. Keep IMM32 available as a compatibility fallback only if evidence shows it is required.

Platform verification recipes

Windows locally

Use nix develop .#win32 from nix/shells/win32-cross.nix, compile through win32-ldc2, and use a fresh WINEPREFIX for each lane. Run direct wine64 processes, not Explorer virtual desktops:

  • winewayland for the Wayland display path;
  • Xvfb plus winex11 for the X11 display path.

Record Wine results as compatibility evidence, not proof of undocumented native Windows behavior. Native Windows CI closes those gaps.

macOS remotely

Never modify /Volumes/Dev/repos/mine/sparkles. Inspect existing worktrees with:

bash
ssh mac-bsn -- git -C /Volumes/Dev/repos/mine/sparkles worktree list

Create/use the dedicated /Volumes/Dev/repos/mine/sparkles-ui-skia worktree, sync a commit or patch there, and run the platform tests through Nix because a bare ldc2 is not installed. Record the exact Darwin version and architecture with the evidence.