diff --git a/static/app.js b/static/app.js index b45b532..169fa0d 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,7 @@ import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, tempChart, precipChart, dryChart, attachChartHover } from "./chart.js"; +import { track } from "./digest.js"; import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel, fmtPrecip, fmtWind, fmtHumid } from "./shared.js"; @@ -47,6 +48,43 @@ const locLabel = document.getElementById("loc-label"); initFindButton(findBtn, "Find a location", () => selected, (lat, lon) => selectLocation(lat, lon)); +// ---- hero ---- +// The hero's grade card is server-rendered showing the most unusual city we're +// tracking; once the visitor has a place of their own it re-points at that. The +// frame and its text slots already exist in the HTML, so swapping the numbers in +// shifts nothing on the page. +const hero = document.getElementById("hero"); + +document.getElementById("hero-locate")?.addEventListener("click", () => { + track("home.locate"); + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => selectLocation(pos.coords.latitude, pos.coords.longitude), + () => { /* declined or unavailable: the map picker is still right there */ }, + { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }, + ); +}); + +document.getElementById("hero-pick")?.addEventListener("click", () => findBtn.click()); + +/** Re-point the hero card at the place the visitor actually chose. */ +function updateHero(data) { + if (!hero) return; + const card = document.getElementById("hero-grade"); + const targetDay = data.recent.find((d) => d.date === data.target_date); + const g = targetDay && (targetDay.tmax || targetDay.tmin); + if (!card || !g) return; + + document.getElementById("hero-where").textContent = `Today in ${placeLabel(data)}`; + document.getElementById("hero-band").textContent = g.grade; + document.getElementById("hero-detail").textContent = + `${targetDay.tmax === g ? "High temp" : "Low temp"} in the ${ord(Math.round(g.percentile))} percentile`; + // Band colour rides the same token set as everything else; the band word is + // always present too, so colour is never the only signal. + card.style.setProperty("--cat", TIER_COLORS[g.class] || ""); + hero.querySelector(".grade-why")?.remove(); +} + dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); }); // ---- grade request + render ---- @@ -116,6 +154,7 @@ const IC = { function render(data) { recentData = data; + updateHero(data); // The place name appears once — as the results heading (

) below, which also // carries the coordinates + climatology context. The top label only holds the // transient coordinates written on selection, so hide it now that the named diff --git a/static/digest.js b/static/digest.js new file mode 100644 index 0000000..c408d65 --- /dev/null +++ b/static/digest.js @@ -0,0 +1,70 @@ +// Digest signup + product-event beacons. Loaded on every page (base.html.j2). +// +// Both halves degrade: the form is a real
that works with +// this file blocked, and the beacons are fire-and-forget with no UI depending +// on them. + +const BASE = new URL("./", import.meta.url).pathname.replace(/\/$/, ""); + +/** Report one product event. Aggregate-only: the server takes the referrer from + * the request itself, so nothing identifying is sent from here. */ +export function track(event) { + try { + const url = `${BASE}/api/v2/event`; + const body = JSON.stringify({ event }); + // sendBeacon survives the page unloading, which a fetch() started on a link + // click usually does not — that's the whole point for nav/records clicks. + if (navigator.sendBeacon) { + navigator.sendBeacon(url, new Blob([body], { type: "application/json" })); + } else { + fetch(url, { method: "POST", body, headers: { "Content-Type": "application/json" }, + keepalive: true }).catch(() => {}); + } + } catch { /* instrumentation must never break the page */ } +} + +// Anything carrying data-event reports itself when activated. +document.addEventListener("click", (e) => { + const el = e.target.closest?.("[data-event]"); + if (el) track(el.dataset.event); +}); + +// --- digest form ------------------------------------------------------------- +for (const form of document.querySelectorAll("form[data-digest]")) { + const status = form.querySelector(".digest-status"); + const button = form.querySelector("button[type=submit]"); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = form.querySelector("input[name=email]")?.value?.trim(); + if (!email) return; + + button.disabled = true; + try { + const res = await fetch(form.action, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, place: form.dataset.place || null }), + }); + const data = await res.json().catch(() => ({})); + if (status) { + status.textContent = data.message || "Something went wrong — try again."; + status.hidden = false; + } + if (res.ok) { + // Collapse the inputs on success so the confirmation is the only thing + // left to read; the form is done either way. + form.querySelector(".digest-row")?.setAttribute("hidden", ""); + form.querySelector(".digest-note")?.setAttribute("hidden", ""); + track("home.digest_signup"); + } + } catch { + if (status) { + status.textContent = "Couldn't reach the server — try again."; + status.hidden = false; + } + } finally { + button.disabled = false; + } + }); +} diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 18810ba..0000000 --- a/static/index.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - Thermograph — how unusual is your weather? - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

Thermograph

-

Grading today's weather against ~45 years of local climate history

-
- -
-
- -
-
-
- - -
-
- - - What do the grades mean? → -
-
- -
-
-

Find a location to begin.

-

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.

-
- -
-
- - - - - diff --git a/static/style.css b/static/style.css index a8672a6..4ba6a8b 100644 --- a/static/style.css +++ b/static/style.css @@ -1757,3 +1757,136 @@ details.hub-country > summary:hover { color: var(--accent); } main.content { padding: 8px 14px 40px; } .today-cards { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } } + +/* ============================================================ + Homepage distribution surfaces: hero, records strip, how it + works, explore cards, city chips, digest form. Mobile-first; + the wide-monitor steps at the top of this file widen `main`, + and these grids flow into whatever room that gives them. + ============================================================ */ + +/* --- hero ---------------------------------------------------------------- */ +.hero { display: grid; gap: 18px; margin: 8px 0 22px; } +.hero h1 { font-size: clamp(28px, 6vw, 44px); line-height: 1.1; letter-spacing: -0.03em; } +.hero-sub { margin: 10px 0 0; color: var(--muted); font-size: clamp(15px, 2.4vw, 19px); max-width: 46ch; } + +/* The grade card is the LCP element. Its three text slots have reserved + heights so hydrating the live numbers can't shift the page. */ +.hero-card { display: grid; gap: 12px; justify-items: start; } +.grade-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 4px solid var(--cat, var(--border)); + border-radius: 11px; + padding: 16px 18px; + width: 100%; + max-width: 460px; +} +.grade-where { margin: 0; color: var(--muted); font-size: 13px; min-height: 18px; } +.grade-band { + margin: 4px 0 0; + font-size: clamp(26px, 5vw, 34px); + font-weight: 700; + letter-spacing: -0.02em; + min-height: 40px; + color: color-mix(in oklab, var(--cat, var(--text)) 78%, var(--text)); +} +.grade-detail { margin: 6px 0 0; font-size: 15px; min-height: 22px; } +.grade-why { margin: 6px 0 0; font-size: 12px; } + +.hero-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.hero-actions button { + min-height: 44px; padding: 11px 18px; border-radius: 10px; + font-size: 16px; font-weight: 600; cursor: pointer; +} +.btn-primary { background: var(--accent); color: #1a1206; border: 1px solid var(--accent); } +.btn-ghost { background: var(--surface); color: var(--text); border: 1px solid var(--border); } +.btn-ghost:hover, .btn-primary:hover { border-color: var(--accent); } +.hero-jump { margin: 0; font-size: 14px; } +.hero-jump a { color: var(--muted); text-decoration: none; } +.hero-jump a:hover { color: var(--accent); } + +/* Desktop: copy beside the card rather than stacked above it. */ +@media (min-width: 900px) { + .hero { grid-template-columns: 1.1fr 1fr; align-items: center; gap: 40px; } +} + +.stance-line { margin: 14px 0 28px; font-size: 13px; } +.stance-line a { color: var(--muted); } +.stance-line a:hover { color: var(--accent); } + +/* --- unusual right now --------------------------------------------------- */ +.unusual-strip { margin: 32px 0; } +.unusual-strip h2, .how-it-works h2, .explore h2, .city-hub h2 { + font-size: 12px; text-transform: uppercase; letter-spacing: .05em; + color: var(--muted); margin: 0 0 12px; +} +/* Scroll-snap, not a JS carousel: keyboard- and touch-scrollable for free. */ +.unusual-row { + display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 10px; + margin: 0; list-style: none; scroll-snap-type: x proximity; +} +.unusual-card { scroll-snap-align: start; flex: 0 0 auto; } +.unusual-card a { + display: grid; gap: 2px; min-width: 150px; padding: 12px 14px; + background: var(--surface); border: 1px solid var(--border); + border-left: 4px solid var(--cat, var(--border)); + border-radius: 11px; text-decoration: none; color: var(--text); +} +.unusual-card a:hover { border-color: var(--accent); } +.unusual-city { font-weight: 600; font-size: 14px; } +.unusual-pct { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; + color: color-mix(in oklab, var(--cat, var(--text)) 78%, var(--text)); } +.unusual-band { font-size: 12px; color: var(--muted); } +.unusual-more { margin: 2px 0 0; font-size: 14px; } +.unusual-more a { color: var(--accent); text-decoration: none; } + +/* --- how it works -------------------------------------------------------- */ +.how-it-works { margin: 32px 0; } +.how-steps { margin: 0 0 10px; padding-left: 22px; display: grid; gap: 8px; } +.how-steps li { line-height: 1.45; } +.how-it-works a { color: var(--accent); } + +/* --- explore ------------------------------------------------------------- */ +.explore { margin: 32px 0; } +.explore-cards { display: grid; gap: 10px; } +@media (min-width: 700px) { .explore-cards { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1400px) { .explore-cards { grid-template-columns: repeat(4, 1fr); } } +.explore-card { + display: grid; gap: 4px; padding: 14px 16px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 11px; text-decoration: none; color: var(--text); +} +.explore-card:hover { border-color: var(--accent); } +.explore-name { font-weight: 600; } +.explore-desc { color: var(--muted); font-size: 14px; line-height: 1.4; } + +/* --- city chips ---------------------------------------------------------- */ +.city-hub { margin: 32px 0; } +.city-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; padding: 0; list-style: none; } +.city-chips a { + display: inline-block; padding: 8px 13px; border-radius: 999px; + background: var(--surface); border: 1px solid var(--border); + color: var(--text); text-decoration: none; font-size: 14px; +} +.city-chips a:hover { border-color: var(--accent); } +.city-hub > p a { color: var(--accent); text-decoration: none; } + +/* --- digest form (site-wide footer pattern) ------------------------------ */ +.digest-form { display: grid; gap: 8px; margin: 0 0 20px; max-width: 480px; } +.digest-form label { font-size: 14px; font-weight: 600; } +.digest-row { display: flex; flex-wrap: wrap; gap: 8px; } +.digest-row input { + flex: 1 1 200px; min-height: 44px; padding: 10px 12px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 10px; color: var(--text); + font-size: 16px; /* hard minimum: smaller makes iOS zoom on focus */ +} +.digest-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.digest-row button { + min-height: 44px; padding: 10px 18px; border-radius: 10px; + background: var(--accent); color: #1a1206; border: 1px solid var(--accent); + font-size: 16px; font-weight: 600; cursor: pointer; +} +.digest-note { margin: 0; font-size: 12px; } +.digest-status { margin: 0; font-size: 14px; color: var(--accent); }