Я проектирую интерфейс сервиса, который разбирает записи звонков отдела продаж. Запись распознаётся, делится на реплики клиента и менеджера, проверяется по чек-листу, а результат уходит в CRM и на экраны руководителя. Модели и интеграции ведёт команда продукта, моя часть это проектирование и дизайн.

Здесь не будет про модели. Будет про то, как длинный неструктурированный результат обработки звонка превращается в экраны, которые читаются за минуту, и что происходит с дизайн-системой, когда её выгружают из Figma в CSS-переменные. Числа из макетов сняты через Plugin API, код токенов взят из выгрузки, данные на скриншотах демонстрационные или обезличенные.

Пять слоёв данных и минута на звонок

На входе у интерфейса пять слоёв данных по одному звонку. Метаданные (направление, длительность, тип звонка), аудио, транскрипт по репликам с таймкодами, оценка по пунктам чек-листа с текстовыми объяснениями и разбор разговора, который модель пишет свободным текстом. Над звонками стоят агрегаты, то есть оценки по менеджерам, воронка, деньги под риском.

Ограничений четыре. Руководитель открывает панель между встречами, на один звонок у него около минуты. Объём текста на звонок большой, транскрипт длинного разговора занимает несколько экранов. Оценка модели иногда ошибается, и интерфейс должен давать с ней спорить. Наконец, данных много и они плотные. Матрица «пункт чек-листа × менеджер» на реальном отделе получается шириной в два десятка колонок.

Модель экрана звонка

Прежде чем рисовать, я записываю, из чего состоит экран, как структуру. Ниже упрощённая модель карточки звонка, по которой раскладывались блоки. Это не схема API, поля названы так, как они звучат на экране.

type Speaker = "client" | "manager";

type Segment = {
  speaker: Speaker;
  start: number;          // секунды от начала записи
  end: number;
  text: string;
};

type ChecklistItem = {
  question: string;       // «Выявлены ли потребности клиента?»
  status: "passed" | "failed";
  explanation: string | null;  // почему такая оценка, 1-2 предложения; null: ещё считается
};

type CallCard = {
  direction: "incoming" | "outgoing";
  durationSec: number;
  callType: "primary" | "repeat";        // от типа зависит набор пунктов
  audio: { waveform: number[]; rate: 1 | 1.5 | 2 };
  transcript: Segment[];
  checklist: { template: string; score: number; items: ChecklistItem[] };
  analysis: {
    essence: string;
    agreements: string;
    managerErrors: string;
    recommendations: { quote: string; at: number; answer: string }[];
  };
  history: { calls: number; inOut: [number, number]; avgScore: number };
};

Из этой записи следует почти вся компоновка. Транскрипт это массив сегментов с таймкодами, значит, его место в колонке-доказательстве, к которой возвращаются при споре. Чек-лист и разбор это вывод, значит, им отдаётся правая колонка и первое внимание. Тип звонка лежит в шапке рядом с длительностью, потому что от него зависит, по какому шаблону звонок оценён. Первичный и повторный разговор проверяются по разным пунктам, и руководитель должен видеть это до того, как начнёт спорить с оценкой.

Карточка звонка. Сверху шапка с типом и длительностью и запись с волной, слева реплики по таймлайну, справа чек-лист с оценкой и объяснением по каждому пункту.
Карточка звонка. Сверху шапка с типом и длительностью и запись с волной, слева реплики по таймлайну, справа чек-лист с оценкой и объяснением по каждому пункту.

На экране у пункта чек-листа три состояния. Пройден, не пройден и «оценка есть, объяснение ещё считается». Третье в модели не отдельный статус, а готовый status при explanation: null. На скриншоте оно видно на втором пункте, галочка уже стоит, а вместо текста серая полоса-заглушка. Звонок обрабатывается по шагам, и показывать карточку только после последнего шага значит заставлять человека ждать то, что уже готово. Поэтому статус приходит раньше объяснения, а у объяснения есть своё состояние загрузки.

Реплики окрашены по говорящему тонкой полосой слева. Красная у клиента, синяя у менеджера. Подпись «Клиент» или «Менеджер» дублирует цвет, чтобы различие не держалось на одном цвете. Каждая реплика несёт свой интервал, 00:06 - 00:09, это те же start и end из модели.

Разбор по именованным полям

Свободный текст модели легко вывести одним блоком. Его никто не прочтёт. Поэтому разбор приходит разложенным по именованным полям, и у каждого поля постоянное место на экране.

Разбор звонка. Суть, договорённости и ошибки слева, справа реплики клиента с таймкодами и ответы на них.
Разбор звонка. Суть, договорённости и ошибки слева, справа реплики клиента с таймкодами и ответы на них.

Слева три поля с цветной линейкой. Суть, договорённости, ошибки менеджера. Цвет здесь работает как адрес, взгляд находит «ошибки» по жёлтой полосе, не читая заголовков. Справа рекомендации, и у каждой есть цитата клиента и таймкод в виде чипа, 0:49 или 1:01. Таймкод это поле at из модели, по нему реплику находят в записи. Рекомендация без привязки к реплике превращается в общий совет, а с привязкой её можно проверить за десять секунд.

Внизу одной строкой история по номеру. Число менеджеров, входящие и исходящие, средний балл, успешность. Строка нужна, чтобы отличить шестой контакт от первого. «Клиент не готов» на первом звонке и на шестом означают разное, и без истории руководитель оценивает звонок в отрыве от сделки.

Тепловая матрица по операторам

Самый плотный экран это матрица «оператор × пункт чек-листа». Строка это менеджер, колонки это число звонков, буквенный грейд, средний балл и дальше проценты выполнения по каждому пункту.

Матрица чек-листа по операторам, тренд качества и объём звонков по менеджерам. Операторы обезличены.
Матрица чек-листа по операторам, тренд качества и объём звонков по менеджерам. Операторы обезличены.

Разрежать такую таблицу нельзя, пропадёт смысл. По строке видно слабого менеджера, по столбцу слабый пункт скрипта у всего отдела, и это разные проблемы. Первую лечат разговором с человеком, вторую правкой скрипта. Поэтому плотность осталась, а читаемость держится на заливке ячеек. Значение раскладывается в несколько цветовых корзин, а не в плавный градиент. Плавную шкалу на тёмном фоне глаз различает плохо, четыре-пять заметно разных цветов различает сразу. Границы корзин в коде ниже условные, важно устройство.

type Cell = number | null;          // null: пункт ни разу не применялся

function cellTone(v: Cell): Tone {
  if (v === null) return "empty";   // прочерк, без заливки
  if (v >= 1.0)  return "good";     // зелёный
  if (v >= 0.8)  return "ok";       // синий
  if (v >= 0.6)  return "warn";     // оранжевый
  return "bad";                     // красный, включая 0%
}

Главное решение здесь в разнице между 0 и null. Ноль значит, что пункт проверялся и ни разу не был выполнен, это красная ячейка. Прочерк значит, что пункт в звонках этого менеджера не встречался, например пункт про медицинские противопоказания в его разговорах ни разу не возникал. Если склеить эти случаи, матрица покраснеет там, где проблемы нет, и руководитель пойдёт разбирать несуществующие ошибки.

Строки отсортированы по среднему баллу. Грейд стоит отдельной колонкой буквой на цветной плашке. Буква считывается быстрее процента, когда нужно найти в списке слабых. Сам процент рядом остаётся, если надо сравнить двух соседей.

Метрики, которые на слух не различить, показаны формой. Баланс речи это доля времени менеджера и клиента в разговоре, перебивания считаются отдельно для менеджера и клиента, плюс общий процент. Рядом с каждым графиком рейтинг, потому что вопрос руководителя «кто хуже всех», а на график с тридцатью столбиками этот ответ не написан.

Баланс речи, перебивания и тренд по одному пункту чек-листа. Данные обезличены.
Баланс речи, перебивания и тренд по одному пункту чек-листа. Данные обезличены.

Карточка показателя на дашборде

Дашборд потерь собран из одинаковых карточек показателя, и проще всего описать их как компонент с пропсами.

type KpiCard = {
  label: string;              // «Выручка под риском», капсом
  icon: IconName;
  value: string;              // «18,4 млн ₽»
  tone: "loss" | "warn" | "gain" | "accent";
  secondary?: string;         // «12 сделок»
  delta?: { value: string; direction: "up" | "down" };
  baseline: string;           // «к прошлому периоду»
};

Цвет числа задаётся смыслом показателя, а не направлением дельты. Деньги под риском красные, сделки без следующего шага оранжевые, сделки, которые можно вернуть, зелёные, падение конверсии фиолетовым акцентом. Дельта со стрелкой стоит отдельной строкой и берёт цвет карточки. Если красить число по дельте, «выручка под риском выросла» окрашивается в зелёный, потому что рост, и экран начинает врать.

Дашборд потерь. Выручка под риском, сделки без следующего шага, воронка, причины зависания и этапы, где теряются деньги. Данные демонстрационные.
Дашборд потерь. Выручка под риском, сделки без следующего шага, воронка, причины зависания и этапы, где теряются деньги. Данные демонстрационные.

baseline обязательное поле. Без подписи «к прошлому периоду» плюс 23% ничего не значит, и я не хотел оставлять разработке возможность его опустить.

Обязательные поля есть и у сообщения о риске, которое руководитель получает в мессенджер. В нём сделка, сумма, менеджер, уровень риска, причина и одно действие на сейчас. Сообщение «есть риск» без действия пролистывают.

Сообщение о критическом риске. Сделка, сумма, менеджер, уровень риска, причина и действие. Сделка и менеджер вымышленные.
Сообщение о критическом риске. Сделка, сумма, менеджер, уровень риска, причина и действие. Сделка и менеджер вымышленные.

Дизайн-система в Figma. Девять стилей и два компонента

Код токенов, который разбираю дальше, выгружен из макета посадочной страницы сервиса. Экраны админки выше показаны как макеты.

Своих переменных в Figma посадочной страницы нет, только стили. Четыре стиля цвета и пять текстовых.

Стиль

Значение

Применений

Main/1

rgb(20, 14, 33), почти чёрный с фиолетовым

48

Main/2

белый

696

Accent/1

rgb(141, 96, 255), фиолетовый

510

Gradient/1

от rgb(141, 96, 255) к rgb(148, 57, 153)

4

Текстовый стиль

Шрифт

Кегль, интерлиньяж

Применений

15

Martian Mono Medium

15, 140%

1 006

60 H1+H2

Inter Tight SemiBold

60, 100%

93

30 H3

Inter Tight Medium

30, 100%

167

Phone/32 H1+H2

Inter Tight SemiBold

32, 100%

46

Phone/24 H3

Inter Tight Medium

24, 100%

63

Счётчики сняты по секции лендинга, без референсов. Из 1 422 текстовых слоёв в ней без стиля 47. Весь мелкий текст, от навигации до подписей, набран моноширинным Martian Mono, на нём держится техническая интонация. Тёмный фон применён стилем всего 48 раз, он лежит в основном на крупных подложках, а не на каждом элементе.

Компонентов в библиотеке два с вариантами. У кнопки три свойства, у поля ввода два.

Компонент

Свойства

Варианты

Button

Fill, Disaibled, Hover

6 из 8 возможных

Input

Active, Error

3 из 4

В имени свойства опечатка, Disaibled. И обратите внимание на шесть вариантов из восьми. У кнопки нет комбинации «disabled и hover», потому что у отключённой кнопки наведения нет. Это правильно, но об этом знает только тот, кто открыл набор вариантов и пересчитал их.

Выгрузка в CSS-переменные

Из файла была собрана выгрузка токенов в CSS и компонентов на React. Токены легли в три слоя. Первый это примитивы с префиксом продукта, второй семантические псевдонимы, которые ссылаются на примитивы.

:root {
  /* примитивы */
  --alc-bg-base: rgb(20, 14, 33);
  --alc-violet-500: rgb(141, 96, 255);
  --alc-violet-500-90: rgba(141, 96, 255, 0.9);
  --alc-white-05: rgba(255, 255, 255, 0.05);  /* заливка карточки на тёмном */
  --alc-white-10: rgba(255, 255, 255, 0.1);   /* тонкая линия, трек слайдера */
  --alc-white-20: rgba(255, 255, 255, 0.2);   /* обводка карточки */
  --alc-white-50: rgba(255, 255, 255, 0.5);   /* второстепенный текст */
  --alc-red-500: rgb(255, 96, 98);

  /* семантика */
  --color-bg-page: var(--alc-bg-base);
  --color-bg-card: var(--alc-white-05);
  --color-border-card: var(--alc-white-20);
  --color-text-primary: var(--alc-white);
  --color-text-secondary: var(--alc-white-50);
  --color-accent: var(--alc-violet-500);
  --color-accent-solid: var(--alc-violet-500-90);
  --color-error: var(--alc-red-500);

  /* состояния через тени, а не через border */
  --shadow-focus-ring: inset 0 0 0 2px var(--alc-violet-500);
  --shadow-error-ring: inset 0 0 0 2px var(--alc-red-500);

  /* типографика сразу шорткатом font */
  --text-body: 500 15px/1.4 var(--font-mono);
  --text-heading-2: 500 30px/1 var(--font-display);
  --text-display-1: 600 60px/1 var(--font-display);
}

Два решения здесь мне нравятся до сих пор. Серых в интерфейсе нет, вместо них белый с прозрачностью 5, 7, 10, 20 и 50%. На тёмном фиолетовом фоне это даёт оттенки, которые всегда в одной гамме с фоном, а серый поверх фиолетового выглядит грязно. И обводки сделаны внутренней тенью inset 0 0 0 2px. Тень не участвует в блочной модели, поэтому смена обводки при ошибке или фокусе не сдвигает соседей на два пикселя.

Типографические токены собраны шорткатом font, то есть начертание, кегль, интерлиньяж и семейство одной переменной. В компоненте это одна строка, font: var(--text-body), и нельзя случайно взять кегль от одного стиля, а интерлиньяж от другого.

Третий слой получился сам и мне не нравится. Рядом с лендингом в том же файле лежит секция референсов, экраны, скопированные из чужих макетов. В ней 431 слой привязан к 170 переменным подключённой библиотеки, и названы эти переменные по сырым числам, item spacing/17_2 или color/white/ 47%. Выгрузка шла по файлу целиком, а не по лендингу, и забрала их вместе с нашими токенами. Отсюда и подпись слоя «Generated from Figma Variables». Всего в выгрузке 217 переменных. Хотя бы раз через var() вызывается 51 из них, считая ссылки внутри самих токенов. Остальные 166 не использует ни один компонент. Вот несколько.

--item-spacing-17-2: 17.205;
--item-spacing-9-42: 9.415;
--letter-spacing-3-36: -3.36;
--color-white-47: rgba(255,255,255,0.47);
--color-white-66: rgba(255,255,255,0.66);

К лендингу эти значения отношения не имеют. Отступ 17,205 и белый 47% пришли из чужого макета, где их, похоже, перевели в переменные автоматом, по одной на каждое встретившееся число. Пока они лежат в выгрузке переменными, разработчик вправе ими пользоваться, и система расползается уже в коде. В блоке тем для светлого и тёмного режима та же беда. Переменная --colors-indigo объявлена в .dark дважды с разными значениями, сработает последняя, и, судя по именам, это пришло из набора системных компонентов iPhone, который лежал в макете ради мокапов.

Компоненты и их состояния в коде

Кнопка в выгрузке, если оставить главное.

function Button({ variant = "fill", disabled = false, ...rest }) {
  const base = {
    height: 61,
    padding: "20px 20px",
    borderRadius: "var(--radius-md)",
    font: "var(--text-button)",
    opacity: disabled ? 0.5 : 1,
    transition: "background 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease",
  };
  const variants = {
    fill:     { background: "var(--color-accent-solid)", backdropFilter: "blur(var(--blur-button))" },
    outline:  { background: "transparent", boxShadow: "var(--shadow-focus-ring)" },
  };
  return (
    <button
      disabled={disabled}
      style={{ ...base, ...variants[variant] }}
      onMouseEnter={e => { if (!disabled && variant === "fill")
        e.currentTarget.style.background = "var(--color-accent-gradient)"; }}
      onMouseLeave={e => { if (!disabled && variant === "fill")
        e.currentTarget.style.background = "var(--color-accent-solid)"; }}
      {...rest}
    />
  );
}

Все числа совпадают с Figma. Высота 61, внутренние отступы 20, радиус 10, размытие фона 10, отключённое состояние это 50% прозрачности. Свойство Fill=Yes/No стало variant="fill" | "outline", и опечатка Disaibled на этом шаге исчезла, потому что имя пропса писалось заново, а не копировалось из Figma. Отсюда правило. Имена свойств компонентов в макете стоит сразу писать так, как они будут называться в коде.

Наведение сделано через onMouseEnter и onMouseLeave с прямой записью в style. Это работает, но ломается в трёх местах. Состояние не видно клавиатуре, у фокуса с клавиатуры наведения нет. На сенсорных экранах браузер эмулирует события мыши, mouseenter срабатывает при касании, а mouseleave только когда коснёшься другого места, и кнопка остаётся в состоянии наведения после нажатия. И градиент через background не анимируется, transition на нём не работает, поэтому смена резкая, хотя переход прописан. Клавиатура и тач решаются в CSS.

.btn-fill { background: var(--color-accent-solid); }

@media (hover: hover) and (pointer: fine) {
  .btn-fill:hover { background: var(--color-accent-gradient); }
}
.btn-fill:focus-visible { box-shadow: var(--shadow-focus-ring); }

Градиент и здесь сменится скачком. Чтобы он проявлялся плавно, его кладут отдельным слоем, например в ::before, и анимируют opacity.

С полем ввода вышло расхождение в другую сторону. В Figma у него три состояния. Обычное с фиолетовой обводкой, активное с белой и ошибка с красной. В коде два, обычное и ошибка. Активного нет, поле в фокусе выглядит так же, как без него. Обводка в выгрузке называется --shadow-focus-ring, но стоит на поле всегда, то есть фокус-кольцо, которое не показывает фокус. Вдобавок у поля стоит outline: none, так что браузерного кольца тоже нет. Решение снова в CSS-состоянии.

.input        { box-shadow: var(--shadow-focus-ring); }
.input:focus  { box-shadow: inset 0 0 0 2px var(--alc-white); }
.input[aria-invalid="true"] { box-shadow: var(--shadow-error-ring); color: var(--color-error-text); }

aria-invalid здесь не для красоты. Ошибку видит не только глаз, но и экранный диктор, а стиль привязывается к тому же признаку, по которому её объявляют.

От чего отказался и что сделал бы иначе

Отказался от калькулятора тарифов на посадочной странице. Для него в библиотеке был компонент полосы значения, RangeDisplay, статичный трек с ползунком, который можно было превратить в настоящий слайдер. Когда тарифы пересобрали по минутам разговоров, конфигуратор с ползунками из макета ушёл, а компонент остался в выгрузке без применения.

Отказался от плавной цветовой шкалы в матрице в пользу корзин. Плавная шкала точнее, но на тёмном фоне её оттенки сливаются, а руководителю нужно быстро найти красное.

Отказался показывать транскрипт первым, хотя он самый осязаемый результат распознавания. Транскрипт это доказательство, его открывают при споре, а начинают с вывода.

Сейчас я завёл бы переменные Figma вместо стилей, с самого начала с режимами светлой и тёмной темы, и выгружал бы токены только со страницы лендинга. Референсы держал бы отдельным файлом, тогда в выгрузку не попали бы 166 чужих и мёртвых переменных из 217. Описывал бы состояния компонентов через CSS-псевдоклассы и атрибуты, а не через JS-обработчики, и проверял бы, что каждый вариант из Figma дошёл до кода, простым сравнением списков. Вариантов у кнопки шесть, у поля три, такая проверка занимает пять минут, а потерянное состояние фокуса без неё нашлось только при разборе для этой статьи.

Оценка модели ошибается чаще всего на спорных пунктах, где и два руководителя ответят по-разному. Кнопки «согласен» и «не согласен» стоят прямо над чек-листом, а не в настройках, и ответы копятся как правила конкретного отдела. Но если руководитель молчит, оценки остаются общими. Интерфейс тут может только сделать ответ дешёвым, заставить отвечать он не может.

Комментарии (1)


  1. kubk
    27.10.2026 05:16

    Что дизайн, что текст, что код - всё сгенерировано через ИИ. Какую ценность это представляет если промпты писать может каждый?

    Не знаю что у вас там за Figma и дизайн-система - на выходе полный вайбкод - все возможные LLM штампы, беспорядочно разбросанные элементы и отсутствие выравнивания