Limitations and traps
Traps first: behaviour that is deliberate and documented in the reference, but that you will not guess from the API surface and that fails quietly. The broader limitations, which follow from the design (a pure core with no pixels) and from uneven browser support, come after.
State and commands
Section titled “State and commands”- A version-1 saved state can lack
config.snapandconfig.urgency. Both keys entered the state without a version bump, and the1 → 2migration does not touchconfig, so a state saved before they existed loads without them. The library tolerates this, but your own code readingstate.config.snap.magnetdirectly will throw. Read{ ...DEFAULT_CONFIG.snap, ...state.config.snap }, or dispatchconfig/set { snap: { ...DEFAULT_CONFIG.snap } }after loading. A bareconfig/set { snap: {} }stores an empty object, becauseconfig/setonly merges into an existing plain object. See Versioning. - Your extension handlers are not guarded. Built-in commands reject bad input with a
command/rejectedevent and never throw;updatedoes not catch exceptions from your handlers. Keep them pure and total. An extension with the sametypeas a built-in overrides it, andCOMMANDSstill lists only the built-ins. To change whatwindow/dropmeans for a layout, register a drop interpreter instead of overriding the command. - Gestures only coalesce consecutive commands. Commands sharing a
gesturetoken form one undo step and one log entry only while they arrive back to back; any other command in between splits the gesture. window/createfocuses the new window unless you passfocus: false, or a rule sends it to a workspace other than the active one. A rule can also send it to a workspace that does not exist, which rejects the create asunknown-workspace.- Rejections leave history and the log untouched. Only commands that change state are recorded, so a rejected or no-op command cannot be undone or replayed.
Layouts and modifiers
Section titled “Layouts and modifiers”- An unknown modifier type is silently ignored by
derive;layout/setonly checks that every entry is an object with a stringtype. - A custom modifier that changes screen axes does not remap drop zones.
applyModifiersToOpsonly knows the built-in modifiers. - Custom layout types are not resizable by
layout/resize-split, and they get the default reading-order drop interpreter unless you register one. To render splitters, putresize: { path, weights }on a row or column yourself and persist the weights. - A stateful custom layout needs a branch everywhere BSP has one.
swapWindows,neighbourOrderandmoveRelativespecial-case stateful layouts; without a branch, swaps emit their events but nothing visibly moves. See Adding a new layout.
In the browser
Section titled “In the browser”- The stage needs a size. Tiled geometry is CSS’s decision, so an unsized root lays out into nothing. The React stage host in particular collapses to 0 px without a height.
- A hidden tab looks frozen.
createFrameSchedulercommits onrequestAnimationFrame, which a background tab never runs. - Browsers cache ES modules aggressively. When you are checking a change, hard-reload with the cache disabled or you will be looking at the old module.
- Keyboard focus and WM focus are two things kept in sync.
attachInputneedssubscribe(orwm) to move DOM focus after a command-driven focus, and it never takes focus from a text field, select orcontenteditableoutside the stage. - A modal-blocked window is not itself
inert, only its contents. It stays hit-testable so a click on it redirects focus to the modal instead of falling through to the window underneath. Don’t “fix” this by making the view inert. - Paint order is not
state.stackorder. Tiled windows paint above thebackgroundlayer and beneath everything else. UsepaintOrder, never raw stacking order, for anything visual. - With several outputs, act on the stage’s own workspace
(
outputActiveWorkspace(state, output)), notstate.activeWorkspace, which is the focused output’s.
Limitations
Section titled “Limitations”- Several browser features are progressive, and the fallbacks differ. CSS anchor
positioning covers
flipand an opposite-sidegravity, but it has no equivalent forslide/resize, which work only under the JS fallback (anchorFallback: true, automatic whereCSS.supports("anchor-name: …")is false). WithoutElement.prototype.moveBefore, a view moving between containers is re-inserted, and an iframe inside it reloads. Without View Transitions,animateis a no-op (by design, as it is underprefers-reduced-motion). - Pop-outs depend on the popup window.
window.openis subject to pop-up blockers: callpopOutfrom a user gesture. A blocked popup is reported as apopup-blockedrejection, not an exception. Most browsers reload an iframe adopted into another document, so iframe state resets on the way out. A pop-in that doesn’t go throughattachPopouts(window/restore, undo) remounts the surface fresh instead of carrying the DOM back. A popped-out window is never the WM’s focused window (focus is only given to windows on the stage), so focusing its popup clears the WM focus. Undoing a pop-out closes the popup; redoing it (or loading a saved state with a popped-out window) cannot reopen one without a user gesture, soattachPopoutspops the window back in instead of leaving it invisible. - The pure core has no pixels. Tiled sizes are CSS’s decision, so
config.drag.tooSmall: "reject"is only enforced when ageometryestimate is supplied (the input adapter measures its ghost), size increments are advisory for tiled windows, and grid tracks are not resizable.deriveand the renderer only see the layout; content that changes size without a commit needsrenderer.reposition()for JS-positioned anchors, whoseResizeObserverwatches only the stage root. - Some surfaces and observers are lazy. A
lazySurfacebuilds on first mount and is never re-asked while its view stays rendered, so swapping a registry entry does not replace a mounted surface.canvasSurfacerepaints on resize only whereResizeObserverexists (it paints once otherwise).<wa-stage>creates a fresh manager on each connect orconfigure()unless you pass your ownwm. - The pure
updatecannot know your custom layouts.layout/setaccepts any stringtype;derivefalls back tocolumnsfor a type with no interpreter instead of throwing, and only the manager rejects it up front (unknown-layout). Stickiness is inherited by dialogs and popovers, and a workspace switch can still bring two fullscreen windows into view (a sticky one and one on the new workspace); only one is presented. - Keyboard accessibility is opt-in and partial. Tabs and splitters work from the
keyboard always; moving and resizing a floating window (Alt+Shift+Arrow,
Ctrl+Alt+Shift+Arrow) needs
attachInput({ keyboard }). Tabs use manual activation (arrows move focus, Enter/Space activates). Moves are not announced, andworkspace/renameleavesconfig.rulesthat name the old id untouched. - Right-to-left mirrors the horizontal axis of the whole stage, not its content. Window content inherits
dir="rtl"from the stage root (setdir="ltr"on content that must not flip). A floating window’splacement.xis measured from the right edge underrtl, so a saved placement does not carry across a direction change. The page’sdiris followed through an explicitdirattribute (or computeddirection: rtl); a page with none leavesconfig.directionalone. - The command palette lists commands, not targets. It asks for the common payload fields only (
window/createtakes an id and a title,layout/seta layout type without options; JSON fields cover the rest), andwindow/pop-outopens a real browser window only when you give it yourattachPopoutshandle. Its default shortcut can be reserved by a browser (Firefox’s private window), so it is configurable. - Browser coverage is real but not exhaustive. Touch is driven with real multi-touch only in Chromium (Playwright has no multi-touch in Firefox or WebKit, where the same pointer streams are dispatched as synthetic
PointerEvents), and the end-to-end tests run on the demo pages, not on every combination of options. - The built-in chrome is one look with tokens, not a design system. Its buttons use text glyphs (replaceable with
icons), a scrolling body is made focusable when it resizes or commits, not when its content grows inside a fixed size, a window in a tab strip has no title bar of its own, and a hand-built title bar (data-wm-handle) stays fully supported next to it. In Safari, Tab skips buttons unless the user enables it, so the chrome buttons follow the browser’s own rule. See Window chrome. - A strict
style-srcblocks two inline sheets. SettingBASE_CSSas a<style>’s text and the palette’s injected<style>are inline styles; writeBASE_CSSandPALETTE_CSSto a.cssfile and passinjectStyles: falsetocreatePalette. See Theming. - Cross-tab sync is same-origin and last-writer-wins.
attachSyncshares whole logical states over aBroadcastChannel; it is not collaboration between users, it does not merge concurrent edits (the loser’s change is dropped), it syncs no surfaces or DOM, and pop-outs stay in the tab that opened them (peers see the window minimized). Two tabs that never changed anything share nothing, so seed them identically. - Touch gestures are opt-in and trade scrolling for recognition. Pinch needs
touch-action: noneon floating windows (their content cannot be panned by touch) and the two-finger workspace swipe needspan-yon the stage (nothing inside scrolls horizontally by touch). Pinch and the workspace swipe are touch-only, a pen being one pointer. Moving or docking a tiled window by touch still needs a still long press, so it does not compete with scrolling. - The bindings commit once per animation frame.
<wa-stage>andWindowManagerStagecoalesce commits withcreateFrameScheduler(), so a hidden tab (which never runsrequestAnimationFrame) does not repaint until it is shown again. Passschedule: immediateSchedulerfor synchronous commits.
Non-goals
Section titled “Non-goals”Replacing an operating-system window manager or compositor, and multi-user
collaborative synchronization (the deterministic command log makes it possible, but it
is not built; syncing one user’s tabs is attachSync).
A runtime dependency is also a non-goal: the bindings take React as an argument rather
than importing it. See the design document.