Dual-mode, глоссарий и управление ценами из Supabase: три волны модернизации webshturm.ru

ООО Веб Штурм Веб-разработка / IT 2026-06 — наст. время
285
автотестов зелёных
5.30:1
контраст WCAG AA
≤60 сек
правка цен без передеплоя
2
аудитории на одном контенте

Услуги

  • complex-systems
  • seo-ai-search
  • legacy-modernization

Стек

  • Astro 5
  • Svelte 5
  • Tailwind 4
  • Supabase Postgres 17
  • PostgREST
  • vitest
  • rehype
  • remark-directive
  • gray-matter

Собственный сайт ООО «Веб Штурм» — IT-услуги B2B. Три волны модернизации webshturm.ru за июнь 2026: исправление светлой темы, запуск dual-mode «Разработчику/Руководителю» и управление ценами из Supabase без передеплоя. 285 автотестов, контраст WCAG AA 5.30

, правка калькуляторов из базы данных отражается на проде за ≤60 секунд.

Контекст

Сайт webshturm.ru запущен в мае 2026 на Astro 5 + Svelte 5 + Tailwind 4 (кейс «Как мы делали свой сайт»). Поступили два отзыва от партнёров: светлая тема выглядит бледно, тексты слишком технические для руководителей. Параллельно потребовалось управлять коэффициентами калькуляторов без цикла сборки.

Реализовали три независимые задачи последовательно — каждая полностью протестирована перед началом следующей.

Волна 1 — светлая тема: корень бага в одной строке

Симптом: партнёры жаловались на «расползающуюся вёрстку» в светлом режиме. Аудит показал, что layout-дефектов нет: 0 визуальных расхождений на 59 страницах (desktop + mobile). Проблема — в контрасте текста.

Корень: class="prose prose-invert" хардкодом в ServiceLayout.astro и смежных layout’ах. Класс prose-invert переключает Tailwind Typography на светлую типографику — всегда, вне зависимости от текущей темы. На белом фоне получался почти незаметный серый текст.

Фикс: prose dark:prose-invertprose-invert применяется только при тёмной теме. Дополнительно ввели токен --accent-strong (oklch(0.50 0.15 162) для light / oklch(0.80 0.15 162) для dark) вместо использования акцентного цвета как текстового: контраст вырос с 2.47

до 5.30
на белом фоне (WCAG AA — минимум 4.5
). Шесть точек применения акцента подтянуты до 4.60–4.82
.

Guard-тесты проверяют, что text-accent (без strong) не попадает в типографику.

Волна 2 — dual-mode «Разработчику/Руководителю»

Архитектурное решение: параллельные коллекции

Сайт статически генерируется (Astro SSG, prerender=true). Для двух индексируемых аудиторий — два URL-дерева:

  • / — текущие URL, контент в src/content/{services,cases,blog}. SEO-equity Phase 1/2 сохранено.
  • /biznesu/ — зеркало, контент в src/content/biz/{services,cases,blog}. Схемы вынесены в общие переменные, dev и biz коллекции используют одну схему без дублирования.

AudienceToggle (Svelte 5 runes)

Тоглер в Navbar вычисляет зеркальный путь (toMirror), запоминает выбор в localStorage (ключ webshturm.audience.v1) и устанавливает cookie для серверной согласованности. Серверных редиректов нет — краулер видит именно запрошенный URL. Тоглер скрыт, если у текущей страницы нет biz-зеркала (итеративная выкатка контента).

Поймали open-redirect в toMirror через автоматический security-review. Фикс: нормализация ведущих слэшей + компонентный гард на протокол-relative URL.

rehype-глоссарий

rehype-glossary.mjs оборачивает первое вхождение термина из glossary.ts в <span class='gloss'> c тултипом. Кириллица потребовала Unicode lookarounds вместо стандартных \b (стандартные не обрабатывают символы за пределами ASCII). Тултип — position:absolute, без влияния на CLS.

Анти-каннибализм: машинный гейт

cannibalization.ts + vitest-тест читают оба дерева контента через gray-matter и проверяют: у dev↔biz пары с одним slug обязан различаться title (для cases), metaTitle/metaDescription/title (для services), title/excerpt (для blog). Тест падает при совпадении любого из полей — CI-деплой блокируется до правки контента.

Итог Волны 2

59 страниц собраны, 271/271 тестов зелёных после задачи. Пилотный manager-контент (3 страницы) прошёл ревью пользователя: тон, факты, анти-каннибализм по интенту подтверждены.

Волна 3 — цены и калькуляторы из Supabase

Задача

Калькуляторы (SEO-аудит, Pentest, Legacy-миграция) содержали коэффициенты в TypeScript-константах: изменить — только через коммит и деплой. Нужно: (1) редактировать коэффициенты из Supabase Studio без передеплоя, (2) скрывать/показывать блок цен без передеплоя, (3) цена должна быть в HTML (видна поисковым и AI-ботам — AEO).

Миграции и схема

Четыре миграции 0007–0010 добавили в схему webshturm:

  • calc_configs (service_id PK, config JSONB, is_active) — конфигурация калькуляторов.
  • price_blocks (service_key PK, is_visible BOOLEAN, note) — флаги видимости блоков.
  • *_versions + BEFORE UPDATE триггеры — снапшот предыдущей версии при изменении контентных полей (история правок без отдельного audit log).

RLS включён, GRANT только для service_role. Anon и authenticated доступа не имеют.

Архитектура fail-open

Функции lib/calculators.ts принимают config аргументом (тестируемы без БД) и экспортируют DEFAULT_*_CONFIGfallback при недоступной базе.

API-роуты /api/pricing/{calculators,visibility} (prerender=false): кэш 60 секунд на уровне Node-модуля, fail-open (ошибка БД → 200 + {} → компонент использует DEFAULT). Калькуляторы-острова делают fetch в onMountHTML рендерится мгновенно с DEFAULT-значениями (без CLS), затем обновляются данными из БД.

Цена в HTML (AEO)

remark-price.mjs превращает директиву :::price{service=X} в <section data-price-block="X"> при сборке. Содержимое — статический HTML с ценой. PriceVisibility.svelte (client

) читает visibility-API и прячет секцию при is_visible===false. Краулер и AI-боты видят цену без JavaScript.

Инфра-гоча: Supabase cutover без грантов

Схему webshturm перенесли на новый хост дампом. PostgREST вернул 403 «permission denied for schema webshturm» — роль service_role не имела USAGE на схему. GRANT был выдан вручную. Урок: после переноса схемы — всегда проверять has_schema_privilege('service_role','<schema>','USAGE') и GRANT на таблицы.

Итог Волны 3

285/285 тестов зелёных. Runtime-e2e подтверждён на проде: UPDATE price_blocks SET is_visible=false в Supabase Studio → блок скрыт на webshturm.ru через 60 секунд без передеплоя. Revert → виден снова.

Что использовали

Astro 5 + Svelte 5 runes + Tailwind 4, Supabase Postgres 17 + PostgREST + RLS, vitest (285 тестов), rehype + remark-directive (глоссарий + директива цен), gray-matter (анти-каннибализм гейт), Gitea Actions (CI, авто-деплой).

Что можем сделать у вас

Если сайт существует в одном режиме, а клиенты — разные (технари и руководители), или нужно управлять ценами без привлечения разработчика — реализуем аналогичную архитектуру на вашем стеке. Обсудить →

Часто задаваемые вопросы

Почему dual-mode реализован через параллельные коллекции, а не client-side тоглом?
Клиентский тогл — одна страница для поисковых краулеров, нельзя индексировать две версии раздельно. Параллельные коллекции `src/content/biz/` дают /biznesu/ как отдельный URL-граф: каждая страница self-canonical, имеет свой title/H1/FAQ, попадает в sitemap и llms.txt самостоятельно. Тогл в Navbar — только UX-переключатель с localStorage+cookie: краулер видит именно тот URL, который запросил.
Как устроен глоссарий-тултип и почему кириллица требовала отдельного решения?
rehype-плагин `rehype-glossary.mjs` обходит AST скомпилированного HTML и оборачивает первое вхождение каждого термина (из `glossary.ts`) в ``. Стандартные word-boundary `\b` в RegExp не работают на кириллице — символы Ё, ё, А-Я не входят в класс `\w`. Плагин использует Unicode lookarounds: `(?
Как цена запекается в HTML и при этом остаётся скрываемой без пересборки?
В markdown :::price{service=X}...::: через remark-directive разворачивается в `
` при сборке. Цена внутри запечена в статический HTML — поисковые и AI-боты видят её без JS (AEO). На клиенте `PriceVisibility.svelte` (client:idle) делает fetch /api/pricing/visibility: если is_visible===false — прячет секцию. API кэшируется 60с, работает fail-open (ошибка БД → 200 + {} → блок остаётся видимым). Правка в Supabase Studio отражается на проде через 60 секунд, без пересборки сайта.
Что такое open-redirect и как его поймали в AudienceToggle?
AudienceToggle вычисляет зеркальный путь через toMirror() и передаёт его в location.assign(). При pathname='/biznesu//evil.com' toMirror() мог вернуть '//evil.com' — protocol-relative URL, который браузер трактует как https://evil.com. Фикс: toMirror() нормализует ведущие слэши через replace(/^\/+/, '/'), компонент дополнительно проверяет путь на /^\//. Поймали автоматическим security-review в ходе code review после слияния всей инфра-фазы.
Что произошло с деплоем после добавления SSR-роутов?
CI синкал только dist/client (статику), а dist/server/entry.mjs (SSR-бандл) оставался на бинд-маунте с даты предыдущего деплоя — 9 мая. Новые /api/pricing/* роуты (prerender=false) живут в dist/server — и возвращали 404, несмотря на «успешный» CI-ран. Баг незаметен на чисто статических страницах: они обновлялись через client-синк. Фикс: деплой переключён на весь dist + smoke-тест добавил SSR-маршрут в список проверок с авто-роллбэком.

Похожая задача?

Расскажите контекст — подскажем, что и как делать.