Facta · Конструктор дашборда · Ш4

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 Принципы и ключевое допущение

  1. Ничего лишнего сверх Ш4. «Модель» как отдельный, переиспользуемый между виджетами каталог измерений/мер (видение SPEC_AUTHORING_UX.md §1–§3) в рантайме сегодня не персистентна: DashboardWidget хранит spec: QuerySpec целиком на себе (types.ts), отдельной сущности «Датасет» на сервере нет. Ш4 работает с тем, что есть: определение измерений/мер происходит **при выборе датасета для виджета** и переиспользуется в рамках одной сессии редактирования дашборда (черновик), а не через отдельный постоянный экран «Модель». Это сознательно уже, чем видение — см. §8 «для фронтендера».
  2. Срезы и общие поля — на уровне источника, не дашборда. PLAN_V1 §3: «Общие поля» живут на источнике, чтобы срезы работали поверх разных датасетов. Холст дашборда может включить/выключить контрол среза для уже существующего общего поля — но не создать новое общее поле. Создание нового общего поля — экран источника/админа, вне Ш4.
  3. Справочники (§5) — часть определения датасета, без UI подключения к виджету напрямую — виджет получает атрибут справочника уже как обычное измерение своего датасета.
Допущение к проверке исследователем. Порядок «тип визуала → лунки» (Power BI) против «поля → тип по умолчанию» (Tableau «Show Me», Looker «сначала поля, вкладка Visualization после»). Решение здесь: тип сначала — см. §3 ниже с обоснованием. Если исследование даст сильные доказательства обратного для новичков без BI-опыта именно на пяти простых типах Facta — пересмотреть; сама архитектура (лунки = функция типа, 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 · выбор таблицы + измерения и меры

Источник и таблица
2Измерения и меры
3Справочники · опционально
pg-prod-01 · public
Продажи
sales · public · ключ времени: order_date

Измерения (4)

Aa region
Aa product
order_date · округление до месяца авто

Меры (3)

Σ amount
Σ count(*)
Σ qty

Слева — каталог источника переиспользует визуальный язык существующих .catalog-tree/.catalog-table-row. Справа — черновик измерений/мер создаётся автоматически при выборе таблицы (текст/дата → измерение, число → мера, служебные *_id → скрыты по умолчанию, помечены «авто»): человек причёсывает, а не строит с нуля, это прямое требование «не ETL». Формы строк — новый компонент .model-row, но построен из тех же токенов, что .catalog-col-row.

Шаг 3 · справочник (§5) · свёрнут по умолчанию

country_code dim_country.code
Ключ уникален — 247 строк, 247 уникальных значений. Добавлено измерение «Регион (dim_country)».

Проверка уникальности ключа переиспользует паттерн .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).

Интерактивный пример · попробуйте типы

Продажи · предпросмотр
Янв Фев Мар Апр Май Июн
Янв Фев Мар Апр Май Июн
$2.84M
Выручка · текущий период
$2.8M выручка
EU42%
NA31%
APAC18%
LATAM9%
ПродуктЗаказыВыручка
Atlas Pro8,412$1,204,900
Orbit Suite6,120$884,300
Beacon5,340$712,050
Ledger X4,880$655,120

Тип

Лунки

Значение1 мера
Σ Выручка
Ось Xизмерение
Месяц
Значения1+ мера
Σ Выручка
Разбивкаизмерение · необязательно
Перетащите измерение сюда
Категория1 измерение · ≤4 для читаемости
Регион
Значение1 мера
Σ Выручка
Колонкиизмерения и меры, по порядку
Продукт Σ Заказы Σ Выручка

Поля датасета «Продажи»

Измерения
  • 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 открывает список подходящих лунок текущего типа, выбор добавляет поле. То же самое действие, второй путь, а не декоративная копия «мышиного» варианта.

Отклонённый приём — сравнение

Ось Xизмерение
Месяц

Обычный приём: измерение → лунка измерения. Принято.

Ось Xизмерение
Σ Выручка
Меры сюда нельзя — здесь нужен разрез

Мера в лунку измерения. Отклонено с объяснением, а не проигнорировано.

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), новых зависимостей не требуется.

Холст · режим редактирования

Продажи и отгрузки Черновик
Срезы
ПериодКв. 1 2026× РегионВсе×
Выручка
$2.84M
За период
Заказы
38,214
За период
Добавить виджет
Выручка по месяцам
Выручка по региону

Наведение/фокус на карточке показывает рамку-акцент + мини-панель (перетащить · редактировать · удалить) — тот же язык, что уже есть у .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 ProEU2026-01$104,900
Atlas ProNA2026-01$88,220
Orbit SuiteEU2026-01$61,050
BeaconAPAC2026-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 без шага «Тип».

Пробелы вне дизайна (нужны бэкендеру/оркестратору)

  1. DashboardWidgetKind не хватает «line». Union сегодня — "kpi" | "bar" | "pie" | "table" (types.ts), а план требует пять типов (§8 Ш4). Рендер уже готов: chart.ts's buildOption уже умеет "line" (просто bar с smooth/areaStyle) — не хватает только расширения union + строки в KIND_TO_WIDGET_TYPE (SpecWidgetCard.tsx) + синхронизации с серверным models.rs.
  2. Публикация. Ни createDashboard, ни updateDashboard не принимают isShared — нужны publish/unpublish эндпоинты, по образцу уже реализованных для reports (см. §5 выше).
  3. Справочник как источник сопоставления общего поля. FieldMapping сегодня — только колонка факта; §5.6 требует сопоставления с атрибутом справочника — нужна новая wire-форма (см. §2 выше).
  4. «Датасет» как переиспользуемая сущность между виджетами одного дашборда, в рамках сессии редактирования — сегодня каждый 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 · внутренний артефакт дизайна. Самодостаточен: открывается в любом браузере без интернета и без сборки.