From 6c218b5898c4c45eaeba5eeb4a85ae3323a4c1db Mon Sep 17 00:00:00 2001 From: kert Date: Sun, 25 Oct 2026 15:21:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(bcda):=20fhirworx=20house=20style=20for=20?= =?UTF-8?q?the=20manager=20=E2=80=94=20theme=20file,=20brand=20masthead=20?= =?UTF-8?q?and=20eyebrows,=20favicon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/bcda/ui/theme.toml is a Streamlit theme file carrying the dashboard tokens from assets/css/dashboard.css (cream paper, near-black ink, navy primary and sidebar, warm beige surfaces, 2px radius, Playfair Display / Source Serif 4 / JetBrains Mono). `python -m bcda.ui` passes it as --theme.base unless the caller set one, so the container and `stack bcda ui` look the same without a .streamlit/config.toml per cwd; the image's streamlit.toml keeps only server settings. bcda.ui.brand adds what a theme file cannot: the Google Fonts import, the dashboard masthead (rule, display title, monospace eyebrow) at the top of the sidebar, and brand.title() eyebrows above every page title. app.py renders its own page links after the masthead (navigation position="hidden") so the masthead comes first as on the dashboard. The favicon is the repo's assets/icons/fav32.png, baked into the image. Family names in the theme file stay double-quoted: Streamlit 1.66 emits the body font unquoted, and an unquoted family with a digit ("Source Serif 4") is an invalid font-family, so the browser silently fell back to its default serif while the headings (quoted on their path) were fine. Found by rendering the rolled container headlessly on the compose network and reading computed styles. tests/bcda/test_ui_brand.py pins the theme's hexes and font stacks to dashboard.css and renders inject/masthead/title through AppTest; test_ui_main covers the --theme.base handling. refs #1139 --- .dockerignore | 4 + .../specs/2026-10-25-bcda-v3-design.md | 6 ++ infra/images/bcda.Dockerfile | 6 +- infra/images/bcda/streamlit.toml | 3 - src/bcda/ui/__main__.py | 10 +- src/bcda/ui/app.py | 15 ++- src/bcda/ui/brand.py | 93 +++++++++++++++++++ src/bcda/ui/theme.toml | 51 ++++++++++ src/bcda/ui/views.py | 16 ++-- tests/bcda/test_ui_brand.py | 92 ++++++++++++++++++ tests/bcda/test_ui_main.py | 23 ++++- tests/bcda/test_ui_views.py | 11 +++ 12 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/bcda/ui/brand.py create mode 100644 src/bcda/ui/theme.toml create mode 100644 tests/bcda/test_ui_brand.py diff --git a/.dockerignore b/.dockerignore index f882ce6..135f994 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,3 +35,7 @@ mirrors/ # Streamlit server config COPYed by bcda.Dockerfile (repo-root context). !infra/images/bcda/ !infra/images/bcda/** +# Favicon COPYed by bcda.Dockerfile; dir entries first so BuildKit can descend. +!assets/ +!assets/icons/ +!assets/icons/fav32.png diff --git a/docs/superpowers/specs/2026-10-25-bcda-v3-design.md b/docs/superpowers/specs/2026-10-25-bcda-v3-design.md index 170ac9e..7fe76cf 100644 --- a/docs/superpowers/specs/2026-10-25-bcda-v3-design.md +++ b/docs/superpowers/specs/2026-10-25-bcda-v3-design.md @@ -86,3 +86,9 @@ Tests: service with `httpx.MockTransport`, runner for thread-routed logs and sta ### `/metrics` (2026-10-25) `bcda.ui.metrics` follows the #579 convention (`perf.meter` → OTel meter + Prometheus reader when `STACK_TELEMETRY` is on, a no-op `MockMeter` otherwise). Streamlit cannot mount a route, so the manager publishes the process's Prometheus registry from a second port (`BCDA_METRICS_PORT`, 8502) via `prometheus_client.start_http_server`, and Prometheus scrapes `bcda:8502/metrics` directly like `api` and `llm`. Instruments: `stack_bcda_runs_total{kind,state}`, `stack_bcda_runs_inflight{kind}`, `stack_bcda_run_seconds{kind}`, `stack_bcda_page_views_total{page}`, and two observable gauges read at scrape time, `stack_bcda_store_jobs{status}` and `stack_bcda_store_bytes`. Run kinds are a closed set (export, adopt, load, vendor_check, other). The image installs the `perf` extra; the compose service joins the observability network and sets `OTEL_SERVICE_NAME=bcda`. + +### Published and styled (2026-10-25) + +Reachability needs more than `stack.toml` + Traefik + CoreDNS: the Cloudflare tunnel runs on a remote ingress list that overrides the wildcard in `infra/cloudflared/config.yml` (an unlisted host answers 530 at the edge), and the public CNAME to the tunnel is created from the same list. Both come from `SUBDOMAINS` in `dev/scripts/bootstrap_sso.py`, which gained `bcda` and a `--dns-only` mode that does just those two writes with a token holding Account:Cloudflare Tunnel:Edit and Zone:DNS:Edit for fhirworx.io. Resolvers that had asked before the record existed cache NXDOMAIN for the zone's negative TTL (1800 s). + +House style: `src/bcda/ui/theme.toml` is a Streamlit theme file carrying the dashboard tokens (`assets/css/dashboard.css`: cream paper, near-black ink, navy primary and sidebar, warm beige surfaces, 2px radius, Playfair Display / Source Serif 4 / JetBrains Mono). `python -m bcda.ui` passes it as `--theme.base` so the container and `stack bcda ui` match without a per-cwd `.streamlit/config.toml`. `bcda.ui.brand` adds what a theme file cannot: the Google Fonts import, the dashboard masthead in the sidebar (rule, display title, monospace eyebrow) and `brand.title()` eyebrows above every page title; the favicon is the repo's `assets/icons/fav32.png`, baked into the image. `tests/bcda/test_ui_brand.py` fails if the theme's hexes or font stacks drift from `dashboard.css`. diff --git a/infra/images/bcda.Dockerfile b/infra/images/bcda.Dockerfile index ff0eb65..b860cfe 100644 --- a/infra/images/bcda.Dockerfile +++ b/infra/images/bcda.Dockerfile @@ -23,9 +23,13 @@ ENV UV_PYTHON_PREFERENCE=only-system \ RUN uv sync --no-dev --extra cli --extra bcda --extra perf && uv pip install -e . COPY stack.toml ./ +# Favicon for st.set_page_config — bcda.ui.brand resolves assets/icons/fav32.png +# relative to the package root (/app here, the repo root in a checkout). +COPY assets/icons/fav32.png assets/icons/fav32.png # Streamlit reads its server settings from here (headless, no usage -# stats, bound to all interfaces, websocket-friendly behind Traefik). +# stats, bound to all interfaces). The fhirworx theme is not here: the +# entrypoint passes src/bcda/ui/theme.toml as --theme.base. COPY infra/images/bcda/streamlit.toml /app/.streamlit/config.toml # 8501 Streamlit · 8502 Prometheus registry (/metrics, perf meter via STACK_TELEMETRY) diff --git a/infra/images/bcda/streamlit.toml b/infra/images/bcda/streamlit.toml index 1025971..68363bd 100644 --- a/infra/images/bcda/streamlit.toml +++ b/infra/images/bcda/streamlit.toml @@ -10,6 +10,3 @@ runOnSave = false [browser] gatherUsageStats = false - -[theme] -base = "light" diff --git a/src/bcda/ui/__main__.py b/src/bcda/ui/__main__.py index 8ab643d..fd87257 100644 --- a/src/bcda/ui/__main__.py +++ b/src/bcda/ui/__main__.py @@ -9,6 +9,9 @@ runs land in the same registry. python -m bcda.ui # :8501, metrics on BCDA_METRICS_PORT python -m bcda.ui --server.port 9000 # extra args go to `streamlit run` + +The fhirworx theme file (``bcda/ui/theme.toml``) is passed as +``--theme.base`` unless the arguments already set one. """ from __future__ import annotations @@ -20,10 +23,15 @@ from pathlib import Path def main(argv: list[str] | None = None) -> int: from streamlit.web import cli as stcli - from bcda.ui import app, metrics + from bcda.ui import app, brand, metrics metrics.serve() args = list(sys.argv[1:] if argv is None else argv) + if not any(a == "--theme.base" or a.startswith("--theme.base=") for a in args): + # The fhirworx look (bcda/ui/theme.toml) travels with the package, so + # the container and `stack bcda ui` render the same without a + # .streamlit/config.toml in every cwd. A caller's --theme.base wins. + args = ["--theme.base", str(brand.THEME_PATH), *args] sys.argv = ["streamlit", "run", str(Path(app.__file__)), *args] try: stcli.main() diff --git a/src/bcda/ui/app.py b/src/bcda/ui/app.py index 929bb5f..15788f5 100644 --- a/src/bcda/ui/app.py +++ b/src/bcda/ui/app.py @@ -8,7 +8,7 @@ from __future__ import annotations import streamlit as st -from bcda.ui import metrics, views +from bcda.ui import brand, metrics, views PAGES = [ st.Page(views.connect, title="Connect", icon="🔌", default=True), @@ -22,17 +22,24 @@ PAGES = [ def main() -> None: - st.set_page_config(page_title="BCDA manager", page_icon="🏥", layout="wide") + st.set_page_config(page_title="BCDA manager", page_icon=brand.icon(), layout="wide") + brand.inject() # web fonts + masthead/eyebrow rules; colours come from theme.toml metrics.serve() # /metrics on BCDA_METRICS_PORT when STACK_TELEMETRY is on; no-op otherwise conn = st.session_state.get("conn") + # position="hidden" keeps Streamlit from drawing its own nav above our + # sidebar content; the masthead goes first (as on the dashboard) and the + # page links follow, in PAGES order. + page = st.navigation(PAGES, position="hidden") with st.sidebar: - st.markdown("### BCDA manager") + brand.masthead() + for p in PAGES: + st.page_link(p, label=p.title, icon=p.icon) st.caption( f"**{conn.label}** · {conn.environment} · {conn.version}" if conn else "not connected" ) - st.navigation(PAGES).run() + page.run() if ( diff --git a/src/bcda/ui/brand.py b/src/bcda/ui/brand.py new file mode 100644 index 0000000..c9b7e77 --- /dev/null +++ b/src/bcda/ui/brand.py @@ -0,0 +1,93 @@ +"""Fhirworx house style for the BCDA manager. + +Colour, radius and font *families* come from :data:`THEME_PATH`, a +Streamlit theme file that ``python -m bcda.ui`` passes as +``--theme.base`` (see :mod:`bcda.ui.__main__`). What a theme file cannot +do — load the web fonts, draw the dashboard's masthead rule and +monospace eyebrows — :func:`inject` adds as one ``") + + +def masthead(product: str = "Fhirworx", eyebrow: str = "bcda · manager") -> None: + """The dashboard masthead, sized for the sidebar: rule, display title, eyebrow.""" + st.html( + '
' + f"

{html.escape(product)}

" + f'{html.escape(eyebrow)}' + '
' + ) + + +def title(text: str, eyebrow: str) -> None: + """A page title with the dashboard's monospace eyebrow above it.""" + st.html(f'{html.escape(eyebrow)}') + st.title(text) diff --git a/src/bcda/ui/theme.toml b/src/bcda/ui/theme.toml new file mode 100644 index 0000000..a75f31b --- /dev/null +++ b/src/bcda/ui/theme.toml @@ -0,0 +1,51 @@ +# Fhirworx theme for the BCDA manager (Streamlit ≥ 1.46 theme file). +# +# Source of truth is the dashboard stylesheet, assets/css/dashboard.css — +# the same tokens the Gitea and marimo themes derive from: cream paper, +# near-black ink, deep-navy primary and sidebar, warm beige surfaces, a +# 2px "civic forms" radius. Fonts are the editorial trio (Playfair +# Display headings, Source Serif 4 body, JetBrains Mono for eyebrows and +# code); bcda.ui.brand imports them from Google Fonts at page start. +# tests/bcda/test_ui_brand.py keeps the hexes in step with dashboard.css. +# +# Loaded by `python -m bcda.ui` as --theme.base , so the +# container and `stack bcda ui` look the same. + +[theme] +base = "light" +primaryColor = "#1C2B3A" +backgroundColor = "#F7F5F0" +secondaryBackgroundColor = "#EDEBE6" +textColor = "#1A1A18" +linkColor = "#2D5F7F" +linkUnderline = true +borderColor = "#D4D0C8" +dataframeBorderColor = "#D4D0C8" +dataframeHeaderBackgroundColor = "#EDEBE6" +codeBackgroundColor = "#EDEBE6" +redColor = "#C0392B" +greenColor = "#2E8B6E" +orangeColor = "#C8702A" +blueColor = "#2D5F7F" +grayColor = "#6B6B68" +# Family names stay double-quoted: Streamlit 1.66 passes the body font +# through unquoted, and an unquoted family with a digit ("Source Serif 4") +# is an invalid CSS font-family — the browser silently falls back to its +# default serif. Quoted, the same stack applies and loads. +font = '"Source Serif 4", Georgia, serif' +headingFont = '"Playfair Display", Georgia, serif' +headingFontWeights = [800, 700, 600] +codeFont = '"JetBrains Mono", "Fira Code", monospace' +baseRadius = "2px" +buttonRadius = "2px" +showWidgetBorder = true +showSidebarBorder = false + +[theme.sidebar] +backgroundColor = "#1C2B3A" +secondaryBackgroundColor = "#253748" +textColor = "#B8C5D0" +primaryColor = "#F0F4F8" +linkColor = "#F0F4F8" +borderColor = "#2E4359" +codeBackgroundColor = "#253748" diff --git a/src/bcda/ui/views.py b/src/bcda/ui/views.py index acfe1e4..9e10ce1 100644 --- a/src/bcda/ui/views.py +++ b/src/bcda/ui/views.py @@ -14,7 +14,7 @@ from typing import Any import polars as pl import streamlit as st -from bcda.ui import metrics +from bcda.ui import brand, metrics from bcda.ui import service as svc from bcda.ui.runner import runner @@ -83,7 +83,7 @@ def runs_panel() -> None: def connect() -> None: metrics.page_view("connect") - st.title("Connect") + brand.title("Connect", "sandbox · credentials · server") st.caption( "BCDA v3 (v1/v2 end 2027-07-30). `/auth` and `/data` are shared by every version; " "only the `/api//` segment differs." @@ -172,7 +172,7 @@ def connect() -> None: def exports() -> None: metrics.page_view("exports") - st.title("Exports") + brand.title("Exports", "bulk export · $export · _typeFilter") conn = _require_conn() if conn is None: return @@ -324,7 +324,7 @@ def exports() -> None: def store() -> None: metrics.page_view("store") - st.title("Store") + brand.title("Store", "jobs · exports on disk") path = svc.store_path() st.caption( f"`{path}` — append-only `jobs.parquet` / `files.parquet`; the last row per key is the current state." @@ -373,7 +373,7 @@ def store() -> None: def load() -> None: metrics.page_view("load") - st.title("Flatten + load") + brand.title("Flatten + load", "ndjson → parquet → replica") st.caption( "Newest export per resource type → `bcda.*` in aco.duckdb (v2 or v3 dialect per resource, " "streamed from multi-part ndjson) → connector pipeline → `input_layer.*` → replica republished." @@ -402,7 +402,7 @@ def load() -> None: def data() -> None: metrics.page_view("data") - st.title("Data") + brand.title("Data", "replica · tables · sql") try: con = svc.replica_connect() except Exception as exc: # noqa: BLE001 — no replica yet @@ -450,7 +450,7 @@ def data() -> None: def dictionary() -> None: metrics.page_view("dictionary") - st.title("Data dictionary + CCLF crosswalk") + brand.title("Data dictionary + CCLF crosswalk", "v3 data dictionary · cclf") try: con = svc.replica_connect() except Exception: # noqa: BLE001 @@ -527,7 +527,7 @@ def dictionary() -> None: def upstream() -> None: metrics.page_view("upstream") - st.title("Upstream: CMSgov/bcda-app + bcda.cms.gov") + brand.title("Upstream: CMSgov/bcda-app + bcda.cms.gov", "vendored sources · drift") status = svc.vendor_status() if not status.get("available"): st.info( diff --git a/tests/bcda/test_ui_brand.py b/tests/bcda/test_ui_brand.py new file mode 100644 index 0000000..d692cf3 --- /dev/null +++ b/tests/bcda/test_ui_brand.py @@ -0,0 +1,92 @@ +"""bcda.ui.brand — the fhirworx look: theme file, fonts, masthead, eyebrow titles.""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +from streamlit.testing.v1 import AppTest + +from bcda.ui import brand + +ROOT = Path(__file__).resolve().parents[2] +DASHBOARD_CSS = ROOT / "assets" / "css" / "dashboard.css" + +# theme.toml key → dashboard.css custom property it must equal. +TOKEN_MAP = { + ("backgroundColor",): "--background", + ("textColor",): "--foreground", + ("primaryColor",): "--primary", + ("secondaryBackgroundColor",): "--secondary", + ("borderColor",): "--border", + ("grayColor",): "--muted-foreground", + ("redColor",): "--destructive", + ("greenColor",): "--chart-4", + ("orangeColor",): "--chart-5", + ("sidebar", "backgroundColor"): "--sidebar", + ("sidebar", "textColor"): "--sidebar-foreground", + ("sidebar", "secondaryBackgroundColor"): "--sidebar-accent", +} +FONT_MAP = { + "font": "--font-body", + "headingFont": "--font-display", + "codeFont": "--font-mono", +} + + +def _css_tokens() -> dict[str, str]: + root = DASHBOARD_CSS.read_text().split(":root", 1)[1].split("}", 1)[0] + return { + m.group(1): m.group(2).strip() + for m in re.finditer(r"(--[\w-]+)\s*:\s*([^;]+);", root) + } + + +def _theme() -> dict: + return tomllib.loads(brand.THEME_PATH.read_text())["theme"] + + +def test_theme_file_tracks_dashboard_css() -> None: + css, theme = _css_tokens(), _theme() + for path, var in TOKEN_MAP.items(): + node = theme + for key in path: + node = node[key] + assert node.upper() == css[var].upper(), f"{'.'.join(path)} vs {var}" + for key, var in FONT_MAP.items(): + # exact stack, quotes included — Streamlit drops an unquoted "Source Serif 4" + assert theme[key] == css[var], key + assert theme[key].startswith('"'), key + assert theme["base"] == "light" and theme["baseRadius"] == "2px" + assert brand.theme() == theme # cached loader returns the same table + + +def test_css_and_icon() -> None: + css = brand.css() + assert brand.FONTS_URL in css and ".fw-eyebrow" in css and ".fw-mast h1" in css + for family in ("Playfair+Display", "Source+Serif+4", "JetBrains+Mono"): + assert family in brand.FONTS_URL + # the repo ships the favicon; the fallback is an emoji when it does not + assert brand.icon() == str(brand.ICON_PATH) and brand.ICON_PATH.is_file() + assert brand.ICON_FALLBACK == "🏥" + + +def test_inject_masthead_and_title_render() -> None: + at = AppTest.from_string( + "import streamlit as st\n" + "from bcda.ui import brand\n" + "brand.inject()\n" + "with st.sidebar:\n" + " brand.masthead(eyebrow='bcda ')\n" + "brand.title('Exports', 'bulk export · $export')\n" + ).run() + assert not at.exception + bodies = [h.value for h in at.html] + assert any("