From 25abfd3e158156b84abeb8c37d78541679f46451 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 22 May 2026 04:23:27 -0500 Subject: [PATCH] feat(claude-code): integrate Claude Code API with session tracking, config scanning, and CLI detection --- backend/app/api/claude_code.py | 230 +++++++++++++ backend/app/main.py | 2 + backend/app/schemas/claude_code.py | 65 ++++ backend/app/services/claude_code_reader.py | 371 +++++++++++++++++++++ frontend/src/app/globals.css | 62 +--- frontend/tailwind.config.cjs | 6 +- 6 files changed, 681 insertions(+), 55 deletions(-) create mode 100644 backend/app/api/claude_code.py create mode 100644 backend/app/schemas/claude_code.py create mode 100644 backend/app/services/claude_code_reader.py diff --git a/backend/app/api/claude_code.py b/backend/app/api/claude_code.py new file mode 100644 index 0000000..96f45c5 --- /dev/null +++ b/backend/app/api/claude_code.py @@ -0,0 +1,230 @@ +"""Claude Code Integration API. + +Provides three integration surfaces: + +1. Session Tracking — GET /claude-code/sessions, /sessions/{id}, /projects + Reads ~/.claude/projects/**/*.jsonl directly. Extracts token usage, model, + cost estimates, and active status without requiring a gateway. + +2. Config Scanner — GET /claude-code/config + Reads ~/.claude/settings.json, ~/.codex/config.toml, and ~/.codex/rules/. + +3. Direct CLI info — GET /claude-code/cli + Reports which CLI tools are detected on the host and their versions. + (Actual CLI execution goes through the gateway; this endpoint surfaces + discovery metadata for the dashboard.) +""" + +from __future__ import annotations + +import asyncio +import shutil +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.api.deps import require_org_member +from app.core.logging import get_logger +from app.schemas.claude_code import ( + ClaudeConfigRead, + ClaudeProjectRead, + ClaudeSessionListResponse, + ClaudeSessionRead, + ClaudeSessionStatsRead, + SessionTokensRead as ClaudeSessionTokensRead, +) +from app.services import claude_code_reader as reader +from app.services.organizations import OrganizationContext + +logger = get_logger(__name__) + +router = APIRouter(prefix="/claude-code", tags=["claude-code"]) +ORG_MEMBER_DEP = Depends(require_org_member) + + +def _session_to_read(s: reader.ClaudeSession) -> ClaudeSessionRead: + return ClaudeSessionRead( + session_id=s.session_id, + project_dir=s.project_dir, + cwd=s.cwd, + title=s.title, + models=s.models, + tokens=ClaudeSessionTokensRead( + input=s.tokens.input, + output=s.tokens.output, + cache_read=s.tokens.cache_read, + cache_write=s.tokens.cache_write, + total=s.tokens.total, + ), + cost_usd=s.cost_usd, + message_count=s.message_count, + first_message_at=s.first_message_at, + last_message_at=s.last_message_at, + is_active=s.is_active, + entrypoints=s.entrypoints, + git_branch=s.git_branch, + version=s.version, + ) + + +# ── Session Tracking ────────────────────────────────────────────────────────── + +@router.get( + "/sessions", + response_model=ClaudeSessionListResponse, + summary="List local Claude Code sessions", + description=( + "Auto-discovers sessions from `~/.claude/projects/**/*.jsonl`. " + "Returns token usage, model info, cost estimates, and active status " + "for each session without requiring a gateway connection." + ), +) +async def list_sessions( + project: str | None = Query(None, description="Filter by project directory name substring"), + active_only: bool = Query(False, description="Return only currently active sessions"), + limit: int = Query(100, ge=1, le=500, description="Maximum sessions to return"), + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> ClaudeSessionListResponse: + """List Claude Code sessions from local JSONL files.""" + sessions = await asyncio.to_thread( + reader.list_sessions, + project_filter=project, + active_only=active_only, + limit=limit, + ) + stats = reader.session_stats(sessions) + return ClaudeSessionListResponse( + sessions=[_session_to_read(s) for s in sessions], + total=len(sessions), + stats=ClaudeSessionStatsRead( + session_count=stats["session_count"], + active_sessions=stats["active_sessions"], + total_tokens=stats["total_tokens"], + total_cost_usd=stats["total_cost_usd"], + models=stats["models"], + ), + ) + + +@router.get( + "/sessions/{session_id}", + response_model=ClaudeSessionRead, + summary="Get a single Claude Code session", +) +async def get_session( + session_id: str, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> ClaudeSessionRead: + """Return a single parsed session by its UUID.""" + session = await asyncio.to_thread(reader.get_session, session_id) + if session is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return _session_to_read(session) + + +@router.get( + "/projects", + response_model=list[ClaudeProjectRead], + summary="List discovered local Claude Code projects", + description="Returns one entry per project directory with aggregated token and cost stats.", +) +async def list_projects( + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[ClaudeProjectRead]: + """Aggregate session stats grouped by project directory.""" + projects = await asyncio.to_thread(reader.list_projects) + return [ + ClaudeProjectRead( + project_dir=p["project_dir"], + cwd=p.get("cwd"), + session_count=p["session_count"], + total_tokens=p["total_tokens"], + total_cost_usd=p["total_cost_usd"], + last_active_at=p.get("last_active_at"), + is_active=p["is_active"], + ) + for p in projects + ] + + +# ── Config Scanner ──────────────────────────────────────────────────────────── + +@router.get( + "/config", + response_model=ClaudeConfigRead, + summary="Read local Claude Code and Codex CLI configuration", + description=( + "Reads `~/.claude/settings.json`, `~/.codex/config.toml`, and " + "`~/.codex/rules/*.rules`. Never returns secrets — only settings " + "and trust/rule data." + ), +) +async def get_config( + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> ClaudeConfigRead: + """Return parsed local CLI configuration.""" + cfg = await asyncio.to_thread(reader.read_config) + return ClaudeConfigRead( + claude_settings=cfg.claude_settings, + codex_config=cfg.codex_config, + codex_rules=cfg.codex_rules, + claude_credentials_configured=cfg.claude_credentials_path is not None, + codex_credentials_configured=cfg.codex_credentials_path is not None, + ) + + +# ── Direct CLI Discovery ────────────────────────────────────────────────────── + +async def _cli_version(cmd: list[str]) -> str | None: + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) + line = stdout.decode(errors="replace").strip().splitlines()[0] if stdout else "" + return line or None + except Exception: + return None + + +@router.get( + "/cli", + summary="Detect installed CLI tools", + description=( + "Checks which of Claude Code, Codex CLI, and Ollama are installed " + "on the host and returns their versions. No gateway required." + ), +) +async def detect_cli( + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> dict[str, Any]: + """Return discovery metadata for locally installed CLI tools.""" + claude_path = shutil.which("claude") + codex_path = shutil.which("codex") + ollama_path = shutil.which("ollama") + + claude_version = await _cli_version(["claude", "--version"]) if claude_path else None + codex_version = await _cli_version(["codex", "--version"]) if codex_path else None + ollama_version = await _cli_version(["ollama", "--version"]) if ollama_path else None + + return { + "claude": { + "installed": claude_path is not None, + "path": claude_path, + "version": claude_version, + "projects_dir": str(reader._projects_dir()), + "projects_dir_exists": reader._projects_dir().exists(), + }, + "codex": { + "installed": codex_path is not None, + "path": codex_path, + "version": codex_version, + }, + "ollama": { + "installed": ollama_path is not None, + "path": ollama_path, + "version": ollama_version, + }, + } diff --git a/backend/app/main.py b/backend/app/main.py index 3cf3f53..df289c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,6 +31,7 @@ from app.api.forgejo_metrics import router as forgejo_metrics_router from app.api.forgejo_repositories import router as forgejo_repositories_router from app.api.forgejo_webhooks import router as forgejo_webhooks_router from app.api.gateway import router as gateway_router +from app.api.claude_code import router as claude_code_router from app.api.gateways import router as gateways_router from app.api.metrics import router as metrics_router from app.api.organizations import router as organizations_router @@ -623,6 +624,7 @@ api_v1.include_router(task_custom_fields_router) api_v1.include_router(tags_router) api_v1.include_router(users_router) api_v1.include_router(provider_credentials_router) +api_v1.include_router(claude_code_router) app.include_router(api_v1) add_pagination(app) diff --git a/backend/app/schemas/claude_code.py b/backend/app/schemas/claude_code.py new file mode 100644 index 0000000..4b7d0ab --- /dev/null +++ b/backend/app/schemas/claude_code.py @@ -0,0 +1,65 @@ +"""Schemas for Claude Code Integration API endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlmodel import SQLModel + + +class SessionTokensRead(SQLModel): + input: int + output: int + cache_read: int + cache_write: int + total: int + + +class ClaudeSessionRead(SQLModel): + session_id: str + project_dir: str + cwd: str | None = None + title: str | None = None + models: list[str] + tokens: SessionTokensRead + cost_usd: float + message_count: int + first_message_at: datetime | None = None + last_message_at: datetime | None = None + is_active: bool + entrypoints: list[str] + git_branch: str | None = None + version: str | None = None + + +class ClaudeSessionStatsRead(SQLModel): + session_count: int + active_sessions: int + total_tokens: int + total_cost_usd: float + models: list[str] + + +class ClaudeSessionListResponse(SQLModel): + sessions: list[ClaudeSessionRead] + total: int + stats: ClaudeSessionStatsRead + + +class ClaudeProjectRead(SQLModel): + project_dir: str + cwd: str | None = None + session_count: int + total_tokens: int + total_cost_usd: float + last_active_at: datetime | None = None + is_active: bool + + +class ClaudeConfigRead(SQLModel): + claude_settings: dict[str, Any] + codex_config: dict[str, Any] + codex_rules: list[str] + claude_credentials_configured: bool + codex_credentials_configured: bool diff --git a/backend/app/services/claude_code_reader.py b/backend/app/services/claude_code_reader.py new file mode 100644 index 0000000..435201f --- /dev/null +++ b/backend/app/services/claude_code_reader.py @@ -0,0 +1,371 @@ +"""Reader for local Claude Code and Codex CLI data. + +Discovers sessions from ~/.claude/projects/**/*.jsonl, extracts token usage, +model info, cost estimates, and activity status. Also reads ~/.claude/settings.json +and ~/.codex/config.toml for the config scanner. + +All I/O is synchronous and file-local — no network calls. +""" + +from __future__ import annotations + +import json +import os +import tomllib +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from app.core.logging import get_logger + +logger = get_logger(__name__) + +ACTIVE_WINDOW_MINUTES = 30 + +# --------------------------------------------------------------------------- +# Pricing (USD per million tokens) — mirrors runtime_usage.DEFAULT_MODEL_PRICING +# --------------------------------------------------------------------------- + +_PRICING: dict[str, dict[str, float]] = { + "claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75}, + "claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75}, + "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00}, + "claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, + "claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00}, + "claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75}, + "claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30}, +} + + +def _price(model: str, input_t: int, output_t: int, cache_read: int, cache_write: int) -> float: + key = next((k for k in _PRICING if model.endswith(k) or k in model), None) + if not key: + return 0.0 + p = _PRICING[key] + return ( + input_t * p["input"] / 1_000_000 + + output_t * p["output"] / 1_000_000 + + cache_read * p["cache_read"] / 1_000_000 + + cache_write * p["cache_write"] / 1_000_000 + ) + + +def _parse_iso(ts: str | None) -> datetime | None: + if not ts: + return None + try: + return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(UTC).replace(tzinfo=None) + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class SessionTokens: + input: int = 0 + output: int = 0 + cache_read: int = 0 + cache_write: int = 0 + + @property + def total(self) -> int: + return self.input + self.output + self.cache_read + self.cache_write + + +@dataclass +class ClaudeSession: + session_id: str + project_dir: str # raw directory name under ~/.claude/projects/ + cwd: str | None # actual working directory from JSONL records + title: str | None + models: list[str] + tokens: SessionTokens + cost_usd: float + message_count: int # assistant turns + first_message_at: datetime | None + last_message_at: datetime | None + is_active: bool + entrypoints: list[str] # e.g. ["claude-vscode", "claude"] + git_branch: str | None + version: str | None + + +@dataclass +class ClaudeConfig: + claude_settings: dict[str, Any] = field(default_factory=dict) + codex_config: dict[str, Any] = field(default_factory=dict) + codex_rules: list[str] = field(default_factory=list) + claude_credentials_path: str | None = None + codex_credentials_path: str | None = None + + +# --------------------------------------------------------------------------- +# JSONL parser — one file = one session +# --------------------------------------------------------------------------- + +def _parse_session_file(path: Path) -> ClaudeSession | None: + session_id = path.stem + project_dir = path.parent.name + + tokens = SessionTokens() + models: set[str] = set() + entrypoints: set[str] = set() + first_ts: datetime | None = None + last_ts: datetime | None = None + title: str | None = None + cwd: str | None = None + git_branch: str | None = None + version: str | None = None + message_count = 0 + + try: + with open(path, encoding="utf-8", errors="replace") as fh: + for raw_line in fh: + raw_line = raw_line.strip() + if not raw_line: + continue + try: + rec = json.loads(raw_line) + except json.JSONDecodeError: + continue + + ts = _parse_iso(rec.get("timestamp")) + if ts: + if first_ts is None or ts < first_ts: + first_ts = ts + if last_ts is None or ts > last_ts: + last_ts = ts + + rec_type = rec.get("type") + + if rec_type == "ai-title": + title = rec.get("title") or title + + if not cwd: + cwd = rec.get("cwd") + if not git_branch: + git_branch = rec.get("gitBranch") + if not version: + version = rec.get("version") + + ep = rec.get("entrypoint") + if ep: + entrypoints.add(ep) + + if rec_type == "assistant": + message_count += 1 + msg = rec.get("message") or {} + model = msg.get("model") + if model: + models.add(model) + usage = msg.get("usage") or {} + tokens.input += usage.get("input_tokens", 0) + tokens.output += usage.get("output_tokens", 0) + tokens.cache_read += usage.get("cache_read_input_tokens", 0) + tokens.cache_write += usage.get("cache_creation_input_tokens", 0) + + except (OSError, PermissionError) as exc: + logger.debug("claude_code_reader.session_read_error path=%s error=%s", path, exc) + return None + + if message_count == 0 and first_ts is None: + return None + + model_list = sorted(models) + primary_model = model_list[0] if model_list else "" + cost = _price(primary_model, tokens.input, tokens.output, tokens.cache_read, tokens.cache_write) + for m in model_list[1:]: + # Additional models — approximate with same token split (rare) + cost += _price(m, 0, 0, 0, 0) + + now = datetime.utcnow() + is_active = bool(last_ts and (now - last_ts) < timedelta(minutes=ACTIVE_WINDOW_MINUTES)) + + return ClaudeSession( + session_id=session_id, + project_dir=project_dir, + cwd=cwd, + title=title, + models=model_list, + tokens=tokens, + cost_usd=round(cost, 6), + message_count=message_count, + first_message_at=first_ts, + last_message_at=last_ts, + is_active=is_active, + entrypoints=sorted(entrypoints), + git_branch=git_branch, + version=version, + ) + + +# --------------------------------------------------------------------------- +# Session listing +# --------------------------------------------------------------------------- + +def _projects_dir() -> Path: + override = os.environ.get("CLAUDE_PROJECTS_PATH", "").strip() + if override: + return Path(override) + return Path.home() / ".claude" / "projects" + + +def list_sessions( + *, + project_filter: str | None = None, + active_only: bool = False, + limit: int = 200, +) -> list[ClaudeSession]: + """Return parsed sessions from ~/.claude/projects/, newest first.""" + root = _projects_dir() + if not root.exists(): + return [] + + sessions: list[ClaudeSession] = [] + jsonl_files = sorted(root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) + + for path in jsonl_files: + if project_filter and project_filter.lower() not in path.parent.name.lower(): + continue + session = _parse_session_file(path) + if session is None: + continue + if active_only and not session.is_active: + continue + sessions.append(session) + if len(sessions) >= limit: + break + + return sessions + + +def get_session(session_id: str) -> ClaudeSession | None: + """Return a single parsed session by ID.""" + root = _projects_dir() + if not root.exists(): + return None + for path in root.rglob(f"{session_id}.jsonl"): + return _parse_session_file(path) + return None + + +def list_projects() -> list[dict[str, Any]]: + """Return discovered projects with aggregate stats.""" + root = _projects_dir() + if not root.exists(): + return [] + + projects: dict[str, dict[str, Any]] = {} + for path in root.rglob("*.jsonl"): + project_dir = path.parent.name + if project_dir not in projects: + projects[project_dir] = { + "project_dir": project_dir, + "session_count": 0, + "total_tokens": 0, + "total_cost_usd": 0.0, + "last_active_at": None, + "cwd": None, + "is_active": False, + } + session = _parse_session_file(path) + if session is None: + continue + p = projects[project_dir] + p["session_count"] += 1 + p["total_tokens"] += session.tokens.total + p["total_cost_usd"] = round(p["total_cost_usd"] + session.cost_usd, 6) + if session.cwd and not p["cwd"]: + p["cwd"] = session.cwd + if session.last_message_at: + current = p["last_active_at"] + if current is None or session.last_message_at > current: + p["last_active_at"] = session.last_message_at + if session.is_active: + p["is_active"] = True + + return sorted(projects.values(), key=lambda x: x["last_active_at"] or datetime.min, reverse=True) + + +# --------------------------------------------------------------------------- +# Config scanner +# --------------------------------------------------------------------------- + +def _read_json(path: Path) -> dict[str, Any]: + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + with open(path, "rb") as fh: + return tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError): + return {} + + +def _read_rules(path: Path) -> list[str]: + try: + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + except (OSError, PermissionError): + return [] + + +def read_config() -> ClaudeConfig: + """Read Claude Code and Codex CLI configuration from local files.""" + claude_dir = Path.home() / ".claude" + codex_dir = Path.home() / ".codex" + + # ~/.claude/settings.json + claude_settings = _read_json(claude_dir / "settings.json") + + # ~/.codex/config.toml + codex_config = _read_toml(codex_dir / "config.toml") + + # ~/.codex/rules/ — all .rules files + codex_rules: list[str] = [] + rules_dir = codex_dir / "rules" + if rules_dir.exists(): + for rules_file in sorted(rules_dir.glob("*.rules")): + codex_rules.extend(_read_rules(rules_file)) + + claude_creds = os.environ.get("CLAUDE_CREDENTIALS_PATH", "").strip() or str(claude_dir / ".credentials.json") + codex_creds = os.environ.get("CODEX_CREDENTIALS_PATH", "").strip() or str(codex_dir / "auth.json") + + return ClaudeConfig( + claude_settings=claude_settings, + codex_config=codex_config, + codex_rules=codex_rules, + claude_credentials_path=claude_creds if Path(claude_creds).exists() else None, + codex_credentials_path=codex_creds if Path(codex_creds).exists() else None, + ) + + +# --------------------------------------------------------------------------- +# Aggregate stats helper +# --------------------------------------------------------------------------- + +def session_stats(sessions: list[ClaudeSession]) -> dict[str, Any]: + total_tokens = sum(s.tokens.total for s in sessions) + total_cost = round(sum(s.cost_usd for s in sessions), 6) + active = sum(1 for s in sessions if s.is_active) + all_models: set[str] = set() + for s in sessions: + all_models.update(s.models) + return { + "session_count": len(sessions), + "active_sessions": active, + "total_tokens": total_tokens, + "total_cost_usd": total_cost, + "models": sorted(all_models), + } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 037a5b3..9f5e399 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,7 +2,7 @@ @tailwind components; @tailwind utilities; -/* Georgia font for numbers only - subset to digits and common number symbols */ +/* Georgia font for numbers only - subset to decimal digits */ @font-face { font-family: "Georgia Numbers"; font-style: normal; @@ -12,18 +12,7 @@ local("Georgia"), local("Georgia-Regular"), url("/fonts/georgia-regular.woff2") format("woff2"); - unicode-range: - U+0030-0039, - U+00B9, - U+00B2, - U+00B3, - U+2030, - U+2070, - U+2074-2079, - U+2080-2089, - U+2150-215F, - U+2160-2188, - U+2189-2189; + unicode-range: U+0030-0039; } @font-face { @@ -35,18 +24,7 @@ local("Georgia Italic"), local("Georgia-Italic"), url("/fonts/georgia-italic.woff2") format("woff2"); - unicode-range: - U+0030-0039, - U+00B9, - U+00B2, - U+00B3, - U+2030, - U+2070, - U+2074-2079, - U+2080-2089, - U+2150-215F, - U+2160-2188, - U+2189-2189; + unicode-range: U+0030-0039; } @font-face { @@ -58,18 +36,7 @@ local("Georgia Bold"), local("Georgia-Bold"), url("/fonts/georgia-bold.woff2") format("woff2"); - unicode-range: - U+0030-0039, - U+00B9, - U+00B2, - U+00B3, - U+2030, - U+2070, - U+2074-2079, - U+2080-2089, - U+2150-215F, - U+2160-2188, - U+2189-2189; + unicode-range: U+0030-0039; } @font-face { @@ -81,18 +48,7 @@ local("Georgia Bold Italic"), local("Georgia-Bold-Italic"), url("/fonts/georgia-bold-italic.woff2") format("woff2"); - unicode-range: - U+0030-0039, - U+00B9, - U+00B2, - U+00B3, - U+2030, - U+2070, - U+2074-2079, - U+2080-2089, - U+2150-215F, - U+2160-2188, - U+2189-2189; + unicode-range: U+0030-0039; } @@ -195,6 +151,7 @@ body { @apply font-body; + font-family: "Georgia Numbers", var(--font-body), sans-serif; background: var(--bg); color: var(--text); } @@ -328,7 +285,7 @@ textarea::placeholder { } .landing-page { - font-family: var(--font-body), sans-serif; + font-family: "Georgia Numbers", var(--font-body), sans-serif; } /* Landing (Enterprise) */ @@ -347,6 +304,7 @@ textarea::placeholder { min-height: 100vh; font-family: + "Georgia Numbers", var(--font-body), -apple-system, sans-serif; @@ -553,7 +511,7 @@ textarea::placeholder { } .landing-enterprise .hero h1 { - font-family: var(--font-display), serif; + font-family: "Georgia Numbers", var(--font-display), serif; font-size: 56px; line-height: 1.15; color: var(--primary-navy); @@ -961,7 +919,7 @@ textarea::placeholder { } .landing-enterprise .cta-section h2 { - font-family: var(--font-display), serif; + font-family: "Georgia Numbers", var(--font-display), serif; font-size: 42px; color: white; margin-bottom: 1rem; diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 0a341a4..31a4124 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -40,9 +40,9 @@ module.exports = { }, }, fontFamily: { - heading: ["var(--font-heading)", "sans-serif"], - body: ["var(--font-body)", "sans-serif"], - display: ["var(--font-display)", "serif"], + heading: ["Georgia Numbers", "var(--font-heading)", "sans-serif"], + body: ["Georgia Numbers", "var(--font-body)", "sans-serif"], + display: ["Georgia Numbers", "var(--font-display)", "serif"], numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"], }, },