diff --git a/paths.py b/paths.py
index 864faad..f5dc078 100644
--- a/paths.py
+++ b/paths.py
@@ -1,10 +1,10 @@
"""Canonical filesystem locations, resolved once from the repo root.
Every path the backend needs (the climate cache, the accounts DB, logs, the
-Jinja templates, the frontend static dir, the bundled city data) is derived
-here from the repository root rather than from each module's own ``__file__``.
-That keeps the locations stable no matter how deep in the package tree a module
-lives, so moving a module never silently re-points the cache or the accounts DB.
+bundled city data) is derived here from the repository root rather than from
+each module's own ``__file__``. That keeps the locations stable no matter how
+deep in the package tree a module lives, so moving a module never silently
+re-points the cache or the accounts DB.
Callers that support an environment override (e.g. ``THERMOGRAPH_ACCOUNTS_DB``)
keep doing so; these are only the defaults.
@@ -33,12 +33,6 @@ REPO_DIR = _repo_root()
BACKEND_DIR = os.path.join(REPO_DIR, "backend")
DATA_DIR = os.path.join(REPO_DIR, "data")
LOGS_DIR = os.path.join(REPO_DIR, "logs")
-FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
-TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates")
-# Structured SSR copy (glossary, static-page SEO meta) — see
-# backend/web/content_loader.py and docs/README.md. A sibling of backend/ and
-# frontend/, not under either: it's the seed of a future thermograph-copy repo.
-CONTENT_DIR = os.path.join(REPO_DIR, "content")
# Bundled reference data shipped alongside the code (generated by gen_cities /
# gen_flavor), not runtime state — hence under backend/, not data/.
diff --git a/requirements.txt b/requirements.txt
index d609f17..df710aa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,10 +17,13 @@ alembic==1.14.0
pywebpush==2.0.0
# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py).
PyNaCl==1.5.0
-# Server-rendered SEO content pages (see backend/web/content.py, templates/).
+# Not used by backend itself (its own content.py/content_loader.py were
+# deleted in repo-split Stage 4) -- installed here because frontend_ssr/
+# shares this requirements.txt in the current shared Dockerfile. See
+# frontend_ssr/content.py (server-rendered SEO content pages).
jinja2==3.1.6
# Structured SSR copy (glossary, static-page SEO meta) — see
-# backend/web/content_loader.py and content/.
+# frontend_ssr/content_loader.py and content/.
PyYAML==6.0.3
# Recurring worker-tier jobs — city warming, IndexNow pings (see
# notifications/scheduler.py). In-process; no broker/queue needed at this scale.
diff --git a/templates/about.html.j2 b/templates/about.html.j2
deleted file mode 100644
index 1c38215..0000000
--- a/templates/about.html.j2
+++ /dev/null
@@ -1,36 +0,0 @@
-{% extends "base.html.j2" %}
-{% block title %}{{ page_title }}{% endblock %}
-{% block description %}{{ page_description }}{% endblock %}
-{% block canonical_path %}{{ canonical_path }}{% endblock %}
-{% block content %}
-
-
-
About Thermograph
-
Thermograph answers one question for any point on Earth: how unusual is this
- weather? Instead of an absolute temperature, it shows where a day falls in that exact
- location's own climate history.
-
-
Where the data comes from
-
The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation)
- comes from the ERA5 reanalysis (ECMWF), served via Open-Meteo.
- Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's
- no weather station, anywhere on the ~4 sq mi grid.
-
-
How a day is graded
-
For each metric and date, Thermograph builds a reference distribution from every historical day
- within ±7 days of that day-of-year, a 15-day seasonal window across all years. The
- observed value is placed on that distribution as an empirical
- percentile, then mapped to a grade from “Below Normal” through “High” to “Near Record”.
-
Everything is relative to the location: a {{ temp(60) }} day can be “Above Normal” in one place and
- “Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
- means heavy for a rainy day here. See the grade guide for the full scale.
-
-
Freshness
-
The historical archive updates as new days are added; recent and forecast conditions refresh hourly.
- City climate pages show the latest recorded day graded against the local normal.
- {# The brand is the page's h1 everywhere EXCEPT the homepage, where the
- hero headline owns the sole h1 and the brand degrades to a
. The
- .brand h1, .brand .site-name selector pair in style.css keeps the
- lockup looking identical either way. #}
- <{{ brand_tag|default('h1') }} class="site-name">Thermograph{{ brand_tag|default('h1') }}>
-
How unusual is the weather? Graded against ~45 years of local climate history.
-
-
-
-
- {# data-view marks the links nav.js rewrites to carry the current
- location hash, so moving between views keeps the place you picked.
- Climate and Alerts deliberately opt out (they aren't cell-scoped).
- Inert on the SEO pages, which never load nav.js. #}
-
-
Average temperatures, precipitation and all-time records for {{ display }},
- with every day graded against ~{{ n_years }} years of local climate history
- ({{ year_range[0] }}–{{ year_range[1] }}).{% if warmest and coldest %} Its warmest month is
- {{ warmest.name }}, averaging a high of {{ warmest.high }}, and its coldest is
- {{ coldest.name }}, with an average low of {{ coldest.low }}.{% endif %}
-
- {% if flavor %}
-
{{ flavor.extract }}{% if flavor.url %}
- via Wikipedia{% endif %}
- {% endif %}
-
- {% if today %}
-
-
How today compares
-
Latest recorded day: {{ today.date }}. Each reading placed on {{ display }}'s
- own ±7-day seasonal distribution.
{{ event.text }}{% if event.url %} Read more →{% endif %}
-
- {% endif %}
-
-
-
{{ name }} average temperatures by month
-
Typical daily high and low, and average precipitation, for each month in {{ display }},
- based on {{ year_range[0] }}–{{ year_range[1] }} records.
- {% if wettest %}The wettest month is {{ wettest.name }} ({{ wettest.precip }} on an average day).{% endif %}
- {{ m.name[:3] }}
-
- {% if m.bar %}{% endif %}
-
- {% if m.range_lo_f is not none and m.range_hi_f is not none %}
- {{ temp_bare(m.range_lo_f) }}–{{ temp_bare(m.range_hi_f) }}
- {% else %}—{% endif %}
-
- {% endfor %}
-
-
Each bar spans the 10th-percentile daily low to the 90th-percentile daily
- high (the range most days fall within), coloured cold → hot on a shared
- {{ temp(-10) }} to {{ temp(115) }} scale, so the whole year's rhythm reads at a glance.
-
-
-
-
Thinking of visiting {{ name }}?
-
Trips live and die by the weather. See how {{ name }}'s comfort stacks up against where
- you live. {{ name }} is pre-loaded, just add your city.
Percentiles compare each day to {{ display }}'s own history, so grades are
- relative to this location: a “Near Record” day here isn't the same temperature as elsewhere.
- How the grades work →
-
-{% endblock %}
diff --git a/templates/home.html.j2 b/templates/home.html.j2
deleted file mode 100644
index 7d7f88e..0000000
--- a/templates/home.html.j2
+++ /dev/null
@@ -1,205 +0,0 @@
-{% extends "base.html.j2" %}
-{# The Weekly view — the product itself — with the distribution surfaces built
- around it. Everything structural is server-rendered so crawlers and no-JS
- readers get a complete page; app.js hydrates the live numbers in place.
-
- The interactive controls below (#find-btn, #date-input, #panel, #results, …)
- are app.js's DOM contract, carried over verbatim from the old static
- frontend/index.html. Do not rename these ids.
-
- section / brand_tag / main_class come from the route's render context
- (content.home_page), not a child-template {% set %} — the SEO pages already
- pass `section` that way. #}
-
-{% block title %}Thermograph: how unusual is your weather?{% endblock %}
-{% block description %}See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile.{% endblock %}
-{% block canonical_path %}/{% endblock %}
-
-{% block head_extra %}
-
-{% endblock %}
-
-{% block jsonld %}
-
-{% endblock %}
-
-{% block content %}
- {# --- Hero ------------------------------------------------------------
- The grade card is the LCP element: its frame is server-rendered and its
- slots are sized here, so hydrating the live numbers causes no layout
- shift. Band colour is always paired with the band word — colour is never
- the only signal. #}
-
-
-
How unusual is your weather?
-
Any day, anywhere on Earth, graded against 45 years of that place's own history.
-
-
-
- {# --cat carries the band colour, the same convention .normal-card uses,
- so the band token stays the single source of truth. #}
-
- {%- if unusual -%}
- {{ unusual.metric_label }} in the {{ unusual.percentile|ordinal }} percentile for {{ unusual.window_label }}
- {%- else -%}
- Pick a place to see how unusual its weather is today.
- {%- endif -%}
-
- {% if unusual and unusual.is_default %}
-
the most unusual weather we're tracking{% if stale %} · as of {{ unusual.date }}{% endif %}
- {% endif %}
-
-
- {# app.js hides the locate button outside a secure context, where the
- browser refuses geolocation and it could only ever fail. #}
-
-
-
Thermograph fetches decades of daily highs, lows, and precipitation
- for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each
- day of the year, and grades recent weather by where it falls on that distribution.
-
-
-
-
- {# Sits outside #panel on purpose: app.js rewrites results.innerHTML
- wholesale on every grade, so anything inside it would be destroyed. #}
-
- Free. No ads. No tracking. No account needed.
- How we work →
-
-
- {# --- Unusual right now ----------------------------------------------
- Scroll-snap row, no JS carousel. Both tails are represented whenever a
- cold-tail city qualifies — two-sided honesty is product identity. #}
- {% if ranked %}
-
-
- {# Each card takes a colour from the grade ramp rather than a decorative
- palette, so the section reads as part of the product's own scale.
- Calendar carries the ramp itself — it IS the heatmap. #}
-
-
-
- {# --- City hub links --------------------------------------------------
- Real HTML links funnelling homepage authority into the ~1000-page
- /climate surface. #}
-
-
-
-{% endblock %}
-
-{% block body_scripts %}
-
-
- {# The records strip renders real temperatures server-side as .temp spans, so
- it needs the same converter the SEO pages use or they'd stay in °F while
- the toggle says °C. Both import units.js, which the module loader dedupes. #}
-
-{% endblock %}
diff --git a/templates/hub.html.j2 b/templates/hub.html.j2
deleted file mode 100644
index 9352b66..0000000
--- a/templates/hub.html.j2
+++ /dev/null
@@ -1,89 +0,0 @@
-{% extends "base.html.j2" %}
-{% block title %}{{ page_title }}{% endblock %}
-{% block description %}{{ page_description }}{% endblock %}
-{% block canonical_path %}{{ canonical_path }}{% endblock %}
-{% block content %}
-
-
-
City climates
-
Average temperatures by month, all-time records, and how today compares, for
- {{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that
- location's own ~45 years of climate history.
-
-
-
-
-
-
-
- {% for country, cs in groups.items() %}
-
- {{ country }} {{ cs | length }}
-
In an average {{ month_name }}, {{ name }} sees a daily high around
- {{ avg_high }} and a low around
- {{ avg_low }}, based on
- {{ year_range[0] }}–{{ year_range[1] }} of local climate records.
-
-
-
- {% for label, value in stats %}
-
{{ label }}
{{ value }}
- {% endfor %}
-
-
-
- {% if records %}
-
{{ month_name }} records
-
The most extreme {{ month_name }} on record for each metric, across
- {{ year_range[0] }}–{{ year_range[1] }}. For rainfall, the wettest and driest are by
- total {{ month_name }} accumulation.
Thermograph is free, has no ads, and needs no account. It is built so that
- there is very little about you to collect in the first place.
-
-
Your location
-
Thermograph never looks up your location from your IP address. The only way the site
- learns where you are is if you press Use my location, which asks your browser for
- permission. You can decline, and picking a place on the map works just as well.
-
Whichever way you choose a place, the coordinates are sent to the server only as part of
- the ordinary request for that grid square's climate data, and are not logged beyond the
- normal web-server request handling every site does. The place you picked is remembered in
- your own browser's local storage, not on the server, so clearing your browser data
- forgets it.
-
-
Analytics
-
There is no analytics library, no cookies for tracking, and no per-visitor identifier.
- The server keeps simple aggregate counts (how many people opened a page or tapped a
- button, and which site referred them) with nothing tying any of it to an individual.
-
-
Email
-
If you sign up for the monthly digest, your address is used for that digest and nothing
- else. It is never sold or shared, and every message can unsubscribe you.
-
-
Accounts
-
An account is optional and only exists to hold weather alerts you have asked for. It
- stores your email address and the places you are watching.
-
-{% endblock %}
diff --git a/templates/records.html.j2 b/templates/records.html.j2
deleted file mode 100644
index eb53b61..0000000
--- a/templates/records.html.j2
+++ /dev/null
@@ -1,96 +0,0 @@
-{% extends "base.html.j2" %}
-{% block title %}{{ page_title }}{% endblock %}
-{% block description %}{{ page_description }}{% endblock %}
-{% block canonical_path %}{{ canonical_path }}{% endblock %}
-{% block jsonld %}{% endblock %}
-{% block content %}
-{# A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted
- chips with the date each occurred. Used for the daytime-high and overnight-low
- columns of the month and season tables. #}
-{% macro extremes(metric) %}
-{% if metric and metric.warm and metric.cold %}
-
▲{{ metric.warm.txt }}{{ metric.warm.date }}
-
▼{{ metric.cold.txt }}{{ metric.cold.date }}
-{% else %}—{% endif %}
-{% endmacro %}
-
-
-
-
{{ display }} weather records
-
Record high and low temperatures for {{ display }}, by month, by season, and
- all-time, with the dates they occurred, across {{ year_range[0] }}–{{ year_range[1] }} of daily
- climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached
- {{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
- {{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}
-
-
-
Records by month
-
The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in
- each calendar month, so each column shows its record high and its record low. Tap a
- month for its full averages and typical range.
The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each
- meteorological season ({{ hemisphere }} Hemisphere, each a three-month span).
-
-
-
Season
Daytime high
Overnight low
-
- {% for s in seasonal %}
-
-
{{ s.name }} ({{ s.span }})
-
{{ extremes(s.high) }}
-
{{ extremes(s.low) }}
-
- {% endfor %}
-
-
-
-
-
-
-
All-time records
-
The most extreme value {{ name }} has recorded for each metric across its entire history.
-
-{% endblock %}
diff --git a/tests/web/test_api.py b/tests/web/test_api.py
index 031f169..dbea466 100644
--- a/tests/web/test_api.py
+++ b/tests/web/test_api.py
@@ -185,18 +185,6 @@ def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch):
assert body["corrected"] is None
-def test_pages_serve_with_origin_filled_in(client):
- """/ moved to frontend_ssr (repo-split Stage 4) -- it renders via real Jinja
- template vars now, not a __ORIGIN__ string substitution, so there's nothing
- left to check about it here. /calendar is still a _page()-served static
- shell with the substitution, unaffected by the split."""
- r = client.get("/thermograph/calendar")
- assert r.status_code == 200
- assert "text/html" in r.headers["content-type"]
- assert "__ORIGIN__" not in r.text
- assert client.head("/thermograph/calendar").status_code == 200
-
-
def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
from web import app as appmod
monkeypatch.setattr(appmod, "_WARM_SEEN", {})
diff --git a/tests/web/test_homepage.py b/tests/web/test_homepage.py
index 621e5c4..ac52db0 100644
--- a/tests/web/test_homepage.py
+++ b/tests/web/test_homepage.py
@@ -11,14 +11,10 @@ import datetime
import json
import time
-import pytest
-
from api import homepage
from data import climate
from data import store
-B = "/thermograph"
-
# --- the precompute ----------------------------------------------------------
def test_build_never_fetches_upstream(monkeypatch):
@@ -179,17 +175,3 @@ def test_staleness(monkeypatch):
assert not homepage.is_stale({"date": today, "generated_at": time.time()})
assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600})
assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()})
-
-
-# --- static frontend, unaffected by the SSR content split --------------------
-@pytest.fixture
-def client():
- from fastapi.testclient import TestClient
- from web import app as appmod
- return TestClient(appmod.app)
-
-
-def test_old_static_index_is_gone(client):
- """index.html must not linger behind the static mount as indexable duplicate
- content now that / is server-rendered (by frontend_ssr)."""
- assert client.get(f"{B}/index.html").status_code == 404
diff --git a/web/app.py b/web/app.py
index 70d4027..b20d449 100644
--- a/web/app.py
+++ b/web/app.py
@@ -5,20 +5,16 @@ import functools
import hashlib
import hmac
import json
-import mimetypes
import os
import queue
import threading
import time
-import html as html_escape
-
import httpx
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse
-from fastapi.staticfiles import StaticFiles
from accounts import api_accounts
from api import content_routes
@@ -40,8 +36,6 @@ from accounts import users
from api import payloads
from accounts.schemas import UserCreate, UserRead, UserUpdate
-FRONTEND_DIR = paths.FRONTEND_DIR
-
# Everything (pages, assets, API) is served under this base path so the app can
# live at https:///thermograph/ behind a shared domain. Override with the
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
@@ -70,10 +64,6 @@ _frontend_client = httpx.AsyncClient(base_url=FRONTEND_BASE, timeout=10.0)
# every other env-driven constant here (BASE, WORKERS, ...).
ROLE = os.environ.get("THERMOGRAPH_ROLE", "all").strip().lower()
-# The PWA manifest is served as a static file; register its media type so
-# StaticFiles labels it correctly (not in Python's default mimetypes map).
-mimetypes.add_type("application/manifest+json", ".webmanifest")
-
def _weather_fetch_error(e) -> HTTPException:
"""A clean, retryable 503 when weather data is temporarily unavailable; the
@@ -792,33 +782,11 @@ async def _proxy_to_frontend(request: Request) -> Response:
return Response(content=upstream.content, status_code=upstream.status_code, headers=resp_headers)
-# The exact path list content.register() used to own -- see backend/web/app.py's
-# git history (repo-split Stage 3) for the Jinja implementation, now in
-# frontend_ssr/content.py. /climate/{slug}/records must be registered before
-# the {month} param route so the literal path wins, same ordering content.py
-# itself used.
-_FRONTEND_ROUTES = [
- f"{BASE}/",
- f"{BASE}/about",
- f"{BASE}/privacy",
- f"{BASE}/glossary",
- f"{BASE}/glossary/{{term}}",
- f"{BASE}/climate",
- f"{BASE}/climate/{{slug}}/records",
- f"{BASE}/climate/{{slug}}/{{month}}",
- f"{BASE}/climate/{{slug}}",
- f"{BASE}/robots.txt",
- f"{BASE}/sitemap.xml",
-]
-for _frontend_path in _FRONTEND_ROUTES:
- app.add_api_route(_frontend_path, _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)
-
-# The IndexNow ownership-verification key file, at a dynamic path fixed at
-# startup -- mirrors how content.register() used to register it.
-import indexnow # noqa: E402 - lazy, same as content.register() did
-
-app.add_api_route(f"{BASE}/{indexnow.key()}.txt", _proxy_to_frontend,
- methods=["GET", "HEAD"], include_in_schema=False)
+# The catch-all fallback registered at the very end of this file (after every
+# backend route) proxies everything else -- content pages, the IndexNow key
+# file, static assets, the SPA shells -- to frontend. See it there for why a
+# single fallback replaced what used to be an enumerated path list (repo-split
+# Stage 7a).
# --- Discord slash commands (HTTP interactions) ----------------------------
@@ -863,73 +831,31 @@ app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2")
app.include_router(discord_link.router, prefix=f"{BASE}/api/v2")
-# --- static frontend (served under BASE) -----------------------------------
-def head_verify_html() -> str:
- """Search-engine ownership-verification tags, from env (empty when
- unset). Injected into every _page()-served page's — Google verifies
- the homepage, which frontend_ssr's own copy of this same logic handles for
- the SSR content pages."""
- metas = []
- google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
- bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
- if google:
- metas.append(f'')
- if bing:
- metas.append(f'')
- return "\n ".join(metas)
-
-
-def _page(name):
- """Route handler for one HTML page. Serves the file with its __ORIGIN__
- placeholders (the link-preview/Open Graph tags) filled in as the request's
- scheme://host + BASE — preview crawlers (Discord, Slack, …) need absolute
- URLs, and the host differs between LAN and prod. The scheme comes from
- X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
- uvicorn; the proxy passes the original Host header through untouched."""
- path = os.path.join(FRONTEND_DIR, name)
-
- def route(request: Request):
- with open(path, encoding="utf-8") as f:
- html = f.read()
- proto = request.headers.get("x-forwarded-proto") or request.url.scheme
- host = request.headers.get("host") or request.url.netloc
- html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
- # Search-engine verification tags (same source as the SEO pages), so
- # the homepage Google/Bing verify against carries them too.
- verify = head_verify_html()
- if verify:
- html = html.replace("", f"\n {verify}", 1)
- etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
- if _not_modified(request, etag):
- return Response(status_code=304, headers={"ETag": etag})
- return Response(html, media_type="text/html", headers={"ETag": etag})
- return route
-
-
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
# resolve correctly. Under a sub-path this keeps the app scoped to BASE (never
# claiming "/", so the domain root stays free for another app via the proxy). When
# BASE is "" the app owns the root: "/" is already the index route below, so there
# is no bare base to redirect (and "" is not a valid route path).
-# Pages also answer HEAD — link-preview crawlers probe with it before fetching.
if BASE:
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
-# NOTE: "/" is not here — the homepage (and the other crawlable SSR pages) are
-# proxied to the frontend_ssr service, registered above (_FRONTEND_ROUTES).
-app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
-app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
-app.add_api_route(f"{BASE}/score", _page("score.html"), methods=["GET", "HEAD"], include_in_schema=False)
-app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
-app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
-app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Monthly-digest signup (the footer form on every page). Answers the same generic
# message whatever the outcome, so it can't be probed for whether an address is
# already subscribed.
app.add_api_route(f"{BASE}/digest", digest.signup, methods=["POST"], include_in_schema=False)
-# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
-# Registered last so the explicit page routes above win. Mounts must start with
-# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win
-# because Starlette matches in registration order.
-app.mount(BASE or "/", StaticFiles(directory=FRONTEND_DIR), name="static")
+# --- everything else: proxy to frontend (repo-split Stage 7a) ---------------
+# Frontend now owns every page (the SSR content pages, and the interactive
+# tool's SPA shells), every static asset (app.js, style.css, manifest,
+# favicons, …), and the dynamic IndexNow key file -- backend serves none of
+# that itself anymore (it used to read them from a sibling frontend/
+# directory; see _proxy_to_frontend's own docstring for why that stops being
+# true once backend and frontend are separate repos). A single catch-all,
+# registered dead last so every real backend route above already won, proxies
+# anything unmatched -- simpler than (and a superset of) the enumerated path
+# list this replaced, since static asset filenames aren't enumerable the way
+# content pages are. Caddy path-splits directly to whichever service owns a
+# path in prod/beta (deploy/Caddyfile), so this is only ever exercised as the
+# fallback for environments with no Caddy in front (LAN dev, bare-metal
+# run.sh) -- same role _proxy_to_frontend has always had.
+app.add_api_route(f"{BASE}/{{path:path}}", _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)