AGENTS.md
August 22, 2026 · View on GitHub
Guidance for AI agents (and new contributors) working on react-x11.
What this project is
A custom React renderer whose host environment is an X11 server, with react-like ergonomics on top of ntk / node-x11 — pure JavaScript implementations of the X11 protocol, no native bridge.
Architecture (see NEXT_STEPS.md for the full rationale): only <window>,
<popup>, <glarea> and <foreign> map to real X11 windows (<glarea>
because GLX needs its own visual, <foreign> because the window is another
process's); everything else is a retained
lightweight node — one yoga-layout node each — painted into the owning
window's double-buffered 2d context on ntk's frame clock, with synthetic
capture/bubble events dispatched via front-to-back hit testing. X11
windows are created top-down in the commit phase
(WindowNode.realize()): createInstance performs no X11 calls (the
render phase is discardable under concurrent React), and every
CreateWindow names its actual parent from the start — no ReparentWindow,
no override-redirect staging (issue #4).
Layout
src/index.js— public entry (createRoot; the legacyrender/unmountComponentAtNodepair was retired in #114).src/Reconciler.js— react-reconciler host config + render entry points. Written against react-reconciler 0.33 (React 19). If you upgrade react-reconciler, expect host config contract changes; the smoke test is the safety net.src/nodes.js— the retained node tree:WindowNode(real X window, paint/event/flex root),BoxNode,TextNode(+ spans/chunks),ImageNode,CanvasNode. Layout (yoga), painting, hit testing.src/glnodes.js—<glarea>: the GL surface. A child X window on a GLX visual (ntk'schooseGLXConfig), positioned by the parent's yoga rect, drawingonDrawframes on its own frame clock. First step of docs/glx.md.src/foreignnodes.js—<foreign>: another process's window, embedded. A seconddrawn: falsenode, over ntk'sXEmbedSocket(docs/embedding.md). Two things here are not obvious and are commented at length. Teardown is synchronous —WindowNodedestroys its own X window in the same turn, andDestroyWindowtakes every inferior with it, so a release that waits for a round trip releases a window that is already gone; the client is reparented to the root and dropped from the save set before the container goes, and never destroyed. And no focus proxy: the classic XEmbed embedder gives the X focus to an InputOnly window that forwards keys, which here would read as the toplevel losing focus and would put every key past the React tree before any handler saw it. The X focus stays on our window and forwarding happens indefaultKeyDown/defaultKeyUp, which is what makes the rule "app chords first, everything else forwards" mechanical.src/frame/—<Frame>: a pane of the application in its own process, its window embedded over<foreign>(docs/frame.md). Four small files along the seams:protocol.js(pure — the six messages, the callback table with its one-update grace window, argument sanitizing),env.js(the context bridge:createFrameContext, theFrameEnvaccumulator the theme publishes into),index.js(the host: fork, supervision, coalesced updates, fallback),childmain.js+child.js(the pane bootstrap behind an injectable transport, which is what lets the tests run a real pane in-process over a loopback —test/frame.test.jsforks for real exactly once, over a TCP bridge onto the in-process server). Two things are deliberate and easy to undo: the update listener goes up before the pane module import starts (a store the tree subscribes to later — a listener scoped to the mounted tree would drop every update that landed during the import), and the host's transport listeners outlive the effect cleanup until the exit, because auseFrameClosehandler flushing through a callback prop sends itsinvokeafter the unmount message.src/scene3d.js— the 3D scene tree inside a<glarea>:<mesh>,<group>, geometry/material nodes and the renderer that compiles each geometry into a server-side display list (a frame is matrices + material state + oneCallListper mesh).src/geometry3d.jsgenerates the primitives,src/mat4.jsis the matrix math,src/raycast3d.js+src/pointer3d.jsare picking and mesh pointer events.src/svgnodes.js—<svg>over ntk'sSvgView:SvgNode(sized from its viewBox like<image>, cached as coverage when the drawing is one colour) andSvgChildNode, the declarative SVG elements underneath it, serialized into the DOM SvgView consumes. It used to hold<markdown>,<html>and<tex>beside it, over ntk's document widgets; those were removed in 2.0 — a document rendered as one opaque widget can neither be selected across blocks nor re-rendered a block at a time, and the successors are<Markdown>/<Formula>in@react-x11/components, composed from public host elements.src/yoga.js— the layout engine. Enums synchronously, WebAssembly behindloadLayout(), whichcreateRoot()awaits before anything builds a node. Never importyoga-layout's default entry — it is a top-level await, and one import costs every app the single-executable build (docs/packaging.md);test/yoga.test.jsenforces this.src/anchor.js— where a<popup>goes:anchorRectand the screen area it flips and clamps against. Core rather than widget code because both callers need it and only one is a widget — a<popup anchor>with an'auto'size learns how big it is insiderealize(), after the content is measured and beforeCreateWindow, which is past the last moment React could have handed it a position (issue #255). So the window places itself from the same functions the widgets call, and the two agree by construction.atanchors to a rect inside a node — a caret — and is node-relative so that everything which moves the node keeps it true.src/components/— the widget set, plain React over the host primitives (no reconciler support needed). One module per widget, with the shared plumbing intheme.js(palette,useTheme,useControl),anchor.js(the React half of the above:useAnchor,useAnchorTracking, label measurement),typeahead.jsandkeys.js.index.jsre-exports the public names.src/menuitem.js,src/dbusmenu.js,src/globalmenu.js— the global menu (#112).menuitem.jsis the item vocabulary, which iscom.canonical.dbusmenu's rather than one of our own so that the arrayMenuBardraws is the array that serialises — one authoring model, no translation layer.dbusmenu.jsis pure: stable ids across re-renders, and the diff that decides betweenItemsPropertiesUpdated(revision unchanged) andLayoutUpdated, which is a performance decision rather than a cosmetic one.globalmenu.jsis the wire: registrar detection, the export, the KDE window properties, anduseGlobalMenu. Two traps live there and are commented at length — detection means a live owner (the opposite ofhasService()'s rule, because a registrar is a directory rather than a feature), and every call to it carriesNO_AUTO_START, without which tidying up after a dead panel launches a new registrar nobody reads.scripts/globalmenu-host.mjsis a panel in a terminal; there is no installable dbusmenu client to test against otherwise.src/application.js,src/activate.js,src/apphooks.js— custom URI schemes and single instances (#173).application.jsis the bus half and imports neither react nor X11, so the seam it would be extracted along stays visible; what keeps it here is thatRequestNamehas to land on the same connection as the menu and the portals, or the desktop sees two half-applications. Two traps live there: the object path is derived from the app id with dashes becoming underscores (get it wrong and the launch reaches a path nobody serves, which looks like nothing happening), and the interface is exported beforeRequestName, because the daemon delivers the queued activating call the instant the name is owned.activate.jsis the raise, and it is the part users judge the feature by —_NET_ACTIVE_WINDOWwith a wrong timestamp is refused by the WM while every layer reports success.npm run labs:urischemeis the manual harness for the two dispatch paths a broker cannot fake.src/styles.js— flat style props → yoga setters; paint prop classification; text style resolution. Also the logical edges (paddingStart,marginEnd,borderStartWidth,start/end) and thedirectionthat decides what they mean: yoga resolves those for the layout, andNode.direction(nodes.js) resolves the same rule a second time for everything outside it — which side a scrollbar sits on, which edge a logical border paints, the base level a paragraph of neutral characters shapes at. The floor under it is the palette'sdirection, seeded from the locale. Also the two prop sets the inheritance rule is written as:INHERITED_TEXT_PROPS(the ink, the face, the size — what travels down the tree) andLOCAL_TEXT_PROPS(what shapes a node's own box and therefore cannot arrive from above). Which side a text property is on decides who has to react when it moves, so a new one goes in exactly one of them. And the two places a style means more than it says:resolveComputedStyle(theflexshorthand, and the defaultsoverflow: 'scroll'implies) andapplyLayoutDefaults(the yoga defaults that are not CSS's —flexShrink, see the gotcha below).src/decorations.js— the two style values that are a small language rather than a number:backgroundImage'slinear-gradient(...)andboxShadow(#345). Pure — strings in, geometry out — so the renderer half innodes.jsis only compositing, and the grammar is testable without a server. Two things there are not obvious and are commented at length. The gradient line carries padding at both ends with the end colours pinned to it, because past its last stop an XRender gradient is transparent rather than clamped (RepeatPadis unset, sidorares/ntk#271) — and that cannot be caught by a pixel test, since node-x11's in-process RENDER clamps by construction where a real server does not. And a CSS blur radius is twice the gaussian's sigma while ntk'ssetBlurFiltertakes the kernel's edge length, so the two numbers that look interchangeable are the two that must not be.insetshadows and colour hints are refused rather than ignored.src/events.js—EventManager: ntk window events → synthetic events (click synthesis, hover enter/leave diffing, the wheel — ntk'swheelevent, XI2 valuators where the server has them and buttons 4-7 where it does not, converted from notches to pixels here (#273) — focus/Tab). Three ancestor-chain diffs live here and share one shape —:hover,:activeover the press chain, and:focus-within. Focus follows visibility (#202): a subtree that goes off the screen —hideInstancefor<Suspense>/<Activity>, adisplay: 'none', a<popup>unmapped — gives up the keyboard, and gets it back on the reveal unless something else took it meanwhile. Two things about that are easy to undo. The question is containment, since React only hides the topmost host instance of a branch and the focused control is usually deeper; and the restore is not a navigation — it neither scrolls nor callsSetInputFocus, because a background window revealing something must not take the keyboard off another application.src/clientmessage.js—<window onClientMessage>: the element-scoped seam for the protocols X layers on ClientMessage (EWMH, XEmbed, the system tray), where the alternative wasX.on('event')over the whole connection — which is whatsrc/xsettings.jsstill does internally, correctly, since a settings daemon's window is nobody's element. Two decisions live there. The type is handed over as the atom's name, so a handler is aswitchover strings rather than an atom table it had to intern first; and because a protocol an application only receives has never named its atoms, an unknown one costs aGetAtomName— behind which every message queues. That FIFO gate is not optional andtest/client-message.test.jspins it: the tray's balloon messages and XEmbed's opcodes reassemble by arrival order alone, so a round trip that let a later message overtake an earlier one would corrupt them undetectably.preventDefault()is the usual default-action seam, and reaches XDND (src/dnd.js), which is the one ClientMessage protocol core answers on the same stream.src/textselection.js+src/textrange.js— selecting read-only text (#259).textrange.jsis the pure half: words, blocks, and the code point ↔ code unit table anything reading ntk run geometry needs, shared with<textinput>'s editing keys so a double click picks the same word in a document and in a field.textselection.jsis the model — the surface aselectableelement becomes, and the registry that holds the one rule no surface can hold for itself: only one selection is visible in an application at a time, so a drag across a document collapses the highlight in the field beside it and vice versa. Two decisions live there. A participant is any node that answers the four geometry accessors onNode(textContent,textIndexAt,textCaretRect,textRangeRects), so a terminal written outside this package joins a document with no registration call. And the separators a copy assembles with come from the layout rather than from the markup — two pieces of text sharing a band of pixels are joined with a tab, one that starts below the last with a newline — because core cannot know which<text>is a table cell, and asking applications to say so would be a second authoring model for something the screen already shows.src/inputtime.js— the two selection timestamps, both public since #18's second gap:lastInputTime(app)is the last input event's server time, stashed off the event stream because ICCCM wants "the timestamp of the event that caused this" and the causing event is four frames above the code that copies;serverTime(app)derives a fresh one for an acquisition no user action caused. The failure they exist to prevent is silent —CurrentTimeis accepted by the server and leaves two clients racing for a selection unorderable — so neither should ever be?? 0-ed at a call site.src/fonts.js+src/fonthooks.js— a font file an application opens for itself (#346):openFont(app, source)reads one,loadFontreads it and registers it,useFontis the hook. The line between the two is the whole design and is easy to erase by accident — registering a face does not only make afontFamilyresolve to it, it puts the face ahead of the system chain for every codepoint the current font is missing, so a font browser that registered what it previewed would change the glyphs its own UI borrows. The family name is derived from the file rather than invented by the caller, and scoped (Inter 2) only where a second file would otherwise be unreachable — a family is a set of faces, so bold beside regular is not a collision. Both verbs go throughapp.fonts, including its_open, because a secondFontfor one file is a second glyph atlas.src/compose.js— dead keys and the Compose key (#272): the sequence table and the state machineEventManagerruns between an application'sonKeyDownand the element'sdefaultKeyDown. The dead-key half is Unicode's composition rather than a table of ours —dead_acuteis U+0301 and the rest isnormalize('NFC')— which is what makes it 30 entries instead of X's 6000 and correct for scripts nobody listed. Only what Unicode has no rule for is written down:ø, the currency signs, and theMulti_keysymbols.probe/applyare separate on purpose, since the key event is dispatched to the application before the composer may eat it.src/accelerators.js+src/acceleratorhooks.js— a menushortcutthat is a binding and not a caption (#351). The matcher is pure and the registry is inEventManager, next to the key path it is the last step of: after the element'sdefaultKeyDownand before Tab's focus cycle, sopreventDefault()is what keeps Ctrl+C in a focused<textinput>. Four decisions are load bearing and none of them is obvious from the call site. The chord's key is resolved fromev.keysym, so a layout switch cannot turn a shortcut off, and a chord naming a symbol also matches the character the key typed —plusis the shifted=andCtrl++has to be pressable — with letters kept out of that path because it is exactly what would fire Ctrl+S on Ctrl+Shift+S. The four modifiers are compared exactly and the locks are not in the comparison at all, which is the line hand-rolled bindings get wrong. A binding is gated by the node it hangs off being on the screen and inside the innermost focus scope, which is what makes a modal<popup>take the application's shortcuts with the keyboard and still leave a menu declared inside it working. And aMenuBarkeeps its bindings when the desktop's panel takes the menu over — the panel draws the rows, but the key is pressed in our window and nothing else will deliver it, which is why the anchor falls back from the bar to the window rather than unregistering.src/priority.js— shared React update-priority state (discrete vs continuous events).src/DevToolsIntegration.js— opt-in React DevTools bridge (REACT_X11_DEVTOOLS=1; needsreact-devtools-core+ws, dev-only).examples/— runnable demos (need a real X server, see below).test/smoke.test.js— headless tests over a mock ntk app object.test/integration.test.js— end-to-end against node-x11's in-process pure-JS X server with pixel-readback assertions. No$DISPLAYneeded.test/wm.test.js— the window-manager example against that same server, two connections: one plays the WM, the other an application. Needs x11 >= 3.2.0, which is where substructure redirect landed.src/index.d.ts+src/types/*.d.ts— hand-written TypeScript declarations for the public API, andsrc/jsx-runtime.{js,d.ts}(plus the dev variant) so a project can setjsxImportSource: "react-x11"and have JSX check against the X11 elements. See docs/typescript.md and the note below.test/types/api.tsx— compiled, not run: the type tests.website/— the documentation site (Docusaurus, deployed to GitHub Pages). It rendersdocs/, it does not restate it — see "The documentation site" below before writing anything there.
Pre-release: there is nothing to be compatible with
The package is published, but nothing has been announced or marketed and there are no users. So backwards compatibility is not a consideration. When a better name, a better shape or a better default needs a breaking change, propose the breaking change — don't design around the old one, don't add an alias, don't keep a deprecated path working beside the new one. A compatibility shim added now is a shim that will still be there when the API does have users, and it will have shaped everything built on top of it in between.
What this does not excuse: the change still has to be the better design and
still has to land completely — every call site, the .d.ts, the type test,
docs/, the examples and the website demos in the same PR (see "The
documentation site"). A rename that leaves half the tree on the old name is
worse than either name. The release automation carries the rest: the commit
type is what release-please reads, so a breaking change says so in the
footer (BREAKING CHANGE:) and gets the version bump on its own.
When the reason for a change is only "this is what it has always been called", that is not a reason yet.
Answer the input, not the outcome
Perceived latency is the metric, and it is measured from the input, not from the work (see also "Protocol efficiency" below, which is about the cost of the work itself — a different axis). Every input gets a visible answer on the event that started it, even when the event that does something is a later one.
The failure mode this exists for: a <Switch> toggles on the mouse button
release, because that is when a click is. Perception starts at the
press. Hold the button half a second — which an unhurried user does
without noticing — and that is half a second of a control that has visibly
not heard you, followed by a state change that now reads as the machine
being slow. Nothing about the code is slow. The control simply said nothing
for the part of the gesture the user was watching.
So a control has a presentation for every state the pointer can put it in, and they are all distinct: resting, hovered, held, and back to hovered on release. The press state is drawn on the press even when the press itself does nothing — it is not a promise that something happened, it is an acknowledgement that the input arrived, and the release is still what acts. Same for the keyboard, and same for anything with a latency behind it: show the answer to the input first, and let the outcome catch up.
Concretely, in this codebase:
:activeis the press state and the renderer maintains it over the whole press chain — the node hit and its ancestors, the same rule:hoverfollows — because the node actually under the pointer is whatever the control is built out of, a label or a thumb, not the control. It narrows as the pointer leaves the chain and grows back as it returns, so it always means "releasing now activates this", which is the same nearest-common-ancestor rule the click is synthesized on (src/events.js,_setPressed).- Prefer a state block to React state:
:hover/:activeare a repaint of one node, where React state re-renders the widget and its label. Reach foruseControl'spressedonly when the part that has to change is not on the press chain — a<Checkbox>'s well is a sibling of the label the press lands on, and no state block can cross that. - A palette has a pressed step for every family that has a hover
(
accentActive,surfaceActive,textMutedActive,dangerActive— the status family's one pressable member). A theme that names only the hover gets one derived from it —stepBeyondtakes the step the palette already made and takes it again, so it darkens a light theme and lightens a dark one. - A
transitionon the pressed property is fine and often better: what matters is that the change starts on the press frame, not that it finishes there. - Where the honest answer to a press is more than a tint, move the action
to the press.
Selectopens its menu on the mousedown: a dropdown is there to be looked at, so holding the button and seeing nothing wastes exactly the time the user was waiting. Safe because the open popup's pointer grab takes the next press itself, so dismiss and toggle never both run. The rest stay on the release, because that is what a click is and because a press you can still change your mind about is worth having.
Known gap, so it is not rediscovered as a bug: the keyboard has no press
state. Space and Enter fire on the down, so the activation is immediate,
but nothing draws held-ness — X sends KeyRelease/KeyPress pairs for
auto-repeat and neither ntk nor node-x11 implements XKB's
DetectableAutoRepeat, so a held key is indistinguishable from a fast
series of taps and :active would strobe at the repeat rate. It needs the
XKB extension upstream first.
Decide it, then leave a way out
A feature ships with a default that serves the main customer out of the box — no configuration, no decision asked of someone who has not got the context to make it — and a seam for everyone else. Both halves, always: a default with no override strands the edge case, and an override with no default makes every app answer a question most of them do not have.
The main customer is whoever will hit this most, not whoever is loudest in the issue. Work out what they would pick if they knew everything you know, make that the behaviour with no arguments, and write down why in the place the default lives.
The seam is not ceremonial and it is not a boolean by reflex. Give it the shape of the real disagreement:
- A config value when the answer is a fact about the app — an id, a timeout, a name it is known by.
- An enumerated choice when there are a few defensible answers and the
cost of a wrong one is only that it fits badly.
startupNotification.completeOnis'paint' | 'map' | 'manual'because those are the three honest moments an app can call itself started. - A hook when the answer is code we cannot write —
notifyStartupComplete()exists for an app that is up only when it says so. - An off switch when a whole feature can be someone else's job. Every feature core turns on for you needs one, because sooner or later an embedder owns the thing we assumed was ours.
Two failure modes to watch for, both of which read as thoughtfulness:
- A default that is only a guess with an escape hatch bolted on. If the documentation for the default is "or set this to the other thing", the decision has not been made — it has been forwarded.
- A seam nobody can reach. An override that needs internal state, or fires before the app can install it, is not an override. If the honest signal is internal — "the first flush that painted" — the seam has to be in core, next to the signal, rather than approximated from outside.
And a default that turns something on still owes the same discipline as one that turns something off: state what it does, what it costs, and how to stop it, in one place — see docs/desktop.md for the worked example.
Commands
npm test— node:test. Headless: no X server needed. Primary feedback loop; keep it green and extend it when touching the host config.npm run lint/npm run format— ESLint 9 (flat config) + Prettier.npm run typecheck—tscover the declarations andtest/types/api.tsx. Runs in CI beside lint. A prop change is not done until the.d.tsand a line in the type test change with it — hand-written declarations drift silently otherwise, and nothing else catches it.npm run examples:{app,theming,simple,simple-nojsx,xeyes,dashboard,tasks,menu,form,selection,widgets,windows,wm}— need a running X server (DISPLAYset; XQuartz on macOS, Xvfb for automation).examples:appis the showcase: it hostsform,widgetsandtasksas tabs by importing the panel each of them exports, so a new control gets demonstrated there rather than in yet another example. All examples export their App and skip auto-running underREACT_X11_NO_AUTORUN=1so scripts/tests can import them.npm run examples:wm— the reparenting window manager (issue #3). It claims the root window, so it needs a display no other WM owns: runXephyr :10 -screen 1200x800and point it there.examples/wm-core.jsis the protocol half (claim the root, answer map/configure requests, keep the client list, EWMH, alt+tab) andexamples/wm.jsxis the React half — each frame is a<window>whose titlebar, buttons and eight resize handles are components. Notes below under "Writing a window manager".npm run examples:tasks:hot— the tasks example with hot reloading via React Fast Refresh: editexamples/tasks.jsxwhile it runs and the edited components update in place — connection, window, and component state (the task list, half-typed input text) survive; a component whose hook signature changed remounts alone. Runs under the supported entry point,src/refresh/(issue #317):--import react-x11/refresh/registerchains a sync babel loader (JSX classic +react-refresh/babel,retainLines, plus a per-module boundary footer) underhot-module-replacement's ESM hooks (Node ≥ 22.15,module.registerHooks);src/refresh/index.jsis the runtime half, auto-imported into every hot module's prelude, exposingonReloadfor tools. A module whose exports are all components self-accepts, so no accept handlers are written by hand. The constraints — no named-import calls at module top level (bindings becomelets initialized in a microtask — use the default import, e.g.React.createContext), classic JSX runtime only, one statement per injected prelude line — are enforced as transform/registration errors (test/refresh-transform.test.jspins the messages;test/refresh-hot.test.jsis the end-to-end reload). Identity that must survive a reload (contexts, stores) lives in its own untouched module (examples/tasks-context.js), or behind theignoreoption ofregisterRefresh(react-x11/refresh/loader).npm run screenshots— regenerate the README/docs images (docs/img/*.png) headlessly: renders the real examples into the in-process X server, drives them through the real event pipeline, and sets text in a system sans-serif (Arial / Liberation / DejaVu — small serif text reads as a document, not a UI). Run it whenever a change affects how the examples look. A 3D shot is not something that script can produce: the in-process X server has no GL, and on XQuartz GL renders whereGetImagecannot read it, so anything showing a<glarea>has to be captured by hand on a real server with indirect GLX (docs/glx.md). Pixels come out ofscripts/capture.jsand nowhere else, andtest/capture.test.jspins it. Every script here used to hand-roll "the server gives BGRA" and swap the channels itself; ntk 5.3.0 madegetImageDataspeak straight RGBA the way canvas does, andscreenshotsthen regenerated every PNG with its reds and blues exchanged — for a whole ntk major, because a committed screenshot has no other reader and CI does not diff them. A window of ours goes throughcaptureWindow; a drawable we do not own — a WM frame — throughcaptureDrawable, which asks the display for the pixel layout rather than assuming one. The script also pins the<textinput>caret on before capturing, alongside the frozen clock and heap usage: a blinking caret madetasks.pngdiffer run to run, which is what makes a dirty tree after a regeneration meaningless.npm run screenshots:framed [scene…]— the same examples captured with the window manager's frame, intodocs/img/framed/(gitignored — the decorations are whatever WM is running locally, so they are generated on demand, not committed). Needs a realDISPLAYwith a reparenting WM;npm run screenshotshas no WM at all and therefore no frames. See "Framed screenshots" below.npm run docs:dev/npm run docs:build— the documentation site inwebsite/(needsnpm installinwebsite/once). See below.
The documentation site
website/ is a Docusaurus site published to
https://sidorares.github.io/react-x11/ by .github/workflows/deploy-docs.yml
on every push to master. Two rules keep it from drifting away from the code,
and both are enforced rather than asked for:
- The API reference is not written there.
website/scripts/sync-docs.mjscopiesdocs/*.mdintowebsite/docs/reference/at build time — adding front matter, rewriting links, copying images — and that directory is gitignored. Editdocs/; the site follows. A new file indocs/appears in the sidebar on its own (unlisted files trail alphabetically after the ones named inORDER), and a deleted one disappears, because the output tree is rebuilt from scratch. Never hand-editwebsite/docs/reference/. Onlyintro.mdandgetting-started.mdare the site's own prose, and they narrate rather than specify — anything normative belongs indocs/. - The playground runs the real renderer, so it fails when the API moves.
website/scripts/build-demo-bundles.mjsbundlessrc/together with React, ntk, node-x11, its pure-JS X server and its GLX-over-WebGL2 emulator into one ESM module;static/demo/runner/index.htmlboots that in an iframe and compiles editor JSX with sucrase.npm testinwebsite/runs all three gates:check-bundle.mjs(the bundle loads, renders a tree, reads pixels back),check-demos.mjs(every demo inwebsite/src/demos/mounts, paints, and responds to injected input) andcheck-share.mjs(the share-link codec round-trips every demo). Thedocsjob in.github/workflows/ci.ymlruns them on every PR. A prop or component rename is not done until the demos that use it still pass.
Share links (?code=…, website/src/lib/share.mjs) carry the whole snippet
in the URL — DEFLATE, then base64url, with a one-character scheme prefix.
There is no server and no stored snippet, so the format is permanent: a
link someone pasted into an issue two years ago still has to decode. Add a
new scheme letter rather than changing what d or p mean. Code that
arrives that way does not auto-run, and says so above the editor, because it
is a stranger's JavaScript running in the page.
Things that were awkward to get right, so they don't get re-broken:
- The bundle is ESM, not an IIFE: ntk's module graph and yoga-layout's WASM loader both use top-level await, which esbuild only emits in that format.
- One React. The entry resolves
reactfromwebsite/node_modulesandsrc/resolves it from the repo root; two instances share no hook dispatcher, so the build aliasesreactto the repo's copy. - The bundle ships a
Bufferpolyfill it installs only when there is no global one — true in a browser, false in node. Both check scriptsdelete globalThis.Bufferbefore importing it, or the x11 client builds packets node'sBuffer.isBufferthen rejects. DevToolsIntegration.jsandClickToComponent.jsare replaced by a stub at bundle time (an esbuild resolver plugin): they are dynamically imported behind environment variables the playground never sets, but bundling them would drag inwsandnode:child_process.- The demo exercises in
check-demos.mjslocate their click targets by the colour they are painted in, not by coordinates, so moving a demo's layout cannot silently turn its assertion into a click on empty background. - react-x11 caches its ntk App at module scope, so the runner keeps one
server and one connection for the page's lifetime and unmounts between
runs. Rebuilding the server per run would leave the second
createRoot()holding a dead socket.
TypeScript declarations
The types are hand-written against the JavaScript, so the only thing keeping
them true is npm run typecheck plus the cases in test/types/api.tsx.
Two things about the shape that are not obvious:
- JSX comes from
react-x11/jsx-runtime, not from augmenting React. Augmentation was tried and does not compile:text,image,canvas,htmlandsvgare DOM element names too, already declared by@types/reactwith incompatible props, and declaration merging cannot replace an existing member. Owning the namespace also makes<div>an error, which augmenting could never do. createStylestakes a mapped parameter,{ [K in keyof T]: Style }, not a bareT extends Record<string, Style>. With the bare form the object literal loses freshness during inference and TypeScript stops reporting unknown properties — the mapped form keepsStyleas each value's contextual type, so a typo in a style key is caught the way the runtime validation catches it.
Writing an example
examples/ is being reworked from a changelog into a set of applications:
each file arrived with a feature and demonstrates that feature's API surface,
which is the right artifact when it lands and the wrong one a year later. The
rules below are what that rework decided; the per-app plan lives outside this
file, but these outlive it.
An example is a program, not a panel. If a reader would not want to keep it running, it belongs in the labs — the harnesses for things CI cannot check (clipboard interop, XDND against a file manager, the desktop's appearance) and the perf instruments. Those are worth keeping and are not the tour.
Demonstrate core, and only core. No example imports
@react-x11/components. These files exist to show what this package can do,
and one that reaches for the sibling package answers a different question —
one that package's own examples are there to answer. It also means an example
never breaks on someone else's release.
Do not build on what is leaving. Tree has moved to
@react-x11/components, and Calendar/DatePicker with it; Table is being
reduced to a plain table with no sorting and no virtualisation; Tabs,
PasswordInput's scribble mask is going the same way; Tree, Calendar,
DatePicker and Canvas3D have gone; <markdown>, <html> and <tex> are
already gone (#315). An example that wants a sortable virtual list builds
one, and that is the better lesson: core gives the primitives, the examples
show the way up from there. examples/monitor.jsx hand-rolls exactly that
over ~780 rows.
Every app carries its own tests. Written with react-x11/test, which is
otherwise undemonstrated — and it is what stops an example rotting silently
when core changes underneath it. Nothing else forces one to keep working.
Put a seam where the world is. A transport, a sampler, a spawn: the thing
that talks to the outside goes behind an interface the tests can replace.
chat's transport and monitor's sampler are the worked examples. Two traps
found the hard way — a fake that answers a question the real one cannot yet
answer hides the state the UI most needs to get right, and a fake that
resolves instantly leaves nothing to assert about the state in between.
Say what does not work. Where an example meets a real gap — no IME, no
HiDPI scale model, no per-node container query — name it and link the issue
rather than steering around it. That matches how docs/ already handles
compatibility ladders, and a reader who hits the same wall is better served by
a sentence than by a silence.
Check it on a real display. This is the one that keeps being relearned. The headless harness renders with the font a test hands it, never the one fontconfig picks, and it clicks where a user hovers. Every visual defect in the recent examples work — text three pixels low, a badge riding high, a sparkline reflowing every row once a second, a load graph drawn as a solid block, a header that never lined up with its table — passed a green suite and was obvious the moment somebody looked. Run it, capture it, look at it.
Writing a window manager
examples/wm.jsx is the other side of everything above: instead of being a
client the window manager decorates, it is the window manager. The things
that are easy to get wrong, all of which cost time here:
- Frames must not unmount while they hold a client. The client is
reparented into the frame, and X destroys children with their parent —
so minimizing unmaps the frame rather than removing it from the tree, and
a client that withdraws is reparented back to the root before its frame
goes away.
addToSaveSetcovers only the case where the WM itself exits. - ConfigureRequest carries a value mask. The geometry fields that the mask does not name hold the window's current values, not a request. Honour the mask or you will "move" windows to where they already are.
- Redirect the frame too, before reparenting into it. A window's requests go to whoever holds SubstructureRedirect on its parent, and after the reparent that is the frame, not the root. Miss this and a client that resizes itself does it behind the WM's back, leaving the window a different size from the frame drawn around it.
- Answer requests you refuse (ICCCM 4.1.5). A reparented client's own
ConfigureNotify has frame-relative coordinates, and a refused request
produces none at all — clients that resize themselves then hang. Both are
answered with
sendConfigureNotifyin root coordinates. - Do not select
ButtonPresson a client window. Only one client at a time may hold it and the application already does, so it fails with BadAccess. Click-to-focus uses a synchronousgrabButtonplusapp.allowEvents('replay'), which hands the same click back to the app. - Focus only viewable windows.
SetInputFocuson a window that is not yet mapped is a BadMatch, so focus is taken after the frame is attached and both are mapped, not when the client is first seen. - Resolve keycodes from
GetKeyboardMapping, not from ntk'skeycode2keysyms: that copy is filled from a reply that may not have landed, and a keycode guessed from a half-built map grabs a key nobody presses. Grab every keycode that carries the keysym, and every combination of CapsLock/NumLock — a passive grab only fires on an exact modifier match. - Drags need a real pointer grab. react-x11's
capturePointer()only routes events that already arrived at the window; the X grab is what keeps the server sending them once the pointer leaves the frame. - Icons come from two places.
_NET_WM_ICON(EWMH) is ARGB pixels in a property, what GTK and Qt set;WM_HINTS(ICCCM) points at a server-side pixmap plus an optional 1-bit mask, what xterm and the other classic clients still set. Reading only one leaves half the windows blank. A depth-1 icon is a stencil, not a picture — draw the set bits in the frame's foreground and leave the rest transparent, or it arrives as a black square.
Framed screenshots
A reparenting WM puts the client window inside a frame window that is a
child of root, and draws the decoration into it — so the frame is ordinary
X pixels and GetImage can read it. On XQuartz this works because
quartz-wm renders the Aqua titlebar through the Apple-WM extension's
FrameDraw; on Linux any normal WM does the same with its own theme.
Two things the script has to get right:
- Find the frame: walk up from the client window to the ancestor whose
parent is root.
ntk'swindow.queryTree()orX.QueryTreeboth do. - Beat occlusion:
GetImageon a window returns the current screen contents of that region, so an overlapping window is captured instead of yours. The script floats the window first — Apple-WMSetWindowLevel(Floating)where available, plusRaiseWindow. Note quartz-wm does not advertise_NET_WM_STATE_ABOVE, so on XQuartz the Apple-WM window level is the only always-on-top mechanism. - Apple-WM's
SetWindowLevelwants the frame, not the client: once reparented the client is no longer a child of root and the request answersBadWindow(opcode 130).
The system icon set
src/components/Icon.js holds the glyphs the widgets are drawn with, and
they are the whole set: an icon added here is a decision about what the
library is, not a convenience. Four rules, all of which someone will
otherwise relitigate.
Affordances, not nouns. The set holds glyphs that say something about the control — there is more here, this one is chosen, this closes, this is hidden. It holds no nouns: no folder, no document, no save, no printer. Nouns belong to an icon theme (lucide, an XDG theme, the app's own art), they are unbounded in number, and a widget set that starts shipping them has taken on a design system. That single rule answers "should X be added?" without a debate; PasswordInput's Caps Lock key is drawn locally rather than here for exactly this reason.
One shape per idea. Before the set, core drew a chevron four ways: a
filled caret in Select, Tree and Table, a stroked chevron in Calendar, and a
▸ text glyph in Menu — tofu on a machine without it, which is the warning
docs/components.md gives applications about string icons. The caret is gone.
If a new widget needs a mark, it takes one from the set or the set grows by
one, deliberately.
Colour and size are themable; geometry is not. Colour rides theme, the
one channel that walks the node tree, or is handed over at the call site;
size derives from theme.fontSize the way the popup radii do. The
drawings are core's vocabulary, and an application that wants a different
chevron wants an icon library — which it is welcome to bring, since a
<svg> or a <canvas> goes anywhere an <Icon> does. There is
deliberately no per-icon override slot: that is a registry, and a registry is
the icon-library problem rather than this one.
A mono drawing never names a colour. The glyphs ride <canvas mono>,
whose whole promise is that the ink comes from style.color, so one drawing
serves every state a control puts it in. A drawing that sets its own
fillStyle breaks that; test/icons.test.js asserts it for every name.
mono buys an a8 coverage entry: the colour is applied at composite time
and therefore out of the cache key, so one rendered copy per name and size
serves every ink. That needs ntk ≥ 7.3.3 and the floor is load-bearing
rather than cosmetic — earlier versions composite coverage as empty under any
clip they cannot express as a rectangle
(ntk#243), and a widget sits
inside clips like that routinely. examples/tasks.jsx nests a rounded card,
a scrolled list, a rounded row and a checkbox well; five of its six ticks
came out blank while every unit test passed.
Two things came out of that and are worth keeping:
- A dependency floor deserves a test that fails on a downgrade. The one in
test/icons.test.jsdrives ntk's API directly — an a8Surfacecomposited under a circular clip — because no tree built from<box>reaches that path: react-x11's own clips are rectangles. A react-x11-level test for it passed happily against the broken version, which is the trap. - This shape of paint bug survives every assertion about layout, keys and
cache statistics.
npm run screenshotsand a look atdocs/img/is what catches it — regenerate on the base first and diff, since the rasterizer shifts edges on its own.
The ink comes from the cascade; the size does not. color is an
inherited property (docs/styling.md), and a mono drawing reads exactly what
a <text> beside it would — so an icon in a row that dims itself dims with
it, and a :hover block that sets color on the row reaches the glyph the
same way it reaches the label. Nothing is handed over at the call site, and
an <Icon color=…> now means "this mark is not the colour of the text
around it" rather than "someone remembered". size stays out of it on
purpose: a glyph has no baseline and no ascent, so it derives from the
palette's fontSize rather than from whatever text is nearby, which keeps a
chevron the same size in a row that shrank its label.
Not a font, and the reasoning is on record so it is not re-derived: ntk's FontManager does take font bytes, and glyphs composite through a solid source picture, so a column of chevrons would be one CompositeGlyphs. It loses on a binary artifact plus a generation toolchain in an otherwise pure-JS package, baseline rather than box alignment, unhinted mush at 12px, and an icon name routed through text layout and the accessibility tree. Revisit only if a profile of a large Tree shows composite count dominating.
Protocol efficiency
X11 is a network protocol even on a local socket. The three libraries have distinct jobs, and this one is the layer that decides how often anything is drawn, so cost is a design concern here rather than an afterthought:
- node-x11 — the protocol and minimal ergonomics. No policy, no drawing strategy.
- ntk — ergonomics and higher-level primitives over it; owns how drawing is encoded.
- react-x11 — React primitives and ecosystem over ntk; owns how often drawing happens.
Rules, in rough order of how much they usually matter:
- Use server-side primitives instead of client-side pixel work.
XRender composites, glyph caches,
CopyArea, server-side clip rectangles. Reach for readback or per-pixel manipulation last. - Batch into as few requests as possible. A shaped paragraph is one glyph run batch, not one request per word or per character. Prefer one request covering N items over N requests.
- Bound work to the damaged area. An operation over the whole surface costs the same on the wire as one over a 20x20 rect and vastly more in the server. This is the failure mode request counts do not show.
- Avoid round trips. A request that waits for a reply stalls the pipeline; cache what the server already told us (atoms, geometry, glyph pages) rather than asking again.
- Prefer server-side text rendering. Shape once, upload glyphs once, draw whole runs.
Measuring it
npm run bench runs scenarios against the in-process X server and reports
requests, bytes, replies, Render composites and the pixel area those
composites touch. That last metric exists because the others hide the
most common regression: a change can add almost nothing to the wire while
multiplying the server's work many times over.
npm run bench -- --saverewritesscripts/bench/baseline.jsonnpm run bench -- --checkfails if a metric regressed past tolerance
For a live app rather than the bench scenarios, REACT_X11_TRACE=summary
(or requests, or chrome:/tmp/t.json for Perfetto) traces the protocol
with the same splitter, REACT_X11_DEBUG_PAINT=1 flashes damage rects and
=full warns — with reason and stack — on silent full-window repaints,
which are the regression class the bench numbers hide until re-run. See
docs/debugging.md; the switches are read once at startup and cost nothing
when off.
Re-run it when touching painting, layout flushing, or anything in ntk's drawing path, and update the baseline in the same PR that changes it, so the diff records the cost.
An error you hit is an error an app developer will hit
Whenever an error turns up while researching, benchmarking or sketching — even in throwaway code, even when it was your own mistake — stop and ask two questions (this is ntk's policy too, sidorares/ntk#170; it found sidorares/ntk#121 before any user filed it):
- Can an app developer reach this? If yes, you have found a bug
report before anyone wrote it. Look hardest at ambient facts about the
machine — no
$DISPLAY, no fontconfig, no GLX, no window manager, an XQuartz quirk — because your box is not the deployment target, and a container has none of those things. - Can the error say what to do about it? Say what was expected, what was found, and what to change — a fix instruction, the diagnostics to work one out, or a link to the page that explains it.
react-x11's consumer makes the first question bite harder than usual: a
React developer with DOM habits and no X11 vocabulary. The layers under us
— ntk, node-x11, yoga, fontconfig, the server itself — all throw in their
vocabulary, from stacks that name nothing the developer wrote. A
BadWindow with a sequence number, a spawnSync fc-match ENOENT from the
first text layout: nothing there to search for, no JSX element, no
component. The renderer's job is to be the translation layer for errors
too, not just for drawing.
The house style already has the pattern; hold new errors to it:
- The unknown-element error names the tag and lists the supported set — and docs/ecosystem.md's compatibility table is built out of those messages being specific enough to be rows.
- A handler throw is reported with the handler name, the element, and the
component that rendered it, plus why no error boundary could catch it
and that dispatch continues (
src/errors.js). - A style property passed flat throws naming the property and showing the corrected JSX.
Two failure classes are specifically ours:
- ntk version skew. The renderer feature-detects ntk APIs and
degrades. Degrading silently is right when the loss is cosmetic (no
setCursormeans no pointer feedback); it is wrong when it is load-bearing — a feature that silently never engages looks broken, not degraded, and the developer has no thread to pull. Load-bearing degradation says so once, in development, naming the ntk version that has the API. - The consumer may be running a different program. X11 protocol conversations — XDND, selections, WM protocols — fail into another application's UI: a reply we never send freezes a drag cursor in a window we do not own, for a user who will never see our stderr. For cross-client protocols, deadlines and watchdog replies are the error message; write them as deliberately as one.
The bar, so this is not a licence to rewrite every throw: the developer
must be able to reach it in a supported setup and act on what it says; the
remedy must be specific (a snippet and an apt-get line, not "configure
fonts"); the cheapest real fix goes first even when it is not ours; and
"your environment lacks something" is distinguished from "your call is
wrong". Where the fix is longer than a sentence, the message links to
docs/ — and a test pins that anchor, because a URL in a string literal
is the one kind of doc link nothing in CI checks.
Gotchas
-
The package is ESM (
"type": "module"). ntk is ESM with top-level await in its graph; yoga-layout is ESM WASM. Everything imports statically now (norequire). -
ntk comes from npm. Yoga is ours (
src/yoga.js) — it used to be imported from ntk so that renderer and ntk's document widgets shared one WASM instance, but those widgets are gone and the renderer is the only layout consumer left. Import the engine from./yoga.js, never fromyoga-layoutdirectly (see the note under Layout).<textinput>caret math uses ntk 3.3.0'sTextLayout.caretPosition/indexAt. -
Text measurement runs through a yoga measure function calling ntk's
FontManager.layout(TextLayout), memoized per max-width. Any change to text content or text style props must call_textContentChanged()→yoga.markDirty(). The mock app in smoke tests has nofonts, so text measures 0×0 headlessly — pixel-level text assertions live in the integration test (StaticFontSource + KaTeX's bundled font, no fontconfig). -
Font family resolution shells out to
fc-match, so it followsPATH. On macOS that usually finds Homebrew's fontconfig before XQuartz's, and Homebrew's ships no macOS system-font aliases:sans-serifresolves to Hiragino Sans, whose Cyrillic sits on full-width advances while its Latin stays proportional — so the wrong font is easy to miss until someone types a non-Latin script. Put/opt/X11/binfirst onPATH. This is also whyscripts/screenshots.jsxnames Arial / Liberation / DejaVu explicitly instead of asking forsans-serif. Nothing in the source works around it yet — issue #86. -
Painting is scheduled through
window.requestAnimationFrame(ntk frame clock: coalescing + server fence) and is bounded by damage when the invalidation can name a region.root.invalidate(layoutChanged, node)takes the node whose appearance changed; the frame clips to itspaintBounds(), refills only that much background, and_outsideDamage()skips any subtree that does not reach into it — that cull is where the protocol saving comes from, since the clip alone would still put every request on the wire. Three rules, and breaking any of them shows up as a pixel diff intest/dirty-rect.test.js:- a layout change is never bounded (
invalidate(true, ...)ignores the node): a node that moved leaves stale pixels at a rect the new one does not cover; - passing no node means the whole window. Partial painting is opt-in
per call site, so forgetting costs speed, not correctness.
NO_DAMAGEis the third state — "I need a frame but changed nothing myself" — which is whatWindowNode.applyPropspasses when only its children changed; - cull on
_subtreeBounds(), neverabs. A child of a non-clipping parent can be drawn outside it, so a parent whose own rect misses the damage may still own a node inside it. - a node only claims damage if something it draws changed. Paint
style is compared by value, so a style object React rebuilt with the same
contents costs nothing; every non-style prop is compared too, because
that is where a subclass's content lives —
<image src>,<canvas onDraw>,value,placeholder.childrenand event handlers are skipped, child mutations having their own invalidation paths. Adding a prop that affects paint needs nothing; adding paint-relevant style means adding it topaintPropsChanged, which is whyborderStyleis named there explicitly alongsidecolor.
Damage is a list of rects, capped at four, not the box around them, so two changes at opposite corners no longer repaint everything between them. The frame paints a pass per rect and clips each pass to that one rect — deliberately, because ntk's server-side clip fast path only recognises a single rectangle and a multi-rect clip path falls back to rasterizing a full-surface mask. Rects that overlap are merged rather than kept, since a node inside two of them would otherwise be painted twice, which is wrong for anything translucent. And a list whose rects nearly fill their box collapses back to the box, because a pass is not free:
SPLIT_SAVINGis the threshold.Presentation is ntk's job, and the two halves have to match to be worth anything: ntk >= 3.10.2 copies just the rects the drawing reported, where earlier versions blit the whole window however little changed. That was the floor in
package.jsonfor a long time for this reason rather than for an API — against 3.10.1 the region list still computes and paints correctly, it just does not reach the screen any faster (NEXT_STEPS §8 item 4). The floor is now 4.3.0, and that one is an API:Window.scrollRegion, which the scroll-blit path below calls. The reporting channel is the clip: ntk takes each operation's clip rectangle as the region it might have touched, so a pass that is not clipped reports nothing and gives up the bound for the whole frame — which is why the frame clips per rect even though culling already skips the work.A scroll is the one layout change that carries a damage bound:
invalidate(true, node)asserts the reflow is confined to that node's subtree and that the node clips its children, so both the old and the new position of anything that moved are inside the bound. theScrollablemixin is the only caller. Everything else still passes no node and repaints in full._subtreeBounds()stops at a node thatclipsChildren()for the same reason: a scroll pane's content extent is not what reaches the surface, and mid-scroll it can be ninety thousand pixels away.On top of that bound sits the scroll-blit fast path (issue #138,
_applyScrollBlits): when a frame is a pure single-axis scroll of one viewport, the surviving band isCopyArea'd inside ntk's backing pixmap (Window.scrollRegion, ntk >= 4.3.0 — the floor, though the call stays feature-detected so a mock or a deduped older copy degrades rather than throws) and the frame's damage narrows to the exposed strip plus the scrollbar repair rects — per wheel notch on a 500-row list that is 44 requests and 0.065 Mpx of Composite work instead of 89 and 0.33. Everything about it is a gate that falls back to the plain repaint: too-small viewports and page-sized jumps are not worth the bookkeeping (SCROLL_BLIT_MIN_AREA,SCROLL_BLIT_MIN_KEEP), fractional offsets and diagonal deltas cannot blit, any other claim near the viewport (checked atinvalidatetime, before rects coalesce and hide it), a border/borderRadius on the scroll pane, an overlapping non-descendant, a debug overlay or DevTools highlight all bail. The invariant, pinned by a pixel test intest/scroll-blit.test.js, is that a blitted frame is byte-identical to the repaint it replaced;REACT_X11_NO_SCROLL_BLIT=1turns the path off for A/B measurement and as field first aid. The bench's scroll scenario is baselined with the blit live, and that is what fences it:--checkonly fails on an increase, so against fallback numbers a change that silently stopped the fast path from firing would pass unnoticed. The fence is verifiable —REACT_X11_NO_SCROLL_BLIT=1 npm run bench -- --checkmust fail, and does (887 requests and 3.28 Mpx against a 437 / 0.65 baseline).Where scrolling actually spent its time was neither of those. Profiling a 50,000-row table found the cost in ntk's
clip(): intersecting a clip allocated a full-surface a8 pixmap, rasterized into it and composited the whole surface — per clip, and a frame nests them constantly. 3412 of 3900 Composite requests over twenty wheel notches came from there. Fixed upstream in sidorares/ntk#107, shipped in ntk 3.10.1. Per wheel notch on the 50,000-row table, same harness, only the ntk clip code differing:3.10.0 3.10.1 requests 2542 1242 Composite calls 242 25 Composite Mpx 153.8 2.1 scripts/bench/protocol.jsnow has a nested-clip scenario, which is the shape no scenario had before, which is why nothing caught it. Client-side work is not the bottleneck for scrolling: layout and paint together were 0.0ms next to what the server then had to redraw.Beware that
text: paragraph, inside a rect clipflaps by three requests between runs — 43/40/43 on identical code.--check's tolerance absorbs it, but it means a small real regression there would not be caught, and a baseline saved on a lucky run can look like a regression on the next. Not diagnosed; suspect glyph-page upload batching.A clip that clips nothing is not free. Each one rebuilds an a8 mask server-side — a
FillRectanglesplus trapezoid rasterization — and ntk brackets every glyph run under a clip with aSetPictureClipRectanglespair.Tablesetsoverflow: hiddenon every cell so that long text truncates, and then almost every cell's text fits: 191 clips a frame, a handful of which do anything._paintChildrennow skips a clip when_childrenCanOverflow()says nothing reaches outside it, which took a scroll frame from 1242 requests and 1098ms to 1014 and 692ms in the in-process server. Rounded corners are never skipped — the clip is not a rectangle then — and the test is inset by a pixel because antialiasing puts ink just outside a glyph's box.test/integration.test.jschecks that a child which really does overflow is still cut; it fails if clipping is disabled.Two traps when measuring this by hand (both hit while building
examples/stress/):- Never put a live counter next to what you are measuring. A changing
number re-measures its
<text>, a re-measure is a layout change, and a layout change is a full repaint by definition — so the readout makes every frame reportFULL WINDOWand destroys the thing it was reporting on. Log to stdout instead;examples/stress/perf.jswrapsflush(). - The first click on a control is not a clean measurement. Pressing a button also changes its own hover and active styling, so that frame legitimately covers the button as well as whatever the press did. Warm up with one click and measure the next.
- An animation frame arrives with
needsPaintstill false._advanceAnimationssets it from insideflush, so instrumentation that samples the flags on entry counts every other frame and misses a transition entirely. Testroot._animating.size > 0as well.
Three routes to an unbounded repaint, all found by hovering controls in
examples/stress/and watching the log sayFULL WINDOW, all now tested intest/dirty-rect.test.js:NO_DAMAGEmust not mark the window dirty. Contributing "no region" is not the same as contributing "unknown": a commit in which every node says so has genuinely nothing to repaint and should schedule no frame at all. Falling through toneedsPaintleft the window dirty with no region recorded, which repaints everything — so an identical re-render, which is what hovering a control that ignores its own hover state produces, cost a full-window repaint.onDrawmatches/^on[A-Z]/, so_paintChangedskips it as an event handler andCanvasNode.applyPropsis the only thing that notices a new closure. It has to claim damage, and it has to claim it bounded — every re-render of a component drawing through<canvas>, which is what aCheckboxtick and aSelectchevron are, otherwise repainted the window.- An animation must claim a region per frame, including its last. A
transition is a repaint every frame for its duration. Nodes that finish
on a tick need claiming too, and the claim has to be decided before
ticking, because a finished tick clears
_animand with it any way to tell a layout animation from a paint-only one. SeedamageForAnimation: an out-of-flow node animating a layout property is bounded by its parent, which contains both where it was and where it is going.
- a layout change is never bounded (
-
A stalled frame clock under synthetic input. ntk paces
requestAnimationFramebehind a fence (aGetInputFocuswhose reply confirms the server consumed the last frame). Driving events withwnd.emit()rather than through the server can leave a frame scheduled and never run, so a fixed sleep returns with the tree committed but not laid out — everyabsstill0,0,0,0, which makes a synthesized click land at the window origin and hit whatever is in the corner. Callroot.flush()(clearingroot._scheduledfirst) instead of sleeping: it is the same frame the scheduler would have run.test/dirty-rect.test.jsandscripts/check-stress.jsxboth do this. -
Request count is not a proxy for cost here; pixel work is. ntk brackets every glyph run under a clip with a
SetPictureClipRectanglespair, which is 379 requests a scroll notch — 37% of all of them. Deleting every one of them changed the frame time by nothing, 681ms either way: they are 8-byte requests with no server pixel work behind them. The change that did move the needle was skipping clips nobody needed, because each of those rebuilt an a8 mask. Measure a change in wall clock or in Composite pixels before optimising a request count —npm run benchreports both for that reason. -
A clip bounds the pixels, not the drawing. Anything drawn per line, per row or per cell has to be culled to the viewport by the code that draws it: the clip discards what falls outside, but only after the request has been built, sent and turned into a masked composite.
<textarea>drew a fill per selected line, so Ctrl+A in a 400-line value cost 422 requests to light the seven lines on screen; drawing only the visible ones — and drawing them as onectx.fillRectsbatch (ntk >= 7.6, oneRender.FillRectangles) — made it 25, and flat in the size of the value.test/selection-batch.test.jsmeasures the two sizes against each other rather than pinning a number. -
Interpolate colours premultiplied.
transparentis black at zero alpha, so lerping the four straight channels drags every fade-in towards black on the way: half way fromtransparentto a near-white hover fill lands on mid grey, and the brightness curve is not even monotonic — it dips dark and comes back.TabsandTreetransitionbackgroundColorbetweentransparentand a hover fill, so moving hover between two adjacent items faded one out as the other faded in and flashed a grey rectangle across both before settling. Measured off the window:#c0c0c2mid-transition against endpoints of#ffffffand#f1f2f6.interpolatepremultiplies, lerps and divides the alpha back out, which is what CSS specifies for this exact reason. The endpoints are identical either way, which is why only a monotonicity test catches it. -
Layouts sized against one font break in another.
sans-serifis whatever fontconfig hands you, and a row of things sized by their own text compresses only as far as the words inside it (see the content floors below) — past that it overflows and gets clipped. Three buttons that sat comfortably in a 250px card under the test fonts ran off the edge of it on a real desktop. Rows of buttons or chips wantflexWrap: 'wrap'.npm run stress:check -- --widerenders the whole app in a monospace UI face and fails on any node overflowing a pinned-width ancestor, which is the pass that would have caught it. -
flexShrinkdefaults to 1 and every flex item carries a content floor (#249,nodes.js:contentSpan,writeContentFloors). Yoga defaults the shrink to 0 and has no floor at all, and neither of its two answers is usable on its own: with 0 a row never squeezes into the space it has, and with 1 everything collapses to nothing — scrolling stops existing, because a pane's content shrinks to its viewport. CSS has both halves, so the renderer measures the missing one: one min-content pass per axis on any frame that changes the layout, written onto the yoga nodes asminWidth/minHeightand taken back off before the next measurement. Things to know before touching it:- the measuring passes run
measuringExactly(yoga's pixel grid off). Rounded sizes summed with exact paddings make a floor a pixel too tall, and a floor that exceeds the natural size does not hold a box, it grows it — a pixel per nesting level, all the way up. - the measuring passes also borrow
flexShrink(setMeasuringShrink): min-content means "nothing gives", so a pass run at the real default would answer that the content needs no room at all. _floorsDirtyis what keeps this off the input path —invalidate()sets it for every layout change except a scroll, which moves no yoga node.- the deliberate deviation from CSS is that a named size is kept: CSS
floors an item at
min(its size, its content), which is fine on the web where a<div>is a block container and its children are not flex items, and is not fine here where every box is a flex container.minHeight: 0is how a style asks for CSS's answer. flexBasisstill wins overwidthon the main axis, so spreading aflexBasis: 0style into a fixed-width box collapses it to nothing.
- the measuring passes run
-
No X11 side effects in the render phase. Window nodes are handles until
realize(parentWindow)runs in the commit phase (appendChildToContainerfor top-levels, parentrealizerecursion or lateappendChildfor nested windows,commitMountfor popups). Creation is top-down (parent window first, children withattributes.parent), mapping bottom-up so subtrees appear at once. The smoke tests pin this: noreparentTo, nooverrideRedirecton nested windows. -
<popup>is aWindowNodesubclass withisPopup = true: allowed as a child of drawn nodes (bookkeeping only — no yoga, no reparent, own paint/event root). A scrolling<box>applies its offset duringabsolutize, so painting and hit testing see shifted rects; it defaultsminWidthandminHeightto 0, which is what lets a viewport be smaller than what is inside it (CSS's own rule, and the content floors read it). The wheel default action (EventManager) scrolls the nearest enclosing scroll pane unlesspreventDefault()is called. -
Closing an app right after
setTitle/setActionscrashed ntk <= 3.1.0 (in-flight InternAtom chains, sidorares/ntk#62 / PR #63); the integration tests drain round trips viasettle(app)beforeapp.close(). -
Event handlers are never registered on the ntk window per-prop; the
EventManagersubscribes once and dispatches from currentnode.props, so handler updates can't go stale. -
render()usesupdateContainerSync+flushSyncWork, so mounts/updates are applied synchronously — tests rely on that. Paint flushes are async (a tick later); testsawaitasetImmediatebefore asserting paint. -
Window geometry props are window state, not yoga style:
WindowNodestripswidth/heightbefore feeding props to yoga and sizes the root yoga node from the actual window size inflush()(the user may have resized the window). -
A
<window>size may be'auto', and leaving it out means'auto'— sized from its content, capped at the monitor's work area (src/screens.js, read once duringcreateRootso the answer is synchronous later).WindowNode._measureNatural()runs beforeCreateWindow, which is the whole point: the window is born the right size rather than resized into it after mapping. Two things about it are easy to undo by accident. The second layout pass is not redundant — the first has no available width so nothing wraps, and a height taken from it cuts wrapped text off; and ntk must never see the keyword, sowindowAttributesdrops an auto axis andrealize()writes the resolved number back. Afterwards_refit()keeps an auto window on its content until_userSized— a ConfigureNotify that does not match_requestedSize, i.e. the user dragging an edge or a WM overriding — hands the size over for good._requestedSizehas to name both axes on every configure, since the echo does. -
Windows cannot be nested inside
<box>(throws); raw strings are only legal inside<text>(throws otherwise). -
Never test an unreleased ntk by symlinking the checkout into
node_modules. ntk then resolvesx11from its ownnode_moduleswhile the tests importx11/lib/xserverfrom this one, so the client and the in-process server are different module instances and property work fails withBad atomon ChangeProperty/GetProperty — deterministically, and with nothing in the message pointing at the cause.npm packin the ntk checkout andnpm install --no-save /tmp/ntk-<version>.tgzinstead; that resolves onex11and the suite passes. -
ntk's
cssColorreturns premultiplied components (right for XRender, since 3.9.1). Anything heading for GL or for interpolation wantscssColorStraight—glClearColorand material colours take unassociated alpha, and a lerp that formats its result back into anrgba()string only round-trips on straight values.src/glnodes.js,src/scene3d.jsandsrc/styles.jsuse the straight parser for exactly that reason. Note that opaque colours are identical either way, so a test with#000000and#ffffffcannot tell the two apart. -
A frame that claims no damage repaints everything, deliberately — the safe default when nothing can say what changed. It also means a test that changes one thing cannot demonstrate a missed repaint: with nothing bounding the region, the fallback covers the bug and the test passes either way. Pair it with a second change that does bound the frame. Three tests in
test/dirty-rect.test.jspassed with the bug planted before their premises were tightened, so each now asserts its own premise. -
Bun honours
tsconfig.json'scompilerOptions.pathsat runtime, and ours mapsreact-x11at the declarations for the type tests. So inside this repobunresolves the bare specifier tosrc/index.d.tsand dies onThe constant "Renderer" must be initialized; Node ignorespathsand resolvessrc/index.js. The examples import../src/index.jsrelatively, so they run fine underbun— but a script here that uses the package name will not. Outside the repo (a normal install) there is no such mapping andbun hello.jsxjust works, which is whatwebsite/docs/getting-started.mddocuments. -
bun --hotcannot drive Fast Refresh for this renderer:import.meta.hotisundefinedin the CLI runtime (that API is the bundler's), so no accept boundary can be declared, and a reload re-instantiates every module —reactincluded. The mounted reconciler then holds a different React than the reloaded components call and the first hook throwsresolveDispatcher(...) is null. Hot reloading stays onexamples/hmr-register.mjs, which deliberately keepsnode_modulesandsrc/out of the hot graph for exactly this reason.
Style
- Style lives in
style, never in flat props. Layout, paint, text,cursor,overflow,zIndex,pointerEventsgo in thestyleprop — an object or an array flattened left-to-right — and everything else is a prop:title, window geometry and size hints,value,focusable, handlers. No name means both, which is what keeps<window width>unambiguous. Passing a style property flat throws in development.:hover/:focus/:active/:disabledblocks resolve in the renderer as repaints, and may only set paint properties. See docs/styling.md. - ESM, Prettier (single quotes); run
npm run formatbefore committing. - Conventional commit messages (
feat:,fix:,chore:, ...) — releases are automated with release-please.
Roadmap pointers
See NEXT_STEPS.md (the "Roadmap refresh" section is the current
source of truth for what's next). Done: phase 0 (ntk 3.1.0), phase 1 (drawn layer),
phase 2 (events, scrolling), phase 3's <popup> and <textinput>
(on ntk 3.2.0: clipboard, cursors, setLineDash), and the layout debug
overlay (REACT_X11_DEBUG_LAYOUT=1). Element default actions (textinput
editing, wheel scrolling, Tab traversal) run via the default* methods on
nodes AFTER user prop handlers, skipped on preventDefault() — a documented
seam a registered element implements too (#251, docs/extending.md). The window-manager example
(issue #3) is done — on ntk 3.9.0 and node-x11 3.2.0, which grew the
substructure-redirect support it needed. Also done since: the widget set
including Select, Menu/MenuBar/ContextMenu, Tabs, Tree,
SplitPane and a virtualized Table; DevTools highlight-on-hover (the
DOM-ish host-instance contract, getClientRects + ownerDocument);
TypeScript declarations; and undo/redo in <textinput>/<textarea>.
react-x11 is published on npm, with release-please keeping a release PR
open for the next version.
The right-click edit menu for the text controls is done too (#90,
src/editmenu.js), which closed #88 — including its second half, where
right-click used to collapse the selection.
The AT-SPI accessibility work is done and in core (NEXT_STEPS §11.3,
docs/accessibility.md): standard role/aria-*
props on every element, src/a11y.js (the model) + src/atspi.js (the
bridge), Orca-verified. An element core did not write reaches the same feed
through the node seam (#257): a11yRole, a11yTextState() +
notifyA11yTextChanged(), and the two optional writes a11ySetSelection /
a11yReplaceText — normalized in customTextState() so a third-party
editor is read by the paths <textinput> is read by, never by a second
model. An element that draws things rather than text says so the same way
(#304): a11yScene() lists them, notifyA11ySceneChanged() says the list
moved, a11ySceneAction() takes what an AT does to one — reconciled by
id into objects carrying ordinary role/aria-* props, so the whole
model reads them as it reads a <box>. Next: a generic Popover and a file open/save
dialog. #85 (keyboard layout switching ignored) is done — ntk decodes
the active XKB group, and src/keyboard.js keeps shortcuts on the Latin
keysym even where XQuartz's keymap rewrite has left no Latin group
(docs/events.md "Layouts"). One open issue left from that pair: #86
sans-serif resolves to a CJK font on macOS.
The system hooks are in (docs/system.md): useScreens()
(Xinerama for the geometry during createRoot, a RandR walk behind it for
names/primary/mm/refresh), useWindowState() (_NET_WM_STATE via ntk's
statechange, VisibilityNotify, focus), useIdle()/useKeepAwake() (SYNC
IDLETIME alarms, no polling, with MIT-SCREEN-SAVER under them),
useKeyboardState() (XKB StateNotify — Caps Lock before the first
keystroke, which PasswordInput wanted), useDesktopSettings() and
useLocale(). The last one is not only a hook: the caret cadence, the
double-click window and the drag threshold were four hardcoded constants and
now come off XSETTINGS, so Net/CursorBlink: 0 — an accessibility setting —
finally stops the caret blinking. Worth knowing when working here: the
in-process test server has RENDER, BIG-REQUESTS and XC-MISC and none of
those extensions, so the reply decoders are tested as pure functions and the
stores through their set*ForTests seams — the end-to-end walks only happen
against a real display. Three bugs in this work were only visible there
(available leaking a whole monitor record, XQuartz's synthetic 1 Hz mode,
and its literal empty XKB layout); scripts/ has no probe for it, so run
one by hand against $DISPLAY before believing a protocol path.
Pull requests
- When a PR contains changes that can be detected by eye (rendering,
widgets, layout), include screenshots rendered by the PR's own code
in the PR description. Headless recipe: render into node-x11's
in-process X server, read back with
getImageData(BGRA byte order), save withpngjs. - Do not commit PR-illustration images to the repo. Upload them as PR
attachments instead — GitHub's user-attachments storage, the same one
used when pasting or drag-&-dropping an image into the PR description.
Commit an image under
docs/img/only when it is useful beyond the PR itself (README, docs site). - user-attachments still has no public API
(github/community#29993) — uploading needs a browser session, so it
can't be done with a PAT or
ghalone. The maintainer drives it with a small local tool that replays the web UI's upload flow using a saved session cookie; if you don't have that, generate the PNGs, leave<!-- drag in: name.png -->placeholders in the PR body, and hand over the file paths. - A freshly uploaded asset is private: its URL 404s for logged-out visitors until it is referenced from content they can see. Embedding it in the PR body is what publishes it — a bare uploaded URL is useless on its own.