Это моя первая статья на Хабре. Буду рад конструктивной критике в комментариях.
Каждый раз, когда я хотел поправить отступ или цвет в процессе разработки, я делал одно и то же:
открыл 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 или пишите в комментарии.
Комментарии (5)

cmyser
30.05.2026 09:32Картинок не хватает, я примерно представляю конечно как это работает, но скорее всего неправильно

Artyx71 Автор
30.05.2026 09:32Согласен, это главный недостаток. Скоро обновлю — добавлю гифки с демонстрацией работы и скриншоты панели. Заодно статья уже устарела: с момента публикации появились поддержка Next.js, элементный поиск, цветовой пикер, клавиатурные шорткаты и ещё несколько фич. Так что будет и обновлённый функционал и визуал.
viiprogrammer
Сейчас везде мода пушить node_modules в репозиторий? (причем в 3 или более экземплярах) Выглядит как ошибка новичка.
А так в целом интересно, но все же devtools кажется более надежным и юзабельным вариантом, как бы лень не было нажать кнопку
В публикации весь код в одну строку, стоило бы поправить
Artyx71 Автор
Спасибо за фидбек, всё по делу.
По node_modules — да, это мой недосмотр. Уже исправил.
По DevTools согласен, для просмотра и отладки они удобнее. LiveStyleSync просто решает другую задачу: позволяет сразу сохранять изменения из браузера в файл, без ручного копирования.
В публикации код был отформатирован