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("