Токены, состояния и тепловая матрица. Как я раскладывал по экранам данные телефонного звонка

Я проектирую интерфейс сервиса, который разбирает записи звонков отдела продаж. Запись распознаётся, делится на реплики клиента и менеджера, проверяется по чек-листу, а результат уходит в 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 дошёл до кода, простым сравнением списков. Вариантов у кнопки шесть, у поля три, такая проверка занимает пять минут, а потерянное состояние фокуса без неё нашлось только при разборе для этой статьи.
Оценка модели ошибается чаще всего на спорных пунктах, где и два руководителя ответят по-разному. Кнопки «согласен» и «не согласен» стоят прямо над чек-листом, а не в настройках, и ответы копятся как правила конкретного отдела. Но если руководитель молчит, оценки остаются общими. Интерфейс тут может только сделать ответ дешёвым, заставить отвечать он не может.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.