npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@johnhenry/window-algebra

v0.1.4

Published

A functional window manager for the browser: pure state updates, a small layout algebra that derives a presentation tree, and CSS (flex, grid, anchor positioning, container queries) as the final layout solver.

Readme

window-algebra

npm version CI license

Full documentation: opensource.johnhenry.me/window-algebra

A functional window manager for browser applications. It is not a drop-in desktop replacement. It is a small set of parts you can put together into a floating, tiling, tabbed, docking or hybrid window manager.

command → update (pure) → state → derive (pure) → layout tree → compile (pure) → render tree → DOM
  • State is immutable data. update(state, command) returns { state, events, effects } and never touches the DOM, timers, the clock or randomness. A bad command is rejected with an event, never thrown.
  • Layout is an algebra. Eleven primitives build a JSON tree. Named layouts such as masterStack, bsp and the docking tree are ordinary functions that return those primitives.
  • CSS is the layout engine. Tiled windows get flex weights, grid tracks and anchor relationships instead of pixel rectangles. The browser works out the geometry, and every window is a container-query container.
  • The DOM is an effectful backend, not the source of truth. The renderer reconciles a keyed render tree, so a window keeps its element, and its mounted surface, when the layout changes around it.

Zero dependencies. ESM only. The core runs in Node, workers and browsers.

Contents

Install

npm install @johnhenry/window-algebra

Provenance: a new package, never published under another name. 0.0.0 is its first version under any name, not a sign of immaturity. Under npm's caret rules ^0.0.0 matches only 0.0.0, so pin the exact version until a deliberate 0.1.0.

Node ≥ 26 (engines.node) for the pure core in Node. In the browser, use it through a bundler, or with no build step through an import map. Import maps don't read package.json exports, so map each entry point you use to its file:

<script type="importmap">
  {
    "imports": {
      "@johnhenry/window-algebra": "/node_modules/@johnhenry/window-algebra/src/index.mjs",
      "@johnhenry/window-algebra/browser": "/node_modules/@johnhenry/window-algebra/src/browser/index.mjs"
    }
  }
</script>

Entry points: @johnhenry/window-algebra (everything pure, plus the manager), /browser (renderer, input, surfaces, schedulers, pop-outs), /react, /element, and the narrower /algebra, /transforms, /layouts and /css. See the entry-point table.

TypeScript: every entry point ships declarations (a types condition on each export), with Command and Event as discriminated unions keyed by type and exact payloads for all 58 commands. No build step and no @types package. See TypeScript types.

Quick start

The pure core, anywhere (Node, a worker, a test):

import { createState, update, derive, compile, presentationContext, toHTML } from "@johnhenry/window-algebra";

let state = createState({ layout: { type: "master-stack", ratio: 0.6 }, config: { gap: 8 } });
for (const id of ["editor", "terminal", "browser"]) state = update(state, { type: "window/create", id }).state;

const out = update(state, { type: "window/focus", id: "nope" });
// out.events → [{ type: "command/rejected", command: "window/focus", id: "nope", reason: "unknown-window" }]

const tree = derive(state);                                 // a JSON layout-algebra tree
const html = toHTML(compile(tree, presentationContext(state))); // flex: 0.6 1 0 …, no pixels

In the browser, the manager drives a DOM renderer and an input adapter:

import { createWindowManager, createState, BASE_CSS } from "@johnhenry/window-algebra";
import {
  createDomRenderer, attachInput, htmlSurface, createSurfaceRegistry, createFrameScheduler,
} from "@johnhenry/window-algebra/browser";

document.head.append(Object.assign(document.createElement("style"), { textContent: BASE_CSS }));
const root = document.querySelector("#desktop");          // give it a size
const surfaces = createSurfaceRegistry();
const wm = createWindowManager({
  state: createState({ layout: { type: "master-stack", ratio: 0.6 }, config: { gap: 6 } }),
  renderer: createDomRenderer({ root, surfaceFor: surfaces }),
  schedule: createFrameScheduler(), // many commands → one commit per frame
  history: true,                    // undo/redo for window management
});
attachInput({ root, wm });          // pointer, keyboard focus sync, drag and drop

surfaces.set("editor", htmlSurface(editorElement));
wm.create({ id: "editor", title: "Editor" });
wm.create({ id: "terminal", title: "Terminal" });
wm.create({ id: "calc", title: "Calculator", mode: "floating", placement: { x: 200, y: 100, width: 320, height: 240 } });

wm.setLayout({ type: "bsp" });
wm.undo();

A surface's markup opts into the pointer adapter: data-wm-handle="move" on a title bar, data-wm-handle="resize-se" on a grip, data-wm-command="window/close" on a button. See Browser adapters › Markup contract. Or skip the markup: chrome: true on createDomRenderer (or <wa-stage>) wraps every window in a ready-made title bar, buttons and resize grips; see Window chrome.

Examples

  • examples/: six self-verifying Node scripts over the pure core (npm run examples; CI runs them), from the pipeline to CSS through drop semantics, migration and the popup/snap geometry.
  • demo/: interactive browser pages. Serve the repository root with any static server and open /demo/ (ES modules don't load from file://). The hub renders a capability checklist of which page exercises each primitive, transform, layout, command, rejection, policy, surface and event. Pages: playground, layouts, desktop, console, surfaces, geometry, IDE, outputs, custom element, React (needs network for esm.sh), and the minimal quick start.

The examples index says what each one demonstrates.

The layout algebra

Every container and modifier takes an options object first, then its child or children. view(id) is the only exception.

| Kind | Primitive | Meaning | CSS realization | | --- | --- | --- | --- | | Leaf | view(id) | a presentation of a window/surface | <wm-view> with container-type: size | | Container | row(options, ...children) | horizontal composition | flex row | | Container | column(options, ...children) | vertical composition | flex column | | Container | grid(options, ...children) | two-dimensional constraint space | CSS Grid (tracks, areas, auto-fit) | | Container | stack(options, ...children) | children share one allocation (active, chrome: "tabs") | one grid cell; inactive children visibility: hidden + inert | | Container | overlay(options, ...children) | independent layers of the same region | one grid cell, z-ordered | | Modifier | place(options, child) | position within the parent's allocation | translate, insets, grid lines/areas, self-alignment | | Modifier | size(options, child) | allocation constraint (weight, width, min, aspectRatio, …) | flex, width/height, min/max, aspect-ratio | | Modifier | gap(options, child) | separation between siblings | gap | | Modifier | inset(options, child) | padding around an allocation | padding | | Modifier | anchor(options, child) | position relative to another view, with xdg_positioner-style flip/slide/resize/gravity | CSS anchor positioning, with a JS fallback |

import { overlay, inset, gap, row, column, size, anchor, view } from "@johnhenry/window-algebra";

const layout = overlay(
  {},
  inset({ all: 8 }, gap({ all: 8 },
    row({ align: "stretch" },
      size({ weight: 2 }, view("editor")),
      size({ weight: 1 }, column({}, view("browser"), view("terminal"))),
    ),
  )),
  anchor({ to: "editor", x: "center", y: "center" }, size({ width: 480, height: "content" }, view("dialog"))),
);

There are three layers, and keeping them apart stops convenience helpers from turning into primitives:

  1. Primitive constructors: the eleven above.
  2. Derived layouts (src/layouts/): masterStack, columns, rows, monocle, tabs, autoGrid, fixedGrid, spiral, bspToLayout, treeToLayout, and the sugar helpers floating, centered, dock and hybrid.
  3. Tree transforms: mirror, flip, rotate, reverse, mapViews, replace, remove, swap, find, views, walk, fold, transform, count, equals.

Trees are deep-frozen and serializable; validate reports an invalid tree by path, and fromJSON round-trips one. Every option is in Layout algebra.

State, commands and events

State is one plain object: windows, workspaces, outputs, focus with history, per-layer stacking, config. A command expresses intent. Events record what actually happened, which can differ: focus may be redirected to a modal, or a duplicate id rejected. Effects (render, focus) are values for the effectful shell.

import { createState, update, reduce, replay, derive, COMMANDS } from "@johnhenry/window-algebra";

let state = createState({ workspaces: ["main", "dev"] });
const out = update(state, { type: "window/create", id: "editor" });
// out.events  → [{ type: "window/created", id: "editor" }, { type: "window/focused", id: "editor", previous: null }]
// out.effects → [{ type: "render" }, { type: "focus", id: "editor" }]

The 58 built-in commands (COMMANDS), each documented with payload, events, effects and rejections in Commands:

  • Windows: window/create, window/close (cascades to child windows), window/focus, window/blur, focus/next, focus/previous, window/raise, window/lower, window/set-layer, window/move, window/resize, window/set-mode, window/toggle-floating, window/minimize, window/maximize, window/fullscreen, window/restore, window/toggle-maximize, window/toggle-fullscreen, window/pop-out, window/pop-in, window/set-title, window/set-constraints, window/swap, window/promote, window/move-to-workspace, window/set-urgent, focus/urgent
  • Drag and drop: window/drop, window/detach, window/swap-next, window/swap-previous, window/move-before, window/move-after, window/set-draggable
  • Scratchpad and sticky: window/to-scratchpad, scratchpad/toggle, window/from-scratchpad, window/set-sticky, window/toggle-sticky
  • Workspaces: workspace/create, workspace/activate, workspace/remove, workspace/rename, workspace/reorder
  • Outputs: output/create, output/remove, output/focus, output/reorder, workspace/move-to-output
  • Layout: layout/set, layout/set-ratio, layout/rotate-split, layout/resize-split, layout/toggle, layout/to-tree
  • Config and rules: config/set, rules/set

Add your own with update(state, command, { "my/command": handler }), or pass extensions to createWindowManager. The 47 event types are catalogued in Events, and the read-only queries (isVisible, focusable, paintOrder, modalTarget, …) in Queries.

createWindowManager wraps all of this imperatively: wm.dispatch, wm.create/focus/close/…, subscribe, undo/redo, a command log where replay(wm.origin, wm.log) always equals wm.getState(), serialize/load with migration, and one renderer per output. See The manager.

Layouts, modifiers and drag-and-drop

Layout specs are plain data: { type: "master-stack", ratio }, { type: "columns" }, { type: "rows" }, { type: "grid", min: 300 }, { type: "spiral" }, { type: "monocle" }, { type: "tabs" }, { type: "floating" }, and the two stateful ones, { type: "bsp", tree } and the n-ary docking { type: "tree", tree } (row/column/tabs containers, in the style of i3, Dockview and GoldenLayout; layout/to-tree converts any layout into one). Add your own interpreters with derive(state, { layouts }) or the manager's layouts option.

  • Persistent, resizable splits. layout/resize-split stores sizes in the spec (a ratio, a BSP node, per-depth spiral ratios, or sizes arrays). compile renders a [data-wm-splitter] handle between resizable children, which you drag or nudge with the arrow keys.
  • Layout modifiers, xmonad-style and serializable: modifiers: [{ type: "smart-gaps" | "no-gaps" | "mirror" | "reflect-x" | "reflect-y" | "max-windows", ... }]. Toggle between two whole layouts with layout/toggle.
  • Drag and drop edits structure, not pixels. window/drop { id, target, zone } means insert-before/after, swap, split or add-as-tab depending on the layout, through a DROPS registry of drop interpreters that you can extend. config.drag controls the modes, edge zone, preview, detaching to floating, dropping floating windows into the layout, cross-workspace drops and a too-small check. Pinned windows (draggable: false) stay put. Children travel with their parent.

Full detail: Layouts and modifiers and Drag and drop.

Policy at a glance

The built-in commands encode window-manager policy borrowed from xmonad, i3/sway, EWMH, ICCCM and Wayland. The commands reference has the exact rules.

  • Requested vs actual geometry. window/move/window/resize store a request; derive uses it only for floating windows. Constraints (minWidth, …) apply to tiled windows as CSS min/max.
  • Size hints (ICCCM). aspectRatio (exact or { min, max }) and terminal-style widthIncrement/heightIncrement, honoured by constrainSize for floating move/resize. geometry.sizeToCells gives a live "80×24".
  • Focus is not stacking. Raising on focus is a policy (config.focusRaises). Stacking is per layer (background, normal, top, modal, popover, notification, system), and raising a window raises its descendants above it. Tiled windows paint above the background layer and beneath everything else (paintOrder).
  • The modal graph. Focusing a window with an open modal descendant focuses the deepest modal instead. A blocked window's contents are inert, but the window itself stays hit-testable, so a click on it redirects focus instead of falling through.
  • Roles are semantic. dialog anchors to its parent's centre, menu/popover/tooltip anchor by side, and notification sits in a corner: derive decides, not the application.
  • Rules (config.rules, like ManageHooks or for_window) set mode, layer, workspace, … at window/create. The caller's explicit fields always win.
  • Scratchpad (i3), sticky (EWMH), urgency (EWMH/X11), pop-out (GoldenLayout/Dockview), multiple outputs (sway). All of these are pure state: window/to-scratchpad + scratchpad/toggle; window/set-sticky; window/set-urgent + focus/urgent; window/pop-out/pop-in; and output/* with a per-output derive(state, { output }).
  • Gestures are one step. Commands sharing a gesture token form one undo step and one log entry, so a whole drag undoes at once.
  • Selection is not an edit. history: { ignore: ["window/focus", "window/raise", …] } (or history: false on one command) applies and logs a command without making it an undo step, so undo skips focus and stacking changes. See Keeping commands out of undo.
  • Versioned state. Every state carries STATE_VERSION, migrate upgrades older ones, and a newer one is refused (state/load-rejected), never half-loaded.

compile and the browser

compile(tree, presentationContext(state)) returns a render tree of { tag, key, attrs, style, children }. It carries ARIA roles (group, dialog + aria-modal, tablist/tab/tabpanel, separator), state attributes (data-focused, data-wm-blocked, data-wm-urgent, …) and view elements keyed view:<id>. toHTML serializes it for server rendering or snapshots. Because every view is a size container, applications adapt with container queries without knowing how they were laid out:

@container wm-view (width < 400px) { .sidebar { display: none; } }

In the browser (@johnhenry/window-algebra/browser):

  • createDomRenderer({ root, surfaceFor, anchorFallback?, animate? }) reconciles top-down and moves views with moveBefore() where available, so iframes, focus and playing media survive layout changes. It measures geometry, positions anchors with JS where CSS anchor positioning is missing, and animates commits with View Transitions (animate), skipping gestures and prefers-reduced-motion.
  • attachInput({ root, wm }) turns pointer and keyboard input into commands: floating move/resize with magnetism and snap zones; tiled drags with a ghost preview rendered by the same derive → compile pipeline; tab and splitter drags; touch long-press; keyboard focus kept in sync with WM focus; a modal focus trap; and opt-in F6 cycling, keyboard moving and aria-live narration.
  • Window chrome (chrome: true, opt-in): a title bar with an icon slot, drag handle and double-click to maximize, minimize / maximize / float / pop-out / close buttons, and eight resize grips, themed by --wa-* tokens, right-to-left aware, touch-sized and working inside pop-outs. chromeSurface() is the same for a custom renderer. See Window chrome and demo/chrome.html.
  • Surfaces share one contract, { mount(target), unmount() }: htmlSurface, lazySurface, iframeSurface, canvasSurface, plus createSurfaceRegistry.
  • createFrameScheduler() coalesces commits to one per frame.
  • attachInput({ touch: true }) (opt-in) adds touch and pen gestures: pinch to resize a floating window, swipe between tabs or workspaces, long-press for a context action. See Touch and pen.
  • Infinite, zoomable canvases (coordinates on createDomRenderer and attachInput, plus config.bounds: "none"): for floating windows on a world the page pans and zooms with a CSS transform, as a spatial notebook does. Drags and resize grips track the cursor 1:1 at any zoom, snap distances stay screen pixels, and windows may sit anywhere, negative coordinates included, with no snap zones or clamping. See Zoomed and unbounded stages and demo/canvas.html.
  • Right-to-left (config.direction: "rtl", or the page's dir): the whole stage mirrors, master on the right, columns, tab strips and grids right to left, floating x from the right edge, drop zones, keyboard arrows, splitters and snap zones mirrored. See Layouts › Right-to-left.
  • attachPopouts({ wm, renderer }) pops a window out into a real browser window, carrying its live DOM there and back.
  • createPalette({ wm }) is a command palette: it lists the commands that make sense for the current state, filters them by fuzzy text, prompts for payload fields and dispatches. WAI-ARIA combobox/listbox, Ctrl/Cmd+Shift+P by default. Also <wa-palette> and attachStage({ palette: true }). See Command palette.
  • attachSync({ wm, channel }) (opt-in) keeps the tabs of one origin in step over a BroadcastChannel: whole-state snapshots, last writer wins by Lamport clock, undo/redo and pop-outs handled. See Cross-tab sync.

Theming. Every colour, radius, spacing, focus ring, splitter, title bar and shadow in the library's CSS is a --wa-* custom property, with light and dark defaults (OS preference or data-theme) and a prefers-contrast: more variant; override any of them on :root. See Theming and demo/theming.html.

Every option and attribute is in compile and CSS and Browser adapters.

Framework bindings

Neither binding adds a dependency.

  • React (@johnhenry/window-algebra/react): createReactBindings(React) returns useWindowManager, useWindowState(wm, selector) and <WindowManagerStage wm renderSurface createPortal>, which renders window content as React portals while the WM owns layout and chrome.
  • Custom element (@johnhenry/window-algebra/element): defineWindowAlgebraElement() registers <wa-stage>, a manager, renderer and input adapter for as long as the element is connected. .configure({ wm, surfaceFor, chrome, palette, sync }), .wm, and the handles it creates: .renderer, .palette (stage.palette.open()), .sync (stage.sync.peers()) and .popouts. attachStage(host, options) is the reusable core. In React, <WindowManagerStage stageRef={ref}> hands you the same handles.

See Framework bindings.

API reference

docs/api/ documents every public export, verified against the source and tests:

| Page | | | --- | --- | | State | state shape, records, constants, every config key and default | | Commands | all 58 commands: payloads, events, effects, rejections | | Events | all 47 event types and the two effects | | Queries | visibility, focus, stacking, rules, presentationContext | | Layout algebra | primitives, options, validation, transforms | | Layouts and modifiers | derive, every layout spec, BSP and docking-tree helpers, split sizing, modifiers | | Drag and drop | drop semantics per layout, interpreters, pointer helpers | | compile and CSS | render tree, CSS mapping, attributes, BASE_CSS custom properties | | Theming | the --wa-* tokens, light/dark/high-contrast defaults | | Geometry and interaction | rects, size hints, positionPopup, gesture math, snap and magnetism | | The manager | options, methods, gestures, log, load/serialize | | Browser adapters | renderer, input, surfaces, schedulers, pop-outs | | Command palette | createPalette, the pure catalog, fuzzy matching, <wa-palette> | | Cross-tab sync | attachSync, the snapshot and Lamport-clock design, undo/redo and pop-outs | | Framework bindings | React and <wa-stage> | | Versioning | migrate, MIGRATIONS, adding a migration | | TypeScript types | the Command/Event unions, State, WindowManager<C>, how they are tested | | Errors | what is rejected vs thrown; every reason |

The design rationale, prior art (xmonad's StackSet, River's policy/compositor split, AwesomeWM's stateless and stateful layouts, Elm's update loop) and open questions are in docs/PRD.md.

Performance

npm run bench (non-gating; bench/run.mjs) measures the pure core in Node and a real drag in browsers. Numbers from one run on an Apple M-series laptop, Node 24, with other work running, so read them as orders of magnitude and compare runs on your own machine. Browser frame times are paced at one pointer move per frame (a 60 Hz mouse); the drag drives a floating window across a stage of 100 views (bench/drag.html).

Throughput, 50 windows (master-stack), mixed commands

| step | ops/s | µs/op | | --- | ---: | ---: | | update | 145,752 | 6.86 | | update + derive | 21,403 | 46.72 | | update + derive + compile | 8,744 | 114 |

Median time per call (ms)

| layout | derive 10 | +compile 10 | derive 100 | +compile 100 | derive 500 | +compile 500 | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | master-stack | 0.01 | 0.03 | 0.07 | 0.20 | 0.53 | 1.27 | | columns | 0.01 | 0.03 | 0.08 | 0.27 | 0.50 | 1.59 | | rows | 0.01 | 0.02 | 0.08 | 0.26 | 0.49 | 1.59 | | grid | 0.01 | 0.02 | 0.08 | 0.20 | 0.51 | 1.22 | | spiral | 0.02 | 0.05 | 0.24 | 0.54 | 1.71 | 3.68 | | bsp | 0.03 | 0.05 | 0.30 | 0.66 | 2.57 | 4.60 | | tree | 0.01 | 0.03 | 0.09 | 0.30 | 0.57 | 1.77 | | tabs | 0.01 | 0.03 | 0.09 | 0.29 | 0.59 | 1.78 |

Drag frame time, a floating window dragged over a stage of 100 windows (ms between frames)

| browser | windows | frames | p50 | p95 | max | frames over 20 ms | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | chromium | 100 | 242 | 16.70 | 16.70 | 33.40 | 1 | | webkit | 100 | 120 | 17.00 | 18.00 | 35.00 | 1 |

Writing the bench found two super-linear spots in deep trees, now fixed: bspReconcile was cubic (a 500-window BSP derive took about 340 ms, now about 3 ms) and compile built a view list per splitter (a 500-window spiral compile took about 90 ms, now about 4 ms).

Testing

npm test runs the pure core and the DOM adapters against a fake DOM (Node). npm run test:types type-checks a usage file against the shipped declarations. npm run test:browser drives the demo pages in Chromium, Firefox and WebKit with Playwright (keyboard tab navigation, floating move/resize by keyboard, drag-and-drop docking with real pointer events, focus following visible windows, pop-out, frame-coalesced commits, RTL, the command palette, touch gestures, cross-tab sync) and scans every demo page with axe-core, light and dark, which must find no serious or critical violation.

Adding a new layout

The docking tree ({ type: "tree" }, commit 7ef2291, with a follow-up fix in 067191b) is the best worked example in this package's own history. It is the most recent built-in layout, and it is the harder of the two kinds: it is stateful, like BSP, so its structure lives in the spec and has to stay in sync with the windows. Its tests are test/tree.test.mjs, and demo/layouts.html and demo/desktop.html show it running.

Smallest: a custom interpreter, no core change. If your layout is a pure function of the tiled ids, like "one big window and the rest in a column", register it under a new type with the manager's layouts option (or derive(state, { layouts })): (spec, ids, context) → tree, built from the primitives. layout/set accepts it, it serializes, drops fall back to reading order, and you can give it a drop interpreter with the drops option (orderDrops(() => "y")). The whole cost is the function. A built-in is warranted only when the layout needs state that must survive between renders (a tree, per-split sizes) or has to reach into commands the core owns (seeding, keyboard neighbour order, layout/to-tree, layout/resize-split).

A genuinely new built-in layout: the docking tree. Each existing layout follows the same small pattern:

  1. src/layouts/<name>.mjs: the pure helpers. treeToLayout interprets the stored structure as primitives. A stateless layout needs only this function (see columns in src/layouts/index.mjs). Re-export it from src/layouts/index.mjs.
  2. src/state/derive.mjs: a LAYOUTS entry, (spec, ids, context) → tree, a copy of bsp's own.
  3. src/state/drops.mjs: a DROPS entry, orderDrops(axis) for an order-based layout, or { ops, apply } for a stateful one (treeDrops beside bspDrops).
  4. The one part that isn't boilerplate: keeping stored structure honest. treeReconcile(spec.tree, ids) runs inside the interpreter and the drop interpreter, dropping leaves for windows that are gone and appending new ones. Because of it, window/create, window/close, window/set-mode and every other command need zero awareness that the tree exists: the stored tree is repaired lazily whenever it is read. BSP does the same thing eagerly (bspAdd/bspDrop in src/state/update.mjs), which is why it touches more commands. What remains in src/state/update.mjs is seeding (layout/set fills an absent tree from the current order), the layout/to-tree conversion, and a layout/resize-split branch if it is resizable (emit resize: { path, weights } on each row/column, and teach readSplitWeights in src/browser/input.mjs its path scheme).

Why the keyboard commands need care: 067191b fixed window/swap-next and window/move-before on tree workspaces. swapWindows and neighbourOrder special-cased only bsp, so swaps reordered ws.windows but not the tree, and nothing visibly moved. A new stateful layout must add its branch to swapWindows, neighbourOrder and moveRelative in src/state/drops.mjs, and a test that swaps a nested leaf.

Tests. Everything above is pure, so test/tree.test.mjs runs in plain Node with no DOM: the helpers, derive, layout/set and layout/to-tree, layout/resize-split, window/drop per zone, the keyboard commands, and undo/replay/serialization. Add the layout to demo/layouts.html and to the checklist in demo/shared/coverage.mjs.

A layout modifier (smart-gaps, mirror, …) is the other extension point, for decorating an existing layout rather than adding one. It is one MODIFIERS entry, plus an applyModifiersToOps case if it changes screen axes. See Layouts › Layout modifiers.

Honest limitations

  • Several browser features are progressive, and the fallbacks differ. CSS anchor positioning covers flip and an opposite-side gravity, but it has no equivalent for slide/resize, which work only under the JS fallback (anchorFallback: true, automatic where CSS.supports("anchor-name: …") is false). Without Element.prototype.moveBefore, a view moving between containers is re-inserted, and an iframe inside it reloads. Without View Transitions, animate is a no-op (by design, as it is under prefers-reduced-motion).
  • Pop-outs depend on the popup window. window.open is subject to pop-up blockers: call popOut from a user gesture. A blocked popup is reported as a popup-blocked rejection, not an exception. Most browsers reload an iframe adopted into another document, so iframe state resets on the way out. 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. A pop-in that doesn't go through attachPopouts (window/restore, undo) remounts the surface fresh instead of carrying the DOM back. 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, so attachPopouts pops 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 a geometry estimate is supplied (the input adapter measures its ghost), size increments are advisory for tiled windows, and grid tracks are not resizable. derive and the renderer only see the layout; content that changes size without a commit needs renderer.reposition() for JS-positioned anchors, whose ResizeObserver watches only the stage root.
  • Some surfaces and observers are lazy. A lazySurface builds on first mount and is never re-asked while its view stays rendered, so swapping a registry entry does not replace a mounted surface. canvasSurface repaints on resize only where ResizeObserver exists (it paints once otherwise). <wa-stage> creates a fresh manager on each connect or configure() unless you pass your own wm.
  • The pure update cannot know your custom layouts. layout/set accepts any string type; derive falls back to columns for 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, and workspace/rename leaves config.rules that 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 (set dir="ltr" on content that must not flip). A floating window's placement.x is measured from the right edge under rtl, so a saved placement does not carry across a direction change. The page's dir is followed through an explicit dir attribute (or computed direction: rtl); a page with none leaves config.direction alone.
  • The command palette lists commands, not targets. It asks for the common payload fields only (window/create takes an id and a title, layout/set a layout type without options; JSON fields cover the rest), and window/pop-out opens a real browser window only when you give it your attachPopouts handle. Its default shortcut can be reserved by a browser (Firefox's private window), so it is configurable.
  • 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, or anything it contains, changes 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.
  • 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.
  • Cross-tab sync is same-origin and last-writer-wins. attachSync shares whole logical states over a BroadcastChannel; 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: none on floating windows (their content cannot be panned by touch) and the two-finger workspace swipe needs pan-y on 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.
  • A canvas is the app's, not the library's. coordinates and config.bounds: "none" make floating windows work on a world the page pans and zooms, but the pan and zoom gestures, the viewport and any culling of off-screen windows are the application's. The hook supports translation and a uniform scale only (no rotation or skew). On an unbounded stage, window/maximize, a centred dialog, notifications and the drag overlay that shields iframes during a tiled drag still use the root element's own box, and anchored popups flip or slide against that box under the JS fallback. Without coordinates, a scaled stage gets screen-pixel math: a dragged window outruns or lags the cursor.
  • Undo restores whole states. History holds snapshots, not inverse commands, so an ignored (focus or stacking) change is undone together with the recorded step before it, and one made after an undo is discarded by the next undo or redo; undo cannot take back a move while keeping a later focus.
  • The bindings commit once per animation frame. <wa-stage> and WindowManagerStage coalesce commits with createFrameScheduler(), so a hidden tab (which never runs requestAnimationFrame) does not repaint until it is shown again. Pass schedule: immediateScheduler for synchronous commits.

Family

window-algebra is one of the family's browser-first libraries. None of them depends on another. They fit together at the page level:

  • @johnhenry/html-modules: declarative HTML modules. <html-import src="./ui.html" as="ui"> turns each <html-export> in an ordinary HTML file into a native custom element (<ui--card>). Those elements are exactly what window-algebra's surfaces host: htmlSurface(document.createElement("ui--card")) (or a lazySurface that creates one on first mount) puts an HTML-module component in a window. window-algebra only ever calls mount(target)/unmount(), so it needs no knowledge of how the element was defined. Neither package depends on the other.
  • @johnhenry/mport: routes JavaScript imports across CDNs and compiles the result to a standard import map. A no-build page using window-algebra needs an import-map entry for each entry point it imports (see Install), and for anything it loads alongside, such as React for the /react binding, which demo/react.html currently fetches from esm.sh by a hard-coded URL. mport can produce that map with fallback across mirrors, instead of hand-written URLs. There is no dependency in either direction; the browser only sees the resulting import map.
  • @johnhenry/safe-fragment: Web Components that render untrusted HTML through versioned security profiles. A <safe-fragment> is an ordinary element, so htmlSurface(safeFragmentEl) puts sanitized, less-trusted content in a window; window-algebra only calls mount/unmount. Prefer its default scope="light" in windows that may pop out: the rendered content moves with the element into the pop-out document (scope="shadow" is not isolation, and its content does not receive the copied page stylesheets). Neither package depends on the other.
  • Untrusted Desk: the Orrery planet (it replaced the retired johnhenry/workbench app) whose desk is window-algebra, with html-modules components and safe-fragment notes.

License

MIT. See LICENSE.