Facta · дизайн-спека и мок · PLAN_V1.md §8 Ш4
Конструктор дашборда: датасет → виджет → холст → публикация
Цель Ш4 дословно из плана: «дашборд из десятка виджетов собирается с нуля мышкой за рабочий день, без SQL». Ничего здесь не выдумано заново — экраны собраны из уже существующей темы и уже существующих компонентов рантайма (WidgetCard, SpecWidgetCard, SliceBar, SliceBadge, DemoDashboard, DashboardHost), плюс минимум нового, отмеченного явно.
Основания: PLAN_V1.md §3 (объектная модель), §4 (срезы), §5 (справочники), §8 Ш4 и блок «Ш3+» · DECISIONS.md Блок E · apps/bi_web/src/types.ts, styles.css, компоненты apps/bi_web/src/components/* · SPEC_AUTHORING_UX.md (видение уровня продукта, использовано выборочно — часть его экранов шире, чем Ш4).
0 Принципы и ключевое допущение
- Ничего лишнего сверх Ш4. «Модель» как отдельный, переиспользуемый между виджетами каталог измерений/мер (видение
SPEC_AUTHORING_UX.md§1–§3) в рантайме сегодня не персистентна:DashboardWidgetхранитspec: QuerySpecцеликом на себе (types.ts), отдельной сущности «Датасет» на сервере нет. Ш4 работает с тем, что есть: определение измерений/мер происходит **при выборе датасета для виджета** и переиспользуется в рамках одной сессии редактирования дашборда (черновик), а не через отдельный постоянный экран «Модель». Это сознательно уже, чем видение — см. §8 «для фронтендера». - Срезы и общие поля — на уровне источника, не дашборда. PLAN_V1 §3: «Общие поля» живут на источнике, чтобы срезы работали поверх разных датасетов. Холст дашборда может включить/выключить контрол среза для уже существующего общего поля — но не создать новое общее поле. Создание нового общего поля — экран источника/админа, вне Ш4.
- Справочники (§5) — часть определения датасета, без UI подключения к виджету напрямую — виджет получает атрибут справочника уже как обычное измерение своего датасета.
Encoding в types.ts уже завязан на WidgetType) это позволяет без переписывания модели.
1 Исследование: что решают зрелые продукты, и какая боль остаётся
CONFIRMED (публичная документация/общеизвестное поведение продукта):
- Power BI — «визуал сначала»: панель Visualizations требует выбрать тип, дальше в неё раскрываются лунки полей под этот тип (Microsoft Learn, «Add visualizations»). Аналитики отмечают, что это делает старт понятнее для новичков, знакомых с офисными инструментами Microsoft.
- Tableau — «поля сначала»: поля тащатся на Rows/Columns/Marks, «Show Me» подсказывает тип по составу полей. Задокументированная боль: свобода перегружает новичка — можно разместить поля, но не понять, почему график выглядит неправильно.
- Looker — «поля сначала»: в Explore сначала выбираются dimensions/measures через поиск по полям, тип графика настраивается отдельно на вкладке Visualization уже после того, как запрос собран (Google Cloud Looker docs, «Creating and editing Explores»/«Retrieve and chart data»).
- Metabase/Superset — «визуал сначала» в мастере создания нового чарта (выбор датасета → выбор типа визуализации → форма редактора зависит от типа).
Задокументированная боль, которую называет сам рынок (форумы/обзоры, уже собранные в DECISIONS.md Блок E для срезов — тот же метод применён здесь к типу визуала): у Tableau — «разместил поля мышкой, но не понимаю, почему график выглядит неправильно»; у Power BI — известный класс проблем «поле стало недоступно/пропало после смены типа визуала» (community-треды, MS Learn «Troubleshoot visualizations»), когда новый тип не поддерживает роль, которую поле играло в старом.
Вывод для Facta (inferred, к проверке исследователем). У Facta пять типов и простые фиксированные роли лунок (KPI: значение; столбцы/линия: ось X + значения + разбивка; круговая: категория + значение; таблица: колонки) — размер решения гораздо меньше, чем у Tableau. При маленьком выборе типов минус «визуал-сначала» (нужно один раз передумать и сменить тип) обходится дешевле, чем минус «поля-сначала» (свобода без ограничений на старте, когда лунок ещё нет вовсе). Плюс это уже физически то, как устроена существующая модель: Encoding/DashboardWidgetKind в types.ts уже типозависимы — лунки не существуют без выбранного типа. Поэтому: тип выбирается первым, как в Power BI/Superset; смена типа после заполнения лунок остаётся возможной, но подсвечивает несовместимые лунки, а не молча роняет поля (см. §3.5) — это и есть урок из задокументированной боли Power BI.
Что Facta забирает как принцип, не как готовый экран: разделение «структурная форма первая, SQL вторым» (PLAN_V1 §2.3) — здесь это буквально «показать SQL» как читаемая, но не редактируемая дверь наружу (§3.6), а не редактор запроса. И принцип E1 (DECISIONS.md) — «применимость видна постоянно, а не только автору» — здесь это работает и в режиме автора: лунки типизированы так же строго, как строится срез, ошибка видна сразу, а не после сохранения.
2 Экран 1 · Датасет: выбор и определение
Точка входа: «+ Виджет» на холсте, если у дашборда ещё нет ни одного датасета в этой сессии редактирования, ИЛИ «Новый датасет» из выбора датасета внутри редактора виджета (§3). Три шага, лёгкие — это не ETL (DECISIONS.md Решение 3): выбор таблицы, разметка измерений/мер (черновик генерируется автоматически, человек причёсывает), опционально — справочник.
Шаг 1–2 · выбор таблицы + измерения и меры
Измерения (4)
Меры (3)
Слева — каталог источника переиспользует визуальный язык существующих .catalog-tree/.catalog-table-row. Справа — черновик измерений/мер создаётся автоматически при выборе таблицы (текст/дата → измерение, число → мера, служебные *_id → скрыты по умолчанию, помечены «авто»): человек причёсывает, а не строит с нуля, это прямое требование «не ETL». Формы строк — новый компонент .model-row, но построен из тех же токенов, что .catalog-col-row.
Шаг 3 · справочник (§5) · свёрнут по умолчанию
Проверка уникальности ключа переиспользует паттерн .test-result.ok и делает ровно то, что требует PLAN_V1 §5.3 — «проверка, а не доверие» — синхронным запросом при объявлении. Присоединение только «многие-к-одному», всегда LEFT — в UI нет выбора направления джойна вообще, потому что опасного направления не существует как опции (§5.4). Атрибут справочника после проверки становится обычным измерением датасета (появляется в модели выше) — виджету всё равно, откуда взято измерение.
FieldMapping сегодня сопоставляет общее поле только с колонкой факта (комментарий в types.ts: «a lookup-attribute mapping (§5.6) is a server-only wire shape with no authoring UI yet»). Чтобы общее поле могло сопоставляться с атрибутом справочника (§5.6, «срез «Регион» работает на датасете, где региона физически нет»), нужно расширить wire-форму сопоставления — сам экран это не решает, только предполагает готовность модели.
3 Экран 2 · Редактор виджета: лунки, тип, предпросмотр, SQL
Главный авторский экран. Полноэкранный, а не маленькое модальное окно — панели полей и предпросмотру нужен простор, которого 560px-модалка (.modal) не даёт. Слева/по центру — крупный живой предпросмотр на реальных токенах .widget-card; справа — панель «тип → лунки → поля источника → SQL», переиспользует существующий .panel (320px, правый док).
Ниже — рабочий пример. Нажмите тип визуала: лунки, предпросмотр и SQL меняются вместе, потому что в реальном коде они и являются функцией одного и того же widget.kind (см. KIND_TO_WIDGET_TYPE в SpecWidgetCard.tsx).
Интерактивный пример · попробуйте типы
Тип
Лунки
Поля датасета «Продажи»
- AaРегион
- AaПродукт
- Месяц
- ΣВыручка
- ΣЗаказы
- ΣЕдиницы
Показать SQL
SELECT date_trunc('month', s.order_date) AS "Месяц",
sum(s.amount) AS "Выручка"
FROM sales AS s
WHERE s.order_date >= DATE '2026-01-01'
AND s.order_date < DATE '2026-04-01' -- срез «Период»
GROUP BY 1
ORDER BY 1
LIMIT 100000
Всё во врезке — реальные токены и классы: предпросмотр переиспользует ровно те SVG-паттерны, что уже нарисованы в design/facta-dashboard.html (столбцы/подписи месяцев), плюс новая, но той же грамматики .chart-line и donut через stroke-dasharray (part-to-whole, ≤4 сегмента, bars/оси от нуля — dataviz-скилл). Панель «Тип → Лунки → Поля → SQL» переиспользует .panel/.viz-types/.well/.fields-list/.sql-disclosure дословно. Переключение — единственный настоящий JS в файле, показывает, что лунки/предпросмотр/SQL синхронно меняются с типом, а не рисуются отдельно для каждого случая.
3.1 Взаимодействие: перетаскивание и клавиатура
- Поле в списке источника перетаскивается (
draggable) в подходящую по роли лунку; лунка, принимающая курсор, получает.is-dropzone(пунктир, чуть темнее фон) — обратная связь «сюда можно» ещё до отпускания. - Несовместимый тип поля отклоняется явно, не молча. Мера, перетащенная в лунку измерения (или наоборот), не принимается: лунка на миг подсвечивается
--bad, появляется.well-errorс текстом «Меры сюда нельзя — здесь нужен разрез» (пример ниже). Это прямая реализация правила изSPEC_AUTHORING_UX.md§4 — «перетаскивание меры в слот измерения не разрешено — типы известны из модели», и то же самое устраняет задокументированную боль Power BI: поле не «пропадает» тихо, оно либо принято, либо явно отказано. - Клавиатурная альтернатива обязательна (HTML5 drag-and-drop недоступен с клавиатуры): у каждого поля в списке — кнопка «+» (
.field-add-btn, видна по фокусу/hover), по нажатию/Enter открывает список подходящих лунок текущего типа, выбор добавляет поле. То же самое действие, второй путь, а не декоративная копия «мышиного» варианта.
Отклонённый приём — сравнение
Обычный приём: измерение → лунка измерения. Принято.
Мера в лунку измерения. Отклонено с объяснением, а не проигнорировано.
3.2 Смена типа после того, как лунки уже заполнены
Совместимые роли переносятся автоматически (Ось X → Категория при переходе столбцы → круговая; первая мера из «Значения» → «Значение»). Поля, для которых в новом типе роли не нашлось (например вторая мера при переходе к круговой, где «Значение» — одна), не удаляются молча: остаются в панели «Поля датасета» как обычно доступные, и появляется разовая подсказка «Не поместилось в «Круговая»: Заказы» под лунками — ровно то предупреждение, которого не хватает Power BI по задокументированным жалобам (§1).
3.3 Состояния предпросмотра
- Пусто (ни одна обязательная лунка не заполнена) —
.editor-empty-hint: «Перетащите поля в лунки, чтобы увидеть график». - Загрузка — идентично рантайму:
.widget-empty«Loading…» (тот же текст/класс, что вWidgetCard/SpecWidgetCardуже сегодня). - Ошибка компиляции/гейта —
.widget-err, тот же класс, что уже используется для ошибок виджета. - Урезано лимитом строк —
.table-footer.warn, тот же паттерн, что уже вWidgetCard. - Срез дашборда к предпросмотру не применяется вовсе — виджет ещё не на холсте, поэтому «срез не применён» здесь неуместен; бейдж появляется только после того, как виджет размещён на холсте (§4).
3.4 «Показать SQL»
Единственная дверь наружу: только для чтения, компилятор эмитит SQL из уже собранного QuerySpec (PLAN_V1 §2.3 — «структурная форма предлагается первой, SQL — вторым»; здесь SQL не редактируется вовсе, только показывается — редактируемый SQL-датасет остаётся отдельным, более высоким уровнем, вне Ш4). переиспользует .sql-disclosure/.sql-block дословно из существующей темы.
4 Экран 3 · Холст в режиме редактирования
Тот же react-grid-layout, что уже рендерит DemoDashboard/DashboardCanvas сегодня, — сейчас с isDraggable={false}/isResizable={false} (только просмотр). Режим редактирования переключает оба в true: CSS для ручек уже подключена глобально (react-grid-layout/css/styles.css + react-resizable/css/styles.css, оба импортированы в main.tsx), новых зависимостей не требуется.
Холст · режим редактирования
Наведение/фокус на карточке показывает рамку-акцент + мини-панель (перетащить · редактировать · удалить) — тот же язык, что уже есть у .widget-card.selected в styles.css (акцентная рамка + мягкая тень), плюс новая .edit-toolbar, построенная из уже стилизованных .widget-action/.widget-x. «Редактировать» открывает Экран 2 с уже заполненными лунками этого виджета. Пустая плитка «+ Добавить виджет» встаёт в сетку как обычный элемент (не плавающая кнопка поверх) — так автор сразу видит, где именно появится новый виджет.
Срезы на холсте. «+ Добавить срез» открывает список общих полей источника, ещё не показанных на этом дашборде (§0.2 — новое общее поле здесь не создаётся, только включается видимость уже существующего). Крестик на активном срезе убирает его контрол с дашборда — сам общий поле не удаляется на уровне источника.
4.1 Пустой холст
Начните с первого виджета
Выберите датасет, перетащите поля в лунки — и он появится здесь. Десять виджетов собираются за рабочий день, без единой строки SQL.
переиспользует .module-empty-inner и EmptyChartIcon дословно из существующей темы (сегодня не задействованы ни в одном реальном экране — первое настоящее применение).
5 Экран 4 · Черновик и публикация
Модель данных уже почти готова. DashboardMeta/DashboardDetail уже несут поле isShared («Published for everyone in the tenant to see (reserved; not yet wired)», types.ts) — Ш4 наконец его использует, вместо того чтобы изобретать новое поле. Редактирование — локальный буфер: тот же паттерн, что уже работает в DemoDashboard для activeSlices (копия из spec, ничего не летит на сервер до явного действия) и в ScenarioBar (сохранение — отдельный явный шаг). Ничего нового по механике, только применение её к виджетам/сетке.
- «Сохранить черновик» — фиксирует локальный буфер через уже существующий
PUT /dashboards/:id(updateDashboard), видимость (isShared) не трогает. - «Опубликовать» — отдельное, редкое, охраняемое действие: меняет видимость на «весь тенант» — подтверждение обязательно (модалка ниже), потому что необратимо по последствиям (увидели все, а не «настройка потерялась»).
- Несохранённые изменения — точка
.unsaved-dotв тулбаре (как только буфер разошёлся с последним сохранённымspec) + подтверждение при попытке уйти/переключить дашборд/закрыть вкладку (beforeunload).
Подтверждение публикации
Уход с несохранёнными изменениями
Обе модалки переиспользуют .modal-overlay/.modal дословно.
createDashboard, ни updateDashboard сегодня не принимают isShared (api.ts) — писать это поле нечем. Рекомендация: отдельные POST /dashboards/:id/publish / /unpublish, а не расширение PUT — этот паттерн уже реализован для reports («паттерн владения копируется дословно из reports: owner_user_id + is_shared + publish/unpublish», CONCEPT_dashboard_authoring.md §4 вариант C) и явное действие безопаснее скрытого побочного эффекта общего PUT. Ролевое ограничение «кто может публиковать» намеренно не проектируется здесь — четырёхролевая модель (Решение 10, DECISIONS.md) отложена до M2 и не относится к Ш4; пока публикация доступна владельцу дашборда, как и остальной CRUD.
6 Экран 5 · Быстрая выгрузка (ad-hoc, без сохранения дашборда)
Отзыв №4, часть «один факт» (PLAN_V1 §8 Ш4). Колонки одного датасета (факт + атрибуты справочников §5) → предпросмотр → выгрузка, без создания виджета и без сохранения дашборда. Ровно тот же экран источника полей, что и Экран 2 (§3) — тот же .fields-list, та же логика перетаскивания, — но лунка одна («Колонки», без разделения на измерения/меры), нет шага «Тип», путь заканчивается на экспорте, а не на «Добавить на холст». Колонки только из ОДНОГО факта — мультифакты вне этапа 1 (§9, Решение 5) — поэтому нет второго выбора датасета на этом экране в принципе, не только по UX-решению.
Быстрая выгрузка
Предпросмотр
| Продукт | Регион | Месяц | Выручка |
|---|---|---|---|
| Atlas Pro | EU | 2026-01 | $104,900 |
| Atlas Pro | NA | 2026-01 | $88,220 |
| Orbit Suite | EU | 2026-01 | $61,050 |
| Beacon | APAC | 2026-01 | $40,410 |
Поля датасета «Продажи»
- AaКанал
- ΣЗаказы
- ΣЕдиницы
Экспорт переиспользует тот же принцип, что SpecExportMenu/export_query уже реализуют сегодня (Ш3+, отзыв №2): выгрузка перезапрашивает данные под собственным, большим лимитом строк, а не берёт то, что показано в предпросмотре. Разница только в источнике спека — здесь он собирается на лету из выбранных колонок, не сохраняется как DashboardWidget/DashboardSpec нигде.
7 Сводка состояний
Каждое состояние ниже уже стилизовано в существующей теме — Ш4 не добавляет новых регистров, только новые места их применения.
- Загрузка (предпросмотр виджета, предпросмотр выгрузки) —
.widget-empty«Loading…». - Пусто (лунки не заполнены / холст без виджетов / список колонок выгрузки пуст) —
.editor-empty-hint/.module-empty-inner. - Ошибка (гейт/компилятор отклонил спек) —
.widget-err. - Урезано лимитом —
.table-footer.warn, с явным текстом причины (как сегодня вWidgetCard.TableFooter). - Срез не применён —
SliceBadgeбез изменений: срабатывает на холсте автоматически, как только виджет туда добавлен, потому что использует ту же паруunappliedSlices/fieldMappings, что и просмотр. - Несовместимое поле в лунке — новое, §3.1:
.well.is-invalid+.well-error, использует--bad/--bad-soft/--bad-border, уже в токенах. - Несохранённые изменения — новое, §5:
.unsaved-dot(--caution) + модалка на выходе.
7.1 Доступность
- Перетаскивание — мышиный путь по умолчанию (цель Ш4 — «мышкой, без SQL»), но у каждого поля источника есть клавиатурная альтернатива: кнопка «+», доступная по Tab, открывающая список валидных лунок (§3.1). Без неё поле в принципе нельзя добавить с клавиатуры — это не «улучшение», а минимально необходимое.
- Отклонённый приём поля не кодируется только цветом: текст
.well-errorназывает причину словами, лунка дополнительно возвращается к обычному виду через ~1.5с (не остаётся навсегда красной). - Ручки изменения размера (
react-resizable) видны по фокусу карточки, не только по наведению — читерская мышь не единственный способ узнать, что виджет resizable. - Кнопки в
.edit-toolbarполучаютaria-label(«Удалить виджет «Выручка»», не просто «Удалить») — важно, когда на холсте десять одинаковых по форме карточек. - Модалка публикации фокус-ловушка +
Escapeзакрывает как «Отмена» — существующий паттерн.modal-overlay(см. использование в остальном приложении). - Кольцо фокуса — общее,
:focus-visible { outline: 2px solid var(--accent) }, не переопределяется нигде в новых компонентах.
8 Для фронтендера: что переиспользуется, что новое
Переиспользуется без изменений
WidgetCard/SpecWidgetCard/renderBody— предпросмотр и рендер виджета на холсте не переписываются; редактор строит тот жеWidget/DashboardWidget, что уже потребляют эти компоненты.SliceBar,SliceBadge,ScenarioBar— без изменений; холст в режиме редактирования добавляет только «+ Добавить срез» рядом (новая, маленькая надстройка, не замена).react-grid-layout— тот же компонент/тот же CSS-импорт, что уже вDemoDashboard/DashboardCanvas; меняются только пропсы (isDraggable/isResizable/static).SpecExportMenu/exportDatasetQuery— быстрая выгрузка (Экран 5) вызывает тот же endpoint с спеком, собранным на лету, а не написанной заново выгрузкой.- Токены/CSS:
.panel,.well/.chips/.chip,.fields-list/.type-badge,.viz-types/.viz-type,.sql-disclosure/.sql-block,.modal-overlay/.modal,.module-empty-inner,.catalog-tree/.catalog-table-row(как визуальный язык для.ds-tree/.ds-table-row),.widget-x,.slice-picker-trigger— все уже вstyles.css, ни один цвет не добавлен.
Новые компоненты (CSS ниже готов к переносу)
.ds-stepper/.ds-tree/.ds-detail/.model-row/.lookup-card— экран определения датасета (§2). Логика: черновик измерений/мер генерируется от структуры таблицы (текст/дата/число), правится по месту..editor-shell/.editor-panel/.wells-set/.preview-variant— редактор виджета (§3); лунки/предпросмотр переключаются отwidget.kind, компонентно это, скорее всего,switchнаkind, выбирающий набор лунок, а не пять независимых форм..well.is-dropzone/.well.is-invalid/.well-error/.ghost-chip— состояния drag-and-drop (§3.1); реальная реализация — скорее всего HTML5 DnD API либо@dnd-kit, конкретную библиотеку решает фронтендер, но контракт состояний (dropzone/invalid/error-текст) заложен здесь..canvas-toolbar/.pill.draft/.pill.published/.unsaved-dot— тулбар черновик/публикация (§5)..edit-overlay/.edit-toolbar/.drag-handle/.add-widget-tile— режим редактирования холста (§4)..add-slice-btn/.slice-remove-x— авторская надстройка над.slice-bar(§4)..quickexport-shell/.qe-well— быстрая выгрузка (§6), по сути урезанный.editor-shellбез шага «Тип».
Пробелы вне дизайна (нужны бэкендеру/оркестратору)
DashboardWidgetKindне хватает «line». Union сегодня —"kpi" | "bar" | "pie" | "table"(types.ts), а план требует пять типов (§8 Ш4). Рендер уже готов:chart.ts'sbuildOptionуже умеет"line"(простоbarсsmooth/areaStyle) — не хватает только расширения union + строки вKIND_TO_WIDGET_TYPE(SpecWidgetCard.tsx) + синхронизации с сервернымmodels.rs.- Публикация. Ни
createDashboard, ниupdateDashboardне принимаютisShared— нужныpublish/unpublishэндпоинты, по образцу уже реализованных дляreports(см. §5 выше). - Справочник как источник сопоставления общего поля.
FieldMappingсегодня — только колонка факта; §5.6 требует сопоставления с атрибутом справочника — нужна новая wire-форма (см. §2 выше). - «Датасет» как переиспользуемая сущность между виджетами одного дашборда, в рамках сессии редактирования — сегодня каждый
DashboardWidgetнесёт полныйQuerySpecна себе, без общего родителя. Для Ш4 этого достаточно (виджет самодостаточен), но клиенту редактора нужен собственный, чисто фронтовый кэш «датасет → измерения/меры этой сессии», чтобы второй виджет на той же таблице не заставлял пользователя переопределять меры заново. Персистентная серверная сущность «Датасет» (видениеSPEC_AUTHORING_UX.md) — за пределами Ш4.
Показать НОВЫЙ CSS (готов к вставке в styles.css)
/* dataset definition — §2 */
.ds-stepper { display:flex; align-items:center; gap:8px; padding:12px 16px; border-bottom:1px solid var(--border); background:var(--surface); font-size:12px; }
.ds-step { display:flex; align-items:center; gap:7px; color:var(--ink-faint); }
.ds-step-num { width:19px; height:19px; border-radius:50%; background:var(--surface-3); color:var(--ink-muted); font-size:10.5px; font-weight:700; display:flex; align-items:center; justify-content:center; }
.ds-step.done .ds-step-num { background:var(--good-soft); color:var(--good); }
.ds-step.active { color:var(--ink); }
.ds-step.active .ds-step-num { background:var(--accent); color:var(--accent-ink); }
.model-row { display:flex; align-items:center; gap:10px; background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:8px 10px; margin-bottom:6px; }
.model-row.is-hidden { opacity:.5; }
.model-row-agg select { padding:5px 6px; font-size:11.5px; }
.lookup-card { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:12px 14px; margin-bottom:10px; }
.add-lookup-btn { border:1px dashed var(--border-strong); background:transparent; color:var(--ink-muted); border-radius:10px; padding:9px 12px; font-size:12.5px; width:100%; text-align:left; }
.add-lookup-btn:hover { border-color:var(--accent); color:var(--accent); }
/* widget editor — §3 */
.editor-shell { display:flex; min-height:560px; }
.editor-panel { width:320px; flex:none; border-left:1px solid var(--border); background:var(--surface-2); overflow-y:auto; padding:16px; }
.well.is-dropzone { border-style:dashed; border-color:var(--border-strong); background:var(--surface-2); }
.well.is-invalid { border-color:var(--bad-border); background:var(--bad-soft); }
.well-error { font-size:11px; color:var(--bad); margin-top:6px; }
.ghost-chip { display:inline-flex; align-items:center; gap:6px; border:1px dashed var(--bad-border); color:var(--bad); border-radius:7px; padding:4px 8px; font-size:12px; opacity:.85; }
.field-add-btn { flex-shrink:0; width:20px; height:20px; border-radius:5px; border:1px solid var(--border); background:transparent; color:var(--ink-muted); display:none; align-items:center; justify-content:center; font-size:13px; }
.fields-list li:hover .field-add-btn, .fields-list li:focus-within .field-add-btn { display:inline-flex; }
/* canvas edit mode — §4 */
.canvas-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 18px; border-bottom:1px solid var(--border); background:var(--surface); }
.pill.draft { color:var(--caution); background:var(--caution-soft); border:1px solid var(--caution-border); }
.pill.published { color:var(--good); background:var(--good-soft); border:1px solid var(--good-border); }
.unsaved-dot { width:7px; height:7px; border-radius:50%; background:var(--caution); }
.edit-overlay { position:absolute; inset:0; border-radius:var(--radius-md); border:1.5px solid transparent; pointer-events:none; }
.edit-card:hover .edit-overlay, .edit-card.is-focused .edit-overlay { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent), 0 8px 30px var(--accent-soft); }
.edit-toolbar { position:absolute; top:-13px; right:10px; display:none; align-items:center; gap:4px; background:var(--surface-3); border:1px solid var(--border-strong); border-radius:8px; padding:3px; box-shadow:var(--shadow-pop); }
.edit-card:hover .edit-toolbar, .edit-card.is-focused .edit-toolbar { display:flex; }
.add-widget-tile { border:1.5px dashed var(--border-strong); border-radius:var(--radius-md); background:transparent; color:var(--ink-muted); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; font-size:12.5px; width:100%; height:100%; }
.add-widget-tile:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
.add-slice-btn { display:inline-flex; align-items:center; gap:5px; height:30px; padding:0 10px 0 8px; border-radius:999px; border:1px dashed var(--border-strong); background:transparent; color:var(--ink-muted); font-size:12.5px; }
.add-slice-btn:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
/* ad-hoc export — §6 */
.quickexport-shell { display:flex; min-height:420px; }
.quickexport-panel { width:280px; flex:none; border-left:1px solid var(--border); background:var(--surface-2); padding:16px; }
Facta · внутренний артефакт дизайна. Самодостаточен: открывается в любом браузере без интернета и без сборки.