feat(web): two-stage questionnaires, screening result and diary by result

- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
  PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
  is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
  with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
  3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
  flags on weekly means against the personal norm; scheduled full scales with change
  (remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
  goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).

ADR-012.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+4 -1
View File
@@ -4,8 +4,10 @@ import type {
CrisisResource,
DemoPersona,
DialogEvent,
Focus,
MedsReference,
Reply,
Results,
TodayPlan,
} from "./types";
@@ -41,12 +43,13 @@ const post = <T>(path: string, body?: unknown) =>
export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
+32
View File
@@ -21,6 +21,9 @@ export type DialogEvent =
export type AuthMode = "emias" | "anonymous";
/** What an anonymous person suspects; the backend maps it to a clinical module. */
export type Focus = "depression" | "ptsd";
export interface AuthResult {
token: string;
mode: AuthMode;
@@ -67,6 +70,35 @@ export interface Questionnaires {
enabled: boolean;
intake: "none" | "active" | "paused" | "complete" | "review";
diary_done: boolean;
diary_next: "morning" | "evening" | null;
}
// app/api/patient_web.py: /me/{token}/results — preliminary conclusion of stage 1 and the questionnaire log.
export interface Conclusion {
day: string;
title: string;
lines: string[];
findings: { code: string; title: string }[];
modules: string[];
disclaimer: string;
}
export interface HistoryItem {
id: string;
kind: string;
kind_title: string;
status: string;
status_title: string;
day: string;
started_at: string;
completed_at: string | null;
title: string | null;
lines: string[];
}
export interface Results {
conclusion: Conclusion | null;
history: HistoryItem[];
}
export interface TodayPlan {
+15
View File
@@ -10,6 +10,7 @@ import { Icon, LogoMark, type IconName } from "./Icon";
* Layout from docs/mock (series 2).
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: navy sidebar | content | right rail (optional `aside` + emergency panel)
* bare: one centred column on every width (questionnaires)
*/
const TABS: { to: string; label: string; icon: IconName }[] = [
@@ -127,6 +128,7 @@ export function AppShell({
aside,
focus = false,
sos = true,
bare = false,
}: {
children: ReactNode;
signedIn?: boolean;
@@ -135,7 +137,20 @@ export function AppShell({
focus?: boolean;
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
sos?: boolean;
/** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */
bare?: boolean;
}) {
if (bare)
return (
<div className="app app--bare">
<div className="app__main">
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
</div>
</div>
);
return (
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} />
+1
View File
@@ -28,6 +28,7 @@ const paths = {
</>
),
chevronRight: <path d="M9.5 6l6 6-6 6" />,
chevronDown: <path d="M6 9.5l6 6 6-6" />,
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { Icon } from "./Icon";
/*
* Short confirmations («отметил приём», «дневник сохранён») as a toast in the top-right corner instead of a separate
* confirmation page: the person lands on the home screen right away and still sees what happened.
*/
interface Toast {
id: number;
text: string;
}
const Ctx = createContext<(text: string) => void>(() => undefined);
const LIFETIME_MS = 6000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const next = useRef(0);
const close = useCallback((id: number) => setToasts((all) => all.filter((t) => t.id !== id)), []);
const show = useCallback(
(text: string) => {
const id = ++next.current;
setToasts((all) => [...all.slice(-2), { id, text }]);
window.setTimeout(() => close(id), LIFETIME_MS);
},
[close],
);
const value = useMemo(() => show, [show]);
return (
<Ctx.Provider value={value}>
{children}
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className="toast">
<span className="toast__icon" aria-hidden="true">
<Icon name="check" size={18} />
</span>
<p className="toast__text">{t.text}</p>
<button type="button" className="toast__close" onClick={() => close(t.id)} aria-label="Закрыть">
<Icon name="close" size={18} />
</button>
</div>
))}
</div>
</Ctx.Provider>
);
}
export function useToast(): (text: string) => void {
return useContext(Ctx);
}
+39 -24
View File
@@ -37,23 +37,21 @@ export const ru = {
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
},
welcome: {
title: "Как вы хотите войти в приложение?",
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
title: "Вход",
emias: {
title: "Через ЕМИАС",
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
title: "Войти через ЕМИАС",
perks: [
"Назначения врача подгружаются автоматически",
"Врач видит ответы и получает сигнал при ухудшении",
"История доступна с любого устройства",
],
},
or: "или",
anonymous: {
title: "Анонимно",
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
howTitle: "Как это работает",
how: [
{ title: "Врач подключает", text: "На приёме вы получаете код или входите анонимно" },
{ title: "Минута в день", text: "Напоминание о лекарстве и короткая отметка самочувствия" },
{ title: "Врач видит динамику", text: "И получает сигнал, если состоянию нужно внимание" },
],
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
emias: {
title: "Введите код от врача",
@@ -70,14 +68,12 @@ export const ru = {
demoDay: (day: number) => `${day}-й день терапии`,
},
anonymous: {
title: "Анонимный вход",
lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
yesTitle: "Что будет доступно:",
yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
noTitle: "Чего не будет:",
no: ["Врач не увидит ответы", "История только на этом устройстве"],
warning: "После выхода историю восстановить нельзя.",
submit: "Продолжить анонимно",
title: "Что вы хотите проверить?",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
focus: {
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
},
submitting: "Создаём профиль…",
},
today: {
@@ -105,7 +101,6 @@ export const ru = {
newMessage: "Новое сообщение",
open: "Открыть",
continue: "Продолжить",
surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`,
intakeStart: "Первичный опрос",
intakeStartHint: "15–20 минут, можно прерваться и продолжить позже",
intakeResume: "Продолжить первичный опрос",
@@ -115,6 +110,9 @@ export const ru = {
intakeDone: "Первичный опрос пройден",
diary: "Дневник самочувствия",
diaryHint: "2–3 минуты: настроение, сон, лекарства",
diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" },
diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" },
results: "Мой результат",
diaryDone: "Дневник заполнен",
therapyDay: (day: number) => `День ${day} лечения`,
effectHint: "Когда ждать эффекта и что важно знать",
@@ -187,6 +185,20 @@ export const ru = {
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
logout: "Выйти",
messages: "Сообщения",
result: "Предварительный результат",
resultNone: "Появится после первичного опроса.",
history: "История опросов",
historySub: "Все опросы и дневники с датами и результатами",
},
results: {
title: "Мои результаты",
conclusion: "Предварительный результат",
conclusionDay: (d: string) => `Первичный опрос от ${d}`,
modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" },
history: "История опросов",
empty: "Здесь появятся пройденные опросы.",
noResult: "Пройдите первичный опрос — после него здесь будет результат.",
toIntake: "Пройти первичный опрос",
},
flow: {
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
@@ -194,6 +206,8 @@ export const ru = {
textPlaceholder: "Напишите несколько слов",
sending: "Отправляем…",
doneTitle: "Готово",
resultTitle: "Результат",
sendEmpty: "Отправить без комментария",
doneLead: "Врач увидит отметку.",
breathe: "Вдох на 4 — выдох на 6",
breatheIn: "Вдох",
@@ -214,11 +228,12 @@ export const ru = {
},
questionN: (n: number) => `Вопрос ${n}`,
recall: (period: string) => `Период: ${period}`,
asOf: (date: string) => `Считайте от ${date}`,
saved: "Ответы сохраняются после каждого вопроса",
hoursLabel: "Сколько часов",
hoursUnit: "ч",
hoursHint: "От 0 до 24, можно с половиной: 6,5",
portionsLabel: "Сколько порций",
portionsUnit: "порц.",
portionsHint: "Если не пили — 0. Можно дробное: 1,5",
drinks: "Таблица стандартных порций",
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен",
+9 -2
View File
@@ -7,6 +7,7 @@ import type { DialogButton, Reply } from "../api/types";
export type Step =
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
| { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
| { kind: "crisis"; text: string; buttons: DialogButton[] }
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
@@ -26,7 +27,7 @@ export interface Survey {
title: string; // the item text, verbatim
footnotes: string[]; // «*…» explanations printed under the item on the paper form
options: DialogButton[];
input: "choice" | "number" | "hours" | "text";
input: "choice" | "number" | "hours" | "portions" | "text";
anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item
asOf?: string; // ISO date the recall period counts from
skip?: DialogButton;
@@ -107,7 +108,10 @@ function survey(batch: Reply[], qIndex: number): Survey | null {
? "choice"
: skip
? "text"
: "hours";
: // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours.
/порци/i.test(title)
? "portions"
: "hours";
return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause };
}
@@ -142,6 +146,9 @@ export function classify(batch: Reply[]): Step | null {
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
const texts = batch.map((r) => r.text);
if (qIndex === -1) {
// Without buttons the engine waits for free text only when it marks the reply as a question; anything else
// («отметил приём») is a confirmation.
if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) };
const { lead, title } = titled(texts);
return { kind: "text", lead, title };
}
+5
View File
@@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { HelpProvider } from "./components/HelpSheet";
import { ToastProvider } from "./components/Toast";
import { DialogProvider } from "./dialog/DialogContext";
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Doctor } from "./screens/Doctor";
@@ -19,6 +20,7 @@ import { Flow } from "./screens/Flow";
import { DoseReminder, Meds } from "./screens/Meds";
import { Messages } from "./screens/Messages";
import { Profile } from "./screens/Profile";
import { Results } from "./screens/Results";
import { Today } from "./screens/Today";
import { Welcome } from "./screens/Welcome";
import { SessionProvider, useSession } from "./session/SessionContext";
@@ -43,6 +45,7 @@ function AppRoutes() {
<Route path="/meds/:ref" element={<Private><DrugInfo /></Private>} />
<Route path="/doctor" element={<Private><Doctor /></Private>} />
<Route path="/profile" element={<Private><Profile /></Private>} />
<Route path="/results" element={<Private><Results /></Private>} />
<Route path="/messages" element={<Private><Messages /></Private>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -54,9 +57,11 @@ createRoot(document.getElementById("root")!).render(
<SessionProvider>
<DialogProvider>
<HelpProvider>
<ToastProvider>
<HashRouter>
<AppRoutes />
</HashRouter>
</ToastProvider>
</HelpProvider>
</DialogProvider>
</SessionProvider>
+27 -54
View File
@@ -2,15 +2,18 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
const YES_ICONS: IconName[] = ["calendar", "pill", "doc"];
const NO_ICONS: IconName[] = ["user", "doc"];
const FOCUS: { id: Focus; icon: IconName }[] = [
{ id: "depression", icon: "smile" },
{ id: "ptsd", icon: "heart" },
];
// A3 · Анонимный вход
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
@@ -18,11 +21,11 @@ export function AnonymousSignIn() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const start = async () => {
const start = async (focus: Focus) => {
setBusy(true);
setError(false);
try {
signIn(await api.signInAnonymous());
signIn(await api.signInAnonymous(focus));
navigate("/flow", { replace: true });
} catch {
setError(true);
@@ -34,58 +37,28 @@ export function AnonymousSignIn() {
<AppShell signedIn={false} focus>
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="split">
<div className="split__lead">
<header className="head">
<span className="tile-icon">
<Icon name="lock" size={26} />
</span>
<div className="stack signin">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<Notice tone="warn">{t.warning}</Notice>
</div>
<div className="split__act">
<section className="panel panel--teal">
<p className="panel__title">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
{t.yesTitle}
</p>
<ul className="iconlist">
{t.yes.map((item, i) => (
<li key={item}>
<Icon name={YES_ICONS[i]} size={20} />
{item}
</li>
<div className="entry-grid">
{FOCUS.map(({ id, icon }) => (
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</ul>
</section>
</div>
<section className="panel panel--red">
<p className="panel__title">
<span className="badge-icon badge-icon--red">
<Icon name="x" size={16} />
</span>
{t.noTitle}
</p>
<ul className="iconlist">
{t.no.map((item, i) => (
<li key={item}>
<Icon name={NO_ICONS[i]} size={20} />
{item}
</li>
))}
</ul>
</section>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<button type="button" className="btn btn--primary" onClick={start} disabled={busy}>
{busy ? t.submitting : t.submit}
</button>
</div>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<p className="muted small">{busy ? t.submitting : t.note}</p>
</div>
</div>
</AppShell>
+92 -67
View File
@@ -1,15 +1,15 @@
import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { Catalog, DialogButton, Section } from "../api/types";
import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step, type Survey } from "../dialog/step";
import { dayMonth } from "./useToday";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
@@ -17,16 +17,6 @@ import { dayMonth } from "./useToday";
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
*/
export const SECTION_ICON: Record<Section, IconName> = {
safety: "shield",
mood: "smile",
trauma: "heart",
alcohol: "info",
about: "user",
diary: "book",
scale: "clipboard",
};
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
@@ -91,19 +81,6 @@ function Progress({ n, total, adaptive = false }: { n: number; total: number; ad
);
}
function FormChip({ section, title }: { section: Section; title?: string }) {
return (
<div className="formchip">
<span className={`chip chip--sec chip--${section}`}>
<Icon name={SECTION_ICON[section]} size={16} />
{ru.flow.sections[section]}
</span>
{/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */}
{title && section !== "safety" && <span className="formchip__title">{title}</span>}
</div>
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
return (
@@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]>
</table>
</div>
<p className="muted small">
{table.title} {table.note}
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
</p>
</section>
);
@@ -248,6 +226,9 @@ function NumberScale({
);
}
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
function FreeText({
skip,
busy,
@@ -291,20 +272,34 @@ function FreeText({
<button
type="button"
className="btn btn--primary"
disabled={!text.trim() || busy}
onClick={() => onSend(text.trim())}
disabled={busy}
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{ru.common.next}
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
</button>
</div>
</>
);
}
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
const NUMERIC = {
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
};
function HoursInput({
busy,
onSend,
kind = "hours",
}: {
busy: boolean;
onSend: (text: string) => void;
kind?: keyof typeof NUMERIC;
}) {
const f = NUMERIC[kind];
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
return (
<form
className="hours"
@@ -314,7 +309,7 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
}}
>
<label htmlFor="hours" className="hours__label">
{ru.flow.hoursLabel}
{f.label}
</label>
<div className="hours__field">
<input
@@ -325,10 +320,10 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
<span aria-hidden="true">{f.unit}</span>
</div>
<p id="hours-hint" className="muted small">
{ru.flow.hoursHint}
{f.hint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
@@ -337,13 +332,12 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
);
}
// No form name or section above the question: the person should not see what a questionnaire screens for while
// answering it. Names and results appear only after it is finished (Results screen).
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
const item = catalog?.items[step.itemId];
const form = item ? catalog?.forms[item.form] : undefined;
return (
<>
<header className="head survey">
{form && <FormChip section={form.section} title={form.title} />}
{step.instruction.length > 0 && (
<div className="survey__instr">
<Body texts={step.instruction} />
@@ -355,12 +349,6 @@ function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }
{f}
</p>
))}
{step.asOf && (
<p className="survey__asof">
<Icon name="calendar" size={16} />
{ru.flow.asOf(dayMonth(step.asOf))}
</p>
)}
</header>
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
</>
@@ -373,12 +361,14 @@ function SurveyAct({
busy,
press,
say,
onPause,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
onPause: (b: DialogButton) => void;
}) {
let answer: ReactNode;
switch (step.input) {
@@ -386,7 +376,8 @@ function SurveyAct({
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
break;
case "hours":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} />;
case "portions":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
break;
case "text":
answer = (
@@ -410,7 +401,7 @@ function SurveyAct({
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
@@ -422,7 +413,6 @@ function SurveyAct({
</button>
)}
</div>
<p className="muted small survey__saved">{ru.flow.saved}</p>
</>
);
}
@@ -467,25 +457,40 @@ function Breathing() {
);
}
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
// explains it. Results stay in «Мои результаты» with the date.
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
const [head = ru.flow.doneTitle, ...rest] = notes
.join("\n\n")
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
<div className="panel">
<Body texts={notes} />
</div>
<h1 className="h1 center">{head}</h1>
{rest.length > 0 && (
<div className="panel">
<Body texts={rest} />
</div>
)}
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
<button type="button" className="btn btn--soft" onClick={onResults}>
{ru.today.results}
</button>
</div>
);
}
const SHORT_PARAGRAPHS = 3;
export function Flow() {
const navigate = useNavigate();
const toast = useToast();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
@@ -502,11 +507,36 @@ export function Flow() {
dialog.markRead();
}, [dialog.pending]);
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
// result (the conclusion of the first questionnaire) keeps its own screen.
const toasted = useRef("");
useEffect(() => {
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
}, [step, navigate]);
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
const texts = step.kind === "ack" ? step.text : step.notes;
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
if (paragraphs.length && toasted.current !== stepKey) {
toasted.current = stepKey;
toast(paragraphs.join(" "));
}
navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]);
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
// The browser «back» is absorbed by a duplicate history entry of this same screen.
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
useEffect(() => {
if (!testing) return;
window.history.pushState(null, "", window.location.href);
const stay = () => window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", stay);
return () => window.removeEventListener("popstate", stay);
}, [testing]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) =>
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
@@ -521,8 +551,10 @@ export function Flow() {
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "ack") {
content = <div className="loader" aria-label="Загрузка" />;
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
@@ -556,7 +588,7 @@ export function Flow() {
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = <TopBar onBack={home}>{progress}</TopBar>;
const top = <TopBar onBack={testing ? undefined : home}>{progress}</TopBar>;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
@@ -670,19 +702,12 @@ export function Flow() {
break;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />;
break;
case "choice": {
const form = step.formId ? catalog?.forms[step.formId] : undefined;
lead = (
<>
{form && <FormChip section={form.section} title={form.title} />}
<Head title={step.title} lead={step.lead} />
</>
);
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
}
content = (
<div className="focus" key={stepKey}>
@@ -699,7 +724,7 @@ export function Flow() {
}
return (
<AppShell focus sos={step?.kind !== "crisis"}>
<AppShell focus bare sos={step?.kind !== "crisis"}>
{content}
</AppShell>
);
+31
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
@@ -14,8 +15,18 @@ export function Profile() {
const { session, signOut } = useSession();
const dialog = useDialog();
const [confirm, setConfirm] = useState(false);
const [conclusion, setConclusion] = useState<string | null>(null);
const ref = useRef<HTMLDialogElement>(null);
// The preliminary conclusion of stage 1 is part of the profile until the doctor's diagnosis replaces it.
useEffect(() => {
if (!session) return;
api
.results(session.token)
.then((r) => setConclusion(r.conclusion?.title ?? null))
.catch(() => undefined);
}, [session]);
useEffect(() => {
const d = ref.current;
if (!d) return;
@@ -68,6 +79,26 @@ export function Profile() {
<section className="stack stack--sm">
<h2 className="h3">{t.data}</h2>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon tile-icon--teal">
<Icon name="clipboard" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.result}</span>
<span className="row-card__sub">{conclusion ?? t.resultNone}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon">
<Icon name="book" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.history}</span>
<span className="row-card__sub">{t.historySub}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
<span className="tile-icon">
<Icon name="message" />
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Results as ResultsData } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { dayMonth } from "./useToday";
/*
* «Мои результаты»: the preliminary conclusion of stage 1 (a screening result, not a diagnosis) and the log of every
* questionnaire with its date, status and the interpretation shown when it was finished. Data come from the server
* (`/me/{token}/results`), so the log is the same on every device.
*/
export function Results() {
const t = ru.results;
const navigate = useNavigate();
const { session } = useSession();
const [data, setData] = useState<ResultsData | null>(null);
const [failed, setFailed] = useState(false);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
if (!session) return;
api
.results(session.token)
.then(setData)
.catch(() => setFailed(true));
}, [session]);
const c = data?.conclusion;
return (
<AppShell>
<div className="stack">
<TopBar onBack={() => navigate("/profile")} title={t.title} />
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
{!data && !failed && <div className="loader" aria-label="Загрузка" />}
{data && (
<section className="stack stack--sm" aria-labelledby="conclusion">
<h2 id="conclusion" className="h3">
{t.conclusion}
</h2>
{c ? (
<div className="result">
<p className="muted small">{t.conclusionDay(dayMonth(c.day))}</p>
<p className="result__title">{c.title}</p>
{c.findings.length > 0 && (
<div className="result__chips">
{c.findings.map((f) => (
<span key={f.code} className="chip chip--teal">
{f.title}
</span>
))}
</div>
)}
{c.lines.map((l) => (
<p key={l}>{l}</p>
))}
{c.modules.length > 0 && (
<p className="muted small">{c.modules.map((m) => t.modules[m as keyof typeof t.modules] ?? m).join(" · ")}</p>
)}
<p className="muted small">{c.disclaimer}</p>
</div>
) : (
<Notice>{t.noResult}</Notice>
)}
</section>
)}
{data && (
<section className="stack stack--sm" aria-labelledby="history">
<h2 id="history" className="h3">
{t.history}
</h2>
{data.history.length === 0 && <p className="muted">{t.empty}</p>}
<ul className="log">
{data.history.map((h) => {
const expanded = open === h.id;
return (
<li key={h.id}>
<button
type="button"
className="log__row"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : h.id)}
disabled={!h.title && h.lines.length === 0}
>
<span className="log__date">{dayMonth(h.day)}</span>
<span className="log__text">
<span className="log__kind">{h.kind_title}</span>
<span className="log__sub">{h.title ?? h.status_title}</span>
</span>
{(h.title || h.lines.length > 0) && <Icon name={expanded ? "chevronDown" : "chevronRight"} />}
</button>
{expanded && (
<div className="log__body">
<p className="muted small">
{h.status_title}
{h.completed_at ? ` · ${new Date(h.completed_at).toLocaleString("ru-RU")}` : ""}
</p>
{h.lines.map((l) => (
<p key={l}>{l}</p>
))}
</div>
)}
</li>
);
})}
</ul>
</section>
)}
</div>
</AppShell>
);
}
+8 -11
View File
@@ -8,7 +8,6 @@ import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { SECTION_ICON } from "./Flow";
import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
@@ -92,7 +91,9 @@ export function Today() {
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
// A paused questionnaire is not an open step: the «continue the questionnaire» card below resumes it.
const paused = waiting?.kind === "notice" && waiting.primary?.data === "intake:resume";
const hasOpenStep = waiting !== null && waiting.kind !== "idle" && waiting.kind !== "ack" && !paused;
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
@@ -151,13 +152,9 @@ export function Today() {
(waiting?.kind === "survey" ? (
<Task
hero
icon={surveyForm ? SECTION_ICON[surveyForm.section] : "clipboard"}
icon={surveyForm?.section === "diary" ? "book" : "clipboard"}
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
sub={
surveyForm && surveyItem
? t.surveyStep(ru.flow.sections[surveyForm.section], surveyItem.index)
: t.intakeResumeHint
}
sub={t.intakeResumeHint}
chip={t.continue}
onClick={() => navigate("/flow")}
/>
@@ -240,10 +237,10 @@ export function Today() {
{diaryDue && waiting?.kind !== "survey" && (
<Task
icon="book"
title={t.diary}
sub={t.diaryHint}
title={q.diary_next ? t.diaryPart[q.diary_next] : t.diary}
sub={q.diary_next ? t.diaryPartHint[q.diary_next] : t.diaryHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", t.diary)}
onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : t.diary)}
/>
)}
{plan && !surveys && !plan.checkin_done && (
+52 -46
View File
@@ -1,70 +1,76 @@
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { telHref, useHelp } from "../components/HelpSheet";
import { Icon, LogoMark } from "../components/Icon";
import { ru } from "../content/ru";
// A1 · Выбор способа входа
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
export function Welcome() {
const navigate = useNavigate();
const { resources, open } = useHelp();
const t = ru.welcome;
const emergency = resources[0];
return (
<AppShell signedIn={false}>
<div className="stack">
<header className="head">
<div className="signin-page">
<main className="signin" id="main">
<div className="signin__brand">
<LogoMark size={44} />
<span>
<span className="signin__name">{ru.product.name}</span>
<span className="signin__tag">{ru.product.tagline}</span>
</span>
</div>
<section className="signin__card">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<div className="entry-grid">
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
</span>
<span className="chip chip--teal">{ru.common.recommended}</span>
</span>
<span className="entry__title">
{t.emias.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.emias.text}</span>
</button>
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
<span className="entry__top">
<span className="tile-icon">
<Icon name="lock" size={26} />
<span className="entry__title">
{t.emias.title}
<Icon name="chevronRight" />
</span>
</span>
<span className="entry__title">
{t.anonymous.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.anonymous.text}</span>
<ul className="signin__perks">
{t.emias.perks.map((p) => (
<li key={p}>
<Icon name="check" size={18} />
{p}
</li>
))}
</ul>
</button>
</div>
<section className="how" aria-labelledby="how-title">
<h2 id="how-title" className="h3">
{t.howTitle}
</h2>
<ol className="how__list">
{t.how.map((step, i) => (
<li key={step.title} className="how__item">
<span className="how__num">{i + 1}</span>
<span>
<span className="how__title">{step.title}</span>
<span className="how__text">{step.text}</span>
</span>
</li>
))}
</ol>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<div className="stack stack--sm">
<button type="button" className="btn btn--ghost" onClick={() => navigate("/anonymous")}>
<Icon name="lock" size={20} />
{t.anonymous.title}
</button>
<p className="muted small center">{t.anonymous.text}</p>
</div>
</section>
<p className="muted small">{t.disclaimer}</p>
</div>
</AppShell>
<div className="signin__foot">
<button type="button" className="signin__sos" onClick={open}>
<Icon name="phone" size={18} />
{ru.help.block}
</button>
{emergency && (
<a className="signin__tel" href={telHref(emergency.phone)}>
{emergency.phone}
</a>
)}
</div>
<p className="muted small center">{t.disclaimer}</p>
</main>
</div>
);
}
+274 -51
View File
@@ -634,6 +634,104 @@ button.row-card:hover:not(:disabled) {
/* ---------------------------------------------------------------- A1 entry */
.signin {
max-width: 640px;
}
/* Standalone sign-in page: no sidebar, one centred card */
.signin-page {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--s-8) var(--s-4);
background: var(--c-page-desk);
}
.signin-page .signin {
width: 100%;
max-width: 480px;
display: grid;
gap: var(--s-5);
}
.signin__brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-3);
}
.signin__name {
display: block;
font-size: var(--fs-18);
font-weight: 700;
color: var(--c-text);
}
.signin__tag {
display: block;
font-size: var(--fs-14);
color: var(--c-text-2);
}
.signin__card {
display: grid;
gap: var(--s-5);
padding: var(--s-8) var(--s-6);
border-radius: 24px;
background: var(--c-surface);
box-shadow: 0 12px 40px rgba(15, 39, 71, 0.08);
}
.signin__card .entry__title {
flex: 1;
}
.signin__perks {
display: grid;
gap: var(--s-2);
font-size: var(--fs-14);
color: var(--c-text-2);
}
.signin__perks li {
display: flex;
align-items: flex-start;
gap: var(--s-2);
}
.signin__perks svg {
flex: none;
margin-top: 1px;
color: var(--c-teal);
}
.signin__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
border-radius: var(--r-tile);
background: var(--c-red-soft);
}
.signin__sos {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-weight: 700;
color: var(--c-red-strong);
}
.signin__tel {
font-size: var(--fs-20);
font-weight: 700;
color: var(--c-red-strong);
text-decoration: none;
}
.entry-grid {
display: grid;
gap: var(--s-4);
@@ -1251,49 +1349,6 @@ button.row-card:hover:not(:disabled) {
justify-self: center;
}
.how {
display: grid;
gap: var(--s-3);
}
.how__list {
display: grid;
gap: var(--s-3);
}
.how__item {
display: flex;
gap: var(--s-3);
align-items: flex-start;
padding: var(--s-4);
border-radius: var(--r-tile);
background: var(--c-tile);
}
.how__num {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--c-navy);
color: #fff;
font-weight: 700;
font-size: var(--fs-14);
}
.how__title {
display: block;
font-weight: 700;
}
.how__text {
display: block;
font-size: var(--fs-13);
color: var(--c-text-2);
}
/* ---------------------------------------------------------------- meds */
.rx {
@@ -1972,10 +2027,6 @@ button.row-card:hover:not(:disabled) {
grid-column: 1 / -1;
}
.how__list {
grid-template-columns: repeat(3, 1fr);
}
.rail {
position: sticky;
top: var(--s-8);
@@ -2113,10 +2164,6 @@ button.row-card:hover:not(:disabled) {
border-top: 1px solid var(--c-border);
}
.survey__saved {
margin: 0;
}
.btn--sm {
width: auto;
min-height: 40px;
@@ -2433,3 +2480,179 @@ button.row-card:hover:not(:disabled) {
transition: none !important;
}
}
/* ---------------------------------------------------------------- bare: questionnaires, centred, no sidebar */
.app--bare {
background: var(--c-page-desk);
}
@media (min-width: 1024px) {
.app--bare {
display: block;
}
.app--bare .app__main {
grid-template-columns: minmax(0, 1fr);
max-width: 760px;
min-height: 100dvh;
margin: 0 auto;
padding: var(--s-10) var(--s-4);
align-content: center;
}
.app--bare .screen > .sosblock {
display: grid;
}
.app--bare .split {
grid-template-columns: minmax(0, 1fr);
gap: var(--s-6);
}
.app--bare .split__lead {
position: static;
}
}
/* ---------------------------------------------------------------- results and questionnaire log */
.result {
display: grid;
gap: var(--s-3);
padding: var(--s-5);
border-radius: var(--r-tile);
background: var(--c-teal-soft);
}
.result__title {
font-size: var(--fs-20);
font-weight: 700;
}
.result__chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
}
.result .chip {
background: var(--c-surface);
}
.log {
display: grid;
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: var(--c-surface);
overflow: hidden;
}
.log > li + li {
border-top: 1px solid var(--c-border);
}
.log__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-4);
padding: var(--s-3) var(--s-4);
text-align: left;
color: var(--c-text);
}
.log__row:disabled {
cursor: default;
}
.log__row svg {
flex: none;
color: var(--c-text-3);
}
.log__date {
flex: none;
width: 64px;
font-size: var(--fs-13);
color: var(--c-text-2);
}
.log__text {
flex: 1;
min-width: 0;
display: grid;
}
.log__kind {
font-weight: 600;
}
.log__sub {
font-size: var(--fs-13);
color: var(--c-text-2);
}
.log__body {
display: grid;
gap: var(--s-2);
padding: 0 var(--s-4) var(--s-4) calc(64px + var(--s-8));
font-size: var(--fs-14);
}
/* ---------------------------------------------------------------- toasts (top right) */
.toasts {
position: fixed;
top: calc(var(--s-4) + var(--safe-top));
right: max(var(--s-4), var(--safe-right));
left: max(var(--s-4), var(--safe-left));
z-index: 60;
display: grid;
gap: var(--s-2);
justify-items: end;
pointer-events: none;
}
.toast {
width: min(100%, 380px);
display: flex;
align-items: flex-start;
gap: var(--s-3);
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-4);
border-radius: var(--r-tile);
background: var(--c-navy);
color: #fff;
box-shadow: 0 12px 32px rgb(15 39 71 / 0.25);
pointer-events: auto;
animation: toast-in 200ms var(--ease);
}
.toast__icon {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--c-teal);
}
.toast__text {
flex: 1;
margin: 3px 0 0;
font-size: var(--fs-14);
line-height: 1.4;
}
.toast__close {
flex: none;
color: rgb(255 255 255 / 0.7);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
}