Это моя первая статья на Хабре. Буду рад конструктивной критике в комментариях.
Каждый раз, когда я хотел поправить отступ или цвет в процессе разработки, я делал одно и то же:
открыл DevTools → нашёл элемент → поменял значение → понравилось → скопировал → переключился в редактор → нашёл файл → вставил.
Это семь шагов ради однострочного изменения. Я сделал LiveStyleSync, чтобы это был один шаг.
Что это такое
LiveStyleSync добавляет небольшую панель поверх вашего Vite-приложения в режиме разработки. Вы кликаете на любой элемент, редактируете CSS-свойства прямо в панели, и изменение записывается в исходный файл. Vite HMR подхватывает изменение мгновенно — без перезагрузки страницы.
Клик на элемент → редактируем значение → Vite HMR обновляет браузер → исходник обновлён
Никакого копи-паста. Никакого переключения вкладок.
Быстрый старт
npm install livestylesync-overlay livestylesync-vite-plugin
// vite.config.tsimport { liveStyleSync } from "livestylesync-vite-plugin"; export default defineConfig({ plugins: [liveStyleSync()],});
// main.tsimport { mount } from "livestylesync-overlay"; if (import.meta.env.DEV) { mount();}
Всё. Панель появится в углу приложения.
Как это работает изнутри
Мост между CSSOM и исходным файлом
Это главная техническая задача в проекте. Браузер знает CSS-правило, но не знает, в какой строке какого файла оно объявлено. Нужно связать document.styleSheets с конкретным .css или .scss файлом на диске.
У объекта CSSStyleSheet есть два пути получить источник:
Случай 1 — внешний файл. Если CSS подключён через <link>, у листа есть sheet.href вида http://localhost:5173/src/styles.css. Из этого URL можно вытащить путь.
Случай 2 — <style> тег. SCSS, CSS Modules, Vue scoped — Vite компилирует их и вставляет как <style> теги прямо в <head>. У таких тегов href равен null. Но Vite добавляет атрибут data-vite-dev-id с абсолютным путём к исходнику:
<style type="text/css" data-vite-dev-id="/home/user/project/src/styles.scss"> .card { background: #1a1a2e; } </style>
Код в оверлее читает этот атрибут:
for (const sheet of Array.from(document.styleSheets)) { let fileUrl = sheet.href; // для <link> тегов if (!fileUrl && sheet.ownerNode instanceof HTMLElement) { fileUrl = sheet.ownerNode.getAttribute("data-vite-dev-id"); // для <style> тегов } // cross-origin или без источника — пропускаем if (!fileUrl) { continue; } }
Получив fileUrl, мы знаем два из трёх нужных координат: файл и CSS-правило (из CSSOM). Третья координата — конкретная строка в файле — это уже задача PostCSS на сервере.
Почему PostCSS, а не regex
Первое, о чём думаешь — найти нужную строку через регулярку или string.replace. Это не работает по нескольким причинам.
Проблема 1: двоеточие в разных контекстах.
CSS использует : в трёх несвязанных местах: селекторах (.foo:hover), значениях (content: "a: b"), самих декларациях (color: red). Regex по color: попадёт не туда.
Проблема 2: форматирование.
Реальный CSS бывает в разных форматах:
/* вариант 1 */ .card { background: #fff; } /* вариант 2 */ .card { background: #fff; } /* вариант 3 — с комментарием */ .card { background: #fff; /* default */ }
Regex нужно поддерживать все варианты или нормализовывать файл — что разрушает форматирование.
Проблема 3: SCSS-нестинг и @media внутри правила.
.card { background: #fff; @media (max-width: 768px) { background: #000; } &:hover { background: #eee; } }
Найти нужное объявление в этой структуре регуляркой — нетривиально. Нужно знать, на каком уровне вложенности находится декларация.
PostCSS разбирает файл в AST. Каждый узел имеет тип: Rule (селектор), Declaration (свойство: значение), AtRule (@media, @container). Нужно найти Rule с нужным selector, в нём найти Declaration с нужным prop — и заменить value. Всё остальное (отступы, комментарии, переносы строк) PostCSS хранит в raws и воспроизводит при toString().
root.walkRules((rule) => { if (rule.selector !== targetSelector) return; rule.walkDecls(prop, (decl) => { // меняем только значение, всё остальное не трогаем decl.value = newValue; }); }); writeFileSync(filePath, root.toString()); // форматирование сохранено
Для SCSS используется postcss-scss — он понимает синтаксис SCSS включая $variables, нестинг и миксины, которые стандартный PostCSS не парсит.
HMR: от setTimeout к подтверждению
Первая версия выглядела так: после отправки патча по WebSocket ждать 400 миллисекунд, потом перечитать CSSOM.
send({ fileUrl, selector, prop, value }); setTimeout(() => { editor.refresh(); }, 400); // фиксированное ожидание
Проблема: после записи файла Vite проходит несколько шагов — файловый watcher обнаруживает изменение, Vite перекомпилирует модуль, отправляет HMR-обновление клиенту через свой WebSocket, браузер применяет новый CSS. Это занимает разное время в зависимости от размера файла и нагрузки. На медленных машинах 400 мс не хватало и оверлей показывал устаревший CSSOM. На быстрых — зря ждал.
Решение: сервер отправляет подтверждение только после записи файла. Клиент ждёт этот сигнал, а не таймер.
// сервер — после writeFileSync: socket.send( JSON.stringify({ type: "patched", }) ); // клиент: if (msg.type === "patched") { setTimeout(() => { editor.refresh(); }, 300); // небольшой буфер для HMR }
Теперь 300 мс отсчитываются от момента, когда файл уже записан — а не от момента отправки запроса. Разница не кажется большой, но при медленном диске или сложном SCSS-файле это существенно.
Псевдо-состояния: как редактировать :hover который не активен
Браузер применяет правило .button:hover { color: red } только когда пользователь держит мышь над элементом. Соответственно, el.matches(".button:hover") возвращает false для элемента, на который только что кликнули.
Если коллектировать правила только через matches, все псевдо-состояния выпадут — пользователь не увидит ни одного hover/focus/active правила в панели.
Решение — двухшаговый матчинг. Сначала пробуем матч как есть. Если не прошёл и в селекторе есть интерактивный псевдо-класс — strip его и пробуем снова:
const INTERACTIVE_PSEUDOS = [ ":hover", ":focus", ":active", ":checked", // ... ]; function stripInteractivePseudos(selector: string): string { let s = selector; for (const p of INTERACTIVE_PSEUDOS) { // убираем ":hover" из ".button:hover" s = s.split(p).join(""); } return s.trim(); // ".button" } // при сборе правил: let matches = el.matches(effectiveSelector); if (!matches && isPseudoRule) { matches = el.matches( stripInteractivePseudos(effectiveSelector) ); }
.button:hover → strip → .button → матч прошёл. Правило попадает в панель с пометкой, что это :hover-состояние.
Для визуального превью пока используется простой подход: значение устанавливается через element.style.setProperty() — inline-стиль, который видно всегда, не только при ховере. Это компромисс для dev-режима: можно увидеть, как будет выглядеть значение, хотя и без условия псевдокласса.
Vue scoped: хэши в селекторах
Vue <style scoped> — это когда стили применяются только к компоненту, а не глобально. Vue добавляет уникальный атрибут к каждому элементу компонента (data-v-3f7bd2) и переписывает все CSS-селекторы, добавляя к ним этот атрибут:
/* исходник в .vue файле */ .card { background: #fff; } /* в браузере после компиляции */ .card[data-v-3f7bd2] { background: #fff; }
Это создаёт два несовпадения между тем, что видит браузер, и тем, что в исходнике:
1. Селектор. CSSOM показывает .card[data-v-3f7bd2], но в файле написано просто .card. Если отправить на сервер .card[data-v-3f7bd2] — PostCSS его не найдёт.
2. URL файла. Vite обслуживает Vue-стили под URL вида main.vue?vue&type=style&index=0&scoped=7bd2. Реальный файл называется main.vue.
Оба случая решаются нормализацией перед отправкой:
// убираем хэш из селектора const selector = effectiveSelector .replace(/\[data-v-[a-f0-9]+\]/g, "") .trim(); // ".card[data-v-3f7bd2]" → ".card" // убираем query-параметры из URL const isVue = fileUrl.includes("?vue&type=style"); const cleanUrl = isVue ? fileUrl.split("?")[0] : fileUrl; // → "main.vue"
На сервере patchVue парсит .vue файл как текст, вычленяет содержимое <style> блока, прогоняет его через PostCSS, находит .card — и записывает обратно только <style> блок, не трогая <template> и <script>.
Технические грабли, на которые я наступил
Универсальный селектор *
В document.styleSheets есть правила вроде , ::before, ::after { box-sizing: border-box }. Без фильтрации эти правила появлялись в панели для каждого элемента на странице. Фикс — пропускать правила, где хотя бы одна часть селектора через запятую равна или начинается с *::
const selParts = selector .split(",") .map((s) => s.trim()); if (selParts.some((p) => p === "*" || p.startsWith("*:"))) { continue; }
CSSContainerRule нет в TypeScript lib
@container правила в TypeScript не типизированы в стандартной библиотеке. instanceof CSSContainerRule — не компилируется. Пришлось определять через duck-typing: у @container есть conditionText, но нет instanceof CSSMediaRule:
if ( !(rule instanceof CSSMediaRule) && !(rule instanceof CSSSupportsRule) && (rule as any).conditionText !== undefined ) { // это @container }
Inline-стили перекрывают откат
При откате изменений через историю стили возвращались в файл, но (element as HTMLElement).style оставался с перезаписанными inline-значениями, которые перекрывали восстановленные стили из файла. CSS-специфичность: inline-стили всегда побеждают правила из таблицы стилей.
Фикс — явно очищать inline-свойство при откате:
(selected as HTMLElement).style.setProperty(prop, oldValue); // или если oldValue пустой: (selected as HTMLElement).style.removeProperty(prop);
Два механизма undo разъехались
В какой-то момент у меня оказалось два независимых стека отмены: один внутри useStyleEditor (только для CSS), второй в общей истории сессии (CSS + SCSS переменные + CSS custom properties). При смешанной сессии они показывали разные состояния. Это открытый баг, рефакторю под единый стек.
Что умеет
Фича | Описание |
|---|---|
Element picker | Клик на любой элемент |
Поиск элементов | Поиск по |
DOM breadcrumbs | Навигация по предкам элемента |
@media и @container | Отдельные вкладки для каждого брейкпоинта/контейнера |
Псевдо-состояния | Редактирование |
CSS custom properties | Браузер и редактор |
SCSS $переменные | Серверный скан всех |
Создание правил | Добавить CSS к элементу, у которого нет исходника |
История сессии | Git-style диффы всех изменений, откат батчами |
Tailwind detection | Предупреждение вместо попытки патчить утилиты |
Поддержка форматов CSS
Формат | Чтение | Патч |
|---|---|---|
Обычный | ✅ | ✅ |
| ✅ | ✅ |
CSS Modules | ✅ | ✅ |
Vue | ✅ | ✅ |
Tailwind-утилиты | ⚠️ определяет, предупреждает | — |
Inline styles | ❌ | — |
Работает с любым фреймворком на Vite
React, Vue, Nuxt, SvelteKit, Astro, Solid — всё, что использует Vite как dev-сервер. Оверлей не имеет peer-зависимости от React: Preact собирается внутрь бандла и изолирован от приложения.
Стек проекта
Монорепо на pnpm workspaces
Оверлей — Preact + TypeScript, собирается через tsup в один файл без внешних зависимостей
Vite-плагин — Node.js +
ws(WebSocket) + PostCSS + postcss-scssТесты — Vitest для патчеров (CSS/SCSS/Vue)
Попробовать
GitHub: https://github.com/Artyx71/livestylesync
npm install livestylesync-overlay livestylesync-vite-plugin
Буду рад фидбэку — особенно если попробуете на проекте, отличном от React, или наткнётесь на кейс с нестандартной структурой CSS. Открывайте issue или пишите в комментарии.
