Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
This commit is contained in:
parent
118efeb158
commit
c4ca6b38ea
4 changed files with 242 additions and 87 deletions
|
|
@ -6,6 +6,7 @@ import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||||
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
||||||
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
||||||
|
import { track } from "./digest.js";
|
||||||
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
|
import { TIER_COLORS, SCALE_TEMP, drynessColor, ord, esc, todayISO, placeLabel,
|
||||||
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
||||||
|
|
||||||
|
|
@ -47,6 +48,43 @@ const locLabel = document.getElementById("loc-label");
|
||||||
initFindButton(findBtn, "Find a location",
|
initFindButton(findBtn, "Find a location",
|
||||||
() => selected, (lat, lon) => selectLocation(lat, lon));
|
() => 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(); });
|
dateInput.addEventListener("change", () => { syncTodayBtn(); selected && runGrade(); });
|
||||||
|
|
||||||
// ---- grade request + render ----
|
// ---- grade request + render ----
|
||||||
|
|
@ -116,6 +154,7 @@ const IC = {
|
||||||
|
|
||||||
function render(data) {
|
function render(data) {
|
||||||
recentData = data;
|
recentData = data;
|
||||||
|
updateHero(data);
|
||||||
// The place name appears once — as the results heading (<h2>) below, which also
|
// The place name appears once — as the results heading (<h2>) below, which also
|
||||||
// carries the coordinates + climatology context. The top label only holds the
|
// carries the coordinates + climatology context. The top label only holds the
|
||||||
// transient coordinates written on selection, so hide it now that the named
|
// transient coordinates written on selection, so hide it now that the named
|
||||||
|
|
|
||||||
70
static/digest.js
Normal file
70
static/digest.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Digest signup + product-event beacons. Loaded on every page (base.html.j2).
|
||||||
|
//
|
||||||
|
// Both halves degrade: the form is a real <form method="post"> 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Thermograph — how unusual is your weather?</title>
|
|
||||||
<meta name="description" content="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 graded by percentile." />
|
|
||||||
<meta name="theme-color" content="#f0803c" />
|
|
||||||
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
|
|
||||||
og:image origins in from this request's scheme://host + base path —
|
|
||||||
crawlers need absolute URLs and don't run JS. -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:site_name" content="Thermograph" />
|
|
||||||
<meta property="og:title" content="Thermograph — how unusual is your weather?" />
|
|
||||||
<meta property="og:description" content="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 graded by percentile." />
|
|
||||||
<meta property="og:url" content="__ORIGIN__/" />
|
|
||||||
<meta property="og:image" content="__ORIGIN__/logo.png?v=2" />
|
|
||||||
<meta property="og:image:width" content="512" />
|
|
||||||
<meta property="og:image:height" content="512" />
|
|
||||||
<meta property="og:image:alt" content="Thermograph logo" />
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
<link rel="canonical" href="__ORIGIN__/" />
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{"@context":"https://schema.org","@type":"WebApplication","name":"Thermograph","url":"__ORIGIN__/","applicationCategory":"WeatherApplication","operatingSystem":"Web, iOS, Android","browserRequirements":"Requires JavaScript","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"description":"See how today's weather compares to ~45 years of local climate history for any place on Earth — graded by percentile."}
|
|
||||||
</script>
|
|
||||||
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
|
||||||
<link rel="icon" href="favicon-48.png" type="image/png" sizes="48x48" />
|
|
||||||
<link rel="icon" href="favicon-32.png" type="image/png" sizes="32x32" />
|
|
||||||
<link rel="icon" href="favicon-16.png" type="image/png" sizes="16x16" />
|
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="manifest.webmanifest" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<link rel="stylesheet" href="style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="brand">
|
|
||||||
<div>
|
|
||||||
<h1><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#fff" stroke-opacity=".08" stroke-width="4"/><rect x="64" y="170" width="384" height="48" rx="8" fill="#27131C"/><rect x="64" y="222" width="384" height="48" rx="8" fill="#38322E"/><rect x="64" y="274" width="384" height="48" rx="8" fill="#1A2D27"/><rect x="64" y="326" width="384" height="48" rx="8" fill="#26333F"/><rect x="64" y="378" width="384" height="48" rx="8" fill="#18293A"/><line x1="64" y1="132" x2="448" y2="132" stroke="#46586E" stroke-width="8" stroke-dasharray="26 20"/><path d="M64 410H126V386H160" fill="none" stroke="var(--cold)" stroke-width="34"/><path d="M160 403V352H244V368H282" fill="none" stroke="var(--cool)" stroke-width="34"/><path d="M282 385V320H312" fill="none" stroke="var(--normal)" stroke-width="34"/><path d="M312 337V248H338" fill="none" stroke="var(--warm)" stroke-width="34"/><path d="M338 265V196H366" fill="none" stroke="var(--hot)" stroke-width="34"/><path d="M366 213V132H396" fill="none" stroke="var(--very-hot)" stroke-width="34"/><rect x="382" y="100" width="64" height="64" rx="12" fill="var(--rec-hot)" stroke="#10141B" stroke-width="12"/></svg></span>Thermograph</h1>
|
|
||||||
<p class="tag">Grading today's weather against ~45 years of local climate history</p>
|
|
||||||
</div>
|
|
||||||
<details class="nav-menu">
|
|
||||||
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
|
|
||||||
<div class="nav-panel">
|
|
||||||
<nav class="view-nav" aria-label="Views">
|
|
||||||
<a href="./" data-view="map" class="active">Weekly</a>
|
|
||||||
<a href="calendar" data-view="calendar">Calendar</a>
|
|
||||||
<a href="day" data-view="day">Day Detail</a>
|
|
||||||
<a href="compare" data-view="compare">Compare</a>
|
|
||||||
<a href="climate">Climate</a>
|
|
||||||
<a href="alerts">Alerts</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="controls">
|
|
||||||
<div class="find-bar">
|
|
||||||
<button type="button" id="find-btn" class="find-btn"></button>
|
|
||||||
<span id="loc-label" class="loc-label" hidden></span>
|
|
||||||
</div>
|
|
||||||
<div class="date-row">
|
|
||||||
<label>Target date
|
|
||||||
<input id="date-input" type="date" />
|
|
||||||
</label>
|
|
||||||
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
|
|
||||||
<span class="hint"><a href="legend">What do the grades mean? →</a></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="panel" class="panel panel-full">
|
|
||||||
<div class="placeholder" id="placeholder">
|
|
||||||
<p>Find a location to begin.</p>
|
|
||||||
<p class="muted">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.</p>
|
|
||||||
</div>
|
|
||||||
<div id="results" hidden></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<script type="module" src="app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
133
static/style.css
133
static/style.css
|
|
@ -1757,3 +1757,136 @@ details.hub-country > summary:hover { color: var(--accent); }
|
||||||
main.content { padding: 8px 14px 40px; }
|
main.content { padding: 8px 14px 40px; }
|
||||||
.today-cards { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
|
.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); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue