diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py
index 82cf53a..cb64e08 100644
--- a/src/gui/components/_legacy.py
+++ b/src/gui/components/_legacy.py
@@ -137,75 +137,55 @@ hr { margin-top: 0.4rem !important; margin-bottom: 0.4rem !important; }
"""
-# Warm editorial palette + typography, lifted from the
-# ``datatools_layout_redesign.html`` mockup. Applied on every page via
-# ``hide_streamlit_chrome``. Tokens are scoped through CSS custom
-# properties so individual rules read cleanly and a future tweak only
-# has to touch the ``:root`` block.
+# Component-level styling that rides on top of the canonical typography
+# + color tokens declared in ``src/gui/theme.py`` (``apply_theme``).
+# This block does NOT redeclare the type scale or the ``--font-sans`` /
+# ``--ink`` etc. variables — that is theme.py's job per
+# ``geist_spec.md`` §9 ("Out of scope: button/input/widget styling.
+# Type only."). Everything below extends the spec with widget chrome
+# (buttons, sidebar, file uploader, expanders, alerts) that the mockup
+# wants but the spec leaves unowned.
+#
+# Reads from theme.py's :root: ``--font-sans``, ``--font-mono``,
+# ``--ink``, ``--ink-secondary``, ``--ink-tertiary``, ``--bg``,
+# ``--surface``, ``--surface-hover``, ``--border``, ``--border-strong``,
+# ``--accent``, ``--accent-hover``, ``--accent-fill``, the severity
+# extensions ``--warn(-fill)`` / ``--info(-fill)`` / ``--success(-fill)``
+# / ``--danger(-fill)``, and the radius scale ``--r-sm/md/lg``.
_DESIGN_TOKENS_CSS = """
@@ -526,6 +485,11 @@ def hide_streamlit_chrome(*, gate_license: bool = True) -> None:
can render its own form without recursion.
"""
st.markdown(_HIDE_CHROME_CSS, unsafe_allow_html=True)
+ # ``apply_theme`` injects the canonical typography + color tokens
+ # (geist_spec.md §3). Must run BEFORE ``_DESIGN_TOKENS_CSS`` so the
+ # component CSS below can read its ``--font-sans`` / ``--ink`` etc.
+ from src.gui.theme import apply_theme
+ apply_theme()
st.markdown(_DESIGN_TOKENS_CSS, unsafe_allow_html=True)
# ``st.markdown`` doesn't execute embedded scripts; ship the
# Upload→Import rewriter through an iframe component the same way
diff --git a/src/gui/theme.py b/src/gui/theme.py
new file mode 100644
index 0000000..7bf8725
--- /dev/null
+++ b/src/gui/theme.py
@@ -0,0 +1,184 @@
+"""Typography + color theme injected into every Streamlit page.
+
+Implements ``geist_spec.md`` §3 verbatim. Single source of truth for
+the type scale and the base color tokens. Component-specific styling
+(buttons, sidebar, file uploader, expanders, alerts, …) lives in
+``components/_legacy.py:_DESIGN_TOKENS_CSS`` and reads the
+``--font-sans`` / ``--font-mono`` / ``--ink`` / ``--bg`` / ``--surface``
+/ ``--border`` / ``--accent`` / ``--accent-fill`` variables this
+module declares.
+
+The spec wants this called at the top of every page right after
+``st.set_page_config()``. In this codebase every page already calls
+``hide_streamlit_chrome()`` for that purpose, so :func:`apply_theme`
+is invoked from there — one place, every page.
+"""
+
+from __future__ import annotations
+
+import streamlit as st
+
+
+_GOOGLE_FONTS_URL = (
+ "https://fonts.googleapis.com/css2"
+ "?family=Geist:wght@400;500;600;700"
+ "&family=Geist+Mono:wght@400;500"
+ "&display=swap"
+)
+
+
+# Spec §3 + §7. Heading rules track the canonical type scale in spec §4:
+# h1 32/600/-0.035em, h2 22/600/-0.025em, h3 18/500/-0.018em, h4 15/500/
+# -0.012em, body 14/400, caption 12.5/400, mono inherits × 0.92.
+# The severity extension tokens (--warn / --info / --success / --danger
+# + their fills) are NOT in spec §7 but live here so the component CSS
+# in _legacy.py has a single :root table to read from.
+_CSS = f"""
+
+
+
+
+
+"""
+
+
+def apply_theme() -> None:
+ """Inject typography + color CSS. Call once at the top of every page,
+ immediately after :func:`streamlit.set_page_config`.
+ """
+ st.markdown(_CSS, unsafe_allow_html=True)