Files
oikos/web/node_modules/@surdeddd/wmkit/README.md
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

371 lines
16 KiB
Markdown

# 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.
[Русская версия](./README.ru.md) · [Live demo](https://wmkit.vercel.app) · [Mirror (Pages)](https://surdeddd.github.io/wmkit/) · [GitHub](https://github.com/Surdeddd/wmkit)
[![CI](https://github.com/Surdeddd/wmkit/actions/workflows/ci.yml/badge.svg)](https://github.com/Surdeddd/wmkit/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@surdeddd/wmkit)](https://www.npmjs.com/package/@surdeddd/wmkit)
[![license](https://img.shields.io/badge/license-MIT-2dd4a8)](./LICENSE)
[![wmkit — live demo desktop](https://raw.githubusercontent.com/Surdeddd/wmkit/main/.github/assets/hero.png)](https://surdeddd.github.io/wmkit/)
<p align="center"><em>Every window above is real — <a href="https://surdeddd.github.io/wmkit/">open the demo</a> and drag one.</em></p>
- 🪟 **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
-**Fast**`transform`-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
```bash
npm install @surdeddd/wmkit
# or
pnpm add @surdeddd/wmkit
```
## Quick start (vanilla)
```js
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
```tsx
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
```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
```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
```tsx
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
```ts
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](https://raw.githubusercontent.com/Surdeddd/wmkit/main/.github/assets/snap.png)](https://surdeddd.github.io/wmkit/)
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.
```ts
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.
```ts
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`
```js
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](https://developer.mozilla.org/docs/Web/API/Document_Picture-in-Picture_API) 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:
```css
[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
```bash
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](./LICENSE) © Maksim Kravcov