Browse Source

Sprint 5: in-prototype screen documentation

Shared data layer + two access points for design-review docs.

- src/docs.js — SCREEN_DOCS dictionary keyed by screen id with
  title, category, goal, tasks[], rationale[], and optional
  variants note; helpers getScreenDoc(screenId, ctx) resolves home
  variants (home:cards / home:list / home:feed) and compound routes
  (doctor:id, article:id, chat:id, etc.); getAllDocs groups by
  category; resolveRouteForDoc maps doc key back to a concrete
  navigable route

- Toggle "Описания" in Tweaks + plashka above the phone in single
  layout: card with category, full-width title, and full goal text
  (line-clamp removed so whole sentence is readable); tap opens a
  full modal with tasks, rationale, and variants

- Live sync: PhoneApp reports top-of-stack via onCurrentChange prop,
  App tracks innerScreen state so the plashka follows the real nav
  inside the phone (clicking "Записаться" on home now updates the
  plashka to the booking screen)

- DocsScreen route "docs" in Tweaks screen selector — categorized
  list of all ~30 screens with collapsible inline descriptions and
  an "Открыть экран" CTA per row

- Convention: SPRINTS.md "Правила разработки" + memory note — when
  editing any src/screens/* file, update the matching entry in
  src/docs.js to keep in-prototype documentation in sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
parent
commit
a9d669e397
  1. 22
      SPRINTS.md
  2. 130
      src/App.jsx
  3. 11
      src/PhoneApp.jsx
  4. 630
      src/docs.js
  5. 106
      src/screens/screens-docs.jsx

22
SPRINTS.md

@ -9,6 +9,13 @@
- По завершении спринта — короткое резюме в блоке **Итоги**
- Нерешённое переносится в следующий спринт
## Правила разработки
- **Документация = часть прототипа.** При любом изменении экрана в `src/screens/*.jsx` — проверить и обновить соответствующую запись в `src/docs.js` (`goal` / `tasks` / `rationale` / `variants`). Эти описания показываются в плашке над телефоном и на экране «Документация», поэтому устаревшие формулировки мешают коллегам на ревью.
- Новый экран → добавить запись в `SCREEN_DOCS`, выбрать категорию из `ORDER``getAllDocs`), для compound-маршрутов (`something:id`) прописать кейс в `resolveRouteForDoc`.
- Мелкие CSS-правки (цвета, отступы, иконки) документировать не нужно — в `docs.js` описывается intent, а не пиксели.
- Варианты экрана с ctx (home cards/list/feed) — отдельная запись на каждый вариант (`home:cards`, `home:list`, `home:feed`).
---
## Спринт 1 · 19 апр 2026
@ -92,5 +99,20 @@ _заполнить в конце спринта_
- [x] Добавить оба экрана в SCREEN_OPTIONS Tweaks
- [x] Динамический рендер: при переключении палитры значения hex обновляются автоматически
---
## Спринт 5 · 20 апр 2026
**Цель:** документация прототипа внутри самого прототипа — чтобы на ревью с коллегами можно было сразу увидеть цель и design-решения по любому экрану.
### План
- [x] Общий слой данных `src/docs.js` — словарь описаний по screen-id: title, category, goal, tasks[], rationale[], variants
- [x] Helper `getScreenDoc(screenId, ctx)` — резолвит варианты home (cards/list/feed) и compound-экраны
- [x] Toggle «Описания» в Tweaks (вкл/выкл)
- [x] Плашка-описание над телефоном в режиме single (только когда тоггл ON) — category + title + 1 строка goal
- [x] Тап по плашке → модал-оверлей поверх сцены с полным описанием (задачи, design-решения, варианты, CTA «Закрыть»)
- [x] Отдельный экран `docs` (Вариант 4) — список всех экранов по категориям с collapsible-описаниями и кнопкой «Открыть экран»
- [x] Наполнить описаниями все ~30 экранов прототипа
### Итоги
_заполнить в конце спринта_

130
src/App.jsx

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IOSDevice } from './frames/IOSDevice.jsx';
import { AndroidDevice } from './frames/AndroidDevice.jsx';
import { PhoneApp } from './PhoneApp.jsx';
import { getScreenDoc } from './docs.js';
const TWEAKS_DEFAULT = {
homeVariant: 'cards',
@ -14,6 +15,7 @@ const TWEAKS_DEFAULT = {
device: 'ios',
scale: 'auto',
showIntro: true,
docsEnabled: false,
};
const SCALE_OPTIONS = [
@ -85,6 +87,7 @@ const SCREEN_OPTIONS = [
{ id: 'prices', lb: 'Цены' },
{ id: 'dev-colors', lb: 'DEV · Палитра' },
{ id: 'dev-examples', lb: 'DEV · Примеры' },
{ id: 'docs', lb: 'Документация' },
];
function applyTheme(tw) {
@ -110,8 +113,8 @@ function applyTheme(tw) {
document.body.style.fontFamily = f.base;
}
function Phone({ device = 'ios', screen, ctx, label, sublabel }) {
const content = <PhoneApp initialScreen={screen} ctx={ctx} />;
function Phone({ device = 'ios', screen, ctx, label, sublabel, onCurrentChange }) {
const content = <PhoneApp initialScreen={screen} ctx={ctx} onCurrentChange={onCurrentChange} />;
const frame = device === 'android'
? <AndroidDevice>{content}</AndroidDevice>
: <IOSDevice>{content}</IOSDevice>;
@ -151,6 +154,10 @@ function TweaksPanel({ tw, setTw, onClose }) {
</select>
)}
{group('Устройство', opts([{id:'ios',lb:'iOS'},{id:'android',lb:'Android'}], 'device'))}
{group('Описания', [
<button key="on" onClick={() => setTw({ ...tw, docsEnabled: true })} className={tw.docsEnabled ? 'on' : ''}>Вкл</button>,
<button key="off" onClick={() => setTw({ ...tw, docsEnabled: false })} className={!tw.docsEnabled ? 'on' : ''}>Выкл</button>,
])}
{tw.layout === 'single' && group('Масштаб', opts(SCALE_OPTIONS, 'scale'))}
{group('Компоновка', opts([
{ id:'single', lb:'1 телефон' },
@ -176,13 +183,111 @@ function TweaksPanel({ tw, setTw, onClose }) {
);
}
function DocPlashka({ doc, onOpen }) {
if (!doc) return null;
return (
<button onClick={onOpen} style={{
width: 402, maxWidth: '92%',
background: '#fff', borderRadius: 14,
padding: '14px 16px',
boxShadow: '0 4px 14px rgba(15,30,40,0.08)',
border: '1px solid var(--c-primary-200)',
display: 'flex', gap: 12, alignItems: 'flex-start', textAlign: 'left',
cursor: 'pointer',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 700, flexShrink: 0,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', textTransform: 'uppercase', letterSpacing: .6, fontWeight: 700, marginBottom: 3 }}>{doc.category}</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4, color: 'var(--c-fg-1)' }}>{doc.title}</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>{doc.goal}</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 999, background: 'var(--c-primary-darker)', color: '#fff',
fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>Детали</span>
</button>
);
}
function DocModal({ doc, onClose }) {
if (!doc) return null;
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(15,30,40,0.5)',
backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 200, padding: '24px 20px',
}}>
<div onClick={e => e.stopPropagation()} style={{
background: '#fff', borderRadius: 20, padding: 22,
maxWidth: 480, width: '100%', maxHeight: '85vh', overflowY: 'auto',
boxShadow: '0 30px 80px rgba(15,30,40,0.35)',
position: 'relative',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 700, flexShrink: 0,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', textTransform: 'uppercase', letterSpacing: .6, fontWeight: 700, marginBottom: 2 }}>{doc.category}</div>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--c-fg-1)' }}>{doc.title}</div>
</div>
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: 999, background: 'var(--c-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, border: '1px solid var(--c-border)',
}}>×</button>
</div>
<div style={{ padding: 12, background: 'var(--c-primary-50)', borderRadius: 10, marginBottom: 16, fontSize: 14, color: 'var(--c-fg-1)', lineHeight: 1.55 }}>
{doc.goal}
</div>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginBottom: 8 }}>Задачи пользователя</div>
<ul style={{ margin: '0 0 18px', padding: 0, listStyle: 'none' }}>
{doc.tasks.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 10, padding: '4px 0', fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-primary-darker)', marginTop: 9, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginBottom: 8 }}>Design-решения</div>
<ul style={{ margin: '0 0 10px', padding: 0, listStyle: 'none' }}>
{doc.rationale.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 10, padding: '4px 0', fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-warm-text)', marginTop: 9, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
{doc.variants && (
<div style={{ marginTop: 14, padding: 12, background: 'var(--c-warm-50)', borderRadius: 10, fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<strong>Варианты.</strong> {doc.variants}
</div>
)}
</div>
</div>
);
}
function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) {
const outerRef = useRef(null);
const [autoScale, setAutoScale] = useState(1);
useEffect(() => {
const outer = outerRef.current;
if (!outer) return;
const stage = outer.parentElement;
const stage = outer.closest('.stage') || outer.parentElement;
if (!stage) return;
const measure = () => {
const padding = 48;
@ -212,11 +317,17 @@ export default function App() {
const [tw, setTw] = useState(TWEAKS_DEFAULT);
const [panelOpen, setPanelOpen] = useState(true);
const [introVisible, setIntroVisible] = useState(tw.showIntro !== false);
const [docModal, setDocModal] = useState(null);
const [innerScreen, setInnerScreen] = useState(tw.screen);
useEffect(() => { applyTheme(tw); }, [tw.accent, tw.font]);
// Sync inner screen when Tweaks changes the initial screen
useEffect(() => { setInnerScreen(tw.screen); }, [tw.screen]);
const palette = ACCENT_OPTIONS.find(a => a.id === tw.accent) || ACCENT_OPTIONS[0];
const ctx = { homeVariant: tw.homeVariant, docVariant: tw.docVariant, density: tw.density, palette };
const currentDoc = tw.docsEnabled ? getScreenDoc(innerScreen, ctx) : null;
const content = useMemo(() => {
if (tw.layout === 'home3') {
@ -262,11 +373,14 @@ export default function App() {
);
}
return (
<FitWrap userScale={tw.scale}>
<Phone device={tw.device} screen={tw.screen} ctx={ctx} />
</FitWrap>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
{currentDoc && <DocPlashka doc={currentDoc} onOpen={() => setDocModal(currentDoc)} />}
<FitWrap userScale={tw.scale}>
<Phone device={tw.device} screen={tw.screen} ctx={ctx} onCurrentChange={setInnerScreen} />
</FitWrap>
</div>
);
}, [tw, ctx.homeVariant, ctx.docVariant, ctx.density]);
}, [tw, ctx.homeVariant, ctx.docVariant, ctx.density, currentDoc]);
const stageClass = tw.layout === 'single' ? 'stage' : 'stage grid-mode';
@ -287,6 +401,8 @@ export default function App() {
{!panelOpen && (
<button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks"></button>
)}
{docModal && <DocModal doc={docModal} onClose={() => setDocModal(null)} />}
</div>
);
}

11
src/PhoneApp.jsx

@ -17,6 +17,7 @@ import { ChatsListScreen, ChatConversationScreen } from './screens/screens-chats
import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.jsx';
import { HomeV2Screen, SearchScreen, ContactsScreen, PricesScreen } from './screens/screens-v2.jsx';
import { DevColorsScreen, DevExamplesScreen } from './screens/screens-dev.jsx';
import { DocsScreen } from './screens/screens-docs.jsx';
function renderScreen(screenId, nav, ctx) {
const parts = screenId.split(':');
@ -54,17 +55,24 @@ function renderScreen(screenId, nav, ctx) {
case 'prices': return <PricesScreen nav={nav} />;
case 'dev-colors': return <DevColorsScreen nav={nav} ctx={ctx} />;
case 'dev-examples': return <DevExamplesScreen nav={nav} ctx={ctx} />;
case 'docs': return <DocsScreen nav={nav} />;
default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>;
}
}
const TAB_IDS = ['home', 'appts', 'doctors', 'chat', 'profile'];
export function PhoneApp({ initialScreen, ctx }) {
export function PhoneApp({ initialScreen, ctx, onCurrentChange }) {
const [stack, setStack] = useState([initialScreen]);
useEffect(() => { setStack([initialScreen]); }, [initialScreen]);
const current = stack[stack.length - 1];
useEffect(() => {
if (onCurrentChange) onCurrentChange(current);
}, [current, onCurrentChange]);
const nav = useMemo(() => ({
push: (id) => setStack(s => [...s, id]),
pop: () => setStack(s => s.length > 1 ? s.slice(0, -1) : s),
@ -72,7 +80,6 @@ export function PhoneApp({ initialScreen, ctx }) {
reset:() => setStack(['home']),
}), []);
const current = stack[stack.length - 1];
const rootId = current.split(':')[0];
const hasSubId = current.includes(':');
const tabId = hasSubId ? null : (rootId === 'home-v2' ? 'home' : (TAB_IDS.includes(rootId) ? rootId : null));

630
src/docs.js

@ -0,0 +1,630 @@
// Описания экранов прототипа — для коллаборативного ревью.
// Читается двумя UI-слоями:
// 1. Плашка над телефоном в Tweaks-режиме «Описания»
// 2. Экран `docs` (список всех экранов с разворачивающимися описаниями)
export const SCREEN_DOCS = {
'home:cards': {
title: 'Главная 1 · Карточки',
category: 'Главная',
goal: 'Быстрый старт: записаться или увидеть ближайший приём. Приветствие + один доминирующий CTA + фасетная сетка входов.',
tasks: [
'Нажать «Записаться» и попасть в 4-шаговый флоу',
'Увидеть ближайший приём и открыть его детали',
'Выбрать специализацию (browse) или быстрое действие (телемед, тест слуха)',
'Прочитать статью врача',
],
rationale: [
'Бежевый градиент шапки — тёплый контакт вместо стерильного белого',
'Единственная доминирующая CTA тёмный teal + тень — видна сразу',
'Сетка 3×2 специализаций — browse-режим для пользователя, не знающего врача',
'Статьи горизонтально — низкий приоритет, не давят вертикально',
],
variants: 'В Tweaks «Главный экран»: Карточки / Лента / Таймлайн — три подхода к приоритизации контента',
},
'home:list': {
title: 'Главная 1 · Лента',
category: 'Главная',
goal: 'Утилитарная навигация: одна компактная карточка со всеми разделами как в iOS Settings. Для частых пользователей, которым не нужно browse.',
tasks: [
'Быстро открыть Медкарту / Анализы / Чат с одного тапа',
'Увидеть ближайший приём (компактно сверху)',
'Попасть на запись через первый выделенный пункт',
],
rationale: [
'Одна плотная карточка-список — минимум визуального шума',
'CTA «Записаться» выделена фоном primary-50 среди остальных',
'Бейджи справа для счётчиков (непрочитанные в чате)',
'Без градиентов и крупных CTA — подчёркнуто утилитарно',
],
},
'home:feed': {
title: 'Главная 1 · Таймлайн',
category: 'Главная',
goal: 'Пациент-центричная лента: восстановление, лекарства, ближайший приём — всё, что влияет на самочувствие сегодня. Для послеоперационных.',
tasks: [
'Увидеть прогресс восстановления с процентом и «что сегодня»',
'Отметить приём лекарства (one-tap)',
'Перейти к ближайшему приёму',
'Прочитать релевантные статьи',
],
rationale: [
'Первый блок — прогресс операции, а не CTA: для послеоперационных это важнее',
'Вопрос «Как Ваше самочувствие?» вместо нейтрального приветствия — эмпатия',
'Лекарство с кнопкой «Принял» — one-tap action',
'Accent-CTA (красная) для записи — выделяется на фоне тёплых карточек',
],
},
'home-v2': {
title: 'Главная 2',
category: 'Главная',
goal: 'Search-first layout: универсальная поисковая строка вверху — врач, симптом, услуга, дата. Для пользователя, который точно знает, что ищет.',
tasks: [
'Ввести симптом или ФИО врача → получить релевантный результат',
'Попасть в Контакты или Цены через тайлы',
'Увидеть статистику клиники (формирование доверия)',
],
rationale: [
'Поисковая строка вверху + AI-бейдж — сигнал «умный поиск»',
'Тайлы Контакты/Цены/Анализы/Восстановление — частые разделы в один тап',
'Градиентная карточка статистики — эмоциональный контакт с клиникой',
'Бордер primary-300 на stats-карточке — демонстрация «живого» цвета в палитре Бриз',
],
},
'doctors': {
title: 'Врачи · список',
category: 'Врачи и запись',
goal: 'Список всех врачей клиники с поиском, фильтрами и тремя вариантами представления карточек.',
tasks: [
'Найти врача по ФИО или специализации',
'Отфильтровать по «свободно сегодня», «кандидаты наук», «детские»',
'Открыть карточку врача → записаться',
],
rationale: [
'Пиллы-фильтры горизонтально скроллятся — помещаются все на узких экранах',
'Три варианта карточек (rich/list/photo) — под разные привычки сканирования',
'Пиллы вынесены из карточки — общий фильтр для всего списка',
],
variants: 'В Tweaks «Карточки врачей»: Карточки+ / Список / Плитка',
},
'doctor': {
title: 'Карточка врача',
category: 'Врачи и запись',
goal: 'Подробная карточка врача: регалии, рейтинг, цены, расписание, отзывы. Всё для решения «записаться — не записаться».',
tasks: [
'Увидеть аватар, имя, специализацию, рейтинг, стоимость',
'Прочитать образование и специализацию',
'Посмотреть расписание (таб)',
'Прочитать отзывы (таб)',
'Нажать «Записаться» внизу → сразу на выбор времени',
],
rationale: [
'Три равных stat-карточки: опыт, рейтинг, цена — ключевая инфа сразу',
'Сегментед-контрол для вкладок — компактно, iOS-паттерн',
'Фиксированная CTA внизу с backdrop-blur — не теряется при прокрутке',
'Чип «К.м.н.» warm-цветом — выделяет научную степень',
],
},
'booking-specs': {
title: 'Запись: специализация',
category: 'Флоу записи',
goal: 'Шаг 1 из 4: выбор направления. Для пользователей, которые не знают конкретного врача.',
tasks: [
'Переключиться между «Взрослому / Ребёнку / Онлайн»',
'Выбрать специализацию из сетки 2×2',
'Перейти к списку врачей специализации',
],
rationale: [
'Сегментед 3 варианта сразу — быстрый split аудитории',
'Крупные карточки специализаций с иконкой, названием, кол-вом врачей, минимальной ценой',
'«Шаг 1 из 4» в шапке — прогресс флоу виден',
],
},
'booking-doctor': {
title: 'Запись: выбор врача',
category: 'Флоу записи',
goal: 'Шаг 2 из 4: список врачей выбранной специализации с поиском и фильтрами.',
tasks: [
'Найти конкретного врача по ФИО',
'Отфильтровать по «свободно сегодня», «детские»',
'Выбрать врача → перейти к выбору времени',
],
rationale: [
'Та же логика, что и в tab «Врачи», но filter по специализации уже применён',
'Rich-карточка с ближайшим временем приёма — дополнительный фильтр «когда»',
],
},
'booking-time': {
title: 'Запись: дата и время',
category: 'Флоу записи',
goal: 'Шаг 3 из 4: выбор даты и времени. Ключевой шаг флоу — тут пользователь принимает финальное решение.',
tasks: [
'Увидеть выбранного врача (повтор сверху)',
'Выбрать дату из горизонтального скролла (7 дней)',
'Выбрать время из трёх групп (Утро/День/Вечер)',
'Нажать «Выбрать · [дата], [время]» → подтверждение',
],
rationale: [
'Дата как горизонтальные тайлы 54px — mobile-friendly, помещаются 5-6 на экране',
'Слоты в сетке 4 колонки — легко сканировать',
'Занятые слоты disabled с зачёркиванием — чтобы пользователь не пробовал',
'CTA закреплена снизу с текущим выбором — пользователь всегда знает, что выберет',
],
},
'booking-confirm': {
title: 'Запись: подтверждение',
category: 'Флоу записи',
goal: 'Шаг 4 из 4: финальный обзор перед подтверждением. Выбор формата (очно/онлайн) и комментарий для врача.',
tasks: [
'Проверить врача, дату, время, адрес',
'Выбрать очно или онлайн',
'Написать комментарий для врача (симптомы)',
'Увидеть стоимость',
'Подтвердить запись',
],
rationale: [
'Вся инфа в одной карточке-таблице — одним взглядом',
'Формат приёма — 2 крупных варианта, выбор чёткий',
'Комментарий опциональный, но есть — врач лучше готовится',
'Warm-плашка про отмену за 3 часа — ожидание поставлено сразу',
],
},
'booking-success': {
title: 'Запись: успех',
category: 'Флоу записи',
goal: 'Визуальное подтверждение успешной записи с ключевой информацией для встречи.',
tasks: [
'Убедиться, что запись состоялась',
'Запомнить адрес и кабинет',
'Перейти к «Моим приёмам» или на главную',
],
rationale: [
'Полноэкранный modal (таббар скрыт) — фокус на событии',
'Большая primary-галочка с ореолом — эмоциональный момент',
'Адрес выделен отдельно — пользователь часто возвращается смотреть',
'Два выхода: appts (для чекинга) и главная (для продолжения)',
],
},
'appts': {
title: 'Мои приёмы',
category: 'Приёмы и результаты',
goal: 'Предстоящие и прошедшие приёмы. Основная точка контроля для активных пациентов.',
tasks: [
'Переключиться между предстоящими и прошедшими',
'Открыть детали приёма',
'Записаться на новый приём (CTA внизу)',
],
rationale: [
'Сегментед с счётчиком предстоящих — важно знать, сколько запланировано',
'Разный фон карточек: предстоящие — градиент primary-100, прошедшие — белый',
'Чип «Заключение» на прошедших приёмах с готовым документом',
],
},
'appt': {
title: 'Детали приёма',
category: 'Приёмы и результаты',
goal: 'Полная карточка приёма: дата/время, врач, адрес, контакты, заключение (для прошедших).',
tasks: [
'Увидеть дату, время, тип приёма',
'Открыть карточку врача',
'Посмотреть адрес на карте',
'Позвонить в клинику',
'Отменить или перенести (для предстоящих)',
'Открыть заключение PDF (для прошедших)',
],
rationale: [
'Крупное время 42px monospace-narrow — главное, что пациент ищет',
'Адрес отдельной секцией с кнопкой карты — частый re-check',
'Кнопка «Отменить» приглушённым danger — чтобы случайно не нажать',
'Перенос primary — предполагаемое действие',
],
},
'results': {
title: 'Анализы и обследования',
category: 'Приёмы и результаты',
goal: 'Список всех анализов и обследований. Часть медкарты, но с фокусом на конкретные документы.',
tasks: [
'Увидеть все результаты с датой и врачом',
'Отфильтровать по типу',
'Открыть результат (аудио/эндоскопия/лаб)',
],
rationale: [
'Статус «Готово» / «В работе» — чип цветом справа',
'Разные иконки для типов результата (audio/image/lab) — быстрая семантика',
'Pending-результаты приглушены opacity: .7 — нельзя открыть',
],
},
'result-audio': {
title: 'Аудиограмма',
category: 'Приёмы и результаты',
goal: 'Экран просмотра аудиограммы: график, заключение сурдолога, рекомендация.',
tasks: [
'Увидеть график слуха для двух ушей',
'Прочитать заключение',
'Получить рекомендацию по контролю',
'Скачать PDF',
],
rationale: [
'SVG-график с сеткой, легендой (правое/левое), зелёной зоной «норма» — стандарт аудиологии',
'Круги + сплошная линия для правого уха, крестики + пунктир для левого — международная нотация',
'Рекомендация выделена primary-50 фоном — подсказка действия',
],
},
'result': {
title: 'Эндоскопия носоглотки',
category: 'Приёмы и результаты',
goal: 'Просмотр результата эндоскопического обследования: снимки, диагноз, рекомендации.',
tasks: [
'Увидеть кто и когда провёл исследование',
'Просмотреть 4 снимка (сетка 2×2)',
'Открыть любой снимок в полноэкранном режиме',
'Прочитать диагноз и заключение',
'Выполнить рекомендации (нумерованный список)',
'Скачать PDF или обсудить с врачом',
],
rationale: [
'CSS-мокапы эндоскопических снимков: радиальный градиент + блик — имитация медицинского изображения',
'Диагноз отдельной карточкой с чипом warm — самое важное',
'Рекомендации нумерованы — чёткий порядок действий',
'Полноэкранный просмотр с точками-навигацией — удобный UX для галерей',
],
},
'recovery': {
title: 'Восстановление',
category: 'Здоровье',
goal: 'Трекер восстановления после операции: прогресс по дням, лекарства, контрольные осмотры.',
tasks: [
'Увидеть процент восстановления',
'Отметить приём лекарства',
'Увидеть план восстановления по дням (таймлайн)',
'Связаться с хирургом в чате (chat:doctor-syndaev)',
],
rationale: [
'Тёмная primary-карточка вверху — «важно», вне обычной иерархии',
'Прогресс-бар на белом фоне контрастно на тёмном',
'Лекарства с счётчиком доз (2/4) — пользователь видит прогресс',
'Таймлайн с линией между шагами, галочки для сделанных, активный день с кольцом',
],
},
'audiotest': {
title: 'Тест слуха',
category: 'Здоровье',
goal: 'Трёхминутный тест слуха в приложении. Скрининг, а не диагноз.',
tasks: [
'Прочитать инструкции (наушники, тихое место, не торопиться)',
'Пройти тест (кнопка «Слышу» при каждом тоне)',
'Увидеть результат по каждому уху',
'Записаться к сурдологу, если нужно',
],
rationale: [
'Три стадии: intro → test → done — чёткая структура',
'Крупная анимированная волна (pulse) — объект фокуса внимания',
'Большая кнопка «Слышу» — one-tap действие, выбор быстрый',
'В результате разные цвета по ушам (success/warning) — сразу видно где норма',
'CTA «Записаться к сурдологу» — естественное продолжение',
],
},
'chat': {
title: 'Чаты · список',
category: 'Коммуникации',
goal: 'Центр всех коммуникаций с клиникой: AI-помощник, врач, администратор.',
tasks: [
'Открыть чат с AI-помощником для быстрого вопроса',
'Продолжить диалог с врачом',
'Написать в регистратуру',
'Начать новый чат с другим врачом',
],
rationale: [
'AI выделен featured-карточкой с градиентом и AI-бейджем — показать как новая возможность',
'Остальные в обычном списке с аватаром, online-точкой, бейджем непрочитанных',
'Непрочитанные выделены fg-1 текстом и accent-бейджем — сразу видно',
'Плашка снизу: чат с врачом ограничен 14 днями — ожидания пользователя правильные',
],
},
'chat:ai': {
title: 'Чат: AI-помощник',
category: 'Коммуникации',
goal: 'Приватный диалог с AI-помощником клиники: напоминания, помощь с записью, объяснение результатов.',
tasks: [
'Задать вопрос о расписании или операции',
'Получать напоминания о лекарствах',
'Подтверждать приём препарата',
'Использовать suggested-reply чипсы для быстрых ответов',
],
rationale: [
'Сообщения от AI — gradient primary-50 → primary-100 + бордер primary-200 — субтильное отличие от человека',
'Suggested-replies над инпутом — снижает барьер начала диалога',
'Плейсхолдер «Спросите что-нибудь» вместо «Сообщение» — AI-контекст',
],
},
'chat:doctor-syndaev': {
title: 'Чат: врач',
category: 'Коммуникации',
goal: 'Приватный чат с лечащим врачом. Продолжение консультации после приёма.',
tasks: [
'Рассказать о самочувствии между приёмами',
'Уточнить режим лечения',
'Согласовать перенос приёма',
'Нажать кнопку видеозвонка для срочной консультации',
],
rationale: [
'Статус «Онлайн · отвечает 5 мин» — ожидания пациента поставлены',
'Кнопка video в хедере только у врача — эскалация в видеозвонок',
'Обычные iOS-bubbles — привычный паттерн',
],
},
'chat:operator': {
title: 'Чат: администратор',
category: 'Коммуникации',
goal: 'Общение с регистратурой: справки, переносы, оплата, документы.',
tasks: [
'Заказать медицинскую справку',
'Перенести приём',
'Уточнить счёт к оплате',
'Позвонить по кнопке в хедере',
],
rationale: [
'Часы работы в подписи — пользователь знает, когда ждать ответ',
'Кнопка phone в хедере — альтернатива чату',
'Иконка 📞 на аватаре — тип коммуникации визуально закодирован',
],
},
'profile': {
title: 'Профиль',
category: 'Сервис',
goal: 'Профиль пациента с группировкой по темам: здоровье, оплата, клиника, настройки.',
tasks: [
'Увидеть имя, возраст, телефон',
'Перейти к медкарте, анализам, лекарствам',
'Проверить способы оплаты и бонусы',
'Показать QR пациента',
],
rationale: [
'Градиентная карточка профиля с аватаром и QR-чипом — личное пространство',
'Секции-группы как в iOS Settings — знакомый паттерн',
'Бейджи на некоторых пунктах (Серебро у бонусов) — достижения',
],
},
'qr': {
title: 'QR пациента',
category: 'Сервис',
goal: 'QR-код пациента для быстрой идентификации на ресепшене. Полноэкранный modal.',
tasks: [
'Показать QR сотруднику',
'Увидеть номер пациента',
],
rationale: [
'Тёмный primary-gradient фон — пользователь воспринимает как важный билет',
'Большой QR на белой карточке — максимальный контраст для сканера',
'Таббар скрыт — фокус на QR',
'Плашка внизу: код обновляется каждые 60 сек — сигнал безопасности',
],
},
'telemed': {
title: 'Видеозвонок',
category: 'Коммуникации',
goal: 'Видеозвонок с врачом. Full-screen экран «в разговоре».',
tasks: [
'Вести разговор с врачом',
'Свернуть (кнопка вниз)',
'Завершить звонок (красная кнопка)',
'Переключить микрофон / видео / чат',
],
rationale: [
'Тёмный фон — стандартная метафора видеозвонка',
'Self-preview окошко в углу — пользователь видит себя',
'Красный таймер с мигающей точкой — идёт запись/звонок',
'Крупные контрольные кнопки снизу — критичные действия большими хит-зонами',
],
},
'medcard': {
title: 'Медицинская карта',
category: 'Здоровье',
goal: 'Медкарта: основное, аллергии, история диагнозов.',
tasks: [
'Увидеть пол, возраст, рост/вес, группу крови',
'Проверить аллергии',
'Просмотреть историю диагнозов с датами',
'Добавить аллергию',
],
rationale: [
'Основные данные в label/value списке — табличная структура',
'Аллергии как красные чипы — критическая инфа',
'История — плоская лента с датой, диагнозом, врачом',
],
},
'notifications': {
title: 'Уведомления',
category: 'Сервис',
goal: 'Лента уведомлений: напоминания, готовые заключения, сообщения, акции.',
tasks: [
'Просмотреть последние уведомления',
'Увидеть время каждого',
],
rationale: [
'Разные tint (primary/warning/warm) по типу уведомления — семантика цветом',
'Первая строка с временем справа — стандарт ленты',
],
},
'articles': {
title: 'Статьи врачей · список',
category: 'Информация',
goal: 'Список всех статей врачей. Образовательный контент, формирует доверие к клинике.',
tasks: [
'Отфильтровать по тегу (Дети / Операции / Беременность / Слух)',
'Открыть статью',
],
rationale: [
'Крупные hero-карточки с emoji и лидом — стимулируют нажатие',
'Теги как pill-фильтры сверху — выбор по интересу',
],
},
'article': {
title: 'Статья врача',
category: 'Информация',
goal: 'Детальная статья с разметкой: лид, подзаголовки, списки, callout-плашки, карточка автора.',
tasks: [
'Прочитать статью',
'Увидеть автора с контактом',
'Записаться к автору',
'Открыть связанные статьи',
],
rationale: [
'Hero с заголовком занимает 72% ширины, emoji 82px справа — журнальный ритм',
'Плавающие back/bookmark кнопки поверх hero — не мешают картинке',
'Callout разных tone (danger/warn/info) — визуально маркированные предупреждения',
'Author card + CTA записи к автору — конверсия из чтения в действие',
],
},
'search': {
title: 'Поиск',
category: 'Информация',
goal: 'Универсальный поиск по врачам, услугам, симптомам, статьям, приёмам.',
tasks: [
'Ввести ФИО / симптом / услугу / дату',
'Получить релевантные результаты по типам',
'Использовать предложенные чипы-запросы или симптомы',
'Перейти на выбранный результат',
],
rationale: [
'Autofocus на инпуте — сразу можно печатать',
'Пустое состояние с популярными запросами — помогает начать',
'Группировка результатов по типам (Врачи, Услуги, Симптомы…) — лёгкий скан',
'Date-detection (сегодня, завтра, апр) показывает приёмы — smart-поведение',
],
},
'contacts': {
title: 'Контакты',
category: 'Информация',
goal: 'Адреса клиник, телефон, часы работы, маршруты.',
tasks: [
'Позвонить в клинику',
'Написать в чат',
'Увидеть адреса с мокапом здания и карты',
'Построить маршрут',
],
rationale: [
'Primary-darker карточка сверху с телефоном 26px narrow — главный контакт',
'CSS-мокапы здания и карты для каждого адреса — прототипное решение без реальных изображений',
'Кнопки «Маршрут / позвонить / видео» в каждой карточке — действия по локации',
],
},
'prices': {
title: 'Цены',
category: 'Информация',
goal: 'Прайс-лист всех услуг с поиском, категориями, группировкой, диапазоном цен.',
tasks: [
'Найти услугу по названию',
'Отфильтровать по категории',
'Увидеть сгруппированные цены',
'Записаться прямо с услуги',
],
rationale: [
'Сводка найдено / от-до — сразу понятен разброс цен',
'Категории как pill-фильтры, группировка в карточках',
'Цены narrow-шрифтом — акцент на числе',
],
},
'dev-colors': {
title: 'DEV · Палитра',
category: 'DEV',
goal: 'Служебный экран для разработчиков. Все цвета дизайн-системы с hex, ролями, применением.',
tasks: [
'Увидеть hex любой CSS-переменной',
'Переключать палитру в Tweaks и наблюдать изменения',
'Понять, где используется конкретный цвет',
'Перейти к примерам применения',
],
rationale: [
'Ключевая полоса из 8 цветов сверху — палитра видна сразу',
'Группировка по ролям (primary / warm / accent / status / text / surfaces)',
'Каждая строка: свотч + имя + css-var + hex + описание — полная информация',
'Подпись «динамически» у групп, меняющихся при переключении палитры',
],
},
'dev-examples': {
title: 'DEV · Примеры',
category: 'DEV',
goal: 'Готовые компоненты дизайн-системы с указанием CSS-переменных, которые они используют.',
tasks: [
'Посмотреть, как выглядит любая кнопка/чип/карточка',
'Прочитать, какие vars внутри',
'Сверить с экраном DEV · Палитра',
],
rationale: [
'Каждый пример помечен VarTag-монокодами',
'Секции по типам: кнопки / чипы / поверхности / статусы / текст / аватары / формы / тени',
],
},
'docs': {
title: 'Документация',
category: 'DEV',
goal: 'Единый дизайн-гайд прототипа. Список всех экранов с описаниями — цели, задачи, design-решения.',
tasks: [
'Просмотреть все экраны по категориям',
'Развернуть описание экрана inline',
'Перейти к реальному экрану для интерактивного просмотра',
],
rationale: [
'Группировка по категориям облегчает навигацию для ревью',
'Описания collapsed-by-default, чтобы список оставался обозримым',
'Кнопка «Открыть экран» на каждой карточке — быстрый переход в демо-режим',
'Работает в паре с toggle «Описания» в Tweaks (плашка над телефоном)',
],
},
};
// Резолвер: по текущему screenId и ctx отдаёт правильное описание.
// Учитывает: варианты главной (homeVariant), compound-маршруты (doctor:id,
// appt:id, result:id, article:id, chat:id).
export function getScreenDoc(screenId, ctx) {
if (!screenId) return null;
const parts = screenId.split(':');
const base = parts[0];
// home с вариантом: home:cards / home:list / home:feed
if (base === 'home') {
const v = ctx?.homeVariant || 'cards';
return SCREEN_DOCS[`home:${v}`] || SCREEN_DOCS['home:cards'];
}
// chat с конкретным id (ai / doctor-syndaev / operator) — отдельные описания
if (base === 'chat' && parts[1]) {
return SCREEN_DOCS[screenId] || SCREEN_DOCS['chat'];
}
// Generic compound: doctor:xxx → doctor, article:xxx → article и т.д.
const compounds = ['doctor', 'appt', 'result', 'article', 'booking-doctor', 'booking-time', 'booking-confirm'];
if (compounds.includes(base)) {
return SCREEN_DOCS[base] || null;
}
return SCREEN_DOCS[screenId] || SCREEN_DOCS[base] || null;
}
// Для экрана «Документация»: список всех уникальных описаний, сгруппированных
// по категориям. Возвращает упорядоченный массив для рендера.
export function getAllDocs() {
const ORDER = ['Главная', 'Врачи и запись', 'Флоу записи', 'Приёмы и результаты', 'Здоровье', 'Коммуникации', 'Информация', 'Сервис', 'DEV'];
const groups = new Map();
for (const [key, doc] of Object.entries(SCREEN_DOCS)) {
if (!groups.has(doc.category)) groups.set(doc.category, []);
groups.get(doc.category).push({ key, ...doc });
}
return ORDER.filter(c => groups.has(c)).map(c => ({ category: c, items: groups.get(c) }));
}
// screenId для навигации из docs screen. Учитывает compound-маршруты.
export function resolveRouteForDoc(key) {
// home:cards → переводим в home + homeVariant cards (но через простой nav.set
// мы не можем поменять homeVariant — это в Tweaks). Поэтому home:* → 'home'.
if (key.startsWith('home:')) return 'home';
// chat:ai / chat:doctor-syndaev / chat:operator — полноценные маршруты
if (key.startsWith('chat:')) return key;
// generic compounds: нужно подставить пример id
if (key === 'doctor') return 'doctor:syndaev';
if (key === 'appt') return 'appt:a1';
if (key === 'result') return 'result:r2';
if (key === 'article') return 'article:otitis-kids';
if (key === 'booking-doctor') return 'booking-doctor:ent';
if (key === 'booking-time') return 'booking-time:syndaev';
if (key === 'booking-confirm') return 'booking-confirm:syndaev:1:16:00';
return key;
}

106
src/screens/screens-docs.jsx

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { I } from '../icons.jsx';
import { ScreenHeader } from '../components.jsx';
import { getAllDocs, resolveRouteForDoc } from '../docs.js';
function DocRow({ doc, route, onOpen }) {
const [open, setOpen] = useState(false);
return (
<div style={{ padding: 0 }}>
<button onClick={() => setOpen(o => !o)} style={{
width: '100%', padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start', textAlign: 'left',
background: 'transparent',
}}>
<div style={{
width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)',
color: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, flexShrink: 0, marginTop: 1,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 3 }}>{doc.title}</div>
<div className="sub" style={{ fontSize: 12, lineHeight: 1.45 }}>{doc.goal}</div>
</div>
<I.chevD size={16} style={{
color: 'var(--c-fg-4)', flexShrink: 0, marginTop: 8,
transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s',
}} />
</button>
{open && (
<div style={{ padding: '0 16px 14px 62px' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginTop: 6, marginBottom: 6 }}>Задачи пользователя</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{doc.tasks.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--c-primary-darker)', marginTop: 8, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginTop: 14, marginBottom: 6 }}>Design-решения</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{doc.rationale.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--c-warm-text)', marginTop: 8, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
{doc.variants && (
<div style={{ marginTop: 10, padding: 10, background: 'var(--c-primary-50)', borderRadius: 8, fontSize: 12, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<strong>Варианты: </strong>{doc.variants}
</div>
)}
<button onClick={() => onOpen(route)} className="btn-s" style={{ marginTop: 12 }}>
Открыть экран <I.arrow size={14} />
</button>
</div>
)}
</div>
);
}
export function DocsScreen({ nav }) {
const groups = getAllDocs();
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Документация" subtitle="Все экраны прототипа" onBack={() => nav.pop()} />
<div style={{ padding: '0 20px 14px' }}>
<div className="card" style={{
padding: 14, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-200)',
}}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<I.shield size={18} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
Список всех экранов прототипа с целями и design-решениями. Разверните любой пункт, чтобы прочитать задачи пользователя и обоснование. Кнопка «Открыть экран» ведёт в демо.
</div>
</div>
</div>
</div>
<div style={{ padding: '0 16px' }}>
{groups.map(g => (
<div key={g.category} style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '4px 4px 10px', display: 'flex', justifyContent: 'space-between' }}>
<span>{g.category}</span>
<span>{g.items.length}</span>
</div>
<div className="card" style={{ padding: 0 }}>
{g.items.map((item, i) => (
<React.Fragment key={item.key}>
<DocRow doc={item} route={resolveRouteForDoc(item.key)} onOpen={(r) => nav.set(r)} />
{i < g.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 62 }} />}
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
);
}
Loading…
Cancel
Save