Files
oikos/web/node_modules/@surdeddd/wmkit
dtoro d4d99a7473
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Problem: the hexagonal refactor churns the backend tree for nine more
phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper,
compose/web image) must move to its own repo first so doc/layout
rewrites land once on a backend-only tree.

Change:
- New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/
  (updateURL repointed to oikos-web releases), compose/, own CI (web +
  desktop jobs), own deploy script (CI-green gate, TOCTOU guard,
  version-tagged images, prune-to-3), own webhook receiver on :9798 +
  launchd unit, own compose project publishing the same 8091:80.
- Cutover executed on mac-mini in order: oikos stack's web service
  stopped+removed, oikos-web project brought up on 8091; outer Caddy
  untouched (targets the published port) — serving + Authentik flow +
  /wails 404 quirk verified post-cutover.
- Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI
  workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install
  targets, the compose web service, oikos-web from deploy.sh's fallback
  prune list; wails + go-keyring dropped from go.mod, vendor synced.
- README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now
  point at the new repo; mbse + mascot design docs carry a path note.

Risk: production SPA serving depends on the new pipeline now; rollback
is versioned-image re-up of the old web service from a pre-split
checkout (port 8091). Desktop builds installed before the split still
check dtoro/oikos releases — one manual reinstall, noted in the
oikos-web release notes.

Verification: go vet, make test (race), make generate-check, golangci
(no new findings; baseline down 400→365); post-cutover curls —
localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
2026-08-15 22:27:52 +02:00
..

wmkit

Headless window manager for the web. Draggable, resizable, snappable windows with a taskbar model, keyboard accessibility and state persistence — for vanilla JS and every major framework.

Русская версия · Live demo · Mirror (Pages) · GitHub

CI npm license

wmkit — live demo desktop

Every window above is real — open the demo and drag one.

  • 🪟 Full window lifecycle — open, close, focus, minimize, maximize, restore, drag, 8-direction resize
  • 🧠 Headless core — a serializable state machine plus a DOM controller; bring your own markup or use the glass theme
  • ⚛️ Official adapters@surdeddd/wmkit/react, /vue, /svelte, /solid, /angular, all thin sugar over one core
  • Snap zones — halves, quarters and drag-to-top maximize with a live preview
  • 🧲 Magnetism — window edges align to neighbours and the viewport while dragging
  • ↩️ Undo/redo — every mutation is one step; a whole drag collapses into a single history entry
  • 🗂️ Named layouts & arrange — save/load desktop snapshots, cascade/tile in one call
  • ⌨️ Accessible — keyboard move/resize, F6 window cycling, focus-trapped modals, aria-live announcements
  • Fasttransform-only positioning, rAF-batched pointer input, structural sharing; 50 windows drag at 60fps
  • 💾 Persistence — one call to serialize the desktop, one call to restore it
  • 🎨 Three themes — dark glass, light glass and Win98 retro, or bring your own CSS
  • 🖼️ Popout (experimental) — send a window into Document Picture-in-Picture
  • 📦 Zero dependencies, strict TypeScript, ESM + CJS, ~9.3 kB brotli core

Install

npm install @surdeddd/wmkit
# or
pnpm add @surdeddd/wmkit

Quick start (vanilla)

import { createWindowManager, attachDesktop } from '@surdeddd/wmkit'
import '@surdeddd/wmkit/themes/glass.css'

const wm = createWindowManager()
const desktop = attachDesktop(wm, document.querySelector('#desktop'))

const win = wm.open({ title: 'Hello', width: 420, height: 280 })

const el = document.createElement('section')
el.innerHTML = `
  <header data-wm-drag>
    <span data-wm-title>Hello</span>
    <span data-wm-controls>
      <button data-wm-minimize aria-label="Minimize"></button>
      <button data-wm-maximize aria-label="Maximize"></button>
      <button data-wm-close aria-label="Close"></button>
    </span>
  </header>
  <div data-wm-content>Anything you want.</div>
`
document.querySelector('#desktop').append(el)
desktop.attachWindow(win.id, el, { removeOnClose: true })

The desktop element becomes the coordinate space. Your markup stays yours — wmkit wires behavior onto data-wm-* attributes:

Attribute Meaning
data-wm-drag drag handle (usually the titlebar); double-click toggles maximize
data-wm-title window title node, linked via aria-labelledby
data-wm-close / data-wm-minimize / data-wm-maximize control buttons, wired by delegation
data-wm-content scrollable content area (styled by themes)

removeOnClose detaches the controller and removes the element when the window closes. Touch devices get larger resize hit areas and snap thresholds automatically (pointer: coarse); tune via attachDesktop(wm, el, { hitAreas: { edge, corner } }).

The controller adds resize handles ([data-wm-resize]), a snap preview ([data-wm-snap-preview]) and a visually hidden live region for screen readers.

React

import { useWindowManager, useDesktop, useWmState, useWmWindowRef } from '@surdeddd/wmkit/react'
import '@surdeddd/wmkit/themes/glass.css'

function Desktop() {
  const wm = useWindowManager()
  const { ref, binder } = useDesktop(wm)
  const state = useWmState(wm)

  return (
    <div ref={ref} style={{ position: 'relative', height: '100vh' }}>
      <button onClick={() => wm.open({ title: 'New window' })}>open</button>
      {state.order.map((id) => {
        const win = state.windows[id]
        return win ? <Win key={id} binder={binder} win={win} /> : null
      })}
    </div>
  )
}

function Win({ binder, win }) {
  const ref = useWmWindowRef(binder, win.id)
  return (
    <section ref={ref}>
      <header data-wm-drag>
        <span data-wm-title>{win.title}</span>
        <span data-wm-controls>
          <button data-wm-minimize aria-label="Minimize" />
          <button data-wm-maximize aria-label="Maximize" />
          <button data-wm-close aria-label="Close" />
        </span>
      </header>
      <div data-wm-content>Your React tree lives here  no portals, no innerHTML.</div>
    </section>
  )
}

useWmState subscribes through useSyncExternalStore; unchanged windows keep referential identity, so memoized children skip re-renders.

Vue

<script setup>
import { ref } from 'vue'
import { useWindowManager, useDesktop, useWmWindowEl, useWmState } from '@surdeddd/wmkit/vue'
import '@surdeddd/wmkit/themes/glass.css'

const wm = useWindowManager()
const desktopEl = ref(null)
const binder = useDesktop(wm, desktopEl)
const state = useWmState(wm)

const noteEl = ref(null)
useWmWindowEl(binder, 'note', noteEl)
wm.open({ id: 'note', title: 'Note' })
</script>

<template>
  <div ref="desktopEl" style="position: relative; height: 100vh">
    <section ref="noteEl">
      <header data-wm-drag><span data-wm-title>{{ state.windows.note?.title }}</span></header>
      <div data-wm-content>composables all the way down</div>
    </section>
  </div>
</template>

Svelte

<script>
  import { createManager, createDesktop, wmWindowStore } from '@surdeddd/wmkit/svelte'
  import '@surdeddd/wmkit/themes/glass.css'

  const wm = createManager()
  const dk = createDesktop(wm)
  wm.open({ id: 'main', title: 'Hello' })
  const main = wmWindowStore(wm, 'main')
</script>

<div use:dk.desktop style="position: relative; height: 100vh">
  <section use:dk.window={{ id: 'main' }}>
    <header data-wm-drag><span data-wm-title>{$main?.title}</span></header>
    <div data-wm-content>stores and actions, no wrapper components</div>
  </section>
</div>

Solid

import { For } from 'solid-js'
import { useWindowManager, createDesktop, useWmState } from '@surdeddd/wmkit/solid'

function Desktop() {
  const wm = useWindowManager()
  const dk = createDesktop(wm)
  const state = useWmState(wm)
  wm.open({ title: 'Hello' })

  return (
    <div ref={dk.desktop} style={{ position: 'relative', height: '100vh' }}>
      <For each={state().order}>
        {(id) => (
          <section ref={dk.window(id)}>
            <header data-wm-drag>
              <span data-wm-title>{state().windows[id]?.title}</span>
            </header>
            <div data-wm-content>fine-grained, obviously</div>
          </section>
        )}
      </For>
    </div>
  )
}

Angular

import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'
import { useWindowManager, createDesktop, useWmState } from '@surdeddd/wmkit/angular'

@Component({
  selector: 'app-desktop',
  standalone: true,
  template: `
    <div #desktop style="position: relative; height: 100vh">
      <section #hello>
        <header data-wm-drag><span data-wm-title>Hello</span></header>
        <div data-wm-content>signals inside</div>
      </section>
    </div>
  `,
})
export class DesktopComponent implements AfterViewInit {
  wm = useWindowManager()
  dk = createDesktop(this.wm)
  state = useWmState(this.wm)
  @ViewChild('desktop') desktopRef!: ElementRef<HTMLElement>
  @ViewChild('hello') helloRef!: ElementRef<HTMLElement>

  ngAfterViewInit(): void {
    this.wm.open({ id: 'hello', title: 'Hello' })
    this.dk.desktop(this.desktopRef.nativeElement)
    this.dk.window('hello')(this.helloRef.nativeElement)
  }
}

useWmState returns a read-only Signal<ManagerState> and useWmWindow(wm, id) a computed per-window signal, so templates track updates fine-grained. Hooks called in an injection context clean up through DestroyRef automatically; outside one they simply skip auto-cleanup.

Snap zones in action

Snap zones — window tiled to the left half

Throw a window against an edge or corner — a live preview shows the target zone, releasing tiles it. Halves, quarters, and drag-to-top maximize.

Core API

createWindowManager(options?)

Pure state machine — no DOM access, safe to create during SSR.

interface ManagerOptions {
  viewport?: { width: number; height: number }
  keepInViewport?: boolean      // clamp windows so the titlebar stays reachable (default true)
  minVisible?: number           // minimum visible strip in px (default 48)
  defaultSize?: { width: number; height: number }
  cascadeOffset?: number        // auto-position step for new windows (default 32)
  cascadeOrigin?: { x: number; y: number }
  idPrefix?: string
  historyLimit?: number         // undo/redo depth (default 50, 0 disables history)
}

Manager methods:

Method Notes
open(init?)WindowState throws on duplicate id; cascades position when x/y omitted
close(id) / closeAll() focus moves to the next eligible window
focus(id) / blur() / cycleFocus(dir?) focusing a minimized window restores it; modals block focus below them
minimize(id) / maximize(id) / restore(id) / toggleMaximize(id) restore returns to the pre-minimize stage, including maximized/snapped
snap(id, zone) 'left' | 'right' | 'top' | 'bottom' | 'top-left' | …
move(id, x, y) / moveBy(id, dx, dy) / resize(id, patch) resizing a snapped window unsnaps it
restoreTo(id, bounds) used for drag-off-snap; stage → normal at explicit bounds
update(id, patch) title, layer, min/max size, per-window flags, meta
setViewport(size) re-derives maximized/snapped bounds, clamps the rest
serialize() / hydrate(data) JSON-safe snapshot of the whole desktop
undo() / redo() / canUndo() / canRedo() / clearHistory() every mutation is one step; a whole drag or resize collapses into a single entry
saveLayout(name) / loadLayout(name) / deleteLayout(name) / layoutNames() named desktop snapshots; getLayout/setLayout for external storage
arrange('cascade' | 'tile') cascade staggers restored sizes, tile fills the viewport in a grid
minimizeAll() / restoreAll() bulk stage switches in one history step
subscribe(fn) / on(event, fn) granular events: open, close, focus, move, resize, stage, update, order, modalblocked
batch(fn) coalesce many operations into one change notification

Windows carry layer: 'normal' | 'floating' | 'modal' — floating stays on top, modals trap focus and block interaction below (blocked attempts emit modalblocked and flash the modal).

attachDesktop(wm, element, options?)

DOM controller: pointer drag with capture (touch/pen included), 8-direction resize, snap detection with preview, keyboard handling, ARIA wiring, FLIP-to-taskbar animation.

interface DesktopOptions {
  snap?: boolean | { threshold?: number; cornerSize?: number; preview?: boolean; topEdge?: 'maximize' | 'top' | 'none' }
  keyboard?: boolean | { moveStep?: number; cycle?: boolean }
  announce?: boolean | Partial<AnnouncerMessages>   // localize screen-reader strings here
  autoViewport?: boolean                            // ResizeObserver → wm.setViewport (default true)
  magnetism?: boolean | { threshold?: number }      // edge-align to neighbours + viewport while dragging (default on, 8 px / 12 px coarse)
  hitAreas?: { edge?: number; corner?: number }     // resize handle thickness (auto-doubles on touch)
  minimizeTarget?: (win: WindowState) => Element | null  // FLIP ghost target on minimize
  onTitlebarContextMenu?: (win: WindowState, event: MouseEvent) => void  // right-click / long-press hook for your own menu
}

Keyboard defaults: arrows move the focused window (16 px), Alt for 1 px steps, Shift+arrows resize, F6 / Shift+F6 cycle windows, Escape cancels an in-flight drag or resize.

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

import { persist } from '@surdeddd/wmkit/persist'

const store = persist(wm, { key: 'my-desktop' })  // auto-restores, then debounce-saves on change
store.clear()

Storage defaults to localStorage (probed safely — SSR and private-mode friendly) and accepts any getItem/setItem/removeItem implementation.

popout(wm, id, contentEl, options?)@surdeddd/wmkit/popout (experimental)

Moves a window's content into a Document Picture-in-Picture always-on-top OS window, keeping the same JS context and state. Feature-detect with isPopoutSupported().

Theming

@surdeddd/wmkit/themes/glass.css styles the data-wm-* attributes and exposes CSS variables:

[data-wm-desktop] {
  --wm-radius: 14px;
  --wm-bg: rgba(22, 24, 34, 0.55);
  --wm-accent: #7c6cff;
  /* --wm-border, --wm-shadow, --wm-titlebar-bg, --wm-text, --wm-blur, --wm-transition … */
}

Two more ready-made themes ship alongside: themes/light.css (light glass) and themes/retro.css (pixel-perfect Win98 nostalgia). All three style the same data-wm-* attributes, so switching is a one-line import swap.

Skip the import entirely and the library stays headless: state attributes (data-wm-stage, data-wm-focused, data-wm-dragging, data-wm-flash, [hidden]) are yours to style.

SSR

The core never touches window/document — create managers and even hydrate() state on the server, then call attachDesktop after mount. persist no-ops without usable storage.

Comparison

wmkit WinBox jsPanel4 Dockview Zag floating-panel
Maintained ✓ 2026 ✗ since 2023 ✗ since 2022
Headless core ~ own UI
Official adapters React·Vue·Svelte·Solid·Angular community React·Vue·Angular via Ark UI
Multi-window (z-order, taskbar, modals) partial partial dock groups ✗ single panel
Snap zones + preview
Keyboard + screen reader partial partial
Persistence built in
Document PiP popout window.open
TypeScript strict @types

(checked July 2026: commit history, npm downloads, open feature requests)

Quality

  • 172 unit tests, 100% line/branch/function/statement coverage on the core state machine and persistence
  • 178+ Playwright scenarios on Chromium, WebKit and mobile emulation: drag, 8-way resize, snap, magnetism, undo after drag, keyboard, touch, persistence across reloads, 50-window stress, modal traps, axe accessibility scans, visual regression screenshots
  • performance benchmarks run in CI on every push (vitest bench): 1 000 windows open in ~150 ms, a move among 50 windows costs ~1.2 µs, a full 100-step undo/redo sweep ~52 µs
  • publint + @arethetypeswrong/cli validate the published package, size-limit guards bundle budgets

Development

pnpm install
pnpm dev          # landing + playground on Vite
pnpm test         # unit tests
pnpm test:e2e     # Playwright matrix
pnpm verify       # the full gate: lint, types, coverage, build, size, publint, e2e

License

MIT © Maksim Kravcov