sparkles:wsi — Specification
Abstract
sparkles:wsi opens and manages native desktop windows for Sparkles applications on Wayland, X11, Windows and macOS without a cross-platform windowing library underneath. It reports what the operating system says about each window, including input, composed text, size, scale, displays and frame timing, as plain values that keep every detail the platform supplied. It hands renderers typed native handles from which to create a GPU surface. Its distinctive property is that it adds no event loop of its own: window events join the single loop that already serves the application's timers, I/O and cross-thread wakes, so a window never blocks that loop or competes with it.
Introduction
A graphical application must open a window, learn its size in both device-independent units and pixels, receive keyboard, pointer and composed text input, and give a GPU renderer what it needs to draw into the window. Each desktop platform offers this through its own client API, with its own threading rules, event pump and coordinate conventions. A Sparkles application also runs on Event Horizon, the asynchronous loop that owns its timers, file and network I/O, and wakes from other threads.
A cross-platform windowing library hides the platform differences but brings its own event pump, which must either own the thread's blocking wait or be polled beside the application's loop. Two loops on one thread wake each other late or spin. The handles such a library gives a renderer carry their lifetime and threading rules in documentation rather than in their types, and on Wayland those rules matter: a Vulkan driver that reads the shared display connection while the window layer holds a prepared read deadlocks both. A window-system layer has to settle who blocks, who reads the native connection, and what a renderer may touch, instead of leaving each application to discover it.
This library confines every window to one owner thread and leaves that thread's only blocking wait to Event Horizon. Its four peer backends are Wayland and X11 on Linux, Win32 on Windows, and AppKit on macOS. Each submits its native event source to Event Horizon and performs only non-blocking native dispatch when the loop reports it ready. Native callbacks do the minimum the platform requires synchronously and append an event to a bounded queue. The application drains that queue when it chooses, so application code never runs inside a native callback. Events, identifiers and commands are Regular values that can be recorded and replayed; an event's text lives beside the queue in a pool, and recording an event copies its text into a pool the recorder owns. Events are lossless: they keep the physical and logical key, composition spans, logical and pixel geometry, and timestamps the platform reported. Converting them into the toolkit's cell-based input vocabulary is left to the application host. Other threads reach the library only through value commands and a wake signal, and a renderer receives one of a closed set of typed handle variants.
The library draws nothing and owns no widgets. The one exception is Wayland client-side decoration, whose regions and frame actions it reports for the renderer above it to paint. It creates no graphics device, shader or swapchain; sparkles:vulkan-wsi and the renderers above it own those. It does not decide how an application gets its window either: sparkles:ui-app chooses between this library and other hosts. An SDL 3 compatibility host lives in a separate package and is never a hidden fallback. Mobile and web platforms stay out of scope until all four desktop backends meet the native-desktop baseline that §13 defines. Windows TSF text input stays out of scope until the IMM32 contract and native desktop integration are complete.
Sections 1–3 state the platform set, package boundaries and ownership model; §4 specifies the integrated loop on each platform and event delivery under load; §5–§8 define the values: geometry, identity and errors, commands, events, and native handles. Section 9 lists each platform's protocol obligations, §10 places the compatibility and renderer layers, and §11–§13 give the evidence lanes, non-goals and completion definition. The requirement ledger is feature-requirements.md, delivery order and progress live in PLAN.md, and comparison.md records per-feature evidence beside nine third-party designs. Unresolved questions are in open-issues.md, the reasoning behind event delivery is in event-delivery.md, and the evidence base is the window-system-integration research.
1. Purpose and scope
sparkles:wsi is Sparkles' dependency-light native window-system integration layer. It creates and owns desktop windows, translates native input without losing information, exposes typed native handles to renderers, and joins native event sources to the one sparkles:event-horizon application loop.
The first complete platform set is:
| Platform | Native backend | Required client APIs |
|---|---|---|
| Linux | Wayland | libwayland-client, generated protocol declarations, xkbcommon |
| Linux | X11 | XCB/Xlib where required by XIM, xkbcommon-x11 |
| Windows | Win32 | User32, DWM, DPI, raw input, IMM32 |
| macOS | AppKit | AppKit, Foundation, CoreGraphics, QuartzCore |
These operating-system client libraries are platform APIs for this specification. The native package does not depend on SDL, GLFW, raylib, Qt, winit, a widget toolkit, a renderer, or Skia. SDL 3 remains a separately named compatibility implementation; it is never a hidden fallback selected by sparkles:wsi.
The layer owns no widgets and draws no application content. Wayland client-side window decorations are the one exception: when the compositor declines server-side decorations, the backend reports decoration regions and frame actions through the WSI contract; the renderer above WSI paints them.
2. Package and dependency boundaries
| Identifier | Value |
|---|---|
| Dub sub-package | sparkles:wsi |
| Source root | libs/wsi/src/sparkles/wsi/ |
| Package module | sparkles.wsi |
| Optional SDL | future sparkles:wsi-sdl3 |
| Vulkan bridge | sparkles:vulkan-wsi |
The native package's direct Sparkles dependencies are deliberately small:
sparkles:wsi -> sparkles:base
-> sparkles:input
-> sparkles:event-horizon
-> expected
-> sparkles:math (source import only, like sparkles:input)The import directions are load-bearing:
sparkles:wsinever importssparkles:ui,sparkles:ui-app, Vulkan, Skia, SDL, raylib, or application code;sparkles:inputremains the toolkit-facing, cell-space interaction vocabulary;- render bridges depend on WSI, not the reverse;
sparkles:ui-appowns backend choice and converts losslessWindowEventvalues into the application/toolkit event vocabulary;- no native platform source is compiled on a different platform merely to satisfy a shared module import.
The planned module layout is:
| Module | Contract |
|---|---|
types | identifiers, logical/physical geometry aliases, scale, configuration, errors |
events | lossless WindowEvent, key, composition, pointer, scroll, output, and lifecycle values |
handles | closed typed native display/window handles and renderer-handoff query |
loop | Wsi, command queue, event drain, Event Horizon attachment, wake/coalescing policy |
platform.wayland | Wayland connection, protocols, seats, outputs, surfaces, popups, data devices |
platform.x11 | XCB/XIM connection, atoms, selections, grabs, outputs, windows |
platform.win32 | HWND lifecycle, messages, raw input, DPI, IMM32, clipboard and OLE drop |
platform.appkit | NSApplication/NSWindow, responder and text-input client, pasteboard, displays |
3. Ownership and threading
One process has one WSI owner on the UI/main thread. Every window, native callback, and mutation belongs to that thread. A WindowId is a generation-checked value, not a pointer; events and commands may therefore be recorded without borrowing a native object.
Other threads may only:
- enqueue a value command through the bounded command channel; and
- call the coalescing
Wakerobtained from Event Horizon.
They may copy a typed raw handle for renderer setup only when the platform-specific handle contract says its lifetime is stable. They may not call window-system APIs through that handle. Destroying Wsi invalidates every WindowId and handle view.
Wsi is neither shared nor movable after attachment because native callbacks retain its address. WindowId, configuration values, events, and raw-handle values are Regular values. An event's text is not part of that value: a payload names it by a pool-local TextId, readable through the delivering Wsi only during the drain callback (ED8). Keeping, replaying, or sending an event to another thread therefore starts with retain, which copies its text into a TextPool the consumer owns.
4. The single integrated loop
There is no WSI-owned blocking loop beside Event Horizon. sparkles:event-horizon is the application's only scheduler, timer source, cross-thread wake mechanism, and blocking wait.
The public shape is callback-free at the application boundary while still respecting platform callbacks internally:
WsiResult!Wsi openWsi(ref Sched sched, WsiConfig config = {});
struct Wsi
{
WsiResult!WindowId createWindow(WindowConfig config);
WsiResult!void command(WindowCommand command);
size_t drain(scope void delegate(scope ref const WindowEvent) @safe consumer);
void close();
}openWsi attaches the selected native source to sched; it does not create a second thread and it does not run the scheduler. drain never blocks and preserves native ordering. A caller normally drains after the scheduler reports readiness and before building the next frame.
4.1 Linux
The Wayland display fd or X11 connection fd is submitted through Event Horizon's waitReadable/OpPollAdd path. The backend performs the required non-blocking native dispatch sequence when readiness completes. It never blocks inside wl_display_dispatch, xcb_wait_for_event, or an equivalent foreign pump.
XCB has two readiness locations: bytes on its connection fd and events it already copied into its private queue while receiving a synchronous reply. The X11 adapter therefore drains xcb_poll_for_event before every possible Event Horizon wait as well as after an OpPollAdd completion, then re-arms the single-shot poll. Detach uses Event Horizon's synchronous cancellation barrier before disconnecting, so the poll callback cannot outlive the WSI owner.
Wayland obeys the prepare-read protocol exactly: dispatch pending events, prepare the read, flush requests, let Event Horizon wait, then read and dispatch; cancellation cancels the prepared read. A writable poll is armed only while wl_display_flush reports backpressure. A renderer API may itself read the shared display: such calls run inside the owner-thread beginNativeIo/endNativeIo pair, which synchronously cancels and reaps the prepared poll before lending the display and restores it after. Holding a prepared read across a Mesa Vulkan WSI round trip deadlocks because neither reader can make progress; raw-handle stability alone is not sufficient synchronization.
4.2 Windows
The UI thread owns a message-only wake window plus every application HWND. Native messages are drained with PeekMessage after IOCP or the WSI waker makes the scheduler runnable. The Event Horizon IOCP backend supplies the blocking wait; WSI does not call an unbounded GetMessage or WaitMessage.
Modal Win32 loops can dispatch messages while the normal scheduler stack is not at its drain point. The window procedure therefore performs the minimum synchronous native reply, appends a Regular event, and wakes the scheduler. Re-entrant delivery never calls application code.
4.3 macOS
AppKit owns the outer main-thread run loop. Event Horizon integrates through CFRunLoopSource/observer hooks and its kqueue waker rather than starting a competing NSApplication pump. Native delegates append events; application callbacks run only at the WSI drain boundary. openWsi fails with wrongThread unless called on the process main thread.
4.4 Pacing
WSI reports frame opportunities; it does not sleep to manufacture vsync. Wayland frame callbacks, DWM composition timing, display-link notifications, and explicit fallback deadlines become a coalesced FrameReady event. Event Horizon Ticker is used only where the platform has no usable frame signal or a caller explicitly asks for a capped cadence. Missed frame ticks are skipped, never replayed.
4.5 Event delivery under load
A consumer that drains slowly must not be able to stop the loop. The rationale, measurements and rejected alternatives are in event-delivery.md; the obligations are here.
ED1: Bounded queue. Each backend owns one event queue whose capacity is fixed at compile time. Exceeding it must not fail the dispatch, the loop, or any command.
ED2: Motion merges in place. While coalescing is enabled for a window, a pointer motion event must merge into the queued motion event of the same window and pointer, if one is still queued, by updating that event in place: latest position, summed relative delta, newest timestamp, and a sample count incremented by one. The merged event keeps its original sequence and its position in the queue. A press, release, enter, or leave for that pointer ends the run, so the last position before a press is always delivered.
ED3: Samples are kept beside the queue. Each (window, pointer) has a sample ring of compile-time depth holding timestamp, position, relative delta, pressure and tilt. A merged event's samples must be readable there in arrival order. The ring drops its oldest sample when full; history is advisory and its loss is not an error.
ED4: Only motion merges. Lifecycle, focus, keyboard, text, composition, scroll, touch, output, clipboard and popup events must never be merged, superseded or reordered. Surface metrics, move and expose keep the latest-observation merge they already have.
ED5: Overflow is counted, not fatal. When the queue is full, the arriving event must be dropped and a per-backend counter incremented. The next drain must deliver one EventsDropped event carrying that count after the events it drains, then reset the counter. The notice carries the sequence of the last dropped event and WindowId.init, since no one window caused the loss. From the first drop until the drain that delivers the notice, every arriving event must be dropped and counted, even one that would fit. Overflow must not set a sticky error or be reported as a dispatch or loop failure.
Rationale: The notice is synthesized at drain time because a full queue has no slot to give it. It goes last because every dropped sequence is above every queued one, which any earlier position would contradict (ED6). The latch keeps that true: an event refused for want of text space (ED8) leaves a slot free, and a later event admitted into it would queue a sequence above a dropped one.
ED6: Drain order. Sequence numbers delivered by one drain must be strictly increasing. Contiguity is explicitly not promised: merging and ED5 both leave gaps.
ED7: Timestamps. Every event and every sample carries the platform's event time converted once by the backend into the loop's monotonic clock. An event the platform supplies no time for carries the time it was observed.
ED8: Text storage and its lifetime. Event text must be stored in a fixed-capacity TextPool owned by the queue, and a payload must name it by a pool-local TextId. Event text here means committed text, pre-edit, and an offer's MIME type. Text must be readable through the delivering WSI's withText(id, fn) while the drain callback that delivered its event runs. Once that callback returns, the id is stale and reading it must return staleId. retain copies an event's text into a TextPool the caller owns and returns the same event naming the copy.
- One text holds at most 65,535 bytes. Longer text must be cut at a UTF-8 boundary, and its id reports
truncated. - A MIME type longer than 255 bytes must not be shortened. The offer is dropped and counted under
ED5instead. - When the queue's text pool cannot hold an event's text, the event must be dropped and counted under
ED5. Lack of space never truncates text.
Rationale: A WindowEvent that holds no pointer is @safe to assign and small enough for the queue to hold many. The price is that text has a lifetime: a consumer that keeps an event longer, records or replays it, or hands it to another thread must retain it first.
ED9: What is configurable. Queue capacity and ring depth are compile-time parameters because they size inline storage. Motion coalescing and relative summing are runtime switches per window, enabled by default, so one process may serve a drawing view and a game view differently. The ED5 policy is not configurable.
ED10: No frame policy below the boundary. WSI does not accumulate per-frame input state. The pressed set, summed deltas and latest position for a frame are assembled by a sparkles:input accumulator fed from a drain, so the SDL and raylib producers reach the same vocabulary.
5. Core values
5.1 Geometry and scale
Public names carry units. Implementations reuse sparkles:math vector storage instead of defining a second point/size implementation.
| Alias | Meaning |
|---|---|
LogicalPosition | top-left-relative device-independent position, floating point |
LogicalSize | device-independent extent, floating point |
PhysicalPosition | signed pixel position; signed because a point may be off-screen |
PhysicalSize | framebuffer/surface extent in pixels |
ScaleFactor | physical pixels per logical unit |
Every resize or scale transition reports logical size, physical surface size, and scale together in one SurfaceMetricsChanged event. Consumers size GPU swapchains only from physicalSize. A zero physical extent means suspended/minimized and is not an error. Conversions specify rounding: positions use nearest integer; enclosing extents round outward; suggested native rectangles remain exact physical values.
5.2 Identity and errors
WindowId, OutputId, SeatId, PointerId, and OfferId are generational handles from sparkles.base.pool: a 0-based index plus a generation that starts at 1. The all-zero value is invalid. Each backend keeps its windows in a fixed Pool, whose records never move, so a native callback may hold a record's address. Stale identifiers return WsiErrorKind.staleId, never address a recycled native object.
Every fallible synchronous API returns Expected!(T, WsiError). WsiError records:
- stable
WsiErrorKind; - backend and operation;
- native error code when one exists; and
- an inline diagnostic suitable for logs.
Native WSI errors are not IoError: a refused Wayland global, failed Objective-C selector, or unsupported cursor is not a byte-stream operation.
6. Window lifecycle and commands
WindowConfig includes title, initial logical size, visibility, decorations, resizability, transparency, startup state, and optional parent. Creation is two-phase: the returned WindowId identifies a live native object, while WindowEvent.ready declares that handles and the first authoritative metrics are usable by a renderer.
The command algebra includes:
- show/hide, close, set title, request redraw;
- set logical size and constraints;
- maximize, minimize, restore, fullscreen, and attention;
- accept or cancel a close request;
- set cursor shape/visibility, begin/end capture and relative mode;
- set IME enabled state and candidate/pre-edit rectangle;
- create/reposition/destroy a grabbed popup;
- read/write clipboard and accept/reject a data offer.
Commands issued while a platform transaction is pending are coalesced where later state supersedes earlier state (title, cursor, redraw), and ordered where every edge matters (capture, clipboard offers, popup grabs). A command that cannot be represented on the active backend returns unsupported; it never silently succeeds.
Close is vetoable. A native close request produces closeRequested; only an explicit accept or a configured default destroys the window. Destruction produces exactly one destroyed event after the native handle is no longer usable.
7. Event vocabulary
WindowEvent is a sum type whose payloads own their metadata and name their text by TextId (ED8). It is a lossless platform boundary, not an alias for sparkles.input.Event: the latter is a toolkit-facing vocabulary whose coordinates are already converted to cells and whose wheel values are already policy-normalized.
The required event families are:
| Family | Required information |
|---|---|
| lifecycle | ready, exposed, occluded, close requested, destroyed |
| metrics | logical/physical size, scale, move, output enter/leave |
| frame | frame opportunity and presentation feedback where available |
| focus | window focus, keyboard focus, pointer enter/leave |
| keyboard | physical key, logical key, location, press/repeat/release, modifiers |
| text/IME | committed UTF-8, pre-edit UTF-8, selection, cursor and segment spans |
| pointer | id, logical and physical position, button, motion, raw relative delta |
| scroll | logical/pixel deltas, discrete steps, source, phase, inversion |
| touch | contact id, phase, pressure/shape where supplied |
| output | add/change/remove, work area, modes, scale, refresh |
| clipboard/DnD | offer MIME types, data completion/error, enter/move/leave/drop |
| popup | configured rectangle, reposition token, dismissed |
Unknown native keys retain their physical platform code. Text commits are separate from key events because IME commits may have no corresponding key and one key may produce no text. UTF-8 is the public encoding. Shared UTF-8/UTF-16 conversion belongs in sparkles:base, is allocation-conscious, and rejects ill-formed input without silently replacing it at a system boundary.
All composition cursor, selection, and segment offsets are byte offsets into the event's UTF-8 pre-edit text as stored (ED8), each held in a ushort: one text holds at most 65,535 bytes. When the text was truncated, each offset is clamped to the kept length, which lies on a code-point boundary. A backend converts native UTF-16 code-unit or platform-string indices before enqueueing the event; consumers never reinterpret those fields as code points or native indices. Segments are ordered, non-overlapping, and may be coalesced when adjacent native spans have the same public style.
The sparkles:ui-app adapter is the sole normalizer into sparkles:input: it applies cell metrics, scroll policy, and capability degradation once.
How queued events are merged, dropped or held back when a consumer cannot keep up is specified in §4.5 (ED1–ED10). Pure conversion helpers live in sparkles:input so SDL and raylib compatibility producers use the same rules.
7.1 Bulk data: clipboard and drag-and-drop
Clipboard and drag-and-drop data never travel in an event. A document copied whole can be tens of MiB, while event text is bounded at 65,535 bytes (ED8). An event therefore names an offer, and the bytes move in a separate, streamed transfer that the application drives and owns. These requirements are the contract for F16 in feature-requirements.md.
Each backend moves the bytes over its platform's own transfer channel:
| Platform | Receiving | Offering |
|---|---|---|
| Wayland | the pipe from wl_data_offer.receive | the requester's pipe from wl_data_source.send |
| X11 | ConvertSelection, then INCR chunks | the selection property, in INCR chunks |
| Win32 | GetClipboardData / IDataObject::GetData | WM_RENDERFORMAT delayed rendering |
| AppKit | NSPasteboard | an NSPasteboardItemDataProvider |
DT1: Offers are named, not carried. A clipboard offer, or a drag entering a window, must be reported by a DataOfferEvent holding an OfferId and one MIME type of at most maxMimeType bytes. No event may carry offer content, whatever its size.
DT2: Receiving is a streamed pull. receive(offer, mimeType, sink) must deliver the offer's content to sink in chunks, as Event Horizon completions, without blocking the loop. A transfer must end in exactly one completion or one error. The application may cancel it, after which sink receives nothing more.
Rationale: The application chooses the destination, whether a heap buffer, a rope, or the document itself, so WSI never decides where tens of MiB live.
DT3: Offering is a streamed push. offerData(mimeTypes, provider) announces data the application can produce. When a client requests a type, the backend must call provider, which writes into the platform's channel without blocking. WSI must not hold a copy of the offered data.
DT4: Memory follows the requester. WSI must buffer at most one chunk per transfer in flight, so a transfer of any size leaves WSI's own memory flat. The rest belongs to whichever side asked for the data.
DT5: Evidence. Each backend's lane must round-trip 32 MiB in both directions, for the clipboard and for drag-and-drop. During the transfer the loop must keep serving timers, and the receiver's resident memory must stay flat apart from the destination it chose.
Text that an input path inserts directly, such as AppKit's insertText: from a Service, is event text under ED8 and bounded by it. Larger text on such a path is out of scope until a backend demonstrates one. The text pool would then gain a spill tier: text above a threshold goes to a heap block owned by its pool slot, under a per-backend byte budget (ED9), and the withText borrow rule stays as it is. Pre-edit keeps its 65,535-byte bound regardless, because composition offsets are 16-bit.
8. Typed native handles
The renderer handoff is a closed sum, not void* and not a bag of nullable fields:
alias DisplayHandle = SumType!(WaylandDisplayHandle, X11DisplayHandle,
Win32DisplayHandle, AppKitDisplayHandle);
alias WindowHandle = SumType!(WaylandWindowHandle, X11WindowHandle,
Win32WindowHandle, AppKitWindowHandle);
struct NativeHandles
{
DisplayHandle display;
WindowHandle window;
}Each variant contains only the exact native objects a renderer needs. Wayland exposes wl_display* plus wl_surface*; X11 exposes connection/display plus window/visual; Win32 exposes HINSTANCE and HWND; AppKit exposes NSWindow* and NSView*. The variant is queryable only after ready and until destruction begins. Backends may add new variants only in a versioned API change; field reinterpretation is forbidden. On Wayland, any consumer that may dispatch or round-trip the display—including a Vulkan ICD—must additionally hold the backend's owner-thread native-I/O borrow.
9. Platform contracts
9.1 Wayland
- Bind globals by advertised version capped at the implementation version.
- Ack every
xdg_surface.configurebefore returning from its listener; state and physical-size decisions may be deferred, acknowledgement may not. - First commit follows the no-buffer/initial-configure handshake.
- Use
xdg-decorationfor SSD when present; otherwise expose own-drawn CSD regions. No libdecor dependency. - Bind fractional scale with viewporter, presentation time, relative pointer/pointer constraints, text-input-v3, data-device, primary selection when present, and xdg-output/standard output state as capabilities rather than hard requirements.
- Popups use
xdg_positionerand an explicit grab serial.
9.2 X11
- Use XCB for event and window operations; use Xlib only where XIM interoperation requires it, with one owned connection/display relationship.
- Preserve both keycode and translated key identity through xkbcommon-x11.
- Implement selections incrementally, including
INCR,TARGETS, and XDND. - Use RandR for output topology and best-effort scale. The API reports that X11 has no authoritative per-surface fractional scale.
- Popups are override-redirect windows with an active pointer/keyboard grab and explicit dismissal.
9.3 Win32
- Opt into Per-Monitor-V2 DPI awareness before creating a window; handle
WM_DPICHANGEDatomically with its suggested rectangle. - Separate physical scan code, virtual/logical key, committed text, and raw input.
- The first IME implementation uses IMM32 (
WM_IME_*, composition/candidate windows). TSF is a later milestone and must preserve this public event contract. - Keep the window responsive through sizing/moving and other modal loops; native replies happen synchronously while application work remains queued.
- Clipboard uses delayed rendering where useful; drag-and-drop uses OLE on an STA UI thread.
9.4 AppKit
- All AppKit work runs on the process main thread.
- Event Horizon remains the application loop: non-blocking NSEvent drains surround a single CFRunLoop wait whose
CFFileDescriptorsource observes Event Horizon's native kqueue descriptor. No helper thread, duplicate kqueue, or polling timeout is used. - The content
NSViewsupplies a layer suitable for Metal and exposes its pixel size from backing conversion, not logical bounds alone. - Keyboard text and composition implement
NSTextInputClient; key events remain separate from insert/marked-text callbacks. - Live resize produces metrics/frame opportunities without blocking Event Horizon.
- Pasteboard, dragging destination/source, cursor rectangles, tracking areas, and display notifications feed the common event/command algebra.
10. Compatibility and renderer layers
sparkles:wsi-sdl3 implements the WSI-facing host contract by delegation and is selected only by the explicit gui-skia-sdl application configuration. It is useful for platform bring-up and regression comparison; it is not part of the native package and cannot claim a native feature that SDL does not expose publicly.
sparkles:vulkan-wsi owns Vulkan instance extension selection, native surface creation, swapchain/frame synchronization, and present/rebuild policy. It validates the closed WSI handle pair, reuses the loader and per-instance dispatch in sparkles:vulkan, creates Wayland/XCB/Win32 surfaces, and selects a graphics+present device. It also owns the backend-neutral CommandPool, FrameSync, and Swapchain, including acquire decisions, resize decisions, and deferred retirement. sparkles:ui-sdl3 consumes those types through compatibility re-exports rather than maintaining a second policy. WSI itself contains no Vulkan type.
Skia Graphite sits above those seams:
ui-app -> ui-skia -> Skia Graphite -> Vulkan (Linux/Windows) -> vulkan-wsi -> wsi
|-> Metal (macOS) -------------------------------> wsi
ui-app -> ui-skia-sdl -> Skia Graphite -> SDL compatibility host
ui-app -> ui-raylib -> existing raylib host11. Testing and evidence
Every feature-bearing WSI commit updates comparison.md: the sparkles:wsi cell moves only from planned to partial or verified when the named automated and manual evidence exists.
The minimum evidence lanes are:
| Lane | Required evidence |
|---|---|
| pure/core | unit tests under the repository test runner; recording/replay invariants |
| Wayland | headless compositor protocol tests plus Mutter live-resize and input pass |
| X11 | Xvfb automation plus a real WM pass for grabs, state and resize |
| Win32 | cross-compile; fresh Wine prefixes under winewayland and Xvfb/winex11; native Windows CI for behaviors Wine cannot certify |
| AppKit | dedicated mac-bsn worktree; automated build/tests plus manual live window, IME, scale and Metal handoff |
Wine results are labelled A[wine], Xvfb A[xvfb], compositor harnesses A[headless], native automation A[native], and human-in-the-loop measurements H[platform]. A compatibility implementation does not substitute for native-backend evidence.
12. Non-goals
- widgets, layout, themes, accessibility trees, or app navigation;
- graphics-device creation, shader compilation, or swapchains;
- silently loading a third-party windowing library;
- mobile/web support before all four desktop backends satisfy the baseline;
- TSF before the IMM32 contract and native desktop integration are complete;
- reproducing Event Horizon's scheduler, timers, channels, IOCP, kqueue, or wake logic.
13. Completion definition
The native-desktop baseline is complete only when all F01–F17 requirements are verified on Wayland, X11, Win32, and AppKit; the comparison matrix cites the evidence; the native handle bridge creates a real Vulkan surface on Linux/Windows and a real Metal layer on macOS; and sparkles:ui-app can select the native host without application code naming a backend.
Graphite and ui-app integration follow that baseline. TSF follows native Graphite and application-host integration so it improves the Windows IME implementation behind an already stable event contract.