Skip to content

API reference ​

Everything exported from @surdeddd/wmkit and its subpaths. Types are the real ones from src/core/types.ts and src/dom/shared.ts. The framework adapters and the <wm-desktop> web components (./elements) are described in Adapters, the WinBox entry (./winbox) in Migrating from WinBox.

Core types ​

ts
interface Size {
  width: number
  height: number
}

interface Bounds {
  x: number
  y: number
  width: number
  height: number
}

type WindowStage = 'normal' | 'minimized' | 'maximized' | 'snapped'
type WindowLayer = 'normal' | 'floating' | 'modal'
type ArrangeMode = 'cascade' | 'tile'

type SnapZone =
  | 'left' | 'right' | 'top' | 'bottom'
  | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
  | 'left-third' | 'center-third' | 'right-third'

interface WindowFlags {
  draggable: boolean
  resizable: boolean
  closable: boolean
  minimizable: boolean
  maximizable: boolean
  snappable: boolean
}

The flags are advisory state: the DOM layer refuses the matching gesture, while the programmatic API always obeys you. wm.close(id) closes a closable: false window on purpose — the flag exists to stop the user, not your code.

WindowGroup, HistoryEntry, SerializedWindowState, SerializedMaxSize and WindowUpdate are exported types too.

WindowState ​

Objects are replaced, never mutated, so previous === next is a valid "nothing changed" check.

ts
interface WindowState extends WindowFlags {
  id: string
  title: string
  bounds: Bounds
  restoreBounds: Bounds | null   // geometry to return to from maximized/snapped
  restoreStage: WindowStage | null // stage to return to from minimized
  stage: WindowStage
  snapZone: SnapZone | null
  snapRect: Bounds | null        // snapTo target in viewport fractions; kept while maximized
  layer: WindowLayer
  workspace: number
  groupId: string | null          // the tab group it belongs to
  minSize: Size
  maxSize: Size | null           // Infinity on an axis means unbounded
  aspectRatio: number | null     // width / height
  openedSeq: number              // monotonic; taskbar ordering
  meta: Record<string, unknown>  // yours, merged on update, serialized
}

Every number in a window is finite, with one exception: an unbounded maxSize axis is Infinity. JSON.stringify writes Infinity as null, so save a desktop through serialize(), which writes that axis as null on purpose and which hydrate() reads back as unbounded, rather than by stringifying the state yourself. meta holds whatever you put in it and is only as JSON-safe as your values.

WindowInit ​

ts
interface WindowInit extends Partial<WindowFlags> {
  id?: string          // generated as `${idPrefix}-${n}` when omitted or empty; duplicates throw
  title?: string       // 'Window'
  x?: number           // cascades when x or y is omitted or not finite
  y?: number
  width?: number       // defaultSize.width when omitted, not finite or not above 0
  height?: number      // defaultSize.height, same rule
  minWidth?: number    // 160 when omitted, negative or not finite
  minHeight?: number   // 100, same rule
  maxWidth?: number    // unbounded when omitted or not above 0
  maxHeight?: number
  aspectRatio?: number // ignored unless finite and > 0
  stage?: WindowStage  // 'snapped' is downgraded to 'normal' (no zone to snap to yet)
  layer?: WindowLayer  // 'normal' when omitted or unknown
  workspace?: number   // active workspace; non-integers and negatives fall back
  meta?: Record<string, unknown>
}

open() throws only for a duplicate id. A field it cannot use falls back to what omitting the field would give, as the comments above say, so a bad number never reaches the state.

ManagerState ​

ts
interface ManagerState {
  windows: Readonly<Record<string, WindowState>>
  order: readonly string[]   // back to front, sorted by layer
  focusedId: string | null
  viewport: Size
  workspace: number
  groups: Readonly<Record<string, WindowGroup>>   // see Tab groups
  extensions: Readonly<Record<string, unknown>>   // plugin state, see setExtension
}

getState() returns a cached object that is replaced on every change, so a !== check is enough to know something happened. Window ids may be any string, constructor and __proto__ included: windows has a null prototype, so read it with Object.keys, in or Object.hasOwn, not windows.hasOwnProperty.

The keys of windows are in opening order, also after hydrate and loadLayout, with one exception that comes from JavaScript itself: keys that look like integers ('42') always come first, in numeric order. When you render a list from windows, use ids that are not bare integers, or sort by openedSeq.

SerializedState ​

ts
interface SerializedState {
  version: 1
  windows: SerializedWindowState[]   // WindowState with maxSize as { width: number | null; height: number | null } | null
  order: string[]
  focusedId: string | null
  workspace: number
  activeTabs: Record<string, string>   // group id -> id of its visible member
  extensions?: Record<string, unknown> // always written; optional on read
}

hydrate() returns false instead of throwing. It rejects the whole snapshot only when its structure is wrong: a value that is not an object, a version other than 1, windows that is not an array, an entry that is not an object, an id that is not a non-empty string, bounds that is not an object, an unknown stage or layer, or a duplicate id. Anything else is repaired window by window, so one damaged window does not cost the rest of the layout:

  • a position that is not finite takes the cascade position; a width or height that is not finite or not above 0 takes defaultSize
  • the size is clamped into the window's own limits; an invalid minimum axis takes its default (160 or 100), an invalid maximum axis becomes unbounded
  • a restoreBounds that is not a valid rectangle becomes null; snapRect is treated the way snapTo treats its rectangle: clamped, or dropped when snapTo would refuse it, and a window left 'snapped' with nothing to snap to becomes 'normal'
  • stage: 'snapped' with neither a known snapZone nor a snapRect becomes 'normal'
  • an openedSeq that is not finite gets a new one, and any other unknown per-window field takes its default

meta is copied, and group members are re-synced to their active tab (an activeTabs entry that names no member falls back to the group's first); the frame of a group honours the limits of all its members, as below. focusedId: null keeps the desktop blurred; only a missing or invalid id falls back to the top window.

createWindowManager(options?) ​

Pure state machine. Safe to construct during SSR.

ts
interface ManagerOptions {
  viewport?: Size                      // { width: 0, height: 0 } — clamping is off until set
  keepInViewport?: boolean             // true
  minVisible?: number                  // 48 — visible strip kept reachable
  defaultSize?: Size                   // { width: 480, height: 320 }
  cascadeOffset?: number               // 32
  cascadeOrigin?: { x: number; y: number } // { x: 32, y: 32 }
  idPrefix?: string                    // 'wm'
  historyLimit?: number                // 50; 0 disables undo/redo, Infinity keeps every step
  workspace?: number                   // 0
}

A numeric option that cannot be used falls back to its default rather than breaking the manager: a viewport or defaultSize with an axis that is not finite and above 0, a cascadeOffset or cascadeOrigin that is not finite, a minVisible that is negative or not finite, and a historyLimit that is negative or NaN. A rejected viewport leaves the viewport unknown.

With viewport at its zero default, clampToViewport is a no-op — so a manager hydrated before mount keeps its stored geometry, and the first setViewport() (usually from the controller's ResizeObserver) re-derives everything. Until the viewport is known, maximize and open({ stage: 'maximized' }) keep the window's own frame, center() returns false, arrange('tile') does nothing, and undo, redo and hydrate do not re-derive geometry either. Under jsdom clientWidth/clientHeight are 0, so with autoViewport on the setViewport at mount is ignored and the constructor viewport stays: pass viewport to createWindowManager in tests.

WindowManager methods ​

A call that changes nothing is a no-op: the state object stays the same, no event fires and no history entry is written. That covers, among others, resize(id, {}), focus on the focused window when it is already on top, a repeated arrange, and snap, snapTo or restoreTo to where the window already is when the focus has nowhere to move (these three return true).

Lifecycle ​

MethodReturnsNotes
open(init?)WindowStatethrows on a duplicate id; cascades when x/y are omitted; focuses unless minimized or on another workspace
close(id)booleanfalse for unknown ids; focus moves to the next eligible window
closeAll()voidevery workspace, one transaction. Emits close for each window in opening order, one order, then group for each group that dissolved, and no focus
destroy()voiddrops all listeners; the state object stays usable

Focus and order ​

MethodReturnsNotes
focus(id)booleanswitches to the window's workspace first; restores it if minimized; false when a modal blocks it (emits modalblocked). Emits focus only when focusedId changes; focusing the focused window only raises it
blur()voidclears focusedId without touching order
cycleFocus(direction?)string | null1 (default) or -1, wraps, skips minimized and other workspaces. 1 raises the bottom-most focusable window; -1 sends the focused window to the back of its layer and focuses the one now on top, so the directions undo each other and both reach every window
sendToBack(id)booleanbottom of the window's own layer band; hands focus over if it was focused

Stage ​

MethodReturnsNotes
minimize(id)booleanremembers the stage it came from
maximize(id)booleanfills the viewport; keeps geometry in restoreBounds
toggleMaximize(id)booleanmaximize, or restore when already maximized
restore(id)booleanfrom minimized returns to the remembered stage (a window returning to normal is re-clamped against the viewport when keepInViewport is on); from maximized/snapped returns to restoreBounds, re-clamped against min/max and the viewport — except a maximized window that carries a snapRect, which goes back to that rectangle
restoreTo(id, bounds)booleanforces normal at explicit bounds; used by drag-off-snap. false when a value is not finite
snap(id, zone)booleanzone geometry, clamped to the window's own min/max; false for an unknown zone
snapTo(id, rect)booleansnaps to any rectangle given in viewport fractions ({ x: 0.5, y: 0, width: 0.5, height: 1 } is the right half); it follows viewport changes like a zone does, and the first drag frame tears the window out. Unlike snap it neither focuses nor raises the window. The dock plugin is built on it

snapTo returns false for a value that is not finite and for a negative width or height. A rectangle whose origin lies inside the unit square is accepted, an empty one too (the window then takes its minimum size), and its extent is clamped at the far edge. A rectangle that starts outside the square but overlaps it is clamped to the overlap: { x: -1, y: 0.5, width: 3, height: 3 } is stored as { x: 0, y: 0.5, width: 1, height: 0.5 }. A rectangle with no overlap returns false.

maximize, snap, restore, restoreTo, group and ungroup focus the window the way focus() does: they switch to its workspace first, even when a modal is up on the current workspace. When a modal blocks the window they skip the focus silently and do not emit modalblocked, which means a blocked focus() call.

Geometry ​

MethodReturnsNotes
move(id, x, y)booleannormal stage only; true when the clamp made it a no-op
moveBy(id, dx, dy)booleanrelative move
center(id)booleancentres a normal window in the viewport; false while the viewport is unknown
resize(id, patch)booleanPartial<Bounds>; false while the window is maximized or minimized. Unsnaps a snapped window, unless its bounds do not actually change; honours aspectRatio, driven by the axis that changed most
update(id, patch)booleantitle, layer, min/max size, aspectRatio, flags, meta (shallow-merged). A patch that sets the values the window already has is a no-op: same object, no events, no history entry. Primitives compare by value; a meta, minSize or maxSize object in the patch always gives a new window object and fires update and change, so update(id, { meta: { data } }) announces a change you made in place. It records an undo step only when a value actually changed. Passing back the very minSize or maxSize object the window already holds changes nothing. Changing the constraints of a snapped window re-derives its zone
setViewport(size)voidre-derives maximized and snapped bounds, clamps the rest; never recorded in history; a size with a dimension that is not finite and above 0 (a hidden desktop, for one) is ignored and the last known viewport stays

A number that is not finite never enters the state: move, moveBy, resize, restoreTo and snapTo given NaN or Infinity change nothing and return false. A partial patch keeps what it does not name, so wm.resize(id, { width: 400, height: undefined }) keeps the height, and so does height: null. Sizes never go below 1 px, whatever the minimum says.

update(id, patch) returns false and changes nothing for an impossible limit — a minSize axis that is negative, not finite or missing, a maxSize axis that is not above 0 or missing (Infinity is fine) — or for an unknown layer. It stores copies of minSize and maxSize, so mutating the object you passed changes nothing later.

Workspaces ​

MethodReturnsNotes
workspace()numberactive index
setWorkspace(n)booleanfalse when unchanged or invalid; refocuses the top window of the target
moveToWorkspace(id, n)booleanfalse for unknown ids or a no-op move

Windows outside the active workspace are hidden by the DOM layer, skipped by focusTargets, minimized(), arrange(), minimizeAll() and drag magnetism.

Tab groups ​

Several windows can share one frame. Members keep their own identity, content and title, but share geometry; exactly one member is visible at a time and the rest behave like hidden windows.

MethodReturnsNotes
group(ids)string | nullnull for fewer than two known windows; the first id is the host whose geometry and stage the group adopts, and it becomes the active tab. Windows that already belong to a group bring their whole group with them; when the ids already form one group, its id is returned and the first id becomes the active tab
ungroup(id)booleandetaches one member, the rest stay contiguous in order (emits order); a group left with a single member dissolves entirely
activateTab(id)booleanmakes a member the visible tab; false when it is already active or not grouped. Focus follows the tab, but only when the member going hidden was the focused window
moveTab(id, index)booleanmoves a member to another slot in the tab order; the index is clamped, and false means nothing moved. The active tab, the focus and the geometry stay put
cycleTab(direction?)string | nullactivates the next (1) or previous (-1) tab of the focused window's group, wrapping around; null when the focused window is not grouped
groupMembers(groupId)readonly WindowState[]members in tab order (back to front)
ts
const groupId = wm.group(['editor', 'preview', 'console'])
wm.activateTab('preview')
wm.groupMembers(groupId).map((win) => win.title)
wm.ungroup('console')

Invariants the manager maintains for you:

  • every member shares bounds, stage, snapZone, layer and workspace — moving, resizing, snapping, maximizing or moving a group to another workspace moves the whole group in one step
  • inactive tabs are skipped by focus, cycleFocus, minimized(), arrange(), minimizeAll(), viewport reflow and drag magnetism, exactly like windows on another workspace — the group counts as one frame, driven by its visible tab
  • a maximized group fills the viewport exactly, member constraints ignored, like a single maximized window
  • otherwise the frame honours the limits of every member, not just the host's: the largest minSize, the smallest maxSize and the smallest aspectRatio; when a minimum exceeds a maximum, the maximum wins. That holds for later moves, resizes and stage changes too, so resizing one tab can grow the frame rather than push a sibling under its own minimum, and the frame comes out the same after undo, after hydrate(serialize()) and after a viewport change. The resize and update events carry the frame the group actually took, which is not always the one you asked for
  • a window that leaves a group, or whose group dissolves, is fitted back into its own limits
  • focus(id) on an inactive tab activates it first, so DOM focus never lands on a hidden element; a focus a modal refuses leaves the active tab untouched
  • members stay contiguous in order, so switching tabs never changes where the frame sits in the stack, and the tab order is yours: focusing, raising or sending the frame to the back never shuffles it, only moveTab does
  • closing the active tab hands the tab over to a sibling; closing the second-to-last member dissolves the group
  • when a change hides the focused window — minimizing the frame through a hidden member, moving it to another workspace — focus falls back to the topmost window that is still reachable

ManagerState.groups exposes the current picture, which is what a tab strip renders from:

ts
interface WindowGroup {
  id: string
  activeId: string
  members: readonly string[]
}

The group event fires on creation, activation and membership changes, including when a member closes and when a group dissolves (members: [], activeId: ''), with { groupId, members, activeId, previous }.

History ​

MethodReturnsNotes
undo() / redo()booleanfalse when the stack is empty
canUndo() / canRedo()boolean
clearHistory()voidboth stacks. Inside a batch history restarts at the call, and later changes in the same batch undo back to it. During a gesture the rest of that gesture is not recorded, and abortInteraction() restores the history from before the gesture, which brings back what was cleared
beginInteraction() / endInteraction()voidcollapse a gesture into one entry; nestable. An interaction whose state ends equal to how it started leaves no entry, and the redo stack survives it
abortInteraction()voidat the outermost level, ends the interaction and drops the entry it created — for cancelled gestures. The changes made inside stay in the state and do not become a step of their own, so roll them back before aborting (the controller does on Escape). Inside a nested interaction it only leaves the inner level, and the outer interaction keeps its single entry

Steps that change only focus and stacking order collapse when they follow each other: three clicks on other windows after a move leave two undo steps, the move and one focus step, and focus changes that end where they began leave no step at all. A cancelled gesture, and one released where it began, leave the undo and redo stacks exactly as they were. Every undo() or redo() that returns true changes the state. An entry that has become identical to the present state, after a viewport change for example, is skipped, so undo() can return false although canUndo() was true a moment before: trust the return value.

hydrate() and loadLayout() clear the history: you cannot undo across a restore. undo() and redo() called inside a batch or an interaction behave as if the batch or the interaction had ended just before the call and started again right after it, so the rest of a gesture becomes a new step.

Layouts and arrangement ​

MethodReturnsNotes
saveLayout(name)SerializedStatesnapshot stored under name and returned
loadLayout(name)booleanfalse when the name is unknown
getLayout(name)SerializedState | undefinedisolated copy
setLayout(name, data)booleanvalidates like hydrate
deleteLayout(name) / layoutNames()boolean / string[]
arrange(mode)void'cascade' staggers from cascadeOrigin; 'tile' fills a ceil(sqrt(n)) grid. Both skip minimized windows and other workspaces, and leave a window placed with snapTo where it is; see below
minimizeAll() / restoreAll()voidactive workspace, one history entry. minimizeAll skips windows with minimizable: false; restoreAll emits at most one order

A window placed with snapTo stays where it is, whoever placed it — the dock plugin or your own layout code; restore() it first to have it arranged. arrange emits move and resize for each window it places, raises the arranged windows once and focuses the last of them, or the top modal when one is up; calling it again with nothing changed is a no-op. arrange, minimizeAll, restoreAll and closeAll take time linear in the number of windows when no tab groups are involved.

Layout storage is in-memory. Persist it yourself with getLayout/setLayout, or use the persist plugin for the live desktop. Layouts are copied with structuredClone, so cyclic values, Dates and Infinity in meta or in an extension survive. A value that cannot be cloned, such as a function, makes the copy go through JSON instead: functions are dropped, Dates become strings and Infinity becomes null.

Reading and subscribing ​

MethodReturnsNotes
get(id)WindowState | undefined
getState()ManagerStatecached until the next change
minimized()readonly WindowState[]active workspace, ordered by openedSeq — taskbar order
serialize()SerializedState
hydrate(data)booleanrepairs or rejects as described under SerializedState, re-derives against the viewport, emits the difference (see Events)
subscribe(fn)() => voidone call per committed transaction; changes made by listeners during a dispatch can share one call. A transaction that only emits events, such as a blocked focus, delivers them without a change
on(event, fn)() => voidgranular events, see below. Subscribing the same function twice gives two subscriptions, and each returned function removes only its own
batch(fn)voidone change, one history entry, events flushed at the end
setExtension(key, value)voidstores plugin state under getState().extensions[key]; it is serialized, restored by hydrate and recorded in history, so a plugin change and the window changes in the same batch undo together. undefined removes the key; the same value is a no-op

Events ​

ts
interface ManagerEvents {
  open: { window: WindowState }
  close: { window: WindowState }
  focus: { window: WindowState; previous: string | null }
  move: { window: WindowState }
  resize: { window: WindowState }
  stage: { window: WindowState; previous: WindowStage }
  update: { window: WindowState }
  order: { order: readonly string[] }
  workspace: { workspace: number; previous: number }
  group: { groupId: string; members: readonly string[]; activeId: string; previous: string | null }
  modalblocked: { window: WindowState }   // the modal that blocked, not the blocked window
  change: { state: ManagerState }
}

Ordering inside one transaction: the specific events fire in the order they happened, then change last. Payload objects are the post-change state.

focus fires only when focusedId changes, so previous is never the window itself; raising the focused window emits order alone. It also fires for undo, redo, hydrate and loadLayout when focus moves, and when an open behind a modal hands focus to the modal. modalblocked is emitted by a blocked focus() call only.

A modal that becomes the top modal through update(…, { layer }) or moveToWorkspace (any tab of a modal group) takes focus; demoting the focused modal hands focus to the next visible modal.

A call made from inside a listener is queued behind the events already being delivered, and its change carries the latest state (it may be shared with the outer one). If a listener throws, the remaining events and change are still delivered and the first error is rethrown from the call that triggered them; an error raised while delivering a nested call's events surfaces at the outermost call. The listeners of the same event registered after a throwing one are skipped. A listener that keeps changing the state for 10 000 delivery rounds makes the call throw wmkit: listeners keep changing the state: the state stays committed and undelivered events go out with the next transaction.

open and close cover every way a window appears or disappears, not just the calls named after them: hydrate, loadLayout, undo and redo all report the difference they made, so mounting your content on open and disposing it on close stays correct across history. Their open payloads carry the final geometry, and a window that appears gets no move or resize before its open. It is safe to call attachWindow straight from an open handler.

Those four calls keep the object of every window they did not change, so previous === next holds across them, and report the windows that did change with the granular events: for each window close, or stage (with the stage the window really had before), move, resize and update; then open for the windows that appeared, one order when the stacking changed, group for each group that changed, and focus. restoreTo emits stage only when the stage changes, plus move and resize for the geometry that did.

The controller moves real DOM focus into the window that focus reports, so an app that applies remote hydrate or loadLayout snapshots while the user types takes the focus away from the input.

attachDesktop(wm, element, options?) ​

Binds the manager to a DOM subtree and returns a DesktopController. The element becomes the coordinate space; it gets data-wm-desktop, tabindex="-1" and position: relative when it was static. Pointer positions account for the element's border, its scroll offset and a scale transform on it.

ts
interface DesktopOptions {
  grouping?: boolean | { dwell?: number }   // drop-to-group gesture, 420 ms hover before it arms
  snap?: boolean | {
    threshold?: number      // 12, or 20 on coarse pointers
    cornerSize?: number     // 64, or 96 on coarse pointers
    preview?: boolean       // true
    topEdge?: 'maximize' | 'top' | 'none'  // 'maximize'
  }
  keyboard?: boolean | {
    moveStep?: number         // 16
    cycle?: boolean           // true — F6
    snapShortcuts?: boolean   // true — Ctrl/Cmd+Alt+arrows
    historyShortcuts?: boolean // true — Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z
    tabShortcuts?: boolean    // true — Ctrl/Cmd+Alt+PageDown/PageUp
  }
  announce?: boolean | Partial<AnnouncerMessages>
  autoViewport?: boolean      // true — ResizeObserver drives setViewport
  hitAreas?: { edge?: number; corner?: number }  // 8/12, or 16/24 on coarse pointers
  magnetism?: boolean | { threshold?: number }   // 8, or 12 on coarse pointers
  stacking?: {
    base?: number      // 0 — z-index of the desktop floor
    gap?: number       // 32 — room left between neighbours for cheap reordering
    isolate?: boolean  // true — isolation:isolate so window layers stay inside the desktop
    deep?: number | false  // 12 — windows further below the top carry data-wm-deep; false turns it off
  }
  animation?: boolean | {
    duration?: number   // 260
    easing?: string     // cubic-bezier(0.32, 0.72, 0, 1)
  }
  gestures?: readonly DesktopGesture[]  // opt-in, from @surdeddd/wmkit/gestures or your own
  interactiveSelector?: string  // what a drag handle must not start on
  beforeClose?: (window: WindowState) => boolean | void  // return false to keep it open
  minimizeTarget?: (window: WindowState) => Element | null
  onTitlebarContextMenu?: (window: WindowState, event: MouseEvent) => void
  skins?: Readonly<Record<string, WindowSkin>>  // named skins for mountWindow and meta.skin
  windowVariant?: (window: WindowState) => string | null  // defaults to meta.variant
}

The nested option objects are exported as types: GroupingOptions, DesktopSnapOptions, DesktopKeyboardOptions, HitAreaOptions, MagnetismOptions, StackingOptions and AnimationOptions.

beforeClose is asked by everything that closes a window on the user's behalf: a data-wm-close button, the close action a skin receives and desktop.actions(id).close(). Return false to keep the window open. wm.close(id) never asks and always closes, so call it to finish after a confirmation of your own:

js
attachDesktop(wm, root, {
  beforeClose: (win) => {
    if (!win.meta.unsaved) return true
    askToDiscard(win.title).then((discard) => discard && wm.close(win.id))
    return false
  },
})

Where ResizeObserver is missing, autoViewport measures the element once at attach and does not follow it afterwards.

Gestures ​

The desktop ships no gestures of its own. Pass the ones you want and only those reach your bundle.

js
import { attachDesktop } from '@surdeddd/wmkit'
import { touchGestures } from '@surdeddd/wmkit/gestures'

attachDesktop(wm, root, { gestures: [touchGestures({ swipe: { workspaces: 4 } })] })

@surdeddd/wmkit/gestures exports three factories: pinch(options?), swipe(options?) and touchGestures({ pinch, swipe }) when you want both from one listener. Their options are the types PinchOptions and SwipeOptions.

OptionDefaultMeaning
pinch.threshold12how far the fingers must spread before it resizes
pinch.lockTouchActionfalseset touch-action: none on the desktop instead of pan-x pan-y (any two-finger gesture sets pan-x pan-y; pinch() and swipe() together restore the original value on destroy)
swipe.threshold72how far two fingers must travel sideways
swipe.workspaces0no upper bound; set it to stop at the last workspace

A lifted finger is heard on the page's window in the capture phase, so content that calls stopPropagation() on pointerup does not leave the gesture holding a finger that is gone, and a new first touch drops any finger left over from before.

A gesture is a function. It receives a GestureContext and returns its own teardown, so you can write your own the same way the shipped ones are written:

ts
type DesktopGesture = (ctx: GestureContext) => () => void

interface GestureContext {
  wm: WindowManager
  doc: Document
  view: Window & typeof globalThis
  desktop: HTMLElement
  toLocal(event: PointerEvent): { x: number; y: number }
  trackRect(): () => void
  windowElement(id: string): HTMLElement | undefined
  busy(): boolean                       // a drag or resize already owns the pointer
  claim(gesture: ActiveGesture): void   // take the pointer, so nothing else acts on it
  release(gesture: ActiveGesture): void
}

claim is what keeps gestures from fighting the built-in drag and resize: the desktop holds one interaction at a time, and busy() tells you whether it is already taken. Claiming while a drag or resize runs cancels that gesture. toLocal returns desktop coordinates, the space window bounds live in.

ts
interface DesktopController {
  element: HTMLElement
  wm: WindowManager
  attachWindow(id: string, element: HTMLElement, options?: WindowAttachOptions): () => void
  mountWindow(id: string, skin: WindowSkin | string, options?: WindowAttachOptions): MountedWindow
  actions(id: string): WindowActions
  addDropTarget(target: DropTarget): () => void
  destroy(): void
}

interface WindowAttachOptions {
  handle?: HTMLElement | string   // defaults to [data-wm-drag]
  resizeHandles?: boolean         // true
  removeOnClose?: boolean         // false for attachWindow, true for mountWindow
}

attachWindow throws for an unknown id, for an id that is already attached and for an element that already drives another window (wmkit: window "<id>" is already attached), and after destroy() (wmkit: the desktop is destroyed). It returns a detach function; destroy() detaches everything, cancels an in-flight gesture and removes the snap preview, and the elements lose data-wm-window and data-wm-focused exactly as with a detach. Detaching a window that still exists also clears hidden on its element.

A window that the manager closes — including one that undo takes away — is detached for you: its listeners and resize handles go, and the element loses data-wm-window so nothing mistakes it for a live window afterwards. The element itself stays in the page unless you passed removeOnClose, and the detach function you were handed is safe to call again at any point, including after you have attached that same element to a different window or after desktop.destroy(). Pass removeOnClose only for elements you created yourself; see createDesktopBinder for why.

desktop.actions(id) ​

The same ten moves the manager exposes, already bound to one window, so a button handler never has to close over an id:

js
const act = desktop.actions('notes')
closeButton.onclick = () => act.close()

focus, close, minimize, maximize, toggleMaximize, restore, center, sendToBack, snap(zone) and moveToWorkspace(n). Each returns the same boolean the manager returns, and each returns false once the window is gone rather than throwing. close() asks beforeClose first and returns false when it refuses.

desktop.addDropTarget(hit) ​

ts
type DropTarget = (clientX: number, clientY: number, id: string, local: { x: number; y: number }) => DropHit | null
interface DropHit {
  preview: Bounds   // desktop-local rectangle shown while the pointer is over the target
  commit(): void    // runs when the drag ends there
}

Lets a plugin claim a window drag. On every drag frame the targets are asked in the order they were added, before tab grouping and snapping, and the first hit wins: its preview is drawn with the snap preview element, marked data-wm-drop, and its commit runs exactly once when the pointer is released there. local is the pointer in desktop coordinates, the space window bounds live in; a target written with three parameters works as well. Cancelling the drag (Escape, a lost pointer) commits nothing. The drop preview shows even with snap: { preview: false }. Returns a function that removes the target. The dock plugin is built on it.

Driving a window from markup ​

Any element inside a window can carry one of these and the controller will act on it, with no listener of your own:

attributeeffect
data-wm-closecloses the window, after asking beforeClose
data-wm-minimizeminimizes it
data-wm-maximizetoggles maximized
data-wm-restorereturns it to its stored bounds
data-wm-centercentres it in the viewport
data-wm-send-backdrops it to the bottom of the stack
data-wm-snap="left"snaps it to that zone, if the window is snappable
data-wm-to-workspace="2"moves it to that workspace

An attribute acts only on the window it sits in, also when one desktop is nested inside another window. An unknown snap zone is ignored rather than thrown, so a typo in a template never takes the page down with it. The buttons and the keyboard work the same for a desktop that lives in another document, such as a same-origin iframe or a popup the app renders into through a portal.

desktop.mountWindow(id, skin, options?) ​

Builds a window's element from a skin instead of taking one you already made.

ts
type WindowSkin = (context: SkinContext) => SkinMount

interface SkinContext {
  doc: Document
  id: string
  window: WindowState
  actions: WindowActions
}

interface SkinMount {
  element: HTMLElement
  content: HTMLElement
  destroy?(): void
}

interface MountedWindow {
  readonly element: HTMLElement
  readonly content: HTMLElement
  detach(): void
}

skin is either a factory or the name of one registered in attachDesktop(wm, root, { skins }); an unregistered name throws. A meta.skin change to an unregistered name, or to a factory that throws, throws once from the call that made the change: the window keeps its current skin and stays fully driven, and the controller does not retry until meta.skin changes again. When several windows change skin in one transaction and one of them fails, the others are still rebuilt and the stacking and focus of that pass still apply; the first error is thrown once the pass is over. The returned element and content are live: after the window is rebuilt they point at the new nodes, so a reference you took at mount time never goes stale. A handle whose window closed keeps pointing at its last nodes and never adopts a later window that reuses the id.

A window is rebuilt when meta.skin changes to another registered name, or when you call mountWindow again for the same id. Either way the content children move across, the id, geometry, focus and stacking survive, and the previous skin's destroy runs after the swap. Windows you attached yourself with attachWindow are never rebuilt behind your back. Mounting under a name other than meta.skin is kept; it is not corrected on the next change.

A skin's destroy runs exactly once: after a rebuild swap, on close, on detach() or on desktop.destroy(), whichever comes first.

What the controller writes ​

On the window element: data-wm-window, data-wm-stage, data-wm-layer, data-wm-workspace, data-wm-focused, data-wm-dragging, data-wm-resizing, data-wm-flash, data-wm-variant (when windowVariant names one), data-wm-group and data-wm-tab (on grouped windows), data-wm-tab-target (on the window a drag would group with), data-wm-live, data-wm-deep, hidden, role="dialog", tabindex="-1", aria-label, aria-labelledby (when [data-wm-title] sits in the same root as the window; a title inside a shadow root is carried by aria-label instead), aria-modal on modal layers, plus inline transform, width, height, z-index. The first sync briefly sets transition: none, and an inline transition your app set is restored afterwards. Theming lists the values; plugins add their own attributes (data-wm-pinching, data-wm-docked, data-wm-skin).

In [data-wm-title] the controller replaces the previous title where it stands — in a direct text child of the node first, then deeper inside, as in <b>{{title}}</b> — so an icon or static text around it stays: <span data-wm-title><i class="icon"></i> {{title}}</span>. When the previous title cannot be found, the controller writes into a direct text child of the node, or appends one, and never into a child element, so a ligature icon keeps its glyph; a title that reads the same as such an icon is still written. One case is left out: when the window is attached with markup whose only text is such an icon and its title already reads the same, the node gets no title text. The previous title is found by its text, so static text in the node that contains the same string can be replaced in its place.

data-wm-flash is set when an interaction is blocked by a modal and stays until the next blocked interaction or until the window detaches, so style the flash with an animation that runs once per set rather than a static rule.

data-wm-live is on every bound window while the desktop element is being resized (the autoViewport path): it is set before the manager learns the new viewport and removed one frame after the frame that drew the last change, and every further change re-arms it. Themes drop their geometry transition while it is there, so windows keep up with the resize instead of easing after it. The controller never removes a data-wm-live it did not set, so a plugin or the application can hold it during its own live edits.

data-wm-deep marks the visible windows of the current workspace that sit more than stacking.deep places below the top of the stack: with the default 12, the 13 topmost visible windows never carry it. Hidden windows never carry it, and it is removed on detach. Themes with backdrop-filter drop the blur on such windows, see Theming.

The controller moves keyboard focus into a window when the manager focuses it, or when its titlebar or a grip is pressed, returning it to the control that last had focus in that window; a press into the content is left to the browser. It parks focus on the desktop when its window closes or hides. The other way round, giving DOM focus to a window element or a control in it — by .focus() or Tab — does not change the manager's focused window by itself; a pointer press or wm.focus(id) does, and so does a frame that takes the focus, as described below. Generated title ids are wmkit-title-<id>-<n>, unique per page.

Under a visible modal the DOM layer refuses pointer interaction on the lower windows: presses, drags, resizes, double and middle clicks, action attributes and the titlebar menu. A blocked press flashes the modal. Keyboard input into lower windows is not blocked: Tab arriving from outside the desktop, or typing in a control that already had focus. Tab inside a modal cycles through its controls, position: fixed ones included. An iframe as the first or last stop of a modal lets Tab leave from inside the frame, because key events inside a frame do not reach the parent document.

Frames inside windows (iframe, object, embed) never send their pointer or key events to the page, so the controller watches where focus goes instead: while the pointer is over a frame, and once whenever the page loses focus, a frame that has just taken focus raises and focuses its window. The watch stops when the pointer leaves the frame, and a frame that already had focus does not pull it back after wm.blur() or when another window opens. Under a visible modal the frames in the lower windows get pointer-events: none until the modal goes away, so a click cannot reach their content; that includes windows attached while the modal is up, a frame added to a lower window is covered from that window's next state change, and the pointer-events value the application had set comes back afterwards.

Touch ​

Every gesture is driven by pointer events, so a finger and a mouse take the same path. The drag handle and the resize grips are given touch-action: none, which is what stops the browser from panning the page out from under a drag, and hit areas grow on coarse pointers — the defaults above show both figures. Only one gesture runs at a time: a second finger landing on another titlebar mid-drag is ignored entirely, so it neither starts a competing drag nor focuses or raises that window. A drag or resize whose setPointerCapture throws does not start, and losing pointer capture cancels the gesture like pointercancel. A press on a control inside a web component in the titlebar does not start a drag (the inner target is read through composedPath()). A long press produces a contextmenu event like anywhere else, which is where onTitlebarContextMenu is called from, with the browser's own menu suppressed for you.

Drop-to-group finds its target from the window bounds rather than by hit-testing the page: the topmost visible window of this desktop under the pointer, and only while the pointer is over that window's drag handle. Elements of the page lying over a titlebar do not block it, and a modal window is never grouped, neither as the dragged window nor as the target. Resizing a tab group honours the same combined limits as the manager — the largest minimum, the smallest maximum and the smallest aspect ratio of its members — and keeps the opposite edge still when one of them stops a resize from the left or top.

The end of a drag or resize (pointerup, pointercancel, lostpointercapture) is heard on the document in the capture phase, so the gesture also ends cleanly when its handle is removed from the page halfway through. Escape restores exactly the state the gesture started from — stage, bounds, restore bounds, snap zone and snapRect, also for a window that was maximized over a snapRect — and the key does not reach the page's own handlers. Closing the window in the middle of its own drag or resize, or from a drop target's commit, ends the gesture as committed: one undo step brings the window back where it was before the gesture.

The desktop element itself gets isolation: isolate, so the z-index values the controller writes stay inside it and can never fight the host page's own layers. Turn that off with stacking: { isolate: false } when the desktop must share a stacking context with the page around it.

Raising a window rewrites exactly one z-index: values are spread gap apart and the controller only re-numbers the windows that actually changed places, falling back to a full renumber when it runs out of room. Reading a window's z-index therefore tells you nothing but its relative position.

It also injects [data-wm-resize] handles, one [data-wm-snap-preview] per desktop and one visually hidden [data-wm-announcer] live region. A <header>, <footer>, <nav> or <aside> used as the drag handle gets role="presentation" so it does not leak a landmark out of the dialog.

Keyboard defaults ​

KeysAction
arrowsmove the focused window by moveStep; only while keyboard focus is on the window element or inside its titlebar, so lists, sliders and scroll areas in the content keep their arrows, and ignored during a pointer drag or resize
Alt + arrowsmove by 1 px
Shift + arrowsresize
Ctrl/Cmd + Alt + ←/→snap to a half
Ctrl/Cmd + Alt + ↑/↓maximize / minimize or restore
Ctrl/Cmd + Alt + PageDown/PageUpnext / previous tab of the focused window's group; left to the page when that window has no tabs
Ctrl/Cmd + Z, + Shiftundo, redo; non-Latin layouts work too, through the layout's Z key
F6, Shift + F6cycle focus; not during a drag
Escape during a gesturecancel and drop it from history
double click on the handletoggle maximize

Holding an arrow key is one undo step: the step ends on keyup, on any other key, when focus moves and when the browser window loses focus.

The shortcuts — undo and redo, snapping, tabs — never fire in an editing surface (input, textarea, select, [contenteditable]), whatever interactiveSelector says, and skip an event that is already defaultPrevented. An application that handles Ctrl+Z itself, on a canvas for example, calls preventDefault() in a listener that runs before the desktop's: on the canvas, or in the capture phase. On a button inside a window, Ctrl+Z undoes. The arrow keys also stay away from anything matching interactiveSelector. F6 and Shift+F6 cycle windows from anywhere, including inside a field, because that is how keyboard users leave one.

createDesktopBinder(wm, options?) ​

Order-independent wrapper used by the adapters: bind windows before or after the desktop exists, and before or after the window is opened.

ts
interface DesktopBinder {
  wm: WindowManager
  controller(): DesktopController | null
  bindDesktop(element: HTMLElement): () => void
  bindWindow(id: string, element: HTMLElement, options?: WindowAttachOptions): () => void
  destroy(): void
}

bindDesktop throws if a desktop is already bound. Unbinding and re-binding is supported — the binder re-subscribes, which is what makes React StrictMode double-mounting harmless. Unbinding the desktop keeps the window bindings, and they attach to the next desktop element.

  • A bound element with no parent (one that was removed, for example by removeOnClose when its window closed) is appended to the desktop element when its window attaches, so a window that comes back through undo or a reopen is visible again. Elements still inside some tree are left where they are, even an off-document one. The element comes back as a direct child of the desktop, not inside the wrapper it lived in, so use removeOnClose only for nodes you created yourself, never for nodes a framework renders. A framework removes its own nodes: render the window only while it exists, as every example in Adapters does.
  • When a window opens, only the first element bound to that id attaches, so wm.open never throws from the binder. When the attached element unbinds, the next element bound to the same id takes over.
  • A bindWindow call that throws (for example "already attached") leaves no binding behind.

Geometry helpers ​

Pure functions, no DOM, exported for building your own layout logic.

ts
clamp(value, min, max): number
clampSize(size, min, max: Size | null): Size
clampToViewport(bounds, viewport, minVisible): Bounds
boundsEqual(a, b): boolean
zoneBounds(zone, viewport): Bounds
applyAspect(size, ratio, min, max, drive: 'width' | 'height'): Size
detectSnapZone(x, y, viewport, options?: SnapDetectOptions): SnapZone | null
magnetize(bounds, targets: readonly Bounds[], threshold): MagnetResult
interface SnapDetectOptions { threshold?: number; cornerSize?: number }

zoneBounds tiles exactly: halves, quarters and thirds add back up to the viewport with no gap or overlap on odd sizes. clampToViewport keeps minVisible pixels reachable and never pushes a window narrower than that off both edges. magnetize returns { x, y, snappedX, snappedY } and aligns any edge pair within threshold.

Animation helpers ​

ts
prefersReducedMotion(win: Window): boolean
flipToTarget(source: HTMLElement, target: Element, options?: FlipGhostOptions): void
flipFromTarget(source: HTMLElement, target: Element, options?: FlipGhostOptions): void
interface FlipGhostOptions { duration?: number; easing?: string }

Both flips render a throwaway ghost element and remove it on finish or cancel. They no-op under reduced motion, without Element.animate, or when either rect has zero width. attachDesktop calls them for you when minimizeTarget is set, for every stage change into or out of minimized, including those made by undo, redo, hydrate and loadLayout.

Announcer ​

ts
createAnnouncer(wm, container, messages?: Partial<AnnouncerMessages>): Announcer
const defaultMessages: AnnouncerMessages   // the English catalogue

interface AnnouncerMessages {
  opened(title: string): string
  closed(title: string): string
  minimized(title: string): string
  restored(title: string): string
  maximized(title: string): string
  snapped(title: string, zone: string): string
  focused(title: string): string
  workspace(index: number): string
  openedMany?(count: number): string     // '5 windows opened'
  closedMany?(count: number): string
  minimizedMany?(count: number): string
  restoredMany?(count: number): string
}

interface Announcer {
  element: HTMLElement
  announce(message: string): void
  destroy(): void
}

attachDesktop creates one unless announce: false; pass an object to localise. A message set to undefined (announce: { opened: undefined }) silences that event. A stage or workspace message always wins over the focus message produced in the same transaction, so the region never narrates "focused" over "maximized". Two identical announcements in a row still change the live region's text (a trailing no-break space toggles), so a screen reader speaks both.

When several windows open, close, minimize or restore in one transaction, the region reads one summary instead of the last window's message. The English summaries are used only while the matching single message is the default one: once you replace opened, the default openedMany is dropped and the region falls back to the last window's message. A translated catalogue should therefore bring its own summaries.

Emitter ​

ts
createEmitter<Events>(): Emitter<Events>
interface Emitter<Events> {
  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void
  emit<K extends keyof Events>(event: K, payload: Events[K]): void
  clear(): void
}
type Listener<T> = (payload: T) => void

Listeners are snapshotted before dispatch, so subscribing or unsubscribing inside a handler is safe. Subscribing the same function twice gives two subscriptions, and each returned function removes only its own. The manager's own events add the re-entrancy and error rules described under Events.

skin(spec) — @surdeddd/wmkit/chrome ​

Turns a markup string into a WindowSkin, the factory mountWindow and the skins registry take.

ts
interface SkinSpec {
  template: string      // one [data-wm-content] attribute, one root element, {{placeholders}} escaped and placed
  styles?: string       // one <style data-wm-skin-styles> per skin per document; needs a name to scope it
  shadow?: boolean      // build the chrome inside an open shadow root
  name?: string         // lands on the window as data-wm-skin
}

function skin(spec: SkinSpec): WindowSkin
function compileTemplate(template: string): (values: Record<string, string>) => string
function windowChrome(messages?: ChromeMessages): WindowSkin
const defaultSkin: WindowSkin    // titlebar, title and the three controls
const barebones: WindowSkin      // titlebar and title, nothing else

interface ChromeMessages {
  minimize: string
  maximize: string
  close: string
}
const chromeMessages: ChromeMessages    // English
const chromeMessagesRu: ChromeMessages  // Russian

Placeholders are {{title}}, {{id}}, {{stage}}, {{layer}}, {{workspace}} and {{variant}}. Every substitution is HTML-escaped with no opt-out ({ included), so a window title can never smuggle markup into the chrome around it; an unknown placeholder expands to nothing rather than throwing, because a template outlives a rename.

A placeholder may sit only in text or in a quoted value of aria-*, data-*, title, alt, class, id, role, lang, dir, for, value, placeholder or label. Anywhere else, compileTemplate() and skin() throw a message naming the placeholder and the markup before it: an unquoted value, a tag or attribute name, any other attribute (URLs, style, on*, srcdoc …), a comment, the content of script, style, textarea, title, xmp, iframe, noembed, noframes and plaintext, anything after <noscript>, and anything after a <script> whose content contains <!-- (the one case where a browser can keep the script open past its first end tag; a plain script compiles).

Inside SVG and MathML the compiler follows the HTML parser's foreign-content rules, and it refuses a template wherever a browser could build a different tree than the compiler expects. It throws for a placeholder after a raw-text element (textarea, style, script, xmp, iframe, noembed, noframes, plaintext) inside SVG. Once the markup holds <math>, <select>, an HTML tag directly inside <svg>, a mismatched end tag, or an <a> inside foreignObject, desc or title, it also throws for a placeholder after any later raw-text element, CDATA section or <title> with markup in it. <svg><title>{{title}}</title></svg>, ordinary inline icons and HTML inside foreignObject keep working.

Inside a tag, only the five characters HTML treats as whitespace may separate its parts: tab, line feed, form feed, carriage return and space. Any other, such as a no-break space typed by accident, throws wmkit: a tag may only be spaced with tab, line feed, form feed, carriage return or space (U+00A0 after '<div title=').

Only {{title}} inside [data-wm-title] follows state afterwards. Every other placeholder is rendered at mount or rebuild and goes stale, aria-label="Close {{title}}" included.

A template must contain exactly one [data-wm-content] attribute (a mention in text, a comment or an attribute value does not count) and a single root element, and skin() throws while you are writing it rather than silently dropping your content; extra roots throw wmkit: a skin template needs a single root element … at mount. styles without a name throws too — there would be nothing to scope the rules to.

Each skin() call owns its <style data-wm-skin-styles="<name>"> per document, so two skins with the same name get one tag each and an edited skin's styles do land. The tag is removed when the last window wearing that skin is detached, rebuilt onto another skin or destroyed with the desktop.

With shadow: true the chrome is built inside an open shadow root; the template's root element is unwrapped, so :host is the box the titlebar and content sit in. Your content is the template's own [data-wm-content] element (attributes and default children kept), moved into the light DOM and projected through a <slot> placed where it stood, so shadow CSS reaches it as ::slotted([data-wm-content]). Styles become one constructable stylesheet per skin per document, with a <style> element as the fallback where constructable sheets are missing.

themeNames, themeStyle(name, options?) — @surdeddd/wmkit/themes ​

The sixteen shipped stylesheets as strings, for skins that cannot be reached by a stylesheet in document.head.

ts
const glassTheme: string                         // one export per theme: amberTheme … terminalTheme
const glassShadowTheme: string                   // and per shadow flavour: amberShadowTheme … terminalShadowTheme
const themeNames: readonly ThemeName[]
const themeCss: Record<ThemeName, string>        // byte for byte the .css files
const themeShadowCss: Record<ThemeName, string>  // the same rules re-anchored to :host
function themeStyle(name: ThemeName, options?: ThemeStyleOptions): string
interface ThemeStyleOptions { shadow?: boolean }

Every theme is its own export in both flavours, <name>Theme and <name>ShadowTheme, so a bundler keeps only the themes you import, about 1.4 kB brotli each: import { retroShadowTheme } from '@surdeddd/wmkit/themes'. themeStyle, themeCss and themeShadowCss reach every theme by name, so using any of them brings all thirty-two strings into the bundle, about 6.3 kB brotli.

Pass { shadow: true }, or take the ShadowTheme export, for a shadow skin. Custom properties inherit through a shadow boundary, so the plain text delivers a theme's tokens and none of its rules — every rule is written from [data-wm-window], and inside a shadow root nothing carries that attribute. See theming.md.

persist(wm, options?) — @surdeddd/wmkit/persist ​

ts
interface PersistOptions {
  key?: string             // 'wmkit'
  storage?: PersistStorage // localStorage, when reading it does not throw
  debounce?: number        // 150 ms
  maxWait?: number         // the larger of 2000 ms and debounce; Infinity removes the bound
  autoRestore?: boolean    // true
  version?: number         // 0
  migrate?: (state: unknown, from: number) => SerializedState | null
  onError?: (error: unknown, phase: 'load' | 'migrate' | 'save') => void  // globalThis.reportError
}

interface PersistStorage {
  getItem(key: string): string | null
  setItem(key: string, value: string): void
  removeItem(key: string): void
}

interface PersistController {
  restore(): boolean
  save(): void
  clear(): void
  destroy(): void
}

Stored shape is { version, state }. A payload from another version is passed to migrate; returning null (or having no migrate) discards it rather than restoring something broken, and a successful migration is written back immediately. Payloads written before the envelope existed are still read, as version 0.

A change is saved debounce ms after the last one; under a steady stream of changes a save still happens at least every maxWait ms. A maxWait that is not a number of 0 or more means the default. A pending save is flushed on pagehide and on destroy(); clear() also cancels a pending save.

Nothing the plugin does throws at you. An absent storage turns it into a no-op, and a failure goes to onError with the phase it happened in:

PhaseWhen
'load'getItem threw, or the payload is not JSON
'migrate'migrate threw; restore() returns false and persist() still returns its controller
'save'the state cannot be serialized (a circular value, a BigInt) or the storage threw, in clear() too

With onError, every failure is passed to it. Without it, the first failure of each phase goes to globalThis.reportError where it exists, which reports it the way an uncaught error is reported without stopping anything, and that phase then stays quiet until it succeeds again. Empty storage, a payload of the wrong shape, migrate returning null and hydrate refusing the data are not errors and are not reported. There is no write probe: a full storage is still read.

popout(wm, id, content, options?) — @surdeddd/wmkit/popout ​

ts
isPopoutSupported(target?: PopoutTarget): boolean     // 'auto' — either target works
listScreens(): Promise<readonly PopoutScreen[] | null> // null: no Window Management API, or permission denied
popout(wm, id, content: HTMLElement, options?: PopoutOptions): Promise<PopoutHandle>

type PopoutTarget = 'auto' | 'pip' | 'window'          // auto: picture-in-picture when available, else a popup

interface PopoutScreen {
  availLeft: number
  availTop: number
  availWidth: number
  availHeight: number
  isPrimary?: boolean
  label?: string
}

interface PopoutOptions {
  target?: PopoutTarget             // 'auto'
  width?: number                    // the window's current width
  height?: number
  screen?: PopoutScreen | 'current' | 'other'   // popup only: centre it on that screen
  left?: number                     // popup only: explicit position in screen coordinates
  top?: number
  mode?: 'move' | 'container'       // 'move' — carry `content`; 'container' — leave it, render into handle.container
  copyStyles?: boolean              // true — styleSheets and adoptedStyleSheets, with their media; disabled sheets skipped
  minimizeWhilePopped?: boolean     // true, unless the window is already minimized
}

interface PopoutHandle {
  target: 'pip' | 'window'
  window: Window
  pipWindow: Window                 // the same object as window, kept for 0.11 code
  container: HTMLElement            // the element in the popout that holds the content
  closed: Promise<void>             // settles once the content is back and the popout is gone
  close(): void                     // safe to call more than once
}

Moves the live element into a separate OS window — same JS context, same manager, no re-render. Two kinds exist. Document Picture-in-Picture (Chrome and Edge 116+, Firefox 151+) is always on top and sized by the browser; there is one per page. A popup (window.open, every engine) can be placed: screen: 'other' centres it on the first screen that is not the current one, screen: 'current' on the current one, or pass a screen from listScreens(). Placement needs the Window Management API (Chromium) and its permission; without either the popup opens where the browser puts it. A popup has to be opened from a user gesture, or the browser blocks it and popout() rejects with wmkit: the popout window was blocked. With screen: 'current' or 'other' the popup opens at once, inside the gesture, and moves to its screen when the screen details arrive; a PopoutScreen object places it straight away.

One window has at most one popout. Calling popout(wm, id, …) again while that window is out, or while its popout is still opening, resolves to the same handle and focuses the popout; the new content and options are ignored. Once the handle has closed, or the first call has rejected, the next call opens a fresh popout.

A comment marker holds the content's place in the original tree. When the popout closes — close(), the user closing it, the window closing in the manager, or the page itself going away — the content goes back to the marker, or into the window's current [data-wm-content] if a skin rebuild discarded the marker's place. The popout's title follows the window's title. A window the popout minimized is restored only if it is still minimized, so a window the user restored and maximized meanwhile is left alone.

React (and any framework that delegates events from its root) loses its handlers on a node moved into another document. Use mode: 'container' and render into the popout through a portal — see the recipe in Recipes.

createDock(desktop, options?) — @surdeddd/wmkit/dock ​

ts
createDock(desktop: DesktopController, options?: DockOptions): Dock

interface DockOptions {
  area?: (viewport: Size) => Bounds   // the whole desktop
  sash?: number                       // 6 px, 12 on coarse pointers
  edgeDrop?: number                   // 24 px band along the area edges that docks to the root; 0 turns it off
  minSize?: Size                      // floor for every panel; a panel never goes below its windows' own minSize
  keyboard?: boolean                  // true — Ctrl/⌘+Alt+Shift+arrows dock the focused window
  messages?: Partial<DockMessages>    // English; dockMessagesRu for Russian
  float?: (event: PointerEvent) => boolean  // (event) => event.shiftKey — true keeps a dragged window floating
}

interface Dock {
  dock(id: string, target?: DockTarget): boolean
  undock(id: string): boolean
  layout(): DockLayout
  setLayout(layout: DockLayout): boolean
  destroy(): void
}

interface DockTarget { stackId?: string; side?: DockSide }
type DockSide = 'left' | 'right' | 'top' | 'bottom' | 'center'
type DockAxis = 'row' | 'column'
interface DockLayout { root: DockNode | null }
type DockNode = DockStack | DockSplit
interface DockStack { kind: 'stack'; id: string; windows: string[]; active: string }
interface DockSplit { kind: 'split'; id: string; axis: DockAxis; children: DockNode[]; sizes: number[] }

interface DockMessages {
  docked(title: string): string
  undocked(title: string): string
  resized(percent: number): string
  separator: string
}
const dockMessages: DockMessages     // English
const dockMessagesRu: DockMessages   // Russian

Tiles windows into a layout of panels, dockview style, while the other windows keep floating above it. A window manager has at most one live dock: a second createDock() throws wmkit: this window manager already has a dock, destroy it before creating another. destroy() may be called more than once and createDock() works again after it, so a framework effect that runs create, destroy, create is fine. A docked window is a snapped window with a snapRect: the panel rectangle in viewport fractions. While docked it loses resizable, minimizable and snappable, and its maxSize and aspectRatio are lifted (null) so that it fills its panel; all of them come back on undock and on destroy(). They are kept in extensions.dock.flags[id], with maxSize as { width: number | null, height: number | null } | null. The element of a docked window carries data-wm-docked (square corners, no shadow). A panel with several windows is a tab group, so tabs come from the group machinery. The layout lives in getState().extensions.dock, which gives three things for free. One dock operation, with all its window changes, is one undo step. persist saves and restores the layout. hydrate places it on a desktop of any size.

  • dock(id) with no target tabs the window into the first panel, or fills the area when nothing is docked. side splits the root, or a panel when stackId is given; center tabs in. false when nothing changed, the window is unknown, or it is on another workspace than the layout.
  • undock(id) returns the window to its floating geometry. There is no keyboard shortcut for it, so call it from a control of your own.
  • setLayout(layout) docks exactly the windows the layout names, undocks the rest and shows the active tab each stack names. false for a layout that breaks an invariant (an empty panel, a split with one child, sizes that do not add up to 1, a window twice) or names an unknown window. dockLayout exports the pure operations (insert, remove, resize, activate, moveInStack, stackOf, windowsOf, rects, validate, emptyLayout) for building one; rects takes a DockGeometry and returns DockRects, made of StackRect and SashRect entries, and those types are exported too.
  • layout() reports the active tab the manager shows. A tab the user picked — by a click, focus, the tab shortcut or maximizing it — stays picked through later dock operations.

After dock() or setLayout() returns, every docked window sits in its own panel, whatever regrouping the change needed, such as moving a tab out of a stack or splitting a stack apart. All three calls work inside wm.batch(), also after windows were closed or restored earlier in the same batch. A layout that cannot be placed yet because the viewport is still unknown — given to setLayout or dock, or stored, when a script runs before the stylesheet has given the desktop its size — is kept, with waiting: true in extensions.dock meanwhile, and placed when the size arrives.

Dragging a window over the area previews where it would land and docks it there on release. Near a panel edge it splits that panel, at the panel's centre it tabs in, and inside the edge band of the area it docks to the root. Windows on the floating or modal layer never dock by drag, and neither does a drop outside area. Drop targeting works in desktop coordinates, so it respects the desktop's scroll, border and scale. The first drag frame of a docked window tears it out and the panels close the gap; dragging a panel of several tabs by its titlebar tears out the whole tab group, which stays grouped. Escape or another cancelled drag puts it back.

float is asked on every pointer move over the desktop. While it returns true — by default while Shift is held — a dragged window stays floating: no preview, no docking. A touch screen has no Shift, so wire it to a control of your own:

js
const toggle = document.querySelector('#float')
createDock(desktop, {
  float: (event) => event.shiftKey || toggle.getAttribute('aria-pressed') === 'true',
})

Separators between panels are div[data-wm-sash][role=separator] with aria-orientation and aria-valuenow/min/max in percent. Drag one with the pointer (one undo step per drag), or focus it and press the arrow keys (16 px, 1 px with Shift) or Home/End for the limits. Escape cancels a separator drag without reaching the page's own key handlers, and the drag ends when its pointer is released even if the separator has left the page. While a separator is dragged, the docked window elements carry data-wm-live, so a theme lets the panels follow the separator instead of easing behind it; the dock removes only the marks it set. A panel never shrinks below the largest minSize of its windows, or options.minSize when that is larger, unless those minimums add up to more than the area: then the panels are scaled below them.

A docked window that closes, is restored, is torn out or moves to another workspace leaves the layout, without an extra undo step: undoing the close brings back both the window and its panel. Maximizing a panel keeps it docked, and restoring returns it to the panel. A viewport change re-tiles the panels without touching history. The layout belongs to one workspace, and its separators hide on the others. arrange() and minimizeAll() leave docked panels in place.

The dock also follows changes made around its API:

  • wm.ungroup(id) on a docked tab takes it out of the layout, and it floats with its flags back
  • wm.group(['a', 'f']) with a docked brings f into a's panel, locked like any docked window; a floating host pulls docked windows out instead
  • wm.moveToWorkspace(id, 1) on a docked window floats it on workspace 1 without changing the focus on the current one; a maximized or minimized docked window still goes through a focus change
  • wm.snap(id, 'left') on a docked window takes it out of the layout and leaves it in the zone

destroy() gives the windows their flags and limits back, leaves them where they are and keeps extensions.dock, so a saved layout survives an unmount. createDock() on a manager that already holds a layout — restored by persist before the dock was created, for example — locks and places those windows at once, as one change with no undo step, so the panels are right from the first frame after a reload at another size. A stored extensions.dock that is not a valid dock state (not an object, flags not an object, workspace not an integer, or a tree that fails validate) is ignored and removed, without an undo step and without throwing. Its windows are not left locked: each window with a usable saved record gets its flags and limits back, and a window without one that still carries the dock's lock (snapped to a rectangle with resizable, minimizable and snappable all off) gets those three set back to true.

To remove docking for good while the manager lives on, call dock.setLayout({ root: null }), then dock.destroy(), then wm.clearHistory(). The history entries recorded while the dock was alive still describe docked, locked windows, and destroy() does not rewrite them, because it runs on every unmount and a later createDock() adopts the layout again. For the same reason a named layout saved with wm.saveLayout() while windows were docked, or an older snapshot given to hydrate, still holds locked windows and the dock state: delete it, or load it only with a dock.

A change that does not concern the dock costs it nothing. area is called again only when the viewport, the layout, the workspace or a docked window changes; if your area depends on state of your own, call dock.setLayout(dock.layout()) to make the dock read it again.

createDevtools(wm, options?) — @surdeddd/wmkit/devtools ​

ts
interface DevtoolsOptions {
  container?: HTMLElement           // document.body
  messages?: Partial<DevtoolsMessages>   // devtoolsMessages (English); devtoolsMessagesRu is exported
  logLimit?: number                 // 50
  events?: readonly (keyof ManagerEvents)[]  // open, close, focus, stage, workspace, group, modalblocked
}

interface DevtoolsController {
  element: HTMLElement
  destroy(): void
}

A panel with the live window table, an event log and undo, redo and copy-state controls. It follows the manager, never the DOM. After its first paint it repaints at most once per animation frame however many events arrive, and appends new log lines instead of rebuilding the log. The row buttons are fixed glyphs; copy writes serialize() as JSON to the clipboard and shows the JSON itself when the clipboard is missing or rejects the write. The entry also exports the types DevtoolsMessages, DevtoolsEvent, DevtoolsAction and DevtoolsLogEntry.