The Jerusalem PostEgypt approves military support for Saudi Arabia after Houthi attacks escalatePunchPolice arrest two over deaths of minors in BauchiInquirerUndas 2026: 4,790 police personnel to be deployed in SoccsksargenESPNThunder star Williams stretchered off after suffering significant leg injuryInquirer EntertainmentAndrea Brillantes bares diving mishap while shooting ‘Laya’ESPN DeportesReal Madrid, a 3 puntos del Barça: así va la tabla de LaLigaCollider'Deadpool's Director Is Presiding Over the Biggest Short Film Contest Ever [Exclusive]ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioAnime News NetworkRomelia War Chronicle ‒ Episode 4BBC NewsHow a movement of students brought down Serbia's VučićMintBrazil election 2026 results: What’s the net worth of Lula Da Silva as he gets re-elected as president for fourth term?Digital SpyUniversity Challenge contestants hit out at "absurd" BBC decision to edit out pride badges
The Daily Newsstand · Free, Always
Monday, October 26, 2026

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

Translate

Я проектирую интерфейс сервиса, который разбирает записи звонков отдела продаж. Запись распознаётся, делится на реплики клиента и менеджера, проверяется по чек-листу, а результат уходит в 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 дошёл до кода, простым сравнением списков. Вариантов у кнопки шесть, у поля три, такая проверка занимает пять минут, а потерянное состояние фокуса без неё нашлось только при разборе для этой статьи.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.