feat(bcda): fhirworx house style for the manager — theme file, brand masthead and eyebrows, favicon
Some checks failed
CI / lint (push) Successful in 39s
CI / test (push) Failing after 2m8s
CI / notebooks-smoke (push) Successful in 1m36s
Infra CI / zotero (push) Successful in 15s
Infra CI / notebooks (push) Successful in 52s
Infra CI / docs (push) Successful in 1m31s
Infra CI / api (push) Successful in 1m2s
Infra CI / llm (push) Successful in 48s
Infra CI / mc (push) Failing after 57s
Infra CI / bcda (push) Successful in 1m29s
CI / lint (pull_request) Successful in 27s
CI / notebooks-smoke (pull_request) Successful in 1m46s
CI / test (pull_request) Failing after 3m23s
Infra CI / notebooks (pull_request) Successful in 1m27s
Infra CI / zotero (pull_request) Successful in 16s
Infra CI / docs (pull_request) Successful in 17s
Infra CI / bcda (pull_request) Successful in 17s
Infra CI / api (pull_request) Successful in 19s
Infra CI / mc (pull_request) Failing after 35s
Infra CI / llm (pull_request) Successful in 16s

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
This commit is contained in:
kert
2026-10-25 15:21:30 -04:00
parent 926acd8f86
commit 6c218b5898
12 changed files with 310 additions and 20 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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)

View File

@@ -10,6 +10,3 @@ runOnSave = false
[browser]
gatherUsageStats = false
[theme]
base = "light"

View File

@@ -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()

View File

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

93
src/bcda/ui/brand.py Normal file
View File

@@ -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 ``<style>`` block, and
:func:`title` / :func:`masthead` are the two page furniture helpers the
views use. All values are read from the theme file so there is a single
source for the look; ``tests/bcda/test_ui_brand.py`` keeps that file in
step with ``assets/css/dashboard.css``.
"""
from __future__ import annotations
import html
import tomllib
from functools import lru_cache
from pathlib import Path
from typing import Any
import streamlit as st
THEME_PATH = Path(__file__).with_name("theme.toml")
# Repo-root asset (``src/bcda/ui`` → parents[3]); the image COPYs it to the
# same relative place under /app. Falls back to an emoji when absent.
ICON_PATH = Path(__file__).resolve().parents[3] / "assets" / "icons" / "fav32.png"
ICON_FALLBACK = "🏥"
FONTS_URL = (
"https://fonts.googleapis.com/css2"
"?family=Playfair+Display:wght@600;700;800"
"&family=Source+Serif+4:ital,wght@0,400;0,600;1,400"
"&family=JetBrains+Mono:wght@400;500;600&display=swap"
)
@lru_cache(maxsize=1)
def theme() -> dict[str, Any]:
"""The ``[theme]`` table of :data:`THEME_PATH` (sidebar nested under ``sidebar``)."""
return tomllib.loads(THEME_PATH.read_text())["theme"]
def icon() -> str:
return str(ICON_PATH) if ICON_PATH.is_file() else ICON_FALLBACK
def css() -> str:
t = theme()
side = t["sidebar"]
return f"""
@import url('{FONTS_URL}');
:root {{
--fw-ink: {t["textColor"]}; --fw-muted: {t["grayColor"]}; --fw-rule: {t["borderColor"]};
--fw-mono: {t["codeFont"]}; --fw-display: {t["headingFont"]};
}}
.fw-eyebrow {{
display: block; font-family: var(--fw-mono); font-size: .7rem; font-weight: 500;
text-transform: uppercase; letter-spacing: .12em; color: var(--fw-muted);
margin: 0 0 -.35rem;
}}
.fw-rule {{ border: 0; border-top: 3px solid var(--fw-ink); margin: 0 0 .6rem; }}
.fw-rule-thin {{ border: 0; border-top: 1px solid var(--fw-rule); margin: .6rem 0 .2rem; }}
.fw-mast {{ padding: .4rem 0 .2rem; }}
.fw-mast .fw-rule {{ border-top-color: {side["textColor"]}; }}
.fw-mast .fw-rule-thin {{ border-top-color: {side["borderColor"]}; }}
.fw-mast h1 {{
font-family: var(--fw-display); font-weight: 800; font-size: 1.65rem; line-height: 1.1;
letter-spacing: -.02em; color: {side["primaryColor"]}; margin: 0; padding: 0;
}}
.fw-mast .fw-eyebrow {{ color: {side["textColor"]}; margin: .25rem 0 0; }}
[data-testid="stAppViewContainer"] h1 {{ letter-spacing: -.02em; }}
"""
def inject() -> None:
"""Load the web fonts and the masthead/eyebrow rules once per page run."""
st.html(f"<style>{css()}</style>")
def masthead(product: str = "Fhirworx", eyebrow: str = "bcda · manager") -> None:
"""The dashboard masthead, sized for the sidebar: rule, display title, eyebrow."""
st.html(
'<div class="fw-mast"><div class="fw-rule"></div>'
f"<h1>{html.escape(product)}</h1>"
f'<span class="fw-eyebrow">{html.escape(eyebrow)}</span>'
'<div class="fw-rule-thin"></div></div>'
)
def title(text: str, eyebrow: str) -> None:
"""A page title with the dashboard's monospace eyebrow above it."""
st.html(f'<span class="fw-eyebrow">{html.escape(eyebrow)}</span>')
st.title(text)

51
src/bcda/ui/theme.toml Normal file
View File

@@ -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 <this file>, 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"

View File

@@ -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/<version>/` 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(

View File

@@ -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 <manager>')\n"
"brand.title('Exports', 'bulk export · $export')\n"
).run()
assert not at.exception
bodies = [h.value for h in at.html]
assert any("<style>" in b and brand.FONTS_URL in b for b in bodies)
mast = [h.value for h in at.sidebar.html]
assert any(
"fw-mast" in b and "Fhirworx" in b and "bcda &lt;manager&gt;" in b for b in mast
)
assert at.title[0].value == "Exports"
assert any('class="fw-eyebrow"' in b and "bulk export" in b for b in bodies)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import sys
from bcda.ui import __main__ as entry
from bcda.ui import metrics
from bcda.ui import brand, metrics
def test_main_serves_metrics_then_boots_streamlit(monkeypatch) -> None:
@@ -23,7 +23,19 @@ def test_main_serves_metrics_then_boots_streamlit(monkeypatch) -> None:
assert calls[0] == "serve"
argv = calls[1][1]
assert argv[:2] == ["streamlit", "run"] and argv[2].endswith("bcda/ui/app.py")
assert argv[3:] == ["--server.port", "9000"]
# the fhirworx theme file rides along unless the caller picked a base
assert argv[3:] == [
"--theme.base",
str(brand.THEME_PATH),
"--server.port",
"9000",
]
calls.clear()
assert entry.main(["--theme.base=dark"]) == 0
assert calls[1][1][3:] == ["--theme.base=dark"]
calls.clear()
assert entry.main(["--theme.base", "light"]) == 0
assert calls[1][1][3:] == ["--theme.base", "light"]
def test_main_passes_exit_code_and_default_argv(monkeypatch) -> None:
@@ -35,7 +47,12 @@ def test_main_passes_exit_code_and_default_argv(monkeypatch) -> None:
monkeypatch.setattr("streamlit.web.cli.main", exits)
assert entry.main() == 3
assert sys.argv[3:] == ["--server.headless", "true"]
assert sys.argv[3:] == [
"--theme.base",
str(brand.THEME_PATH),
"--server.headless",
"true",
]
monkeypatch.setattr("streamlit.web.cli.main", lambda: None)
assert entry.main([]) == 0

View File

@@ -433,6 +433,17 @@ def test_app_shell_renders() -> None:
).run()
assert not at.exception
assert any("not connected" in c.value for c in at.sidebar.caption)
# masthead first, then one page link per page, in order
assert any("fw-mast" in h.value for h in at.sidebar.html)
assert [p.label for p in at.sidebar.page_link] == [
"Connect",
"Exports",
"Store",
"Flatten + load",
"Data",
"Dictionary + crosswalk",
"Upstream",
]
at.session_state["conn"] = SMALL
at.run()
assert any("Small Model Entity" in c.value for c in at.sidebar.caption)