Files
PTSRaiAgent/dashboard/theme.py
Pavel Eliseev 2a2fdb171e
CI / ruff · pytest (pull_request) Successful in 2m16s
feat(ui): extend minimal doctor design across all screens
Refs #85
2026-10-04 16:09:57 +03:00

268 lines
16 KiB
Python

"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template.
Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open,
grey = info only). They always ship together with an icon and a text label, never colour alone.
Chart series use a separate neutral blue so that a line is never confused with a status.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Tone = Literal["red", "yellow", "green", "grey", "blue"]
@dataclass(frozen=True)
class ToneSpec:
solid: str # marks, stripes, dots
ink: str # text on light surface (WCAG AA on white / on the tint)
tint: str # badge / banner background
icon: str
label: str
TONES: dict[str, ToneSpec] = {
"red": ToneSpec("#A83236", "#A83236", "#FAEEEE", "🔴", "Красная зона"),
"yellow": ToneSpec("#A37524", "#805714", "#FBF3E4", "🟡", "Жёлтая зона"),
"green": ToneSpec("#527F61", "#38634B", "#EEF4EE", "🟢", "Стабильно"),
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
}
INK = "#213B38"
INK_2 = "#526660"
INK_3 = "#63736F"
SURFACE = "#FFFFFF"
CANVAS = "#F6F7F3"
LINE = "#E1E7E3"
SERIES = TONES["blue"].solid
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
CSS = f"""
<style>
:root {{
--ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS};
--red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint};
--yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint};
--green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint};
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
}}
.stApp {{ background: var(--canvas); color: var(--ink); font-family: {FONT_STACK}; color-scheme: light; }}
.block-container {{ padding: 2rem 3rem 2.5rem; max-width: 1440px; }}
[data-testid="stHeader"] {{ background: var(--canvas); }}
[data-testid="stSidebar"] {{ background: var(--surface); border-right: 1px solid var(--line); }}
[data-testid="stSidebarNavLink"] {{ border-radius: 8px; color: var(--ink-2); }}
[data-testid="stSidebarNavLink"][aria-current="page"] {{ background: #edf3ee; color: #285e4e; }}
[data-testid="stSidebarNavLink"] span, [data-testid="stSidebarNav"] span {{ color: inherit; }}
[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] {{ color: var(--ink-2); }}
[data-testid="stWidgetLabel"], [data-testid="stCaptionContainer"] {{ color: var(--ink-2); }}
[data-testid="stTextInputRootElement"], [data-baseweb="select"] > div {{
background: var(--surface); border-color: var(--line); color: var(--ink); border-radius: 8px; }}
[data-testid="stApp"] input, [data-testid="stApp"] textarea {{ color: var(--ink); background: var(--surface); }}
[data-testid="stBaseButton-secondary"], [data-testid="stBaseButton-tertiary"] {{
background: var(--surface); color: var(--ink); border-color: var(--line); border-radius: 8px; }}
[data-testid="stBaseButton-primary"] {{ background: #285e4e; border-color: #285e4e; color: #fff; border-radius: 8px; }}
[data-testid="stBaseButton-primary"]:hover {{ background: #204b3e; border-color: #204b3e; color: #fff; }}
[data-testid="stBaseButton-secondary"]:hover {{ background: #edf3ee; border-color: #b4c6ba; color: #285e4e; }}
button:focus-visible, a:focus-visible, input:focus-visible, [tabindex="0"]:focus-visible {{
outline: 3px solid #598d7a !important; outline-offset: 3px; }}
.mt-sidebar-note {{ margin-top:36px; border-top:1px solid var(--line); padding-top:20px; color:var(--ink-3);
font-size:.78rem; line-height:1.6; }}
.mt-sidebar-note strong {{ display:block; font-weight:550; color:var(--ink); margin-bottom:5px; }}
.mt-workspace {{ display:flex; justify-content:space-between; align-items:center; gap:16px;
border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:20px; color:var(--ink-3); font-size:.8rem; }}
.mt-doctor {{ display:flex; gap:10px; align-items:center; }}
.mt-doctor strong {{ display:block; color:var(--ink); font-weight:550; font-size:.8rem; }}
.mt-doctor small {{ display:block; font-size:.7rem; }}
.mt-doctor-icon {{ width:34px; height:34px; background:#e7ede5; color:#285e4e; border-radius:50%;
display:grid; place-items:center; font-size:22px; }}
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
/* ---------- page header ---------- */
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
margin: 10px 0 12px 0; }}
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
.mt-head h1 {{ font-size: 2.15rem; font-weight: 550; margin: 0; padding: 0; line-height: 1.2; }}
.mt-sub {{ color: var(--ink-2); font-size: .9rem; margin-top: 10px; }}
.mt-kicker {{ color: var(--ink-3); font-size: .68rem; text-transform: uppercase; letter-spacing: .13em;
margin-bottom:8px;
font-weight: 600; }}
/* ---------- overview and patient list ---------- */
.mt-kpis {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); background:var(--surface);
border:1px solid var(--line); border-radius:12px; padding:22px 0; margin:10px 0 18px; }}
.mt-kpi {{ padding:0 24px; border-right:1px solid var(--line); min-width:0; }}
.mt-kpi:last-child {{ border-right:0; }}
.mt-kpi .lbl {{ color:var(--ink-2); font-size:.78rem; font-weight:500; }}
.mt-kpi .lbl::before {{ content:""; display:inline-block; width:6px; height:6px; border-radius:50%;
background:var(--kpi-accent,var(--line)); margin-right:7px; vertical-align:2px; }}
.mt-kpi .val {{ color:var(--ink); font-size:2rem; font-weight:550; line-height:1.3; margin-top:8px;
font-variant-numeric:tabular-nums; }}
.mt-kpi .hint {{ color:var(--ink-3); font-size:.72rem; margin-top:3px; }}
.mt-section-head {{ display:flex; justify-content:space-between; align-items:center; gap:16px; margin:14px 0 4px; }}
.mt-section-head h2 {{ font-size:1.25rem; font-weight:550; margin:0; padding:0; }}
.mt-section-head h2 span {{ background:#e6ece5; font-size:.75rem; padding:3px 7px; border-radius:5px;
vertical-align:middle; margin-left:6px; }}
.mt-section-head > span {{ font-size:.72rem; color:var(--ink-3); }}
.st-key-patient-list {{ background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:20px; }}
.st-key-patient-list [data-variant="segmented_control"] {{
color:var(--ink-2); background:var(--surface); border-color:var(--line); font-size:.8rem; }}
.st-key-patient-list [data-variant="segmented_control"][data-selected] {{
background:#edf3ee; color:#285e4e; border-color:#b4c6ba; font-weight:600; }}
.mt-cols, .mt-row {{ display:grid; align-items:center; gap:18px;
grid-template-columns:minmax(0,1.8fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,.8fr) minmax(0,1.2fr); }}
.mt-cols {{ color:var(--ink-3); font-size:.63rem; letter-spacing:.05em; text-transform:uppercase;
font-weight:550; padding:12px 0 4px; }}
.mt-row {{ color:var(--ink); font-size:.82rem; }}
.mt-row > div {{ min-width:0; overflow-wrap:anywhere; }}
.mt-name {{ font-weight:600; font-size:.92rem; }}
.mt-meta {{ color:var(--ink-3); font-size:.7rem; margin-top:4px; }}
.mt-num {{ font-variant-numeric:tabular-nums; font-weight:550; }}
.mt-dim {{ color:var(--ink-3); }}
.mt-dot {{ width:6px; height:6px; border-radius:50%; display:inline-block; flex:none; }}
.mt-scale-label {{ display:inline-block; min-width:42px; font-size:.67rem; color:var(--ink-3); margin-right:6px; }}
.mt-scores > div + div {{ margin-top:4px; }}
.mt-adherence .mt-bar {{ max-width:60px; height:3px; }}
.mt-mobile-label {{ display:none; }}
.mt-row-details {{ grid-column:1 / -1; display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;
font-size:.75rem; color:var(--ink-2); padding-top:12px; border-top:1px dashed var(--line); line-height:1.6; }}
.mt-field-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:4px; }}
div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4px; }}
.mt-list-footer {{ border-top:1px solid var(--line); padding-top:16px; margin-top:8px; display:flex;
justify-content:space-between; gap:12px; color:var(--ink-3); font-size:.72rem; }}
.mt-empty {{ text-align:center; padding:42px 20px; color:var(--ink-2); }}
.mt-empty strong {{ display:block; color:var(--ink); font-size:1rem; font-weight:550; }}
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }}
.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }}
.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }}
.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }}
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
justify-content:center; font-weight: 700; font-size: .85rem; }}
.mt-tip {{ position: relative; cursor: help; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ content: attr(data-tip); position: absolute;
z-index: 50; left: 0; top: 120%;
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
/* ---------- banners & panels ---------- */
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
border: 1px solid; margin-bottom: 6px; }}
.mt-banner.t-red {{ background: #fffafa; border-color: #e7cbcb; border-left-width:3px; color: var(--red-ink); }}
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
.mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }}
.mt-alert.t-yellow {{ border-left-color: var(--yellow); }}
.mt-alert.t-grey {{ border-left-color: var(--grey); }}
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
/* ---------- dose strip ---------- */
.mt-doses {{ display:grid; gap: 4px; align-items:center; }}
.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center;
font-size: .72rem; font-weight: 700; color: #fff; }}
.mt-doses .c-taken {{ background: var(--green); }}
.mt-doses .c-missed {{ background: var(--red); }}
.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }}
.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }}
.mt-doses .c-none {{ background: transparent; }}
.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }}
.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }}
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }}
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
border-top: 1px solid var(--line); padding-top: 12px; }}
@media (max-width: 1100px) {{
.block-container {{ padding-left:1.5rem; padding-right:1.5rem; }}
.mt-kpi {{ padding:0 16px; }}
.mt-cols, .mt-row {{ gap:12px; }}
.mt-section-head > span {{ display:none; }}
}}
@media (max-width: 760px) {{
.block-container {{ padding:1.5rem 1rem; }}
.mt-workspace {{ flex-wrap:wrap; padding-bottom:18px; margin-bottom:10px; }}
.mt-head h1 {{ font-size:1.75rem; }}
.mt-kpis {{ grid-template-columns:repeat(2,minmax(0,1fr)); padding:0; }}
.mt-kpi {{ padding:18px; }}
.mt-kpi:nth-child(2) {{ border-right:0; }}
.mt-kpi:nth-child(-n+2) {{ border-bottom:1px solid var(--line); }}
.st-key-patient-list {{ padding:16px; }}
.mt-cols {{ display:none; }}
.mt-row {{ grid-template-columns:1fr 1fr; gap:16px; }}
.mt-person {{ grid-column:1 / -1; }}
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
*, *::before, *::after {{ animation:none !important; scroll-behavior:auto !important; transition:none !important; }}
}}
</style>
"""
def plotly_layout(**overrides: object) -> dict[str, object]:
"""Shared plotly layout: recessive grid, no chart junk, the same font as the page."""
layout: dict[str, object] = {
"font": {"family": FONT_STACK, "size": 12, "color": INK_2},
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(0,0,0,0)",
"margin": {"l": 8, "r": 8, "t": 36, "b": 8},
"hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}},
"showlegend": False,
"hovermode": "x unified",
}
layout.update(overrides)
return layout
AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""}
PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True}