diff --git a/app/api/fixtures.py b/app/api/fixtures.py
index 79ea55a..9be6541 100644
--- a/app/api/fixtures.py
+++ b/app/api/fixtures.py
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
from app.api.schemas import (
AlertOut,
+ DemoPersona,
DiscontinuationRisk,
DoseOut,
PatientCard,
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova"
+# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
+DEMO_PERSONAS: list[DemoPersona] = [
+ DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
+]
+
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
diff --git a/app/api/patient_web.py b/app/api/patient_web.py
new file mode 100644
index 0000000..129f975
--- /dev/null
+++ b/app/api/patient_web.py
@@ -0,0 +1,93 @@
+"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`).
+
+The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
+exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
+
+Two ways in:
+- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
+ patient is linked to the attending doctor, who sees the data on the dashboard.
+- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
+ supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
+ stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
+
+The token returned here is the session credential of the prototype. Production needs short-lived sessions
+issued by the server instead of handing out the pseudonymous token itself.
+"""
+
+import logging
+import secrets
+from typing import Literal
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.api import fixtures
+from app.core.safety import CrisisResource, crisis_reply
+from app.db import repo
+from app.db.identity import TOKEN_LEN
+from app.db.session import get_clinical_session
+from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(tags=["patient-web"])
+_emias = EmiasMock()
+
+
+class AuthOut(BaseModel):
+ token: str
+ mode: Literal["emias", "anonymous"]
+ display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
+
+
+class EmiasCodeIn(BaseModel):
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
+ code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
+
+
+class EmiasDemoIn(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ persona_token: str = Field(min_length=1, max_length=64)
+
+
+async def _attach_doctor(token: str, doctor_id: str) -> None:
+ async with get_clinical_session() as s:
+ if await repo.get_profile(s, token) is None:
+ await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
+
+
+@router.post("/auth/emias", response_model=AuthOut)
+async def auth_emias(body: EmiasCodeIn) -> AuthOut:
+ """Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
+ person = await _emias.verify(body.code)
+ if person is None:
+ log.info("web auth: emias code rejected")
+ raise HTTPException(401, "code not accepted")
+ await _attach_doctor(person.token, person.doctor_id)
+ log.info("web auth: emias tok=%s", person.token[:6])
+ return AuthOut(token=person.token, mode="emias")
+
+
+@router.post("/auth/emias/demo", response_model=AuthOut)
+async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
+ """Demo ЕМИАС account: signs in as one of the synthetic personas (`/demo/personas`)."""
+ persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
+ if persona is None:
+ raise HTTPException(404, "persona not found")
+ await _attach_doctor(persona.token, fixtures.DOCTOR_ID)
+ log.info("web auth: emias demo tok=%s", persona.token[:6])
+ return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
+
+
+@router.post("/auth/anonymous", response_model=AuthOut)
+async def auth_anonymous() -> AuthOut:
+ """Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
+ token = secrets.token_hex(TOKEN_LEN // 2)
+ log.info("web auth: anonymous tok=%s", token[:6])
+ return AuthOut(token=token, mode="anonymous")
+
+
+@router.get("/crisis/resources", response_model=list[CrisisResource])
+async def crisis_resources() -> list[CrisisResource]:
+ """Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
+ return crisis_reply([], "patient").resources
diff --git a/app/api/routes.py b/app/api/routes.py
index 3c6d4e2..4f91337 100644
--- a/app/api/routes.py
+++ b/app/api/routes.py
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
from fastapi import APIRouter, HTTPException
from app.api import fixtures
+from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
AckIn,
@@ -29,6 +30,7 @@ from app.scheduler import jobs as scheduler
router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router)
+router.include_router(patient_web_router)
# ------------------------------------------------------------------ doctor dashboard
@@ -114,8 +116,4 @@ async def demo_reset() -> dict[str, str]:
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]:
- return [
- DemoPersona(
- token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
- )
- ]
+ return fixtures.DEMO_PERSONAS
diff --git a/app/integrations/emias_mock.py b/app/integrations/emias_mock.py
new file mode 100644
index 0000000..c29fcbf
--- /dev/null
+++ b/app/integrations/emias_mock.py
@@ -0,0 +1,35 @@
+"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
+
+Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
+code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
+class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
+
+It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
+(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
+production provider for all channels (#14).
+
+This module may use the identity DB: it is the registration boundary (guardrail #5).
+"""
+
+from app.core import clock
+from app.db import identity_repo
+from app.db.identity import ConnectCode
+from app.db.session import get_identity_session
+from app.integrations.base import VerifiedPerson
+
+CODE_MAX_LEN = 32
+
+
+class EmiasMock:
+ async def verify(self, auth_code: str) -> VerifiedPerson | None:
+ code = auth_code.strip().upper()
+ if not code or len(code) > CODE_MAX_LEN:
+ return None
+ async with get_identity_session() as s:
+ row = await s.get(ConnectCode, code)
+ if row is None:
+ return None
+ token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
+ if token is None:
+ return None
+ return VerifiedPerson(token=token, doctor_id=row.doctor_id)
diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py
new file mode 100644
index 0000000..e8da9eb
--- /dev/null
+++ b/tests/test_api_patient_web.py
@@ -0,0 +1,86 @@
+"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
+
+from collections.abc import AsyncIterator
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.api import fixtures
+from app.core import clock
+from app.db import identity_repo, repo, session
+from app.db.bootstrap import init_db
+from app.main import app
+
+MEM = "sqlite+aiosqlite:///:memory:"
+TOKEN = "b" * 32
+
+
+@pytest.fixture
+async def client() -> AsyncIterator[AsyncClient]:
+ await session.configure(identity_url=MEM, clinical_url=MEM)
+ await init_db()
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ yield c
+ await session.configure()
+
+
+async def _patient_with_code(code: str) -> None:
+ async with session.get_identity_session() as s:
+ await identity_repo.create_patient(
+ s,
+ token=TOKEN,
+ display_name="Синтетический П.",
+ snils="000-000-000 00",
+ clinic_id="clinic-1",
+ doctor_id="doc-1",
+ now=clock.now(),
+ )
+ await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
+
+
+async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient) -> None:
+ first = (await client.post("/api/v1/auth/anonymous")).json()
+ second = (await client.post("/api/v1/auth/anonymous")).json()
+ assert first["mode"] == "anonymous" and first["display_name"] is None
+ assert len(first["token"]) == 32 and first["token"] != second["token"]
+ async with session.get_identity_session() as s:
+ assert await identity_repo.get_patient(s, first["token"]) is None
+ async with session.get_clinical_session() as s:
+ assert await repo.get_profile(s, first["token"]) is None
+
+
+async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
+ token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
+ replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
+ buttons = [b["data"] for r in replies for row in r["buttons"] for b in row]
+ assert "consent:yes" in buttons and "consent:no" in buttons
+
+
+async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None:
+ await _patient_with_code("DEMO-0042")
+ ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "})
+ assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None}
+ async with session.get_clinical_session() as s:
+ assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1"
+ again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"})
+ assert again.status_code == 401
+
+
+async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
+ assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401
+ assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422
+
+
+async def test_emias_demo_persona(client: AsyncClient) -> None:
+ persona = fixtures.DEMO_PERSONAS[0]
+ res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
+ assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
+ async with session.get_clinical_session() as s:
+ assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
+ missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
+ assert missing.status_code == 404
+
+
+async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
+ phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
+ assert "112" in phones
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..f4e2c6d
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.tsbuildinfo
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..db2e6a3
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,43 @@
+# Веб-приложение пациента
+
+Ещё один канал к тому же движку диалога, что и Telegram-бот (ADR-004): согласие, онбординг, шкалы, чек-ины и
+кризисный скрипт приходят из `app/dialog` через `/api/v1/dialog/{token}/...`. Бот продолжает работать параллельно.
+
+## Запуск
+
+```bash
+make up # или: uv run uvicorn app.main:app --port 8000
+cd web && npm install
+npm run dev # http://localhost:5173 — /api проксируется на :8000
+```
+
+Интерфейс открывается на **:5173**; на :8000 работает только API (`/docs`).
+
+## Вход
+
+| Способ | Что происходит | API |
+|---|---|---|
+| ЕМИАС (демо) | одноразовый код от врача → токен пациента, привязка к врачу; или синтетический демо-пациент | `POST /auth/emias`, `POST /auth/emias/demo` |
+| Анонимно | случайный токен, в identity-БД ничего не пишется, врач не привязан | `POST /auth/anonymous` |
+
+Реальная авторизация ЕМИАС/ЕСИА подключается как `IdentityProvider` (`app/integrations/emias_mock.py`) без
+изменений во фронтенде.
+
+## Структура (под перенос в мобильное приложение)
+
+```
+src/theme/tokens.css цвета, шрифт, отступы — единственный источник; три цвета = три направления сервиса
+src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
+src/api/ типы-зеркала контрактов бэкенда и клиент
+src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
+src/screens/ экраны: Welcome, EmiasSignIn, AnonymousSignIn, Chat
+```
+
+Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
+Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
+
+## Известные ограничения прототипа
+
+- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии.
+- Подбор кодов не ограничен по частоте.
+- В демо нет сида кодов подключения: для показа используйте демо-пациента.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..379a015
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Сопровождение пациента
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..3dfda63
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1802 @@
+{
+ "name": "ptsr-patient-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ptsr-patient-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@fontsource/golos-text": "^5.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.3",
+ "typescript": "~5.6.3",
+ "vite": "^5.4.10"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@fontsource/golos-text": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/golos-text/-/golos-text-5.3.0.tgz",
+ "integrity": "sha512-R+gM4qOZSrge4TtEGps0zTpU8X4e7e32L+3yy0J/2LnVB/IERlidEpXPWSZVGzTaL5QnPRVmoebRXSuVtjUIVg==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.4",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
+ "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz",
+ "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz",
+ "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz",
+ "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz",
+ "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz",
+ "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz",
+ "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz",
+ "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz",
+ "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz",
+ "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz",
+ "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz",
+ "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz",
+ "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz",
+ "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz",
+ "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz",
+ "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz",
+ "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz",
+ "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz",
+ "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz",
+ "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz",
+ "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz",
+ "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz",
+ "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz",
+ "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz",
+ "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001814",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
+ "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.444",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz",
+ "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
+ "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
+ "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4",
+ "react-router": "6.30.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz",
+ "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.0",
+ "@rollup/rollup-android-arm64": "4.64.0",
+ "@rollup/rollup-darwin-arm64": "4.64.0",
+ "@rollup/rollup-darwin-x64": "4.64.0",
+ "@rollup/rollup-freebsd-arm64": "4.64.0",
+ "@rollup/rollup-freebsd-x64": "4.64.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.0",
+ "@rollup/rollup-linux-arm64-musl": "4.64.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.0",
+ "@rollup/rollup-linux-loong64-musl": "4.64.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-gnu": "4.64.0",
+ "@rollup/rollup-linux-x64-musl": "4.64.0",
+ "@rollup/rollup-openbsd-x64": "4.64.0",
+ "@rollup/rollup-openharmony-arm64": "4.64.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.0",
+ "@rollup/rollup-win32-x64-gnu": "4.64.0",
+ "@rollup/rollup-win32-x64-msvc": "4.64.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..66b24ad
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "ptsr-patient-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -b"
+ },
+ "dependencies": {
+ "@fontsource/golos-text": "^5.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.28.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.3",
+ "typescript": "~5.6.3",
+ "vite": "^5.4.10"
+ }
+}
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 0000000..7830f3e
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..c512b5c
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,42 @@
+import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply } from "./types";
+
+// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
+const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
+
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+async function request(path: string, init?: RequestInit): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${BASE}${path}`, {
+ ...init,
+ headers: { "Content-Type": "application/json", ...init?.headers },
+ });
+ } catch {
+ throw new ApiError(0, "network");
+ }
+ if (!res.ok) throw new ApiError(res.status, res.statusText);
+ return (await res.json()) as T;
+}
+
+const post = (path: string, body?: unknown) =>
+ request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
+
+export const api = {
+ signInEmias: (code: string) => post("/auth/emias", { code }),
+ signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }),
+ signInAnonymous: () => post("/auth/anonymous"),
+ demoPersonas: () => request("/demo/personas"),
+ crisisResources: () => request("/crisis/resources"),
+ sendEvent: async (token: string, event: DialogEvent) =>
+ (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
+ outbox: async (token: string) =>
+ (await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
+};
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
new file mode 100644
index 0000000..1e40513
--- /dev/null
+++ b/web/src/api/types.ts
@@ -0,0 +1,41 @@
+// Mirrors of the backend contracts (app/dialog/contracts.py, app/api/schemas.py, app/api/patient_web.py).
+// Keep in sync by hand: the frozen contracts change only through a [contract] PR.
+
+export interface DialogButton {
+ label: string;
+ data: string;
+}
+
+export type ReplyKind = "normal" | "question" | "crisis" | "safety_plan" | "info";
+
+export interface Reply {
+ text: string;
+ buttons: DialogButton[][];
+ kind: ReplyKind;
+}
+
+export type DialogEvent =
+ | { type: "start"; params?: Record }
+ | { type: "text"; text: string }
+ | { type: "button"; button: string };
+
+export type AuthMode = "emias" | "anonymous";
+
+export interface AuthResult {
+ token: string;
+ mode: AuthMode;
+ display_name: string | null;
+}
+
+export interface CrisisResource {
+ name: string;
+ phone: string;
+ note: string;
+}
+
+export interface DemoPersona {
+ token: string;
+ display_name: string;
+ therapy_day: number;
+ scenario: string | null;
+}
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
new file mode 100644
index 0000000..42dbd69
--- /dev/null
+++ b/web/src/components/AppShell.tsx
@@ -0,0 +1,64 @@
+import { useState, type ReactNode } from "react";
+import { Link } from "react-router-dom";
+
+import { ru } from "../content/ru";
+import { HelpSheet } from "./HelpSheet";
+import { Icon, LogoMark } from "./Icon";
+
+export function AppShell({ children, footer = true }: { children: ReactNode; footer?: boolean }) {
+ const [helpOpen, setHelpOpen] = useState(false);
+
+ return (
+
+
+
+ {children}
+
+ {footer && (
+
+
+
+
+ {ru.footer.law}
+
+
{ru.footer.prototype}
+
+
+ )}
+
+
setHelpOpen(false)} />
+
+ );
+}
+
+export function BackLink({ to, children }: { to: string; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export function Notice({ tone = "info", children }: { tone?: "info" | "warning"; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/web/src/components/HelpSheet.tsx b/web/src/components/HelpSheet.tsx
new file mode 100644
index 0000000..b112a9f
--- /dev/null
+++ b/web/src/components/HelpSheet.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useRef, useState } from "react";
+
+import { api } from "../api/client";
+import type { CrisisResource } from "../api/types";
+import { ru } from "../content/ru";
+import { Icon } from "./Icon";
+
+// 112 is shown even when the API is unreachable: the help sheet must never be empty.
+const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: ru.help.fallbackNote }];
+
+const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
+
+export function HelpSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const ref = useRef(null);
+ const [resources, setResources] = useState(FALLBACK);
+
+ useEffect(() => {
+ api
+ .crisisResources()
+ .then((r) => r.length && setResources(r))
+ .catch(() => undefined);
+ }, []);
+
+ useEffect(() => {
+ const dialog = ref.current;
+ if (!dialog) return;
+ if (open && !dialog.open) dialog.showModal();
+ if (!open && dialog.open) dialog.close();
+ }, [open]);
+
+ return (
+ e.target === ref.current && onClose()}
+ >
+
+
+
+ {ru.help.title}
+
+
+
+
+
+
{ru.help.lead}
+
+
+
+ );
+}
diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx
new file mode 100644
index 0000000..d9279ec
--- /dev/null
+++ b/web/src/components/Icon.tsx
@@ -0,0 +1,78 @@
+// Minimal line icon set (24px grid, 1.75 stroke). Decorative by default: pass `label` to expose to screen readers.
+
+const paths = {
+ check: ,
+ minus: ,
+ phone: (
+
+ ),
+ arrowLeft: ,
+ arrowRight: ,
+ send: ,
+ info: (
+ <>
+
+
+ >
+ ),
+ alert: (
+ <>
+
+
+ >
+ ),
+ lock: (
+ <>
+
+
+ >
+ ),
+ pill: (
+ <>
+
+
+ >
+ ),
+ pulse: ,
+ doctor: (
+ <>
+
+
+
+ >
+ ),
+ close: ,
+ logout: ,
+} as const;
+
+export type IconName = keyof typeof paths;
+
+export function Icon({ name, size = 20, label }: { name: IconName; size?: number; label?: string }) {
+ return (
+
+ {paths[name]}
+
+ );
+}
+
+export function LogoMark({ size = 32 }: { size?: number }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts
new file mode 100644
index 0000000..e8b851f
--- /dev/null
+++ b/web/src/content/ru.ts
@@ -0,0 +1,103 @@
+/*
+ * All interface texts of the patient app. No user-facing strings inside components (same rule as config/texts/
+ * for the bot). Dialog content (consent, questions, scales) comes from the engine and is NOT duplicated here.
+ */
+export const ru = {
+ product: {
+ name: "Сопровождение пациента",
+ tagline: "между визитами к врачу",
+ prototype: "Прототип · синтетические данные",
+ },
+ help: {
+ button: "Нужна помощь",
+ title: "Если вам сейчас очень тяжело",
+ lead: "Не ждите ответа врача, если помощь нужна прямо сейчас. Позвоните:",
+ call: "Позвонить",
+ close: "Закрыть",
+ fallbackName: "Экстренные службы",
+ fallbackNote: "если есть угроза жизни прямо сейчас",
+ },
+ welcome: {
+ title: "Лечение продолжается и между приёмами",
+ lead: "Напомним о лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание. На ответы уходит пара минут.",
+ day: {
+ caption: "Обычный день с сервисом",
+ meds: { time: "09:00", track: "Лекарства", text: "Утренний приём", done: "Принято" },
+ mood: { time: "14:00", track: "Самочувствие", text: "Как вы сегодня?", low: "Тяжело", high: "Хорошо" },
+ doctor: { time: "20:00", track: "Врач", text: "Динамика за неделю видна врачу" },
+ },
+ choose: "Как вы хотите войти?",
+ emias: {
+ title: "Через ЕМИАС",
+ badge: "Рекомендуем",
+ facts: [
+ { ok: true, text: "Назначения врача подгружаются автоматически" },
+ { ok: true, text: "Врач видит ваши ответы и получает сигнал при ухудшении" },
+ { ok: true, text: "История доступна с любого устройства" },
+ ],
+ action: "Войти через ЕМИАС",
+ },
+ anonymous: {
+ title: "Анонимно",
+ facts: [
+ { ok: true, text: "Не нужны имя, полис и телефон" },
+ { ok: true, text: "Опросы самочувствия и помощь в кризисе доступны" },
+ { ok: false, text: "Врач не видит ваши ответы и не получает сигналов" },
+ { ok: false, text: "История доступна только на этом устройстве" },
+ ],
+ action: "Продолжить анонимно",
+ },
+ disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
+ },
+ emias: {
+ back: "Способы входа",
+ title: "Вход через ЕМИАС",
+ demoNotice:
+ "Демонстрационный режим. Авторизация в ЕМИАС не выполняется: вход подтверждается одноразовым кодом, который врач выдаёт на приёме.",
+ codeLabel: "Код подключения от врача",
+ codeHint: "Код указан в памятке или под QR-кодом, который вы получили на приёме.",
+ codePlaceholder: "Код из памятки",
+ submit: "Войти",
+ submitting: "Проверяем код…",
+ codeError: "Код не подошёл. Проверьте написание или попросите врача выдать новый код.",
+ networkError: "Сервис временно недоступен. Попробуйте ещё раз через минуту.",
+ or: "или",
+ demoTitle: "Демо-пациенты",
+ demoLead: "Нет кода? Войдите под синтетической учётной записью, чтобы посмотреть сценарий.",
+ demoDay: (day: number) => `${day}-й день терапии`,
+ demoAction: "Войти",
+ demoEmpty: "Демо-пациенты недоступны.",
+ },
+ anonymous: {
+ back: "Способы входа",
+ title: "Анонимный вход",
+ lead: "Мы не спрашиваем имя, полис и телефон. Ваши ответы хранятся под случайным идентификатором, по которому вас нельзя узнать.",
+ points: [
+ "Доступны опросы самочувствия, дневник и помощь в кризисной ситуации.",
+ "Лечащий врач не видит ваши ответы и не получает сигналов об ухудшении.",
+ "Перед началом вы дадите согласие на обработку ответов — без него ничего не сохраняется.",
+ ],
+ warning:
+ "История привязана к этому устройству. Если вы выйдете или очистите данные браузера, восстановить её будет нельзя.",
+ submit: "Начать анонимно",
+ submitting: "Создаём анонимный профиль…",
+ },
+ chat: {
+ modeEmias: "ЕМИАС",
+ modeAnonymous: "Анонимно",
+ logout: "Выйти",
+ logoutConfirm: "Выйти? Анонимную историю восстановить будет нельзя.",
+ inputLabel: "Сообщение",
+ inputPlaceholder: "Напишите сообщение",
+ send: "Отправить",
+ loading: "Подключаемся…",
+ sendError: "Сообщение не отправлено. Проверьте соединение и попробуйте ещё раз.",
+ you: "Вы",
+ assistant: "Помощник",
+ crisisLabel: "Важно",
+ },
+ footer: {
+ law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
+ prototype: "Прототип для хакатона MedITron. Используются только синтетические данные.",
+ },
+} as const;
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..a1563b2
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,39 @@
+import "@fontsource/golos-text/400.css";
+import "@fontsource/golos-text/500.css";
+import "@fontsource/golos-text/600.css";
+import "./theme/tokens.css";
+import "./theme/global.css";
+
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
+
+import { AnonymousSignIn } from "./screens/AnonymousSignIn";
+import { Chat } from "./screens/Chat";
+import { EmiasSignIn } from "./screens/EmiasSignIn";
+import { Welcome } from "./screens/Welcome";
+import { SessionProvider, useSession } from "./session/SessionContext";
+
+// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
+function AppRoutes() {
+ const { session } = useSession();
+ return (
+
+ : } />
+ } />
+ } />
+ } />
+ } />
+
+ );
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx
new file mode 100644
index 0000000..364ce8b
--- /dev/null
+++ b/web/src/screens/AnonymousSignIn.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { api } from "../api/client";
+import { AppShell, BackLink, Notice } from "../components/AppShell";
+import { Icon } from "../components/Icon";
+import { ru } from "../content/ru";
+import { useSession } from "../session/SessionContext";
+
+export function AnonymousSignIn() {
+ const t = ru.anonymous;
+ const navigate = useNavigate();
+ const { signIn } = useSession();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(false);
+
+ const start = async () => {
+ setBusy(true);
+ setError(false);
+ try {
+ signIn(await api.signInAnonymous());
+ navigate("/chat", { replace: true });
+ } catch {
+ setError(true);
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
{t.back}
+
+
+ {t.title}
+
+ {t.lead}
+
+ {t.points.map((p) => (
+
+
+ {p}
+
+ ))}
+
+ {t.warning}
+ {error && (
+
+ {ru.emias.networkError}
+
+ )}
+
+ {busy ? t.submitting : t.submit}
+
+
+
+
+
+ );
+}
diff --git a/web/src/screens/Chat.tsx b/web/src/screens/Chat.tsx
new file mode 100644
index 0000000..041c986
--- /dev/null
+++ b/web/src/screens/Chat.tsx
@@ -0,0 +1,267 @@
+import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { api } from "../api/client";
+import type { DialogButton, DialogEvent, Reply } from "../api/types";
+import { AppShell } from "../components/AppShell";
+import { Icon } from "../components/Icon";
+import { ru } from "../content/ru";
+import { useSession } from "../session/SessionContext";
+import { sessionStore, type ChatEntry } from "../session/store";
+
+// The engine pushes reminders and check-ins to the outbox; the web channel polls it (Telegram gets them pushed).
+const OUTBOX_POLL_MS = 6000;
+
+let seq = 0;
+const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
+
+const assistantEntries = (replies: Reply[]): ChatEntry[] =>
+ replies.map((reply) => ({ id: newId(), from: "assistant", reply, answered: false }));
+
+// Phone numbers in engine texts (crisis script, help lines) become tap-to-call links.
+const PHONE_RE = /(\+7[\d\s()-]{9,}\d|8 \(\d{3}\) \d{3}|\b11[2]\b|\b10[23]\b)/g;
+
+function RichText({ text }: { text: string }) {
+ return (
+ <>
+ {text.split("\n").map((line, i) => (
+
+ {i > 0 && }
+ {line.split(PHONE_RE).map((part, j) =>
+ j % 2 === 1 ? (
+
+ {part}
+
+ ) : (
+ {part}
+ ),
+ )}
+
+ ))}
+ >
+ );
+}
+
+function AssistantMessage({
+ entry,
+ disabled,
+ onPress,
+}: {
+ entry: Extract;
+ disabled: boolean;
+ onPress: (button: DialogButton) => void;
+}) {
+ const { reply } = entry;
+ const special = reply.kind === "crisis" || reply.kind === "safety_plan";
+ return (
+
+
+ {special && (
+
+
+ {ru.chat.crisisLabel}
+
+ )}
+
+
+
+
+ {reply.buttons.length > 0 && !entry.answered && (
+
+ {reply.buttons.map((row, r) => (
+
+ {row.map((b) => (
+ onPress(b)}
+ >
+ {b.label}
+
+ ))}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function Chat() {
+ const t = ru.chat;
+ const navigate = useNavigate();
+ const { session, signOut } = useSession();
+ const token = session?.token ?? "";
+ const [entries, setEntries] = useState(() => (token ? sessionStore.loadChat(token) : []));
+ const [draft, setDraft] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(false);
+ const [confirmLogout, setConfirmLogout] = useState(false);
+ const endRef = useRef(null);
+ const started = useRef(false);
+
+ useEffect(() => {
+ if (token) sessionStore.saveChat(token, entries);
+ endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
+ }, [entries, token]);
+
+ // Any patient action closes the open buttons: the engine has moved on, old choices are stale.
+ const dispatch = useCallback(
+ async (event: DialogEvent, optimistic: ChatEntry[]) => {
+ setBusy(true);
+ setError(false);
+ let reopened = new Set();
+ setEntries((prev) => {
+ reopened = new Set(prev.filter((e) => e.from === "assistant" && !e.answered).map((e) => e.id));
+ return [...prev.map((e) => (reopened.has(e.id) ? { ...e, answered: true } : e)), ...optimistic];
+ });
+ try {
+ const replies = await api.sendEvent(token, event);
+ setEntries((prev) => [...prev, ...assistantEntries(replies)]);
+ return true;
+ } catch {
+ // Roll back the optimistic patient message and re-open the buttons.
+ const ids = new Set(optimistic.map((o) => o.id));
+ setEntries((prev) =>
+ prev.filter((e) => !ids.has(e.id)).map((e) => (reopened.has(e.id) ? { ...e, answered: false } : e)),
+ );
+ setError(true);
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ },
+ [token],
+ );
+
+ // First visit: the engine opens the dialog (consent → onboarding) exactly as for /start in Telegram.
+ useEffect(() => {
+ if (!token || started.current) return;
+ started.current = true;
+ if (entries.length === 0) void dispatch({ type: "start" }, []);
+ }, [token, entries.length, dispatch]);
+
+ useEffect(() => {
+ if (!token) return;
+ const timer = window.setInterval(() => {
+ api
+ .outbox(token)
+ .then((replies) => replies.length && setEntries((prev) => [...prev, ...assistantEntries(replies)]))
+ .catch(() => undefined);
+ }, OUTBOX_POLL_MS);
+ return () => window.clearInterval(timer);
+ }, [token]);
+
+ useEffect(() => {
+ if (!session) navigate("/", { replace: true });
+ }, [session, navigate]);
+
+ if (!session) return null;
+
+ const sendText = async (e?: FormEvent) => {
+ e?.preventDefault();
+ const text = draft.trim();
+ if (!text || busy) return;
+ setDraft("");
+ const ok = await dispatch({ type: "text", text }, [{ id: newId(), from: "patient", text }]);
+ if (!ok) setDraft(text);
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) void sendText();
+ };
+
+ const press = (b: DialogButton) =>
+ void dispatch({ type: "button", button: b.data }, [{ id: newId(), from: "patient", text: b.label }]);
+
+ return (
+
+
+
+
+
+ {session.mode === "emias" ? t.modeEmias : t.modeAnonymous}
+ {session.displayName && · {session.displayName} }
+
+ {confirmLogout ? (
+
+ {t.logoutConfirm}
+
+ {t.logout}
+
+ setConfirmLogout(false)}>
+
+
+
+ ) : (
+ (session.mode === "anonymous" ? setConfirmLogout(true) : signOut())}
+ >
+
+ {t.logout}
+
+ )}
+
+
+
+
+
+ {entries.map((entry) =>
+ entry.from === "assistant" ? (
+
+ ) : (
+
+ {t.you}:
+
+
+ ),
+ )}
+ {busy && (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/screens/EmiasSignIn.tsx b/web/src/screens/EmiasSignIn.tsx
new file mode 100644
index 0000000..1cb5987
--- /dev/null
+++ b/web/src/screens/EmiasSignIn.tsx
@@ -0,0 +1,137 @@
+import { useEffect, useState, type FormEvent } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { ApiError, api } from "../api/client";
+import type { AuthResult, DemoPersona } from "../api/types";
+import { AppShell, BackLink, Notice } from "../components/AppShell";
+import { Icon } from "../components/Icon";
+import { ru } from "../content/ru";
+import { useSession } from "../session/SessionContext";
+
+export function EmiasSignIn() {
+ const t = ru.emias;
+ const navigate = useNavigate();
+ const { signIn } = useSession();
+ const [code, setCode] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [personas, setPersonas] = useState(null);
+
+ useEffect(() => {
+ api
+ .demoPersonas()
+ .then(setPersonas)
+ .catch(() => setPersonas([]));
+ }, []);
+
+ const finish = (auth: AuthResult) => {
+ signIn(auth);
+ navigate("/chat", { replace: true });
+ };
+
+ const fail = (e: unknown) => {
+ setError(e instanceof ApiError && e.status === 401 ? t.codeError : t.networkError);
+ setBusy(false);
+ };
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!code.trim() || busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ finish(await api.signInEmias(code.trim()));
+ } catch (err) {
+ fail(err);
+ }
+ };
+
+ const demo = async (persona: DemoPersona) => {
+ setBusy(true);
+ setError(null);
+ try {
+ finish(await api.signInEmiasDemo(persona.token));
+ } catch (err) {
+ fail(err);
+ }
+ };
+
+ return (
+
+
+
+
{t.back}
+
+
+ {t.title}
+
+ {t.demoNotice}
+
+
+
+
+ {t.codeLabel}
+
+
setCode(e.target.value)}
+ placeholder={t.codePlaceholder}
+ autoComplete="one-time-code"
+ autoCapitalize="characters"
+ spellCheck={false}
+ maxLength={32}
+ aria-invalid={error ? true : undefined}
+ aria-describedby={error ? "code-error" : "code-hint"}
+ />
+ {error ? (
+
+ {error}
+
+ ) : (
+
+ {t.codeHint}
+
+ )}
+
+
+ {busy ? t.submitting : t.submit}
+
+
+
+
+ {t.or}
+
+
+ {t.demoTitle}
+ {t.demoLead}
+ {personas === null ? null : personas.length === 0 ? (
+ {t.demoEmpty}
+ ) : (
+
+ {personas.map((p) => (
+
+ demo(p)} disabled={busy}>
+
+ {p.display_name.charAt(0)}
+
+
+ {p.display_name}
+ {t.demoDay(p.therapy_day)}
+
+
+ {t.demoAction}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/screens/Welcome.tsx b/web/src/screens/Welcome.tsx
new file mode 100644
index 0000000..d5e5bc4
--- /dev/null
+++ b/web/src/screens/Welcome.tsx
@@ -0,0 +1,145 @@
+import { useNavigate } from "react-router-dom";
+
+import { AppShell } from "../components/AppShell";
+import { Icon } from "../components/Icon";
+import { ru } from "../content/ru";
+
+const SCALE = [1, 2, 3, 4, 5] as const;
+const WEEK = [38, 52, 46, 60, 58, 72, 80]; // illustrative trend, %
+
+function Facts({ items }: { items: readonly { ok: boolean; text: string }[] }) {
+ return (
+
+ {items.map((f) => (
+
+
+ {f.text}
+
+ ))}
+
+ );
+}
+
+// Illustration of a day with the service (decorative: the same facts are stated in text).
+function DayPreview() {
+ const d = ru.welcome.day;
+ return (
+
+ {d.caption}
+
+
+ {d.meds.time}
+
+
+
+ {d.meds.track}
+
+
+ {d.meds.text}
+
+
+ {d.meds.done}
+
+
+
+
+
+ {d.mood.time}
+
+
+
+ {d.mood.track}
+
+
{d.mood.text}
+
+ {SCALE.map((n) => (
+
+ {n}
+
+ ))}
+
+
+ {d.mood.low}
+ {d.mood.high}
+
+
+
+
+ {d.doctor.time}
+
+
+
+ {d.doctor.track}
+
+
+ {d.doctor.text}
+
+ {WEEK.map((v, i) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+export function Welcome() {
+ const navigate = useNavigate();
+ const t = ru.welcome;
+
+ return (
+
+
+
+
+
+ {t.title}
+
+ {t.lead}
+
+
+
+
+ {t.choose}
+
+
+
+
+
+
+
+
{t.emias.title}
+ {t.emias.badge}
+
+
+ navigate("/emias")}>
+ {t.emias.action}
+
+
+
+
+
+
+
+
+
{t.anonymous.title}
+
+
+ navigate("/anonymous")}>
+ {t.anonymous.action}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/session/SessionContext.tsx b/web/src/session/SessionContext.tsx
new file mode 100644
index 0000000..f344c3e
--- /dev/null
+++ b/web/src/session/SessionContext.tsx
@@ -0,0 +1,38 @@
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
+
+import type { AuthResult } from "../api/types";
+import { sessionStore, type Session } from "./store";
+
+interface SessionApi {
+ session: Session | null;
+ signIn: (auth: AuthResult) => void;
+ signOut: () => void;
+}
+
+const Ctx = createContext(null);
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [session, setSession] = useState(() => sessionStore.load());
+
+ const signIn = useCallback((auth: AuthResult) => {
+ const next: Session = { token: auth.token, mode: auth.mode, displayName: auth.display_name };
+ sessionStore.save(next);
+ setSession(next);
+ }, []);
+
+ const signOut = useCallback(() => {
+ setSession((current) => {
+ sessionStore.clear(current);
+ return null;
+ });
+ }, []);
+
+ const value = useMemo(() => ({ session, signIn, signOut }), [session, signIn, signOut]);
+ return {children} ;
+}
+
+export function useSession(): SessionApi {
+ const value = useContext(Ctx);
+ if (!value) throw new Error("useSession outside SessionProvider");
+ return value;
+}
diff --git a/web/src/session/store.ts b/web/src/session/store.ts
new file mode 100644
index 0000000..1f52d87
--- /dev/null
+++ b/web/src/session/store.ts
@@ -0,0 +1,74 @@
+import type { AuthMode, Reply } from "../api/types";
+
+/*
+ * Session persistence behind one small interface. Web uses localStorage; the mobile shell swaps the backend for
+ * secure device storage (e.g. Capacitor Preferences / Keychain) without touching screens.
+ */
+
+export interface Session {
+ token: string;
+ mode: AuthMode;
+ displayName: string | null;
+}
+
+export type ChatEntry =
+ | { id: string; from: "assistant"; reply: Reply; answered: boolean }
+ | { id: string; from: "patient"; text: string };
+
+interface KeyValue {
+ get(key: string): string | null;
+ set(key: string, value: string): void;
+ remove(key: string): void;
+}
+
+const browserStorage: KeyValue = {
+ get: (k) => {
+ try {
+ return window.localStorage.getItem(k);
+ } catch {
+ return null;
+ }
+ },
+ set: (k, v) => {
+ try {
+ window.localStorage.setItem(k, v);
+ } catch {
+ /* private mode / quota: the session simply is not remembered */
+ }
+ },
+ remove: (k) => {
+ try {
+ window.localStorage.removeItem(k);
+ } catch {
+ /* ignore */
+ }
+ },
+};
+
+const SESSION_KEY = "ptsr.session.v1";
+const chatKey = (token: string) => `ptsr.chat.v1.${token}`;
+
+function read(storage: KeyValue, key: string): T | null {
+ const raw = storage.get(key);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+export function createSessionStore(storage: KeyValue = browserStorage) {
+ return {
+ load: () => read(storage, SESSION_KEY),
+ save: (s: Session) => storage.set(SESSION_KEY, JSON.stringify(s)),
+ clear: (s: Session | null) => {
+ storage.remove(SESSION_KEY);
+ if (s) storage.remove(chatKey(s.token));
+ },
+ loadChat: (token: string) => read(storage, chatKey(token)) ?? [],
+ saveChat: (token: string, entries: ChatEntry[]) => storage.set(chatKey(token), JSON.stringify(entries)),
+ };
+}
+
+export const sessionStore = createSessionStore();
diff --git a/web/src/theme/global.css b/web/src/theme/global.css
new file mode 100644
index 0000000..7df0c76
--- /dev/null
+++ b/web/src/theme/global.css
@@ -0,0 +1,1306 @@
+/* Mobile-first. Breakpoints: 640px (large phone / tablet), 960px (desktop). Only tokens from tokens.css. */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: var(--font);
+ font-size: var(--fs-16);
+ line-height: var(--lh-text);
+ color: var(--c-text);
+ background: var(--c-page);
+ -webkit-font-smoothing: antialiased;
+ -webkit-tap-highlight-color: transparent;
+ font-feature-settings: "tnum" 1; /* aligned digits: times, phones, scales */
+}
+
+h1,
+h2,
+h3,
+p,
+ul,
+ol,
+figure {
+ margin: 0;
+}
+
+ul,
+ol {
+ padding: 0;
+ list-style: none;
+}
+
+a {
+ color: var(--c-primary);
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+ color: inherit;
+}
+
+:focus-visible {
+ outline: 2px solid var(--c-focus);
+ outline-offset: 2px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+.nowrap {
+ white-space: nowrap;
+}
+
+/* ---------------------------------------------------------------- shell */
+
+.shell {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: var(--c-surface);
+ border-bottom: 1px solid var(--c-border);
+ padding-top: var(--safe-top);
+}
+
+.topbar__inner,
+.footer__inner,
+.chat-bar__inner {
+ max-width: var(--content-max);
+ margin: 0 auto;
+ padding-left: max(var(--s-4), var(--safe-left));
+ padding-right: max(var(--s-4), var(--safe-right));
+}
+
+.topbar__inner {
+ height: var(--header-h);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-3);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ color: var(--c-text);
+ text-decoration: none;
+ min-width: 0;
+}
+
+.brand__text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.brand__name {
+ font-weight: 600;
+ font-size: var(--fs-16);
+ line-height: var(--lh-tight);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.brand__tagline {
+ display: none;
+ font-size: var(--fs-12);
+ color: var(--c-text-3);
+}
+
+.help-btn {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+ min-height: 40px;
+ padding: 0 var(--s-3);
+ border: 0;
+ border-radius: var(--r-control);
+ background: var(--c-danger-soft);
+ color: var(--c-danger);
+ font-weight: 600;
+ font-size: var(--fs-14);
+ cursor: pointer;
+}
+
+.help-btn:hover {
+ background: #fadadd;
+}
+
+.footer {
+ margin-top: auto;
+ border-top: 1px solid var(--c-border);
+ background: var(--c-surface);
+ padding-bottom: var(--safe-bottom);
+}
+
+.footer__inner {
+ padding-top: var(--s-5);
+ padding-bottom: var(--s-5);
+ display: grid;
+ gap: var(--s-2);
+}
+
+.footer__line {
+ display: flex;
+ gap: var(--s-2);
+ align-items: flex-start;
+ font-size: var(--fs-14);
+ color: var(--c-text-2);
+}
+
+.footer__line svg {
+ flex: none;
+ margin-top: 3px;
+}
+
+.footer__line--muted {
+ color: var(--c-text-3);
+ font-size: var(--fs-12);
+}
+
+/* ---------------------------------------------------------------- shared type & blocks */
+
+.display {
+ font-size: var(--fs-28);
+ font-weight: 600;
+ line-height: var(--lh-tight);
+ letter-spacing: -0.02em;
+ text-wrap: balance;
+ max-width: 18ch;
+}
+
+.h2 {
+ font-size: var(--fs-22);
+ font-weight: 600;
+ line-height: var(--lh-tight);
+ letter-spacing: -0.01em;
+}
+
+.h3 {
+ font-size: var(--fs-16);
+ font-weight: 600;
+ margin-bottom: calc(-1 * var(--s-3));
+}
+
+.lead {
+ font-size: var(--fs-16);
+ color: var(--c-text-2);
+ max-width: 56ch;
+}
+
+.muted {
+ color: var(--c-text-2);
+ font-size: var(--fs-14);
+}
+
+.disclaimer {
+ font-size: var(--fs-14);
+ color: var(--c-text-3);
+ max-width: 60ch;
+}
+
+.page {
+ width: 100%;
+ max-width: var(--content-max);
+ margin: 0 auto;
+ padding: var(--s-4) max(var(--s-4), var(--safe-left)) var(--s-10) max(var(--s-4), var(--safe-right));
+}
+
+.narrow {
+ max-width: 560px;
+ margin: 0 auto;
+ display: grid;
+ gap: var(--s-3);
+}
+
+.panel {
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: var(--r-card);
+ padding: var(--s-6) var(--s-5);
+ display: grid;
+ gap: var(--s-5);
+}
+
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+ min-height: var(--tap);
+ color: var(--c-primary);
+ font-weight: 500;
+ text-decoration: none;
+ justify-self: start;
+}
+
+.back-link:hover span {
+ text-decoration: underline;
+}
+
+/* ---------------------------------------------------------------- welcome */
+
+.welcome {
+ /* Solid tinted band behind the hero; hard stop, not a gradient wash. */
+ background: linear-gradient(var(--c-band) 0 320px, transparent 320px);
+}
+
+.welcome__inner {
+ max-width: var(--content-max);
+ margin: 0 auto;
+ padding: var(--s-8) max(var(--s-4), var(--safe-left)) var(--s-12) max(var(--s-4), var(--safe-right));
+ display: grid;
+ gap: var(--s-6);
+}
+
+.welcome__intro {
+ display: grid;
+ gap: var(--s-3);
+}
+
+.welcome__aside {
+ display: grid;
+ gap: var(--s-5);
+ align-content: start;
+}
+
+.signin {
+ display: grid;
+ gap: var(--s-3);
+}
+
+.signin__title {
+ font-size: var(--fs-18);
+ font-weight: 600;
+}
+
+.option {
+ display: grid;
+ gap: var(--s-4);
+ padding: var(--s-5);
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: var(--r-card);
+}
+
+.option--emias {
+ border-color: var(--c-primary);
+ box-shadow: inset 0 3px 0 var(--c-primary);
+}
+
+.option__head {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+}
+
+.option__icon {
+ flex: none;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--r-control);
+}
+
+.option--emias .option__icon {
+ background: var(--c-doctor-soft);
+ color: var(--c-doctor);
+}
+
+.option--anon .option__icon {
+ background: #eceff4;
+ color: var(--c-ink);
+}
+
+.option__title {
+ flex: 1;
+ font-size: var(--fs-18);
+ font-weight: 600;
+}
+
+.badge {
+ font-size: var(--fs-12);
+ font-weight: 600;
+ color: var(--c-primary);
+ background: var(--c-primary-soft);
+ border-radius: var(--r-chip);
+ padding: 3px var(--s-2);
+}
+
+.facts {
+ display: grid;
+ gap: var(--s-2);
+}
+
+.facts--spaced {
+ gap: var(--s-3);
+}
+
+.fact {
+ display: flex;
+ gap: var(--s-2);
+ align-items: flex-start;
+ font-size: var(--fs-14);
+}
+
+.fact svg {
+ flex: none;
+ margin-top: 2px;
+ color: var(--c-success);
+}
+
+.fact--no {
+ color: var(--c-text-2);
+}
+
+.fact--no svg {
+ color: var(--c-text-3);
+}
+
+/* Day preview: the three care tracks in their colours. */
+
+.day {
+ display: grid;
+ gap: var(--s-3);
+}
+
+.day__caption {
+ font-size: var(--fs-14);
+ font-weight: 600;
+ color: var(--c-text-2);
+}
+
+.day__list {
+ display: grid;
+ gap: var(--s-3);
+ position: relative;
+}
+
+.day__item {
+ --track: var(--c-doctor);
+ --track-soft: var(--c-doctor-soft);
+ --track-ink: var(--c-doctor);
+ display: grid;
+ grid-template-columns: 48px 1fr;
+ gap: var(--s-3);
+ align-items: start;
+}
+
+.day__item--meds {
+ --track: var(--c-meds);
+ --track-soft: var(--c-meds-soft);
+ --track-ink: var(--c-meds);
+}
+
+.day__item--mood {
+ --track: var(--c-mood);
+ --track-soft: var(--c-mood-soft);
+ --track-ink: var(--c-mood-ink);
+}
+
+.day__time {
+ padding-top: 14px;
+ font-size: var(--fs-14);
+ font-weight: 600;
+ color: var(--c-text-2);
+}
+
+.day__card {
+ display: grid;
+ gap: var(--s-2);
+ padding: var(--s-3) var(--s-4) var(--s-4);
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-left: 4px solid var(--track);
+ border-radius: var(--r-control);
+}
+
+.day__track {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ justify-self: start;
+ padding: 2px var(--s-2);
+ border-radius: var(--r-chip);
+ background: var(--track-soft);
+ color: var(--track-ink);
+ font-size: var(--fs-12);
+ font-weight: 600;
+}
+
+.day__row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-3);
+}
+
+.day__text {
+ font-weight: 500;
+}
+
+.day__done {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px var(--s-2);
+ border-radius: var(--r-chip);
+ background: var(--c-meds);
+ color: #fff;
+ font-size: var(--fs-12);
+ font-weight: 600;
+}
+
+.scale {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 6px;
+ margin-top: var(--s-1);
+}
+
+.scale__dot {
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--r-field);
+ border: 1.5px solid currentColor;
+ font-weight: 600;
+ font-size: var(--fs-14);
+}
+
+.scale__dot--1 {
+ color: var(--c-scale-1);
+}
+.scale__dot--2 {
+ color: var(--c-scale-2);
+}
+.scale__dot--3 {
+ color: #c99a00;
+}
+.scale__dot--4 {
+ color: #4f9a2e;
+}
+.scale__dot--5 {
+ color: var(--c-scale-5);
+}
+
+.scale__dot.is-picked {
+ background: #3f8a22;
+ border-color: #3f8a22;
+ color: #fff;
+}
+
+.scale__ends {
+ display: flex;
+ justify-content: space-between;
+ font-size: var(--fs-12);
+ color: var(--c-text-3);
+}
+
+.spark {
+ flex: none;
+ width: 76px;
+ height: 32px;
+ display: flex;
+ align-items: flex-end;
+ gap: 3px;
+}
+
+.spark i {
+ flex: 1;
+ border-radius: 2px 2px 0 0;
+ background: var(--c-doctor-soft);
+}
+
+.spark i:last-child {
+ background: var(--c-doctor);
+}
+
+/* ---------------------------------------------------------------- controls */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s-2);
+ min-height: var(--tap);
+ padding: 0 var(--s-5);
+ border-radius: var(--r-control);
+ border: 1.5px solid transparent;
+ font-weight: 600;
+ font-size: var(--fs-16);
+ cursor: pointer;
+ transition:
+ background-color 120ms var(--ease),
+ border-color 120ms var(--ease),
+ transform 120ms var(--ease);
+}
+
+.btn:active:not(:disabled) {
+ transform: scale(0.985);
+}
+
+.btn--block {
+ width: 100%;
+}
+
+.btn--sm {
+ min-height: 36px;
+ padding: 0 var(--s-3);
+ font-size: var(--fs-14);
+}
+
+.btn--primary {
+ background: var(--c-primary);
+ color: #fff;
+}
+
+.btn--primary:hover {
+ background: var(--c-primary-hover);
+}
+
+.btn--primary:active {
+ background: var(--c-primary-active);
+}
+
+.btn--secondary {
+ background: var(--c-surface);
+ color: var(--c-ink);
+ border-color: var(--c-border-strong);
+}
+
+.btn--secondary:hover {
+ border-color: var(--c-ink);
+}
+
+.btn--danger {
+ background: var(--c-danger);
+ color: #fff;
+}
+
+.btn--danger:hover {
+ background: var(--c-danger-hover);
+}
+
+.btn:disabled {
+ background: var(--c-border);
+ border-color: var(--c-border);
+ color: var(--c-text-3);
+ cursor: not-allowed;
+}
+
+.icon-btn {
+ flex: none;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: var(--r-control);
+ background: transparent;
+ color: var(--c-text-2);
+ cursor: pointer;
+}
+
+.icon-btn:hover {
+ background: var(--c-page);
+}
+
+.link-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+ min-height: 40px;
+ border: 0;
+ background: none;
+ color: var(--c-primary);
+ font-weight: 500;
+ font-size: var(--fs-14);
+ cursor: pointer;
+ padding: 0;
+}
+
+.notice {
+ display: flex;
+ gap: var(--s-3);
+ align-items: flex-start;
+ padding: var(--s-3) var(--s-4);
+ border-radius: var(--r-control);
+ background: var(--c-primary-soft);
+ font-size: var(--fs-14);
+}
+
+.notice svg {
+ flex: none;
+ margin-top: 1px;
+ color: var(--c-primary);
+}
+
+.notice--warning {
+ background: var(--c-warning-soft);
+}
+
+.notice--warning svg {
+ color: var(--c-mood-ink);
+}
+
+.form {
+ display: grid;
+ gap: var(--s-4);
+}
+
+.field {
+ display: grid;
+ gap: var(--s-2);
+}
+
+.field__label {
+ font-weight: 600;
+ font-size: var(--fs-14);
+}
+
+.input {
+ width: 100%;
+ min-height: 52px;
+ padding: 0 var(--s-4);
+ border: 1.5px solid var(--c-border-strong);
+ border-radius: var(--r-field);
+ background: var(--c-surface);
+ font-size: var(--fs-18); /* ≥16px: no zoom-on-focus in iOS */
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.input::placeholder {
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: var(--fs-16);
+ color: var(--c-text-3);
+}
+
+.input:hover {
+ border-color: var(--c-text-3);
+}
+
+.input:focus-visible {
+ outline: none;
+ border-color: var(--c-primary);
+ box-shadow: 0 0 0 3px var(--c-primary-soft);
+}
+
+.field--error .input {
+ border-color: var(--c-danger);
+}
+
+.field__hint {
+ font-size: var(--fs-12);
+ color: var(--c-text-3);
+}
+
+.field__error {
+ font-size: var(--fs-14);
+ color: var(--c-danger);
+}
+
+.divider {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ color: var(--c-text-3);
+ font-size: var(--fs-14);
+}
+
+.divider::before,
+.divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--c-border);
+}
+
+.list {
+ display: grid;
+ gap: var(--s-2);
+}
+
+.list-row {
+ width: 100%;
+ min-height: 68px;
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-3) var(--s-4);
+ border: 1.5px solid var(--c-border);
+ border-radius: var(--r-control);
+ background: var(--c-surface);
+ text-align: left;
+ cursor: pointer;
+}
+
+.list-row:hover {
+ border-color: var(--c-primary);
+}
+
+.list-row:disabled {
+ cursor: wait;
+ opacity: 0.6;
+}
+
+.avatar {
+ flex: none;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--c-doctor-soft);
+ color: var(--c-doctor);
+ font-weight: 600;
+}
+
+.list-row__text {
+ flex: 1;
+ min-width: 0;
+}
+
+.list-row__title {
+ display: block;
+ font-weight: 600;
+}
+
+.list-row__sub {
+ display: block;
+ font-size: var(--fs-14);
+ color: var(--c-text-2);
+}
+
+.list-row__action {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-1);
+ color: var(--c-primary);
+ font-weight: 600;
+ font-size: var(--fs-14);
+}
+
+/* ---------------------------------------------------------------- help sheet */
+
+.sheet {
+ width: 100%;
+ max-width: 100%;
+ max-height: 90dvh;
+ margin: auto auto 0;
+ padding: 0;
+ border: 0;
+ border-radius: var(--r-card) var(--r-card) 0 0;
+ background: var(--c-surface);
+ color: var(--c-text);
+}
+
+.sheet::backdrop {
+ background: rgb(19 35 58 / 0.55);
+}
+
+.sheet[open] {
+ animation: sheet-up 220ms var(--ease);
+}
+
+.sheet__body {
+ padding: var(--s-5) max(var(--s-4), var(--safe-left)) calc(var(--s-6) + var(--safe-bottom));
+ display: grid;
+ gap: var(--s-4);
+}
+
+.sheet__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--s-3);
+}
+
+.sheet__title {
+ font-size: var(--fs-22);
+ font-weight: 600;
+ line-height: var(--lh-tight);
+}
+
+.sheet__lead {
+ color: var(--c-text-2);
+}
+
+.hotlines {
+ display: grid;
+ gap: var(--s-3);
+}
+
+.hotline {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ padding: var(--s-3) var(--s-4);
+ min-height: 76px;
+ border: 1.5px solid var(--c-border);
+ border-radius: var(--r-control);
+ color: var(--c-text);
+ text-decoration: none;
+}
+
+.hotline:hover {
+ border-color: var(--c-danger);
+}
+
+.hotline__icon {
+ flex: none;
+ width: 44px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--c-danger);
+ color: #fff;
+}
+
+.hotline__text {
+ flex: 1;
+ display: grid;
+ min-width: 0;
+}
+
+.hotline__phone {
+ font-size: var(--fs-22);
+ font-weight: 600;
+ line-height: var(--lh-tight);
+}
+
+.hotline__name {
+ font-size: var(--fs-14);
+}
+
+.hotline__note {
+ font-size: var(--fs-12);
+ color: var(--c-text-3);
+}
+
+.hotline__call {
+ flex: none;
+ font-weight: 600;
+ font-size: var(--fs-14);
+ color: var(--c-danger);
+}
+
+@keyframes sheet-up {
+ from {
+ transform: translateY(24px);
+ opacity: 0;
+ }
+}
+
+/* ---------------------------------------------------------------- chat */
+
+.chat-bar {
+ position: sticky;
+ top: calc(var(--header-h) + var(--safe-top));
+ z-index: 9;
+ background: var(--c-surface);
+ border-bottom: 1px solid var(--c-border);
+}
+
+.chat-bar__inner {
+ min-height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s-3);
+}
+
+.mode {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: 4px var(--s-2);
+ border-radius: var(--r-chip);
+ font-size: var(--fs-14);
+ font-weight: 600;
+ min-width: 0;
+}
+
+.mode--emias {
+ background: var(--c-doctor-soft);
+ color: var(--c-doctor);
+}
+
+.mode--anonymous {
+ background: #eceff4;
+ color: var(--c-ink);
+}
+
+.mode__name {
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.logout-confirm {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+}
+
+.logout-confirm__text {
+ display: none;
+ font-size: var(--fs-12);
+ color: var(--c-text-2);
+}
+
+.chat {
+ flex: 1;
+ width: 100%;
+ max-width: 760px;
+ margin: 0 auto;
+ padding: var(--s-5) max(var(--s-4), var(--safe-left)) var(--s-4) max(var(--s-4), var(--safe-right));
+}
+
+.messages {
+ display: grid;
+ gap: var(--s-3);
+}
+
+.msg {
+ display: grid;
+ gap: var(--s-2);
+}
+
+/* Only the newest message animates in: one answer-driven motion, not a cascade. */
+.msg:last-child {
+ animation: msg-in 200ms var(--ease);
+}
+
+.msg__bubble {
+ max-width: 88%;
+ padding: var(--s-3) var(--s-4);
+ border-radius: var(--r-card);
+}
+
+.msg__text {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.msg--assistant .msg__bubble {
+ justify-self: start;
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-top-left-radius: var(--r-chip);
+}
+
+.msg--question .msg__bubble {
+ border-left: 4px solid var(--c-mood);
+}
+
+.msg--patient .msg__bubble {
+ justify-self: end;
+ background: var(--c-primary);
+ color: #fff;
+ border-top-right-radius: var(--r-chip);
+}
+
+.msg--info .msg__bubble {
+ background: transparent;
+ border-style: dashed;
+ color: var(--c-text-2);
+ font-size: var(--fs-14);
+}
+
+.msg--crisis .msg__bubble,
+.msg--safety_plan .msg__bubble {
+ max-width: 100%;
+ border: 1px solid var(--c-danger);
+ border-left-width: 4px;
+ background: var(--c-danger-soft);
+}
+
+.msg--safety_plan .msg__bubble {
+ border-color: var(--c-warning-border);
+ background: var(--c-warning-soft);
+}
+
+.msg__label {
+ display: flex;
+ align-items: center;
+ gap: var(--s-1);
+ margin-bottom: var(--s-2);
+ font-size: var(--fs-14);
+ font-weight: 600;
+ color: var(--c-danger);
+}
+
+.msg--safety_plan .msg__label {
+ color: var(--c-mood-ink);
+}
+
+.tel {
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.choices {
+ display: grid;
+ gap: var(--s-2);
+ max-width: 88%;
+}
+
+.choices__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-2);
+}
+
+.choice {
+ flex: 1 1 auto;
+ min-height: var(--tap);
+ padding: var(--s-2) var(--s-4);
+ border: 1.5px solid var(--c-border-strong);
+ border-radius: var(--r-control);
+ background: var(--c-surface);
+ color: var(--c-ink);
+ font-weight: 500;
+ font-size: var(--fs-16);
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 120ms var(--ease),
+ background-color 120ms var(--ease);
+}
+
+.choice:hover {
+ border-color: var(--c-primary);
+ background: var(--c-primary-soft);
+}
+
+.choice:active {
+ background: var(--c-primary);
+ border-color: var(--c-primary);
+ color: #fff;
+}
+
+.choice:disabled {
+ opacity: 0.5;
+ cursor: wait;
+}
+
+.dots {
+ display: inline-flex;
+ gap: 4px;
+ padding: 6px 0;
+}
+
+.dots i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--c-text-3);
+ animation: dot 1s infinite ease-in-out;
+}
+
+.dots i:nth-child(2) {
+ animation-delay: 0.15s;
+}
+
+.dots i:nth-child(3) {
+ animation-delay: 0.3s;
+}
+
+.composer {
+ position: sticky;
+ bottom: 0;
+ background: var(--c-surface);
+ border-top: 1px solid var(--c-border);
+ padding: var(--s-3) max(var(--s-4), var(--safe-right)) calc(var(--s-3) + var(--safe-bottom))
+ max(var(--s-4), var(--safe-left));
+}
+
+.composer__row {
+ max-width: 760px;
+ margin: 0 auto;
+ display: flex;
+ gap: var(--s-2);
+ align-items: flex-end;
+}
+
+.composer__error {
+ max-width: 760px;
+ margin: 0 auto var(--s-2);
+ font-size: var(--fs-14);
+ color: var(--c-danger);
+}
+
+.composer__input {
+ flex: 1;
+ min-height: var(--tap);
+ max-height: 160px;
+ padding: 11px var(--s-4);
+ border: 1.5px solid var(--c-border-strong);
+ border-radius: var(--r-control);
+ resize: none;
+ field-sizing: content;
+ font-size: var(--fs-16);
+ line-height: 1.5;
+}
+
+.composer__input:focus-visible {
+ outline: none;
+ border-color: var(--c-primary);
+ box-shadow: 0 0 0 3px var(--c-primary-soft);
+}
+
+.send-btn {
+ flex: none;
+ width: var(--tap);
+ height: var(--tap);
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: var(--r-control);
+ background: var(--c-primary);
+ color: #fff;
+ cursor: pointer;
+}
+
+.send-btn:disabled {
+ background: var(--c-border);
+ color: var(--c-text-3);
+ cursor: not-allowed;
+}
+
+@keyframes msg-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+}
+
+@keyframes dot {
+ 0%,
+ 80%,
+ 100% {
+ opacity: 0.3;
+ }
+ 40% {
+ opacity: 1;
+ }
+}
+
+/* ---------------------------------------------------------------- ≥ 640px */
+
+@media (min-width: 640px) {
+ .brand__tagline {
+ display: block;
+ }
+
+ .panel {
+ padding: var(--s-8);
+ }
+
+ .page {
+ padding-top: var(--s-8);
+ }
+
+ .display {
+ font-size: var(--fs-40);
+ }
+
+ .lead {
+ font-size: var(--fs-18);
+ }
+
+ .logout-confirm__text {
+ display: inline;
+ }
+
+ .sheet {
+ max-width: 480px;
+ margin: auto;
+ border-radius: var(--r-card);
+ }
+
+ .sheet__body {
+ padding: var(--s-6);
+ }
+
+ .msg__bubble,
+ .choices {
+ max-width: 75%;
+ }
+}
+
+/* ---------------------------------------------------------------- ≥ 960px */
+
+@media (min-width: 960px) {
+ .welcome {
+ background: linear-gradient(var(--c-band) 0 340px, transparent 340px);
+ }
+
+ .welcome__inner {
+ grid-template-columns: minmax(0, 1fr) 440px;
+ grid-template-areas:
+ "intro choice"
+ "aside choice";
+ column-gap: var(--s-16);
+ row-gap: var(--s-10);
+ padding-top: var(--s-16);
+ align-items: start;
+ }
+
+ .welcome__intro {
+ grid-area: intro;
+ gap: var(--s-4);
+ }
+
+ .signin {
+ grid-area: choice;
+ }
+
+ .welcome__aside {
+ grid-area: aside;
+ max-width: 520px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/web/src/theme/tokens.css b/web/src/theme/tokens.css
new file mode 100644
index 0000000..ac0a2cc
--- /dev/null
+++ b/web/src/theme/tokens.css
@@ -0,0 +1,90 @@
+/*
+ * Design tokens — the single source of colours, type and spacing.
+ * When the app moves to native (Capacitor wraps this same bundle; a React Native port would map these 1:1 to a
+ * theme object), only this file changes. Components never use raw colours or pixel sizes outside the scale.
+ */
+:root {
+ /* Base: cool ink on cool white. */
+ --c-ink: #13233a;
+ --c-text: #13233a;
+ --c-text-2: #46566b;
+ --c-text-3: #6b7a8e;
+ --c-page: #f4f7fb;
+ --c-surface: #ffffff;
+ --c-band: #e9f0fb; /* solid tint behind the welcome hero */
+ --c-border: #dbe3ee;
+ --c-border-strong: #b3c0d1;
+
+ /* Care tracks: one colour per thing the service does. The same colour marks the track everywhere
+ (welcome preview, chat questions), so colour carries meaning, never decoration. */
+ --c-meds: #0e8c7f;
+ --c-meds-soft: #e2f4f1;
+ --c-mood: #e79a0c;
+ --c-mood-soft: #fdf1da;
+ --c-mood-ink: #8a5800; /* text on mood-soft (AA) */
+ --c-doctor: #2350c8;
+ --c-doctor-soft: #e7eefc;
+
+ /* Action colour = the doctor/ЕМИАС blue. */
+ --c-primary: #2350c8;
+ --c-primary-hover: #1c43ac;
+ --c-primary-active: #163689;
+ --c-primary-soft: #e7eefc;
+
+ /* Mood scale 1 → 5 (heavy → good); used only for the scale. */
+ --c-scale-1: #d9485f;
+ --c-scale-2: #ef8a3c;
+ --c-scale-3: #f2c230;
+ --c-scale-4: #7cc35b;
+ --c-scale-5: #2fa36b;
+
+ --c-danger: #c8283a;
+ --c-danger-hover: #a91f2f;
+ --c-danger-soft: #fdebed;
+ --c-success: #1e8a55;
+ --c-warning-soft: #fff4dc;
+ --c-warning-border: #e7b448;
+ --c-focus: #3b82f6;
+
+ /* Type: Golos Text — Russian open-source UI typeface (OFL), bundled locally so the app works offline. */
+ --font: "Golos Text", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --fs-12: 0.75rem;
+ --fs-14: 0.875rem;
+ --fs-16: 1rem;
+ --fs-18: 1.125rem;
+ --fs-22: 1.375rem;
+ --fs-28: 1.75rem;
+ --fs-40: 2.5rem;
+ --lh-tight: 1.15;
+ --lh-text: 1.5;
+
+ /* Spacing: 4 px grid. */
+ --s-1: 4px;
+ --s-2: 8px;
+ --s-3: 12px;
+ --s-4: 16px;
+ --s-5: 20px;
+ --s-6: 24px;
+ --s-8: 32px;
+ --s-10: 40px;
+ --s-12: 48px;
+ --s-16: 64px;
+
+ /* Radius hierarchy: containers > controls > fields > chips. */
+ --r-card: 14px;
+ --r-control: 10px;
+ --r-field: 8px;
+ --r-chip: 6px;
+
+ --tap: 48px; /* minimum touch target */
+ --header-h: 64px;
+ --content-max: 1160px;
+
+ /* Safe areas (notch, home indicator) for the mobile shell. */
+ --safe-top: env(safe-area-inset-top, 0px);
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-left: env(safe-area-inset-left, 0px);
+ --safe-right: env(safe-area-inset-right, 0px);
+
+ --ease: cubic-bezier(0.2, 0, 0, 1);
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..1a8a5dc
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..271cd86
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,13 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// Dev: the API runs on :8000 (uvicorn / docker compose); /api is proxied so the app needs no CORS.
+// Build output is a static bundle (dist/) — the same bundle is wrapped by Capacitor for the mobile app.
+export default defineConfig({
+ plugins: [react()],
+ base: "./",
+ server: {
+ port: 5173,
+ proxy: { "/api": { target: process.env.API_TARGET ?? "http://localhost:8000", changeOrigin: true } },
+ },
+});