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