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.
9.9 KiB
wmkit
Headless оконный менеджер для веба. Перетаскиваемые окна с ресайзом, снэпом, таскбаром, клавиатурной доступностью и персистом состояния — для vanilla JS и всех основных фреймворков.
English version · Живое демо · Зеркало (Pages) · GitHub
Все окна выше настоящие — откройте демо и потаскайте.
- 🪟 Полный жизненный цикл окна — открытие, закрытие, фокус, сворачивание, разворачивание, восстановление, drag, ресайз в 8 направлениях
- 🧠 Headless-ядро — сериализуемая стейт-машина плюс DOM-контроллер; своя разметка или готовая стеклянная тема
- ⚛️ Родные адаптеры —
@surdeddd/wmkit/react,/vue,/svelte,/solid,/angular, тонкий сахар над одним ядром - ⊞ Snap-зоны — половины, четверти и максимизация от верхнего края с живым превью
- 🧲 Магнетизм — края окна прилипают к соседям и вьюпорту при перетаскивании
- ↩️ Undo/redo — каждая мутация = один шаг; целый drag схлопывается в одну запись истории
- 🗂️ Именованные layout'ы и arrange — снапшоты рабочего стола,
cascade/tileодним вызовом - ⌨️ Доступность — move/resize с клавиатуры, цикл окон по F6, focus-trap в модалках,
aria-live-анонсы - ⚡ Производительность — позиционирование только через
transform, rAF-батчинг ввода, structural sharing; 50 окон таскаются на 60fps - 💾 Персист — один вызов сериализует рабочий стол, один — восстанавливает
- 🎨 Три темы — тёмное стекло, светлое стекло и Win98-ретро, либо полностью свой CSS
- 🖼️ Popout (experimental) — вынос окна в Document Picture-in-Picture
- 📦 Ноль зависимостей, строгий TypeScript, ESM + CJS, ~9.3 kB brotli
Установка
npm install @surdeddd/wmkit
# или
pnpm add @surdeddd/wmkit
Быстрый старт (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: 'Привет', width: 420, height: 280 })
const el = document.createElement('section')
el.innerHTML = `
<header data-wm-drag>
<span data-wm-title>Привет</span>
<span data-wm-controls>
<button data-wm-minimize aria-label="Свернуть"></button>
<button data-wm-maximize aria-label="Развернуть"></button>
<button data-wm-close aria-label="Закрыть"></button>
</span>
</header>
<div data-wm-content>Что угодно.</div>
`
document.querySelector('#desktop').append(el)
desktop.attachWindow(win.id, el, { removeOnClose: true })
Элемент рабочего стола становится системой координат. Разметка остаётся вашей — wmkit вешает поведение на data-wm-* атрибуты:
| Атрибут | Смысл |
|---|---|
data-wm-drag |
ручка перетаскивания (обычно тайтлбар); двойной клик — toggle maximize |
data-wm-title |
узел заголовка, связывается через aria-labelledby |
data-wm-close / data-wm-minimize / data-wm-maximize |
кнопки управления, работают через делегирование |
data-wm-content |
скроллируемая область контента |
removeOnClose сам отвязывает контроллер и удаляет элемент при закрытии окна. На тач-устройствах хит-зоны ресайза и порог снэпа автоматически крупнее (pointer: coarse); настраиваются через attachDesktop(wm, el, { hitAreas: { edge, corner } }).
Контроллер добавляет ресайз-хендлы ([data-wm-resize]), превью снэпа ([data-wm-snap-preview]) и скрытый live-регион для скринридеров.
Адаптеры
Примеры для React, Vue, Svelte, Solid и Angular — в английском README и на лендинге (табы «Фреймворки»). Принцип один: контент окна живёт в дереве вашего фреймворка, никакого innerHTML.
API ядра — кратко
const wm = createWindowManager({ keepInViewport: true, defaultSize: { width: 480, height: 320 } })
wm.open({ id: 'docs', title: 'Документы', layer: 'floating' })
wm.snap('docs', 'left') // 'right' | 'top-left' | 'bottom-right' | …
wm.minimize('docs') // restore вернёт предыдущий stage, включая maximized/snapped
wm.update('docs', { title: 'Новый заголовок', meta: { pinned: true } })
const json = wm.serialize() // JSON-безопасный снапшот
wm.hydrate(json)
wm.on('stage', ({ window, previous }) => console.log(previous, '→', window.stage))
wm.batch(() => { /* много операций — одно уведомление */ })
wm.undo(); wm.redo() // история изменений, drag = одна запись (historyLimit, default 50)
wm.saveLayout('работа'); wm.loadLayout('работа') // именованные снапшоты рабочего стола
wm.arrange('tile') // или 'cascade'; плюс minimizeAll() / restoreAll()
Слои: normal < floating (always-on-top) < modal. Модалка блокирует фокус нижних окон (попытка — событие modalblocked и flash-анимация), Tab заперт внутри.
Клавиатура по умолчанию: стрелки двигают сфокусированное окно (16 px), Alt — шаг 1 px, Shift+стрелки — ресайз, F6/Shift+F6 — цикл по окнам, Escape отменяет активный drag/resize.
Магнетизм включён из коробки (порог 8 px, на тач-устройствах 12 px): attachDesktop(wm, el, { magnetism: { threshold: 16 } }) или magnetism: false. Свой контекст-меню тайтлбара — через onTitlebarContextMenu(win, event) (правый клик и long-press на таче).
Персист
import { persist } from '@surdeddd/wmkit/persist'
persist(wm, { key: 'my-desktop' }) // авто-восстановление + debounce-сохранение
Popout (experimental)
import { popout, isPopoutSupported } from '@surdeddd/wmkit/popout'
if (isPopoutSupported()) await popout(wm, 'docs', contentElement)
Окно уезжает в настоящее always-on-top окно ОС (Document Picture-in-Picture) с тем же JS-контекстом и состоянием.
Темизация
Подключите @surdeddd/wmkit/themes/glass.css и переопределяйте CSS-переменные (--wm-radius, --wm-bg, --wm-accent, …) — или не подключайте ничего и стилизуйте data-wm-stage, data-wm-focused, data-wm-dragging, [hidden] сами.
Ещё две готовые темы: themes/light.css (светлое стекло) и themes/retro.css (Win98-ностальгия). Все три стилизуют одни и те же data-wm-* атрибуты — переключение = замена одного импорта.
SSR
Ядро не трогает window/document: менеджер можно создавать и гидрейтить на сервере, attachDesktop вызывается после маунта. persist тихо выключается без доступного storage.
Качество
- 172 юнит-теста, 100% покрытие стейт-машины и persist по строкам/веткам/функциям
- 178+ Playwright-сценариев на Chromium, WebKit и мобильной эмуляции: drag, ресайз во все стороны, снэп, магнетизм, undo после drag, клавиатура, touch, персист через перезагрузку, стресс на 50 окон, модальные ловушки, axe-аудиты доступности, визуальная регрессия по скриншотам
- перф-бенчмарки в CI на каждый push (
vitest bench): 1 000 окон открываются за ~150 мс, move среди 50 окон ~1.2 мкс, полный undo/redo-проход на 100 шагов ~52 мкс publint+@arethetypeswrong/cliпроверяют валидность пакета,size-limitследит за бюджетами
Лицензия
MIT © Максим Кравцов
