# Typography integration spec — Geist (single-family)

**Target environment:** Streamlit GUI for Data Cleaning Mastery bundle
**Stack:** Python 3.11+, Streamlit ≥ 1.30
**Status:** Spec ready for implementation
**Alternative to:** `fraunces_spec.md` — choose one; do not mix.

---

## 1. Goal

Apply **Geist** as the single typeface across all Streamlit pages in the bundle. Use weight and size to create hierarchy — no display serif, no font-family contrast. Replace Streamlit's default Source Sans Pro entirely. Match the typographic scale defined in section 4.

Success state: every page renders in Geist (with Geist Mono for filenames and inline code). Headings differ from body by weight and size, not by font.

---

## 2. Fonts to load

| Font | Role | Source | Weights needed |
|---|---|---|---|
| Geist | Everything except mono | Google Fonts | `400, 500, 600, 700` |
| Geist Mono | Filenames, codes, IDs | Google Fonts | `400, 500` |

**Single Google Fonts URL:**

```
https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap
```

---

## 3. Streamlit injection pattern

Create `src/gui/theme.py`:

```python
"""Typography and color theme injected into every Streamlit page."""

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

_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;
    --border: #e7e5dc;
    --accent: #c2410c;
    --accent-fill: #fef4ed;
  }}

  /* Base — Geist everywhere by default */
  html, body, [class*="css"], .stApp, .stMarkdown, .stText,
  .stButton button, .stTextInput input, .stSelectbox, .stDataFrame,
  [data-testid="stSidebar"] {{
    font-family: var(--font-sans);
    color: var(--ink);
    font-feature-settings: "ss01", "cv01", "cv11";  /* Geist stylistic alternates */
  }}

  /* Headings — same family, different weight and tracking */
  .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 — page title, 32px / 600 */
  .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 — section title, 22px / 600 */
  .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 — subsection, 18px / 500 */
  .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 — minor heading, 15px / 500 */
  .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 */
  .stApp p, [data-testid="stMarkdownContainer"] p {{
    font-size: 14px;
    font-weight: 400;
    line-height: 1.55;
    color: var(--ink);
  }}

  /* Caption / muted */
  .stCaption, [data-testid="stCaptionContainer"] {{
    font-family: var(--font-sans);
    font-size: 12.5px;
    font-weight: 400;
    color: var(--ink-tertiary);
  }}

  /* Monospace */
  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."""
    st.markdown(_CSS, unsafe_allow_html=True)
```

Usage at the top of `app.py` and every file in `src/gui/pages/`:

```python
import streamlit as st
from src.gui.theme import apply_theme

st.set_page_config(page_title="...", layout="wide")
apply_theme()  # must come AFTER set_page_config
```

---

## 4. Type scale (canonical reference)

| Element | Font | Size | Weight | Letter-spacing | Line-height |
|---|---|---|---|---|---|
| h1 (page title) | Geist | 32px | 600 | -0.035em | 1.1 |
| h2 (section) | Geist | 22px | 600 | -0.025em | 1.2 |
| h3 (subsection) | Geist | 18px | 500 | -0.018em | 1.25 |
| h4 (minor) | Geist | 15px | 500 | -0.012em | 1.35 |
| Display number (stats) | Geist | 28px | 600 | -0.03em | 1.0 |
| Body | Geist | 14px | 400 | 0 | 1.55 |
| Body emphasis | Geist | 14px | 500 | 0 | 1.55 |
| Caption / meta | Geist | 12.5px | 400 | 0 | 1.5 |
| Label (form) | Geist | 13px | 500 | 0 | 1.4 |
| Eyebrow (uppercase) | Geist | 11.5px | 500 | 0.08em | 1.4 |
| Mono inline | Geist Mono | inherits × 0.92 | 400 | 0 | inherits |
| Mono emphasis | Geist Mono | inherits × 0.92 | 500 | 0 | inherits |

**Rules:**

1. **Negative tracking is non-optional at display sizes.** Geist looks slack and amateur without it. Anything 18px+ needs at least -0.018em.
2. **Two weight pairs only: 400/500 for body and small headings, 600 for large headings.** Do not use 700 in primary UI — it reads aggressive. 700 is reserved for emphasis inside paragraphs of dense data tables, if needed.
3. **No italics.** Geist's italic is a true italic (not oblique) and reads well, but italics in UI labels reduce scanability. Reserve for inline citations or block quotes only.

---

## 5. Geist variable axis (reference)

Geist on Google Fonts ships as a variable font with a single axis.

| Axis | Tag | Range | What the spec uses |
|---|---|---|---|
| Weight | `wght` | 100–900 | 400, 500, 600 |

You can express weights via `font-weight: 500` (works on both static and variable) or `font-variation-settings: "wght" 500` (variable only). Use `font-weight` — it's cleaner and Streamlit's CSS pipeline handles it more reliably.

Skip weights 100/200/300 (too thin for UI) and 700/800/900 (too heavy, see section 4 rule 2).

---

## 6. Streamlit-specific gotchas

1. **Selector specificity.** Every selector in section 3 is scoped under `.stApp` or `[data-testid="..."]` deliberately — do not remove the prefix. Streamlit's stylesheet wins against unscoped selectors.

2. **`st.title` vs `st.header` vs `st.markdown("# ...")`.** All three produce different DOM and inconsistent margins. **Recommendation:** use `st.markdown("# ...")` consistently and skip `st.title` / `st.header`.

3. **`config.toml` theme limitations.** Streamlit's `[theme]` block supports `font = "sans serif"` only — generic family. It cannot load Google Fonts. Do not rely on it.

4. **Flash of unstyled text.** Google Fonts loads asynchronously with `display=swap`. First paint will be system sans, then swap to Geist ~100–300ms later. Acceptable.

5. **Single-family advantage over the Fraunces spec.** With one font family, you don't need separate hooks for display vs body. If Streamlit's CSS pipeline strips your styles in one place, the fallback is system sans for everything — which still looks coherent. With a serif/sans pairing, partial failure produces visible mismatch.

6. **Geist's stylistic sets.** Geist offers `cv01` (alternate `a`), `cv11` (alternate single-story `g`), and `ss01` (alternate `r`). The spec enables `cv01, cv11, ss01` because they give Geist more character. If you don't want them, remove `font-feature-settings` from the base rule. Geist Mono separately uses `ss02` for the slashed zero, which the spec enables.

7. **Sidebar.** Streamlit's sidebar is `[data-testid="stSidebar"]`. The spec applies Geist there as well. Sidebar headings, if any, inherit the h1–h4 rules — no special sidebar styles needed.

8. **Data tables.** `st.dataframe` renders in an iframe and ignores parent CSS. Tables show in Streamlit's default font. Acceptable; do not try to override.

---

## 7. Color tokens (paired with this typography)

Identical to the Fraunces spec — colors are independent of font choice. Defined in the `:root` block in section 3.

| Token | Value | Use |
|---|---|---|
| `--ink` | `#1c1917` | Primary text, headings |
| `--ink-secondary` | `#57534e` | Body paragraphs, descriptions |
| `--ink-tertiary` | `#a8a29e` | Captions, meta, placeholders |
| `--bg` | `#fafaf7` | Page background |
| `--surface` | `#ffffff` | Cards, panels |
| `--border` | `#e7e5dc` | Dividers, card borders |
| `--accent` | `#c2410c` | Primary actions, links, focus |
| `--accent-fill` | `#fef4ed` | Accent backgrounds (pills, hover) |

---

## 8. Acceptance criteria

The implementation is done when all of these are true:

- [ ] `theme.py` exists and is imported at the top of `app.py` and every file in `src/gui/pages/`.
- [ ] `apply_theme()` is called immediately after `st.set_page_config()` on every page.
- [ ] Rendered h1 element shows `font-family: "Geist", ...` in browser DevTools computed styles.
- [ ] Rendered h1 element shows `font-weight: 600` and `letter-spacing: -0.035em` in computed styles.
- [ ] Rendered body `<p>` element shows `font-family: "Geist"` and `font-weight: 400`.
- [ ] Visual check at the Deduplicator page: page title is Geist 600, section headings are Geist 600, body is Geist 400, filenames are Geist Mono, no element falls back to Source Sans Pro.
- [ ] No console errors related to font loading.
- [ ] First-paint-to-font-swap latency is under 400ms on a fresh browser cache (test with DevTools Network throttling = "Fast 3G").
- [ ] At display sizes (h1, h2), tracking visibly tightens the headline — letters touch each other more than in Streamlit defaults. If headings look loose, the letter-spacing rule did not apply.

---

## 9. Out of scope

Explicitly not part of this task:

- Replacing Streamlit's default button, input, or widget styling. Type only.
- Dark mode. Add later as a separate spec.
- Pages outside the Streamlit GUI (CLI, README, landing page, demo deployment).
- Streamlit Cloud deployment compatibility — should work identically, but verify after merging.

---

## 10. Notes on choosing this over the Fraunces spec

This spec gives up the editorial-serif character that `fraunces_spec.md` provided. The product will read as "modern SaaS tool" rather than "warm editorial productivity tool." Specifically:

- **Looks similar to:** Linear, Vercel, Cal.com, Resend, modern Y Combinator-batch UIs.
- **Risk:** converges with generic dev-tool aesthetic. Less differentiation from competitors. Less appropriate for the small-business / non-developer buyer persona named in BUSINESS.md §3, who is not the target user for tools that look like Linear.
- **Gain:** one font family to maintain, faster page load (one woff2 instead of two-plus opsz variants), more resilient to Streamlit CSS changes, easier dark-mode retrofit later.

**Pick this spec if:** you want simpler, faster, lower-maintenance, and accept that the product looks like other modern SaaS tools.

**Pick `fraunces_spec.md` if:** differentiation and editorial warmth matter more than implementation simplicity, and you want the product to register as "designed" rather than "templated."

Do not mix the two specs. Decide and commit.
