Это моя первая статья на Хабре. Буду рад конструктивной критике в комментариях.


Каждый раз, когда я хотел поправить отступ или цвет в процессе разработки, я делал одно и то же:

открыл 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

Клик на любой элемент

Поиск элементов

Поиск по .class, #id, CSS-селектору с подсветкой

DOM breadcrumbs

Навигация по предкам элемента

@media и @container

Отдельные вкладки для каждого брейкпоинта/контейнера

Псевдо-состояния

Редактирование :hover, :focus, :active

CSS custom properties

Браузер и редактор :root переменных

SCSS $переменные

Серверный скан всех .scss файлов, редактирование $var

Создание правил

Добавить CSS к элементу, у которого нет исходника

История сессии

Git-style диффы всех изменений, откат батчами

Tailwind detection

Предупреждение вместо попытки патчить утилиты

Поддержка форматов CSS

Формат

Чтение

Патч

Обычный .css

.scss

CSS Modules .module.css

Vue <style scoped>

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 или пишите в комментарии.