Темизация
wmkit ничего не стилизует по именам классов. Он пишет атрибуты data-wm-* и инлайн-геометрию, а тема — это обычный CSS, который реагирует на эти атрибуты. В пакете шестнадцать тем; заменив их своей, вы снимаете все требования к CSS, какие есть у библиотеки.
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 по этому атрибуту переопределяет токены.
wm.open({ id: 'log', meta: { variant: 'ghost' } })[data-wm-window][data-wm-variant="ghost"] {
--wm-shadow: none;
opacity: 0.88;
}По умолчанию вариант берётся из meta.variant. Чтобы вычислять его из чего-то другого, передайте windowVariant в attachDesktop:
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 оставьте для всего, что не читают:
.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, чтобы обвязка, которая читает этот токен, сохраняла прямые углы).
Чтобы подстроить встроенную тему, переопределите переменные:
[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])), поэтому окна успевают за изменением размера, а не догоняют его. Своя тема или скин с собственным переходом геометрии должны делать то же самое:
[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>:
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 |
| скин | одного окна | саму разметку |
Тема и вариант перекрашивают то, что уже есть. Скин это заменяет: другие элементы, другие кнопки, другой тайтлбар. Берите скин, когда у окна отличается структура, и вариант, когда отличаются только цвета.
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 для теневого корня.
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 не попадает вовсе.
Тема с нуля
Минимальная, но полная тема — это примерно тридцать строк. Структурных требований всего два: окно — блочный бокс с явной высотой, а свёрнутое окно перестаёт занимать место.
[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;
}
}В четырёх правилах легко ошибиться:
- Для
[hidden]нужно повторитьdisplay: none.display: flexпобеждает правило[hidden]из стилей браузера, и свёрнутое окно осталось бы видимым. - Отключайте переходы во время перетаскивания. Переход на
transformво время перетаскивания заставляет окно отставать от курсора, а при изменении размера рабочего стола — от края, за которым оно следует: отключайте его приdata-wm-dragging,data-wm-resizingиdata-wm-live. - Не ставьте
overflow: hiddenна само окно, если не хотите заодно обрезать ручки ресайза: они немного выступают за border box. - Никогда не пишите голое правило
section { }на окружающей странице. Если ваши окна — элементы<section>, оно их заденет; ровно такой баг уже однажды попал в релиз.
Ручки ресайза
createResizeHandles вставляет восемь абсолютно позиционированных div с data-wm-resize. Они прозрачные, их размер задаёт hitAreas, и у каждой свой cursor. Стилизуйте их, только если хотите видимые ручки:
[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. Своя тема должна делать то же самое: одного цвета никогда не хватает, чтобы различать кнопки.