feat(ui): replace Fraunces with Geist per geist_spec.md

Switches the type system to the single-family Geist spec referenced
in ``Business/DataTools/geist_spec.md`` and the matching
``datatools_layout_redesign2.html`` mockup. Editorial-serif headings
are out; the product now reads as modern SaaS-tool typography per
the spec's positioning note (§10).

  src/gui/theme.py (new)
    Implements geist_spec.md §3 verbatim — preconnect + Google Fonts
    link for Geist (400/500/600/700) and Geist Mono (400/500), the
    canonical ``:root`` token table (§7) plus severity extensions,
    and the type scale (§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 0.92× ss02. ``apply_theme()`` is the single entry
    point.

    Two deviations from the spec, both anticipated by spec §6.1:
    - ``font-family: var(--font-sans) !important`` on the base rule.
      Streamlit applies ``font-family: "Source Sans"`` directly to
      ``[data-testid="stMarkdownContainer"]`` and a few widget
      wrappers at equal-or-higher specificity than the spec's
      selector list, so plain inheritance loses the cascade.
    - The base selector list explicitly enumerates
      ``stSidebarNav``, ``stMarkdownContainer``, ``stVerticalBlock``
      and a few siblings so Streamlit's per-widget font reset
      doesn't reach descendant text.

  src/gui/components/_legacy.py
    - ``_DESIGN_TOKENS_CSS`` no longer redeclares fonts or the
      heading rules — those are theme.py's job (spec §9 says the
      spec is type-only; everything below is component chrome).
    - Token references switched from ``--dt-*`` to the spec names
      (``--ink``, ``--bg``, ``--surface``, ``--border``, ``--accent``,
      ``--font-sans``, ``--font-mono``, …).
    - Sidebar section-label rule tightened to 11.5px / 500 to match
      the "Eyebrow" row in spec §4.
    - Primary-button text color now also targets every descendant
      (``button[kind="primary"] *``) so the inner
      ``stMarkdownContainer > p`` doesn't pick up
      ``color: var(--ink)`` from the base rule and render
      near-invisible ink-on-ink.
    - ``hide_streamlit_chrome`` now calls ``apply_theme`` before
      injecting component CSS so the base tokens are defined first.

Acceptance criteria from spec §8 verified at 1920×1050:
  - h1 computes ``font-family: Geist``, ``font-weight: 600``,
    ``letter-spacing: -1.12px`` (= 32px × -0.035em), size ``32px``.
  - Body ``<p>`` inside ``stMarkdownContainer``: Geist 400 / 14px.
  - Caption: Geist 400 / 12.5px.
  - Inline mono filenames: Geist Mono in accent-fill chip.
  - No Source Sans Pro leaks into any text the user reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 00:21:52 +00:00
parent 444dffbc63
commit 2501119ac2
2 changed files with 296 additions and 148 deletions

View File

@@ -137,75 +137,55 @@ hr { margin-top: 0.4rem !important; margin-bottom: 0.4rem !important; }
""" """
# Warm editorial palette + typography, lifted from the # Component-level styling that rides on top of the canonical typography
# ``datatools_layout_redesign.html`` mockup. Applied on every page via # + color tokens declared in ``src/gui/theme.py`` (``apply_theme``).
# ``hide_streamlit_chrome``. Tokens are scoped through CSS custom # This block does NOT redeclare the type scale or the ``--font-sans`` /
# properties so individual rules read cleanly and a future tweak only # ``--ink`` etc. variables — that is theme.py's job per
# has to touch the ``:root`` block. # ``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 = """ _DESIGN_TOKENS_CSS = """
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap');
:root {
--dt-bg: #fafaf7;
--dt-bg-sidebar: #f5f4ef;
--dt-surface: #ffffff;
--dt-surface-hover: #f8f7f3;
--dt-border: #e7e5dc;
--dt-border-strong: #d6d3c7;
--dt-ink: #1c1917;
--dt-ink-secondary: #57534e;
--dt-ink-tertiary: #a8a29e;
--dt-accent: #c2410c;
--dt-accent-hover: #9a3412;
--dt-accent-fill: #fef4ed;
--dt-warn: #b45309;
--dt-warn-fill: #fef3c7;
--dt-info: #0369a1;
--dt-info-fill: #e0f2fe;
--dt-success: #15803d;
--dt-success-fill: #dcfce7;
--dt-danger: #b91c1c;
--dt-danger-fill: #fee2e2;
--dt-r-sm: 6px;
--dt-r-md: 10px;
--dt-r-lg: 14px;
--dt-font-display: "Fraunces", Georgia, serif;
--dt-font-body: "Geist", -apple-system, BlinkMacSystemFont, sans-serif;
--dt-font-mono: "Geist Mono", "SF Mono", Menlo, monospace;
}
/* ---------- Page surface ---------- */ /* ---------- Page surface ---------- */
body, .stApp { body, .stApp {
background: var(--dt-bg) !important; background: var(--bg) !important;
font-family: var(--dt-font-body) !important; font-family: var(--font-sans) !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* ---------- Sidebar — cream paper, soft right edge ---------- */ /* ---------- Sidebar — cream paper, soft right edge ---------- */
[data-testid="stSidebar"] { [data-testid="stSidebar"] {
background: var(--dt-bg-sidebar) !important; background: #f5f4ef !important;
border-right: 1px solid var(--dt-border) !important; border-right: 1px solid var(--border) !important;
} }
[data-testid="stSidebar"] > div:first-child { [data-testid="stSidebar"] > div:first-child {
background: var(--dt-bg-sidebar) !important; background: #f5f4ef !important;
} }
/* Section labels in the page-nav: tiny uppercase tracking. Streamlit /* Section labels in the page-nav: tiny uppercase tracking — the
renders these as <span> nodes with class ``st-emotion-cache-…`` "Eyebrow" row from spec §4. Streamlit renders these as <span> nodes
inside ``stSidebarNav`` — class hashes are unstable across versions, with class ``st-emotion-cache-…`` inside ``stSidebarNav`` — class
so we lean on the structural position (the bare span/h2 directly hashes are unstable across versions, so we lean on the structural
inside the nav list) rather than emotion classes. */ position (the bare span / h2 directly inside the nav list) rather
than emotion classes. */
[data-testid="stSidebarNav"] h2, [data-testid="stSidebarNav"] h2,
[data-testid="stSidebarNav"] h3, [data-testid="stSidebarNav"] h3,
[data-testid="stSidebarNavSeparator"] span, [data-testid="stSidebarNavSeparator"] span,
[data-testid="stSidebarNavSectionHeader"] { [data-testid="stSidebarNavSectionHeader"] {
font-family: var(--dt-font-body) !important; font-family: var(--font-sans) !important;
font-size: 10.5px !important; font-size: 11.5px !important;
text-transform: uppercase !important; text-transform: uppercase !important;
letter-spacing: 0.08em !important; letter-spacing: 0.08em !important;
color: var(--dt-ink-tertiary) !important; color: var(--ink-tertiary) !important;
font-weight: 500 !important; font-weight: 500 !important;
padding-top: 14px !important; padding-top: 14px !important;
padding-bottom: 4px !important; padding-bottom: 4px !important;
@@ -215,136 +195,115 @@ body, .stApp {
/* Nav items: comfortable padding, soft hover. */ /* Nav items: comfortable padding, soft hover. */
[data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"], [data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"],
[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a { [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a {
color: var(--dt-ink-secondary) !important; color: var(--ink-secondary) !important;
font-size: 13.5px !important; font-size: 13.5px !important;
line-height: 1.3 !important; line-height: 1.3 !important;
padding: 7px 10px !important; padding: 7px 10px !important;
border-radius: var(--dt-r-sm) !important; border-radius: var(--r-sm) !important;
transition: background 0.12s ease, color 0.12s ease; transition: background 0.12s ease, color 0.12s ease;
} }
[data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"]:hover, [data-testid="stSidebarNav"] a[data-testid="stSidebarNavLink"]:hover,
[data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a:hover { [data-testid="stSidebarNav"] [data-testid="stSidebarNavLinkContainer"] a:hover {
background: rgba(0,0,0,0.04) !important; background: rgba(0,0,0,0.04) !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
} }
/* Active nav item — white pill with subtle shadow. Streamlit marks the /* Active nav item — white pill with subtle shadow. Streamlit marks the
active anchor with ``aria-current="page"``. */ active anchor with ``aria-current="page"``. */
[data-testid="stSidebarNav"] a[aria-current="page"] { [data-testid="stSidebarNav"] a[aria-current="page"] {
background: var(--dt-surface) !important; background: var(--surface) !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
font-weight: 500 !important; font-weight: 500 !important;
box-shadow: 0 1px 2px rgba(28,25,23,0.04) !important; box-shadow: 0 1px 2px rgba(28,25,23,0.04) !important;
} }
/* ---------- Typography ---------- */ /* Inline + block code → mono with subtle accent chip. theme.py owns
.stApp h1, [data-testid="stMain"] h1 { the family + size; this layer adds the warm-fill background. */
font-family: var(--dt-font-display) !important; [data-testid="stMarkdownContainer"] code {
font-weight: 500 !important; background: var(--accent-fill) !important;
font-size: 32px !important; color: var(--accent-hover) !important;
letter-spacing: -0.025em !important;
line-height: 1.1 !important;
color: var(--dt-ink) !important;
}
.stApp h2, [data-testid="stMain"] h2 {
font-family: var(--dt-font-display) !important;
font-weight: 500 !important;
font-size: 22px !important;
letter-spacing: -0.02em !important;
color: var(--dt-ink) !important;
}
.stApp h3, [data-testid="stMain"] h3 {
font-family: var(--dt-font-display) !important;
font-weight: 500 !important;
font-size: 18px !important;
letter-spacing: -0.015em !important;
color: var(--dt-ink) !important;
}
/* st.caption + subheadings are body-sans, secondary ink. */
[data-testid="stCaption"],
[data-testid="stCaptionContainer"] {
color: var(--dt-ink-secondary) !important;
font-size: 13.5px !important;
}
/* Inline + block code in user-facing text → Geist Mono. */
[data-testid="stMarkdownContainer"] code,
[data-testid="stCode"] pre {
font-family: var(--dt-font-mono) !important;
background: var(--dt-accent-fill) !important;
color: var(--dt-accent-hover) !important;
padding: 1px 5px !important; padding: 1px 5px !important;
border-radius: 4px !important; border-radius: 4px !important;
font-size: 0.92em !important;
} }
[data-testid="stCode"] pre { [data-testid="stCode"] pre {
padding: 12px 14px !important; padding: 12px 14px !important;
background: var(--dt-surface-hover) !important; background: var(--surface-hover) !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
border: 1px solid var(--dt-border) !important; border: 1px solid var(--border) !important;
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
} }
/* ---------- Buttons — ink primary, outlined secondary ---------- */ /* ---------- Buttons — ink primary, outlined secondary ---------- */
[data-testid="stButton"] button, [data-testid="stButton"] button,
[data-testid="stDownloadButton"] button { [data-testid="stDownloadButton"] button {
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
font-family: var(--dt-font-body) !important; font-family: var(--font-sans) !important;
font-weight: 500 !important; font-weight: 500 !important;
font-size: 13.5px !important; font-size: 13.5px !important;
letter-spacing: -0.005em !important;
line-height: 1 !important; line-height: 1 !important;
padding: 9px 16px !important; padding: 9px 16px !important;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
} }
/* Primary = dark ink (matches mockup's ``btn-primary``). */ /* Primary = dark ink (mockup ``.btn-primary``). Color is set on the
button AND every descendant text node — the inner
``stMarkdownContainer`` and its ``<p>`` would otherwise pick up
``color: var(--ink)`` from theme.py's base rule and turn the label
nearly invisible against the dark background. */
[data-testid="stButton"] button[kind="primary"], [data-testid="stButton"] button[kind="primary"],
[data-testid="stButton"] button[data-testid="stBaseButton-primary"], [data-testid="stButton"] button[data-testid="stBaseButton-primary"],
[data-testid="stDownloadButton"] button[kind="primary"] { [data-testid="stDownloadButton"] button[kind="primary"] {
background: var(--dt-ink) !important; background: var(--ink) !important;
color: var(--dt-bg) !important; color: var(--bg) !important;
border: 1px solid var(--dt-ink) !important; border: 1px solid var(--ink) !important;
}
[data-testid="stButton"] button[kind="primary"] *,
[data-testid="stButton"] button[data-testid="stBaseButton-primary"] *,
[data-testid="stDownloadButton"] button[kind="primary"] * {
color: var(--bg) !important;
} }
[data-testid="stButton"] button[kind="primary"]:hover, [data-testid="stButton"] button[kind="primary"]:hover,
[data-testid="stButton"] button[data-testid="stBaseButton-primary"]:hover, [data-testid="stButton"] button[data-testid="stBaseButton-primary"]:hover,
[data-testid="stDownloadButton"] button[kind="primary"]:hover { [data-testid="stDownloadButton"] button[kind="primary"]:hover {
background: #292524 !important; background: #292524 !important;
border-color: #292524 !important; border-color: #292524 !important;
color: var(--dt-bg) !important; color: var(--bg) !important;
} }
/* Secondary = surface + warm border. */ /* Secondary = paper surface + warm border. */
[data-testid="stButton"] button[kind="secondary"], [data-testid="stButton"] button[kind="secondary"],
[data-testid="stButton"] button[data-testid="stBaseButton-secondary"], [data-testid="stButton"] button[data-testid="stBaseButton-secondary"],
[data-testid="stDownloadButton"] button[kind="secondary"] { [data-testid="stDownloadButton"] button[kind="secondary"] {
background: var(--dt-surface) !important; background: var(--surface) !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
border: 1px solid var(--dt-border-strong) !important; border: 1px solid var(--border-strong) !important;
} }
[data-testid="stButton"] button[kind="secondary"]:hover, [data-testid="stButton"] button[kind="secondary"]:hover,
[data-testid="stButton"] button[data-testid="stBaseButton-secondary"]:hover { [data-testid="stButton"] button[data-testid="stBaseButton-secondary"]:hover {
background: var(--dt-surface-hover) !important; background: var(--surface-hover) !important;
border-color: var(--dt-ink-tertiary) !important; border-color: var(--ink-tertiary) !important;
} }
/* Disabled state — same look both kinds, low-contrast. */ /* Disabled state — same low-contrast for both kinds. */
[data-testid="stButton"] button:disabled { [data-testid="stButton"] button:disabled {
background: var(--dt-surface-hover) !important; background: var(--surface-hover) !important;
color: var(--dt-ink-tertiary) !important; color: var(--ink-tertiary) !important;
border-color: var(--dt-border) !important; border-color: var(--border) !important;
cursor: not-allowed !important; cursor: not-allowed !important;
} }
/* ---------- File uploader — soft cream dropzone ---------- */ /* ---------- File uploader — soft cream dropzone ---------- */
[data-testid="stFileUploader"] section, [data-testid="stFileUploader"] section,
[data-testid="stFileUploaderDropzone"] { [data-testid="stFileUploaderDropzone"] {
background: var(--dt-surface-hover) !important; background: var(--surface-hover) !important;
border: 1px dashed var(--dt-border-strong) !important; border: 1px dashed var(--border-strong) !important;
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
} }
[data-testid="stFileUploader"] button { [data-testid="stFileUploader"] button {
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
} }
/* The per-file chip rows the uploader emits after a file is staged. */ /* The per-file chip rows the uploader emits after a file is staged. */
[data-testid="stFileUploaderFile"] { [data-testid="stFileUploaderFile"] {
background: var(--dt-surface) !important; background: var(--surface) !important;
border: 1px solid var(--dt-border) !important; border: 1px solid var(--border) !important;
border-radius: var(--dt-r-sm) !important; border-radius: var(--r-sm) !important;
} }
/* Hide Streamlit's built-in compact file-chip row once files exist — /* Hide Streamlit's built-in compact file-chip row once files exist —
the home page renders its own canonical "Imported files" list with the home page renders its own canonical "Imported files" list with
@@ -359,21 +318,21 @@ body, .stApp {
/* ---------- Expanders + bordered containers → editorial cards ---------- */ /* ---------- Expanders + bordered containers → editorial cards ---------- */
[data-testid="stExpander"] details, [data-testid="stExpander"] details,
[data-testid="stExpander"] { [data-testid="stExpander"] {
background: var(--dt-surface) !important; background: var(--surface) !important;
border: 1px solid var(--dt-border) !important; border: 1px solid var(--border) !important;
border-radius: var(--dt-r-lg) !important; border-radius: var(--r-lg) !important;
overflow: hidden !important; overflow: hidden !important;
box-shadow: 0 1px 2px rgba(28,25,23,0.03); box-shadow: 0 1px 2px rgba(28,25,23,0.03);
} }
[data-testid="stExpander"] details > summary { [data-testid="stExpander"] details > summary {
background: var(--dt-surface-hover) !important; background: var(--surface-hover) !important;
border-bottom: 1px solid var(--dt-border) !important; border-bottom: 1px solid var(--border) !important;
padding: 12px 16px !important; padding: 12px 16px !important;
font-weight: 500 !important; font-weight: 500 !important;
color: var(--dt-ink) !important; color: var(--ink) !important;
} }
[data-testid="stExpander"] details[open] > summary { [data-testid="stExpander"] details[open] > summary {
border-bottom: 1px solid var(--dt-border) !important; border-bottom: 1px solid var(--border) !important;
} }
[data-testid="stExpander"] details > div { [data-testid="stExpander"] details > div {
padding: 14px 16px !important; padding: 14px 16px !important;
@@ -382,16 +341,16 @@ body, .stApp {
/* ``st.container(border=True)`` — same card treatment. */ /* ``st.container(border=True)`` — same card treatment. */
[data-testid="stVerticalBlockBorderWrapper"], [data-testid="stVerticalBlockBorderWrapper"],
div[data-testid="stContainer"][data-border="true"] { div[data-testid="stContainer"][data-border="true"] {
background: var(--dt-surface) !important; background: var(--surface) !important;
border: 1px solid var(--dt-border) !important; border: 1px solid var(--border) !important;
border-radius: var(--dt-r-lg) !important; border-radius: var(--r-lg) !important;
box-shadow: 0 1px 2px rgba(28,25,23,0.03); box-shadow: 0 1px 2px rgba(28,25,23,0.03);
} }
/* ---------- Alerts — soft fills, no harsh borders ---------- */ /* ---------- Alerts — soft fills, no harsh borders ---------- */
[data-testid="stAlert"] [data-testid="stAlertContainer"], [data-testid="stAlert"] [data-testid="stAlertContainer"],
[data-testid="stAlertContainer"] { [data-testid="stAlertContainer"] {
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
border: 1px solid transparent !important; border: 1px solid transparent !important;
padding: 10px 14px !important; padding: 10px 14px !important;
font-size: 13.5px !important; font-size: 13.5px !important;
@@ -400,23 +359,23 @@ div[data-testid="stContainer"][data-border="true"] {
legacy class hooks and the newer per-kind ``data-baseweb-color``. */ legacy class hooks and the newer per-kind ``data-baseweb-color``. */
[data-testid="stAlertContainer"][kind="info"], [data-testid="stAlertContainer"][kind="info"],
.stAlert[data-baseweb="notification"][kind="info"] { .stAlert[data-baseweb="notification"][kind="info"] {
background: var(--dt-info-fill) !important; background: var(--info-fill) !important;
color: var(--dt-info) !important; color: var(--info) !important;
} }
[data-testid="stAlertContainer"][kind="success"], [data-testid="stAlertContainer"][kind="success"],
.stAlert[data-baseweb="notification"][kind="success"] { .stAlert[data-baseweb="notification"][kind="success"] {
background: var(--dt-success-fill) !important; background: var(--success-fill) !important;
color: var(--dt-success) !important; color: var(--success) !important;
} }
[data-testid="stAlertContainer"][kind="warning"], [data-testid="stAlertContainer"][kind="warning"],
.stAlert[data-baseweb="notification"][kind="warning"] { .stAlert[data-baseweb="notification"][kind="warning"] {
background: var(--dt-warn-fill) !important; background: var(--warn-fill) !important;
color: var(--dt-warn) !important; color: var(--warn) !important;
} }
[data-testid="stAlertContainer"][kind="error"], [data-testid="stAlertContainer"][kind="error"],
.stAlert[data-baseweb="notification"][kind="error"] { .stAlert[data-baseweb="notification"][kind="error"] {
background: var(--dt-danger-fill) !important; background: var(--danger-fill) !important;
color: var(--dt-danger) !important; color: var(--danger) !important;
} }
/* ---------- Inputs (text, select, multiselect) — paper surface ---------- */ /* ---------- Inputs (text, select, multiselect) — paper surface ---------- */
@@ -426,33 +385,33 @@ div[data-testid="stContainer"][data-border="true"] {
[data-testid="stSelectbox"] div[role="combobox"], [data-testid="stSelectbox"] div[role="combobox"],
[data-testid="stMultiSelect"] div[role="combobox"], [data-testid="stMultiSelect"] div[role="combobox"],
[data-baseweb="select"] > div { [data-baseweb="select"] > div {
background: var(--dt-surface) !important; background: var(--surface) !important;
border-radius: var(--dt-r-sm) !important; border-radius: var(--r-sm) !important;
border-color: var(--dt-border-strong) !important; border-color: var(--border-strong) !important;
font-family: var(--dt-font-body) !important; font-family: var(--font-sans) !important;
} }
/* Divider — softer warm gray instead of cool Streamlit default. */ /* Divider — softer warm gray instead of cool Streamlit default. */
[data-testid="stMarkdownContainer"] hr, [data-testid="stMarkdownContainer"] hr,
.stApp hr { .stApp hr {
border-color: var(--dt-border) !important; border-color: var(--border) !important;
} }
/* Tabs — pill-style with active underline in accent. */ /* Tabs — pill-style with active underline in accent. */
[data-testid="stTabs"] [role="tab"] { [data-testid="stTabs"] [role="tab"] {
font-family: var(--dt-font-body) !important; font-family: var(--font-sans) !important;
font-size: 13.5px !important; font-size: 13.5px !important;
color: var(--dt-ink-secondary) !important; color: var(--ink-secondary) !important;
} }
[data-testid="stTabs"] [role="tab"][aria-selected="true"] { [data-testid="stTabs"] [role="tab"][aria-selected="true"] {
color: var(--dt-ink) !important; color: var(--ink) !important;
font-weight: 500 !important; font-weight: 500 !important;
} }
/* DataFrame surface — warm card, mono cells. */ /* DataFrame surface — warm card, mono cells. */
[data-testid="stDataFrame"] { [data-testid="stDataFrame"] {
border-radius: var(--dt-r-md) !important; border-radius: var(--r-md) !important;
border: 1px solid var(--dt-border) !important; border: 1px solid var(--border) !important;
overflow: hidden !important; overflow: hidden !important;
} }
</style> </style>
@@ -526,6 +485,11 @@ def hide_streamlit_chrome(*, gate_license: bool = True) -> None:
can render its own form without recursion. can render its own form without recursion.
""" """
st.markdown(_HIDE_CHROME_CSS, unsafe_allow_html=True) 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(_DESIGN_TOKENS_CSS, unsafe_allow_html=True)
# ``st.markdown`` doesn't execute embedded scripts; ship the # ``st.markdown`` doesn't execute embedded scripts; ship the
# Upload→Import rewriter through an iframe component the same way # Upload→Import rewriter through an iframe component the same way

184
src/gui/theme.py Normal file
View File

@@ -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"""
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="{_GOOGLE_FONTS_URL}" rel="stylesheet">
<style>
:root {{
--font-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
--ink: #1c1917;
--ink-secondary: #57534e;
--ink-tertiary: #a8a29e;
--bg: #fafaf7;
--surface: #ffffff;
--surface-hover: #f8f7f3;
--border: #e7e5dc;
--border-strong: #d6d3c7;
--accent: #c2410c;
--accent-hover: #9a3412;
--accent-fill: #fef4ed;
--accent-fill-strong: #fde4d3;
--warn: #b45309;
--warn-fill: #fef3c7;
--info: #0369a1;
--info-fill: #e0f2fe;
--success: #15803d;
--success-fill: #dcfce7;
--danger: #b91c1c;
--danger-fill: #fee2e2;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
}}
/* Base — Geist everywhere by default. Spec §3 + §6 item 6.
``!important`` on the family is a deviation from the spec but
necessary: Streamlit sets ``font-family: "Source Sans"`` on
``[data-testid="stMarkdownContainer"]`` (and a few other widget
wrappers) at equal-or-higher specificity than the spec's selector
list, so plain inheritance from html/body loses. */
html, body, [class*="css"], .stApp, .stMarkdown, .stText,
.stButton button, .stTextInput input, .stSelectbox, .stDataFrame,
[data-testid="stSidebar"],
[data-testid="stSidebarNav"],
[data-testid="stSidebarNav"] a,
[data-testid="stSidebarNav"] span,
[data-testid="stMarkdownContainer"],
[data-testid="stVerticalBlock"],
[data-testid="stHorizontalBlock"] {{
font-family: var(--font-sans) !important;
color: var(--ink);
font-feature-settings: "ss01", "cv01", "cv11";
}}
/* Headings — same family, hierarchy via weight + size + tracking.
Spec §4 rule 1: negative tracking is non-optional at display sizes. */
.stApp h1, .stApp h2, .stApp h3, .stApp h4,
[data-testid="stMarkdownContainer"] h1,
[data-testid="stMarkdownContainer"] h2,
[data-testid="stMarkdownContainer"] h3,
[data-testid="stMarkdownContainer"] h4 {{
font-family: var(--font-sans);
color: var(--ink);
}}
/* h1 — 32px / 600 / -0.035em (spec §4) */
.stApp h1, [data-testid="stMarkdownContainer"] h1 {{
font-size: 32px;
font-weight: 600;
letter-spacing: -0.035em;
line-height: 1.1;
margin: 0 0 4px;
}}
/* h2 — 22px / 600 / -0.025em (spec §4) */
.stApp h2, [data-testid="stMarkdownContainer"] h2 {{
font-size: 22px;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.2;
margin: 1.5rem 0 0.75rem;
}}
/* h3 — 18px / 500 / -0.018em (spec §4) */
.stApp h3, [data-testid="stMarkdownContainer"] h3 {{
font-size: 18px;
font-weight: 500;
letter-spacing: -0.018em;
line-height: 1.25;
margin: 1.25rem 0 0.5rem;
}}
/* h4 — 15px / 500 / -0.012em (spec §4) */
.stApp h4, [data-testid="stMarkdownContainer"] h4 {{
font-size: 15px;
font-weight: 500;
letter-spacing: -0.012em;
line-height: 1.35;
margin: 1rem 0 0.5rem;
}}
/* Body — 14px / 400 / 1.55 (spec §4). Family + !important added on
top of the spec because Streamlit's CSS sets ``font-family`` on
``[data-testid="stMarkdownContainer"]`` itself (parent rule with
equal specificity), so plain inheritance from body loses the
cascade tiebreaker. Spec §6.1 explicitly anticipates this. */
.stApp p, [data-testid="stMarkdownContainer"] p {{
font-family: var(--font-sans) !important;
font-size: 14px;
font-weight: 400;
line-height: 1.55;
color: var(--ink);
}}
/* Body emphasis — 14px / 500 (spec §4) */
[data-testid="stMarkdownContainer"] strong,
.stApp strong {{
font-weight: 500;
color: var(--ink);
}}
/* Caption / muted — 12.5px / 400 (spec §4) */
.stCaption, [data-testid="stCaptionContainer"] {{
font-family: var(--font-sans) !important;
font-size: 12.5px;
font-weight: 400;
line-height: 1.5;
color: var(--ink-tertiary);
}}
/* Monospace — inherits × 0.92, ss02 slashed zero (spec §4, §6) */
code, .stCode, pre,
[data-testid="stMarkdownContainer"] code {{
font-family: var(--font-mono);
font-size: 0.92em;
font-feature-settings: "ss02";
}}
</style>
"""
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)