Skip to content

Темизация ​

wmkit ничего не стилизует по именам классов. Он пишет атрибуты data-wm-* и инлайн-геометрию, а тема — это обычный CSS, который реагирует на эти атрибуты. В пакете шестнадцать тем; заменив их своей, вы снимаете все требования к CSS, какие есть у библиотеки.

js
import '@surdeddd/wmkit/themes/glass.css'  // тёмная полупрозрачная, вид по умолчанию
import '@surdeddd/wmkit/themes/light.css'  // светлая полупрозрачная
ФайлВид
glass.cssтёмная полупрозрачная, по умолчанию
light.cssсветлая полупрозрачная
retro.cssфаски в духе Win98, без размытия
terminal.cssмоноширинный шрифт, люминофорный зелёный, квадратные кнопки
paper.cssтёплый почти белый, заголовки с засечками, смещённая тень цвета чернил
neon.cssглубокий индиго с пурпурным и голубым свечением
aqua.cssглянцевый тайтлбар в тонкую полоску, заголовок по центру
frost.cssбледное матовое стекло, сильное размытие, скруглённые углы
candy.cssпастельный градиент в тайтлбаре, сильные скругления, пружинистая анимация
carbon.cssплоская индустриальная тёмная, квадратные углы, синяя полоса фокуса
brutalist.cssчёрная рамка 2px, жёсткая смещённая тень, моноширинный шрифт капслоком
blueprint.cssтёмно-синяя чертёжная сетка, пунктирное превью снэпа
amber.cssянтарный ЭЛТ-монитор со строками развёртки и свечением
noir.cssчистый чёрно-белый, линии в один пиксель
forest.cssтёмная оливковая с заголовками с засечками
synth.cssзакатный градиент в тайтлбаре поверх глубокого фиолетового

Все они соблюдают один контракт: кнопки управления окном с областью нажатия 24px, блок prefers-reduced-motion, который убирает переходы, и блок forced-colors, который заменяет кнопки подписанными символами.

Отдельное окно ​

Тема оформляет все окна. Когда одному окну нужно выглядеть иначе, дайте ему вариант: рабочий стол переносит его в data-wm-variant, а ваш CSS по этому атрибуту переопределяет токены.

js
wm.open({ id: 'log', meta: { variant: 'ghost' } })
css
[data-wm-window][data-wm-variant="ghost"] {
  --wm-shadow: none;
  opacity: 0.88;
}

По умолчанию вариант берётся из meta.variant. Чтобы вычислять его из чего-то другого, передайте windowVariant в attachDesktop:

js
attachDesktop(wm, root, {
  windowVariant: (win) => (win.layer === 'modal' ? 'sheet' : null),
})

Контракт атрибутов ​

Пишете вы, читает библиотека ​

АтрибутНа чёмСмысл
data-wm-dragлюбой потомокручка перетаскивания; двойной клик разворачивает или восстанавливает окно; правый клик или долгое нажатие вызывает onTitlebarContextMenu
data-wm-titleлюбой потомок, в том числе в теневом корнеузел заголовка; его текст следует за состоянием, а связь через aria-labelledby ставится, только если узел в том же корне
data-wm-contentлюбой потомокпрокручиваемая область контента (только для стилей)
data-wm-controlsлюбой потомокгруппа кнопок управления (только для стилей)
data-wm-closeкнопказакрывает окно, если closable
data-wm-minimizeкнопкасворачивает, если minimizable
data-wm-maximizeкнопкаразворачивает или восстанавливает, если maximizable

Кнопки управления работают через делегирование, поэтому их можно вложить куда угодно внутри окна.

Пишет библиотека, читает ваш CSS ​

АтрибутНа чёмЗначения
data-wm-desktopэлемент рабочего столаприсутствует
data-wm-windowэлемент окнаid окна
data-wm-stageокноnormal, minimized, maximized, snapped
data-wm-layerокноnormal, floating, modal
data-wm-workspaceокноиндекс рабочего стола
data-wm-focusedокноприсутствует на окне в фокусе
data-wm-draggingокноприсутствует во время перетаскивания
data-wm-resizingокнонаправление: n, se, …
data-wm-pinchingокноприсутствует, пока два пальца меняют его размер (плагин gestures)
data-wm-variantокноимя варианта, если оно у окна есть
data-wm-groupокноid вкладочной группы, у окон в группе
data-wm-tabокноactive или inactive, у окон в группе
data-wm-tab-targetокноприсутствует на окне, с которым объединит перетаскивание, когда жест готов сработать
data-wm-liveокноприсутствует, пока у рабочего стола меняется размер, а на закреплённых окнах — пока тащат разделитель дока; см. ниже
data-wm-deepокноприсутствует на видимых окнах глубоко в стопке; см. ниже
data-wm-dockedокноприсутствует, пока окно закреплено (плагин dock)
data-wm-skinокноимя скина, у окон, собранных именованным скином (плагин chrome)
data-wm-flashокноставится, когда модалка блокирует взаимодействие; остаётся до следующего заблокированного взаимодействия или до отвязки окна. Анимация проигрывается один раз на каждую установку, а атрибут потом не снимается: если его снять, элемент снова получит входную анимацию темы и проиграет её заново. Оформляйте вспышку анимацией, а не статичным правилом
data-wm-resizeвставленные ручкинаправление
data-wm-snap-previewвставленное превьюприсутствует
data-wm-dropвставленное превьюприсутствует, пока превью показывает, куда окно поставит цель сброса
data-wm-announcerвставленный live-регионприсутствует
hiddenокносвёрнуто, на другом рабочем столе или неактивная вкладка

Геометрия задаётся инлайн-стилями transform: translate3d(), width, height и z-index. Не перебивайте её свойствами left/top в CSS — двигайте окна через менеджер.

CSS-переменные ​

glass.css и light.css задают один и тот же набор переменных на [data-wm-desktop]:

ПеременнаяДля чего
--wm-radiusрадиус скругления окна и тайтлбара
--wm-bg, --wm-bg-focusedфон окна
--wm-bg-solidнепрозрачный фон для окон, которые теряют размытие, см. ниже
--wm-border, --wm-border-focusedрамка окна
--wm-shadow, --wm-shadow-focusedтень окна
--wm-titlebar-bgзаливка тайтлбара
--wm-text, --wm-text-dimтекст контента и второстепенный текст
--wm-accentкольца фокуса, рамки и превью снэпа
--wm-accent-inkакцент там, где его нужно читать, — см. ниже
--wm-blurрадиус размытия фона
--wm-transitionплавность перемещения и ресайза

Почему акцентов два ​

--wm-accent подбирается так, чтобы выделяться на фоне окна: яркая рамка, кольцо фокуса, превью снэпа. Тот же цвет в мелком тексте часто нечитаем: небесно-голубой у frost даёт контраст 1.4:1 на собственной поверхности, а розовый у candy — 2.1:1, оба далеко ниже 4.5:1, которых WCAG AA требует от основного текста.

Поэтому каждая тема несёт ещё и --wm-accent-ink: тот же тон, сдвинутый в читаемую сторону, пока контраст с фоном окна этой темы не превысит 4.5:1. На тёмных темах оба цвета обычно совпадают. Красьте им текст, а --wm-accent оставьте для всего, что не читают:

css
.my-value { color: var(--wm-accent-ink, var(--wm-accent)); }
.my-chip  { border-color: var(--wm-accent); }

Запасное значение важно: самописная тема, которая старше этого токена, продолжает работать, просто без гарантии контраста.

У прозрачности есть предел ​

Тема с полупрозрачным окном смешивается с тем, что лежит под ним. Раньше frost был белым на 48%: на светлой странице это яркое матовое стекло, а на тёмной — плоский средне-серый, на котором не читается ни один цвет текста светлой темы. Теперь встроенные полупрозрачные темы достаточно плотные, чтобы их поверхность узнавалась на любом рабочем столе; ощущение матового стекла создаёт размытие, а не прозрачность.

Если пишете свою полупрозрачную тему, проверьте её на тёмном фоне до релиза. tests/e2e/theme-contrast.spec.ts измеряет отрисованные пиксели демо под всеми шестнадцатью встроенными темами и падает, если контраст ниже 4.5:1.

retro.css — другая визуальная система, она задаёт --wm-face, --wm-face-light, --wm-face-dark, --wm-face-darker, --wm-title-active-a, --wm-title-active-b, --wm-title-inactive, --wm-title-text, а также общие --wm-text, --wm-text-dim, --wm-accent и --wm-radius (0, чтобы обвязка, которая читает этот токен, сохраняла прямые углы).

Чтобы подстроить встроенную тему, переопределите переменные:

css
[data-wm-desktop] {
  --wm-radius: 3px;
  --wm-accent: #d3ff4e;
  --wm-bg: rgba(12, 15, 19, 0.7);
  --wm-blur: 18px;
}

Большие рабочие столы и изменение размера ​

Два атрибута помогают теме оставаться дешёвой, когда на рабочем столе много работы.

data-wm-live стоит на окнах, пока у элемента рабочего стола меняется размер, а на закреплённых окнах — пока тащат разделитель дока; приложение тоже может ставить его на время своих живых правок. Каждая встроенная тема с переходом геометрии перестаёт плавно анимировать transform, width и height такого окна ([data-wm-window][data-wm-live], а в теневом варианте — :host([data-wm-live])), поэтому окна успевают за изменением размера, а не догоняют его. Своя тема или скин с собственным переходом геометрии должны делать то же самое:

css
[data-wm-window][data-wm-live] {
  transition: none;
}

data-wm-deep стоит на видимых окнах, которые лежат в стопке больше чем на stacking.deep мест ниже верхнего, по умолчанию на 12. Шесть тем с backdrop-filter — glass, light, neon, aqua, frost и synth — убирают у таких окон размытие и заливают их --wm-bg-solid, непрозрачной версией --wm-bg. Окно так глубоко в стопке почти целиком закрыто другими, а размытие под каждым из десятков окон GPU пересчитывает на каждом кадре. Когда меняете --wm-bg, меняйте вместе с ним и --wm-bg-solid. stacking: { deep: false } в attachDesktop отключает атрибут.

Переключение тем на лету ​

Все файлы тем написаны для [data-wm-desktop], поэтому, если импортировать две темы сразу, победит последняя. Чтобы переключать тему на лету, загружайте файлы как URL и меняйте <link>:

js
import glassUrl from '@surdeddd/wmkit/themes/glass.css?url'
import lightUrl from '@surdeddd/wmkit/themes/light.css?url'
import retroUrl from '@surdeddd/wmkit/themes/retro.css?url'

const link = document.createElement('link')
link.rel = 'stylesheet'
document.head.append(link)

export function setTheme(name) {
  link.href = { glass: glassUrl, light: lightUrl, retro: retroUrl }[name]
  desktopEl.dataset.theme = name
}

Атрибут data-theme позволяет ограничить ваши переопределения одной темой, чтобы они не задевали другие, — именно так это сделано в демо.

Тема, вариант, скин ​

Три слоя, от самого широкого к самому узкому:

слойдостаёт доменяет
темавсех окон на рабочем столетокены
вариантодного окнатокены, через data-wm-variant
скинодного окнасаму разметку

Тема и вариант перекрашивают то, что уже есть. Скин это заменяет: другие элементы, другие кнопки, другой тайтлбар. Берите скин, когда у окна отличается структура, и вариант, когда отличаются только цвета.

js
import { skin } from '@surdeddd/wmkit/chrome'

const desktop = attachDesktop(wm, root, {
  skins: {
    plain: skin({ template: PLAIN }),
    compact: skin({ template: COMPACT }),
  },
})

desktop.mountWindow('notes', 'plain')
wm.update('notes', { meta: { skin: 'compact' } })  // пересобирается на месте

Темы в виде текста ​

Таблица стилей, загруженная в document.head, не достаёт внутрь теневого корня, поэтому её не видят ни скин, собранный с shadow: true, ни обвязка веб-компонента <wm-window>. Для этого случая те же шестнадцать тем поставляются строками, каждая в двух вариантах: carbonTheme для документа и carbonShadowTheme для теневого корня.

js
import { carbonShadowTheme } from '@surdeddd/wmkit/themes'

const dressed = skin({
  name: 'dressed',
  shadow: true,
  styles: carbonShadowTheme,
  template: '<section><header data-wm-drag><span data-wm-title>{{title}}</span></header><div data-wm-content></div></section>',
})

Если тема выбирается во время работы, themeStyle(name, { shadow: true }) возвращает тот же текст по имени. Для <wm-window> один раз назначьте тему элементу рабочего стола — desktop.styles = carbonShadowTheme, — и её получат все окна этого стола; см. Веб-компоненты.

Теневой вариант здесь обязателен, и причину стоит знать. Кастомные свойства наследуются через теневую границу, так что токены проникают внутрь сами, — но правила, которые их используют, записаны как [data-wm-window] [data-wm-drag], а внутри теневого корня нет предка с data-wm-window: этот атрибут стоит на хосте, снаружи. Подключите обычный текст — и цвета будут определены, но ничем не применены.

Теневой вариант — та же таблица стилей с другой точкой привязки: селектор окна становится :host, его уточнения — :host([data-wm-focused]), потомки теряют префикс, которому больше не могут соответствовать, а блок рабочего стола сводится к токенам, которые он определяет. Правила контента записаны дважды: как ::slotted([data-wm-content]) для скина, который проецирует контент через слот, и как обычный [data-wm-content] для области контента внутри самого теневого корня, как у <wm-window>. Комбинаторы > сохраняются как :host > x, псевдоклассы уходят внутрь :host(...), а правила, которые не могут сработать внутри теневого корня, отбрасываются. Всё это генерируется заранее, а не переписывается во время работы: themeCss совпадает с файлами .css байт в байт, а themeShadowCss лежит рядом.

Теневой скин берёт тему из собственных styles, какая бы тема ни была у рабочего стола, поэтому переопределения на [data-wm-desktop] до него не доходят. Чтобы подстроить одно теневое окно, задайте токены на хосте окна — [data-wm-window] или [data-wm-skin="<name>"]; правило страницы на хосте сильнее токенов :host из теневого корня.

themeNames — полный список в том же порядке, в каком темы лежат в папке. Оба варианта получаются из pnpm themes; тест перегенерирует их и падает, если кто-то правит таблицу стилей, не перезапустив генератор.

Каждая тема — отдельный экспорт, поэтому бандлер оставит только те, что вы импортировали: около 1.4 кБ в brotli на тему. themeStyle, themeCss и themeShadowCss находят тему по имени и приносят в бандл все тридцать две строки, около 6.3 кБ в brotli, так что берите их, только если тема выбирается во время работы. Тема, подключённая обычным путём, импортом .css, в ваш JavaScript не попадает вовсе.

Тема с нуля ​

Минимальная, но полная тема — это примерно тридцать строк. Структурных требований всего два: окно — блочный бокс с явной высотой, а свёрнутое окно перестаёт занимать место.

css
[data-wm-desktop] {
  position: relative;
  overflow: hidden;
}

[data-wm-window] {
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
  background: #14161c;
  color: #e7eaf0;
  border: 1px solid #2a2f3a;
  transition:
    transform 240ms cubic-bezier(0.32, 0.72, 0, 1),
    width 240ms cubic-bezier(0.32, 0.72, 0, 1),
    height 240ms cubic-bezier(0.32, 0.72, 0, 1);
}

[data-wm-window][hidden] {
  display: none;                 /* [hidden] проигрывает display:flex — повторите его */
}

[data-wm-window][data-wm-focused] {
  border-color: #5b6472;
}

[data-wm-window][data-wm-dragging],
[data-wm-window][data-wm-resizing] {
  transition: none;              /* никогда не анимируйте наперекор указателю */
  user-select: none;
}

[data-wm-window][data-wm-live] {
  transition: none;              /* успевайте за изменением размера рабочего стола */
}

[data-wm-window] [data-wm-drag] {
  flex-shrink: 0;
  cursor: default;
  user-select: none;
}

[data-wm-window] [data-wm-content] {
  flex: 1;
  overflow: auto;
}

[data-wm-snap-preview] {
  background: rgba(211, 255, 78, 0.18);
  border: 1.5px solid rgba(211, 255, 78, 0.55);
}

@media (prefers-reduced-motion: reduce) {
  [data-wm-window],
  [data-wm-snap-preview] {
    transition: none;
    animation: none;
  }
}

В четырёх правилах легко ошибиться:

  1. Для [hidden] нужно повторить display: none. display: flex побеждает правило [hidden] из стилей браузера, и свёрнутое окно осталось бы видимым.
  2. Отключайте переходы во время перетаскивания. Переход на transform во время перетаскивания заставляет окно отставать от курсора, а при изменении размера рабочего стола — от края, за которым оно следует: отключайте его при data-wm-dragging, data-wm-resizing и data-wm-live.
  3. Не ставьте overflow: hidden на само окно, если не хотите заодно обрезать ручки ресайза: они немного выступают за border box.
  4. Никогда не пишите голое правило section { } на окружающей странице. Если ваши окна — элементы <section>, оно их заденет; ровно такой баг уже однажды попал в релиз.

Ручки ресайза ​

createResizeHandles вставляет восемь абсолютно позиционированных div с data-wm-resize. Они прозрачные, их размер задаёт hitAreas, и у каждой свой cursor. Стилизуйте их, только если хотите видимые ручки:

css
[data-wm-resize] { background: transparent; }
[data-wm-window][data-wm-focused] [data-wm-resize='se'] {
  background: linear-gradient(135deg, transparent 50%, #5b6472 50%);
}

Ручки скрываются сами, когда окну нельзя менять размер и когда его стадия — ни normal, ни snapped.

Движение и доступность ​

Каждая встроенная тема отключает анимации при prefers-reduced-motion: reduce, а flipToTarget/flipFromTarget отказываются от них ещё и на уровне JS. Анимацию сворачивания можно целиком выключить или поменять её тайминг в контроллере: animation: false или animation: { duration, easing }. Держите фокус видимым — библиотека ставит окну tabindex="-1" и фокусирует его, так что обводка :focus-visible на [data-wm-window] пригодится. Если меняете --wm-text-dim, проверьте контраст: этим цветом пишется текст неактивного тайтлбара, и он тоже должен читаться.

Области нажатия ​

Кнопки управления окном маленькие нарочно, поэтому каждая тема увеличивает их область нажатия прозрачным псевдоэлементом, а не саму точку. В итоге каждая кнопка занимает не меньше 24×24 CSS-пикселей, и соседние области никогда не перекрываются — именно этого требует WCAG 2.5.8. Если перерисовываете кнопки, соблюдайте оба условия: уменьшайте точку сколько угодно, но перепроверьте inset псевдоэлемента и gap на [data-wm-controls], чтобы области оставались в 24px друг от друга.

Высокий контраст Windows ​

При forced-colors: active платформа отбрасывает ваши цвета, и без дополнительной работы три кнопки-«светофора» превратились бы в три одинаковых круга. Каждая встроенная тема в этом режиме перерисовывает их как кнопки с рамкой и символами ✕, – и □, красит рамку окна в Canvas/CanvasText, отмечает окно в фокусе цветом Highlight и превращает превью снэпа в сплошную обводку Highlight. Своя тема должна делать то же самое: одного цвета никогда не хватает, чтобы различать кнопки.