2026-07-11 00:29:47 +00:00
|
|
|
|
:root {
|
|
|
|
|
|
--bg: #0f1216;
|
|
|
|
|
|
--surface: #171b21;
|
|
|
|
|
|
--surface-2: #1e242c;
|
|
|
|
|
|
--border: #2a323c;
|
|
|
|
|
|
--text: #e7ecf2;
|
|
|
|
|
|
--muted: #9aa6b2;
|
2026-07-11 08:06:30 +00:00
|
|
|
|
--accent: #f0803c;
|
|
|
|
|
|
|
|
|
|
|
|
/* temperature grades: 9-step diverging cold -> green -> hot (colorblind-safe).
|
|
|
|
|
|
rec-* are the "Near Record" danger tiers — deliberately dark/saturated. */
|
|
|
|
|
|
--rec-cold: #0a2f6b;
|
|
|
|
|
|
--very-cold: #2166ac;
|
|
|
|
|
|
--cold: #4393c3;
|
|
|
|
|
|
--cool: #92c5de;
|
|
|
|
|
|
--normal: #4a9d5b; /* middle of the diverging temp scale = green */
|
|
|
|
|
|
--warm: #f6c18a;
|
|
|
|
|
|
--hot: #ef9351;
|
|
|
|
|
|
--very-hot: #dd6b52;
|
|
|
|
|
|
--rec-hot: #8f0e20;
|
|
|
|
|
|
|
|
|
|
|
|
/* precipitation: "dry" + 9 rain-intensity tiers, light green -> teal -> dark navy */
|
|
|
|
|
|
--dry: #c9a24a;
|
|
|
|
|
|
--wet-1: #d9f0d3;
|
|
|
|
|
|
--wet-2: #b7e2b1;
|
|
|
|
|
|
--wet-3: #8fd18f;
|
|
|
|
|
|
--wet-4: #5cba9f;
|
|
|
|
|
|
--wet-5: #35a1c0; /* middle of the rain scale */
|
|
|
|
|
|
--wet-6: #2b8cbe;
|
|
|
|
|
|
--wet-7: #226bb3;
|
|
|
|
|
|
--wet-8: #164a97;
|
|
|
|
|
|
--wet-9: #08306b;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--bg: #f4f6f9;
|
|
|
|
|
|
--surface: #ffffff;
|
|
|
|
|
|
--surface-2: #eef1f5;
|
|
|
|
|
|
--border: #dde3ea;
|
|
|
|
|
|
--text: #1a2029;
|
|
|
|
|
|
--muted: #5b6673;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
line-height: 1.45;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
header {
|
|
|
|
|
|
padding: 18px 24px;
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
}
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Header content shares the main column's width so the logo and tabs line up
|
|
|
|
|
|
with the content edges instead of hugging the monitor corners. (1152 = main's
|
|
|
|
|
|
1200 max minus its 24px side paddings; the header carries its own padding.) */
|
|
|
|
|
|
.brand { display: flex; align-items: center; gap: 14px; max-width: 1152px; margin: 0 auto; }
|
2026-07-11 08:23:43 +00:00
|
|
|
|
/* The title block (logo's neighbour) grows and may shrink, so the unit toggle is
|
|
|
|
|
|
pushed to the top-right on the logo's row and the long tagline wraps inside it
|
|
|
|
|
|
instead of bumping the toggle onto its own line. */
|
Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
|
|
|
|
.brand > div:not(.unit-toggle):not(.acct) { flex: 1 1 0; min-width: 0; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.logo {
|
2026-07-16 05:42:59 +00:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
flex-shrink: 0;
|
2026-07-18 05:58:55 +00:00
|
|
|
|
position: relative;
|
|
|
|
|
|
top: -2px; /* optical: tile centers on the wordmark's cap height */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.logo svg { width: 28px; height: 28px; display: block; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
h1 { margin: 0; font-size: 22px; letter-spacing: -0.02em; }
|
2026-07-18 05:19:39 +00:00
|
|
|
|
/* Brand wordmark: monospace stack (instrument/data feel, no webfonts) beside
|
2026-07-18 05:49:00 +00:00
|
|
|
|
the mark, larger than the old Inter treatment. The mark lives INSIDE the h1
|
|
|
|
|
|
(and inside its home link on server-rendered pages) so glyph and wordmark
|
2026-07-18 07:55:43 +00:00
|
|
|
|
are one lockup, aligned to each other on every page regardless of tagline.
|
|
|
|
|
|
|
|
|
|
|
|
Matched on .site-name as well as h1: the homepage renders the brand as a <p>
|
|
|
|
|
|
so its hero headline can own the page's only h1. Without the class here the
|
|
|
|
|
|
lockup falls back to block layout, which baseline-aligns the mark instead of
|
|
|
|
|
|
centring it (it rides ~8px high) and drops the monospace wordmark. */
|
|
|
|
|
|
.brand h1,
|
|
|
|
|
|
.brand .site-name {
|
2026-07-18 05:19:39 +00:00
|
|
|
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, "Liberation Mono", monospace;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
|
letter-spacing: -0.04em;
|
2026-07-18 05:49:00 +00:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
2026-07-18 05:19:39 +00:00
|
|
|
|
}
|
Make the brand lockup a home link on every page (#182)
The mark and wordmark were already a home link on the Jinja pages (homepage,
about, climate, glossary, privacy), but the five static tool pages — calendar,
day, compare, legend, alerts — built their header separately and left the brand
as a bare <h1>, so clicking it did nothing.
Wrap the lockup in `<a href="./">` there, matching the relative form the nav
already uses: it resolves to the app root whether the app is served at the
domain root or under a base path.
The link wraps the whole lockup, so the mark is clickable too, not just the
word. Its colour/decoration moves from `.site-name a` up to the shared
`.brand h1 a, .brand .site-name a` rule so it covers both the static pages'
<h1> form and the homepage's <p>, with an accent hover so the affordance is
visible.
Adds a parametrized test over every page: the two header implementations are
easy to change independently, so this asserts each one carries a home link that
wraps the mark.
2026-07-18 08:06:14 +00:00
|
|
|
|
/* The whole lockup is the home link on every page, so it inherits the wordmark's
|
|
|
|
|
|
colour rather than looking like body copy that happens to be a link. */
|
2026-07-18 07:55:43 +00:00
|
|
|
|
.brand h1 a,
|
Make the brand lockup a home link on every page (#182)
The mark and wordmark were already a home link on the Jinja pages (homepage,
about, climate, glossary, privacy), but the five static tool pages — calendar,
day, compare, legend, alerts — built their header separately and left the brand
as a bare <h1>, so clicking it did nothing.
Wrap the lockup in `<a href="./">` there, matching the relative form the nav
already uses: it resolves to the app root whether the app is served at the
domain root or under a base path.
The link wraps the whole lockup, so the mark is clickable too, not just the
word. Its colour/decoration moves from `.site-name a` up to the shared
`.brand h1 a, .brand .site-name a` rule so it covers both the static pages'
<h1> form and the homepage's <p>, with an accent hover so the affordance is
visible.
Adds a parametrized test over every page: the two header implementations are
easy to change independently, so this asserts each one carries a home link that
wraps the mark.
2026-07-18 08:06:14 +00:00
|
|
|
|
.brand .site-name a {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.brand h1 a:hover,
|
|
|
|
|
|
.brand .site-name a:hover { color: var(--accent); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.tag { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
|
2026-07-11 07:20:13 +00:00
|
|
|
|
/* Compact header (e.g. Calendar): no tagline, roughly half the height.
|
|
|
|
|
|
Only the vertical padding is trimmed so the responsive side padding stands. */
|
|
|
|
|
|
header.compact { padding-top: 9px; padding-bottom: 9px; }
|
2026-07-16 05:42:59 +00:00
|
|
|
|
header.compact .logo svg { width: 22px; height: 22px; }
|
2026-07-18 07:55:43 +00:00
|
|
|
|
header.compact h1,
|
|
|
|
|
|
header.compact .site-name { font-size: 21px; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|
|
|
|
|
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Larger monitors: widen the content column in steps (1440p, 2K, 4K) so pages
|
|
|
|
|
|
use the screen instead of a 1200px strip. Each page's own grids (normals,
|
|
|
|
|
|
calendar months, day cards) flow into the extra room. */
|
|
|
|
|
|
@media (min-width: 1680px) {
|
|
|
|
|
|
main { max-width: 1440px; }
|
|
|
|
|
|
.brand { max-width: 1392px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (min-width: 2400px) {
|
|
|
|
|
|
main { max-width: 1640px; }
|
|
|
|
|
|
.brand { max-width: 1592px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (min-width: 3400px) {
|
|
|
|
|
|
main { max-width: 1880px; }
|
|
|
|
|
|
.brand { max-width: 1832px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.controls { margin-bottom: 18px; display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
/* ---- shared control recipes ----
|
|
|
|
|
|
One home for the accent-button and text-input look every view reuses. The
|
|
|
|
|
|
16px input font is load-bearing (below it iOS zooms on focus); buttons keep
|
|
|
|
|
|
their ≥44px touch targets in the per-component rules, which carry only each
|
|
|
|
|
|
control's differences from these. */
|
|
|
|
|
|
.date-row button, .range-refresh, .find-btn, .mp-search button, .mp-confirm {
|
|
|
|
|
|
border-radius: 10px; border: none; cursor: pointer;
|
|
|
|
|
|
background: var(--accent); color: #1a1206; font-weight: 600;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.date-row input, .cal-range input, .day-date input, .mp-search input {
|
2026-07-11 00:29:47 +00:00
|
|
|
|
padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border);
|
|
|
|
|
|
background: var(--surface); color: var(--text); font-size: 16px;
|
|
|
|
|
|
}
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.date-row button { padding: 11px 18px; }
|
|
|
|
|
|
|
|
|
|
|
|
.date-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
|
|
|
|
|
.date-row label { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
/* "Today" quick-reset chip beside the target date. Outlined when the target is a
|
|
|
|
|
|
past date (a clickable "jump to today"); filled accent while already on today. */
|
|
|
|
|
|
.date-row .today-chip {
|
|
|
|
|
|
align-self: flex-end; padding: 9px 14px; border-radius: 10px; min-height: 40px;
|
|
|
|
|
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
|
|
|
|
background: transparent; color: var(--muted); border: 1px solid var(--border);
|
|
|
|
|
|
transition: background .12s ease, color .12s ease, border-color .12s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.date-row .today-chip:hover { border-color: var(--accent); color: var(--text); }
|
|
|
|
|
|
.date-row .today-chip.active {
|
|
|
|
|
|
background: var(--accent); color: #1a1206; border-color: var(--accent); cursor: default;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hint { color: var(--muted); font-size: 12.5px; max-width: 220px; }
|
|
|
|
|
|
|
|
|
|
|
|
.panel {
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.placeholder p { color: var(--text); }
|
|
|
|
|
|
.muted { color: var(--muted); font-size: 13.5px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* --- results --- */
|
|
|
|
|
|
.loc-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
|
|
|
|
|
.loc-head h2 { margin: 0 0 2px; font-size: 18px; }
|
|
|
|
|
|
.loc-head .meta { color: var(--muted); font-size: 12.5px; margin: 0; }
|
2026-07-11 07:10:46 +00:00
|
|
|
|
/* Context stacked top-down, one fact per line, instead of dot-joined tags. */
|
|
|
|
|
|
.loc-meta { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
|
|
|
|
.loc-meta li { color: var(--muted); font-size: 12.5px; line-height: 1.35; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.share { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.share button {
|
2026-07-11 00:29:47 +00:00
|
|
|
|
padding: 6px 11px; border-radius: 8px; border: 1px solid var(--border);
|
|
|
|
|
|
background: var(--surface-2); color: var(--text); font-size: 12px; cursor: pointer;
|
|
|
|
|
|
white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; gap: 5px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Inline monochrome icons (currentColor) used in place of emoji. */
|
|
|
|
|
|
.ic { width: 1em; height: 1em; vertical-align: -0.14em; flex-shrink: 0; }
|
|
|
|
|
|
.ic-lg { width: 26px; height: 26px; }
|
|
|
|
|
|
.wx { width: 1.15em; height: 1.15em; vertical-align: -0.2em; }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.share button:hover { border-color: var(--accent); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Call-to-action into the 2-year calendar — a compact button-sized link, so the
|
|
|
|
|
|
graded results stay the page's focal point. */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cta-cal {
|
2026-07-11 18:47:52 +00:00
|
|
|
|
display: inline-flex; align-items: center; gap: 9px;
|
|
|
|
|
|
margin: 0 0 20px; padding: 10px 16px; min-height: 44px;
|
|
|
|
|
|
border: none; border-radius: 10px; text-decoration: none;
|
|
|
|
|
|
font-size: 14px; font-weight: 700;
|
2026-07-11 08:06:30 +00:00
|
|
|
|
color: #1a1206; background: linear-gradient(135deg, #f79556, #ea722c);
|
2026-07-11 18:47:52 +00:00
|
|
|
|
box-shadow: 0 2px 8px rgba(240, 128, 60, .25);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
transition: transform .12s ease, box-shadow .12s ease, filter .12s ease;
|
|
|
|
|
|
}
|
2026-07-11 18:47:52 +00:00
|
|
|
|
.cta-cal .ic-lg { width: 18px; height: 18px; }
|
|
|
|
|
|
.cta-cal:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(240, 128, 60, .35); filter: brightness(1.04); }
|
|
|
|
|
|
.cta-cal:active { transform: translateY(0); box-shadow: 0 2px 6px rgba(240, 128, 60, .3); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cta-cal:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; }
|
2026-07-11 18:47:52 +00:00
|
|
|
|
.cta-cal-icon { line-height: 1; flex-shrink: 0; display: inline-flex; }
|
|
|
|
|
|
.cta-cal-arrow { font-size: 16px; font-weight: 800; flex-shrink: 0; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
#chart-wrap { position: relative; margin-bottom: 22px; }
|
|
|
|
|
|
#chart-wrap svg { width: 100%; height: auto; display: block; border-radius: 10px; touch-action: pan-y; }
|
|
|
|
|
|
/* High / Low / Weather metric toggle above the trend chart; active tab takes the
|
|
|
|
|
|
metric's own color so the control matches the chart it drives. */
|
|
|
|
|
|
.chart-toggle {
|
|
|
|
|
|
display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px;
|
|
|
|
|
|
overflow: hidden; margin-bottom: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart-toggle button {
|
|
|
|
|
|
padding: 8px 18px; background: var(--surface); color: var(--text); border: none;
|
|
|
|
|
|
cursor: pointer; font-size: 13.5px; font-weight: 600; min-height: 40px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart-toggle button + button { border-left: 1px solid var(--border); }
|
2026-07-11 19:04:30 +00:00
|
|
|
|
/* Beyond phones the switch shrinks to its tabs and sits centered over the chart
|
|
|
|
|
|
(phones keep the full-width 4-per-row grid from the 640px rules). */
|
|
|
|
|
|
@media (min-width: 641px) {
|
|
|
|
|
|
.chart-toggle { width: fit-content; margin-left: auto; margin-right: auto; }
|
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.chart-toggle button.active[data-metric="tmax"] { background: var(--very-hot); color: #1a1206; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="tmin"] { background: var(--cold); color: #10222f; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="feels"] { background: var(--accent); color: #1a1206; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="humid"] { background: #2ea9bf; color: #04222a; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="wind"] { background: #5aa9a3; color: #08201e; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="gust"] { background: #8f86d8; color: #100c26; }
|
|
|
|
|
|
.chart-toggle button.active[data-metric="precip"] { background: var(--wet-6); color: #fff; }
|
2026-07-11 08:06:30 +00:00
|
|
|
|
.chart-toggle button.active[data-metric="dry"] { background: #c9a24a; color: #241a04; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.chart-legend {
|
|
|
|
|
|
display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
|
|
|
|
.chart-legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
|
|
|
|
|
|
.chart-legend i.swatch-band { border: 1px solid; border-radius: 3px; }
|
|
|
|
|
|
.chart-legend .legend-note { font-style: italic; opacity: .85; }
|
|
|
|
|
|
.chart-tip {
|
|
|
|
|
|
position: absolute; z-index: 10; pointer-events: none;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 9px;
|
|
|
|
|
|
padding: 8px 10px; font-size: 12px; line-height: 1.5; min-width: 148px;
|
|
|
|
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
|
|
|
|
|
}
|
|
|
|
|
|
.chart-tip .tt-date { font-weight: 700; margin-bottom: 3px; }
|
|
|
|
|
|
.chart-tip .tt-grade { color: var(--muted); }
|
|
|
|
|
|
.chart-tip .tt-norm { color: var(--muted); margin-top: 3px; border-top: 1px solid var(--border); padding-top: 3px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Flex (not fixed 3-col) so 7 cards fill each row evenly — no lonely trailing card. */
|
|
|
|
|
|
.normals {
|
|
|
|
|
|
display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 22px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.normal-card {
|
|
|
|
|
|
flex: 1 1 130px;
|
|
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 11px; padding: 12px 14px;
|
|
|
|
|
|
text-decoration: none; color: var(--text); cursor: pointer;
|
|
|
|
|
|
transition: border-color .12s ease, background .12s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.normal-card:hover { border-color: var(--accent); background: var(--surface); }
|
2026-07-11 07:20:13 +00:00
|
|
|
|
/* Title on the left, grade status pinned to the top-right corner of the card. */
|
|
|
|
|
|
.normal-card .nc-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
|
|
|
|
|
.normal-card h3 { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
/* Big value is the day's own reading, tinted by its grade (lifted toward the text
|
2026-07-11 07:20:13 +00:00
|
|
|
|
color so dark tiers stay legible); the line under it shows the normal median. */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.normal-card .big { font-size: 22px; font-weight: 800; color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 15%); }
|
2026-07-11 07:20:13 +00:00
|
|
|
|
.normal-card .range { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
|
|
|
|
.normal-card .nc-grade { font-size: 11px; font-weight: 700; text-align: right; line-height: 1.2; color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 28%); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
.section-title {
|
|
|
|
|
|
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
|
|
|
|
|
|
color: var(--muted); margin: 4px 0 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* grade-scale key (low -> high, relative categories) — swatch + name only;
|
|
|
|
|
|
the percentile figures live on the full guide, not here. */
|
|
|
|
|
|
.tier-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -2px 0 8px; }
|
|
|
|
|
|
.tk-seg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
|
|
|
|
|
|
.tk-swatch { width: 14px; height: 10px; border-radius: 3px; flex-shrink: 0; }
|
|
|
|
|
|
.tk-label { font-weight: 600; }
|
|
|
|
|
|
.tk-note { margin: 0 0 16px; font-size: 12.5px; }
|
|
|
|
|
|
|
2026-07-11 08:10:40 +00:00
|
|
|
|
/* --- header °F/°C unit toggle (sits at the top-right, left of the tabs) --- */
|
|
|
|
|
|
.unit-toggle {
|
2026-07-11 08:23:43 +00:00
|
|
|
|
margin-left: auto; align-self: center; flex-shrink: 0; display: inline-flex; gap: 2px;
|
2026-07-11 08:10:40 +00:00
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 11px; padding: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.unit-toggle button {
|
|
|
|
|
|
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
|
|
|
|
|
font-size: 14px; font-weight: 700; padding: 8px 12px; border-radius: 8px;
|
|
|
|
|
|
min-height: 38px; min-width: 44px; line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.unit-toggle button.active { background: var(--accent); color: #fff; }
|
|
|
|
|
|
.unit-toggle button:hover:not(.active) { color: var(--text); }
|
|
|
|
|
|
/* The view switcher already claims the right edge; with the toggle to its left,
|
|
|
|
|
|
drop its auto-margin so the two sit together in the corner. */
|
|
|
|
|
|
.unit-toggle + .view-nav { margin-left: 8px; }
|
|
|
|
|
|
|
Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
|
|
|
|
/* --- account entry (header) + auth modal --- */
|
|
|
|
|
|
.acct { align-self: center; flex-shrink: 0; position: relative; }
|
|
|
|
|
|
.acct-btn {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
|
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border); color: var(--text);
|
|
|
|
|
|
border-radius: 11px; padding: 8px 12px; min-height: 40px; font-size: 14px; font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-btn:hover { border-color: var(--accent); }
|
|
|
|
|
|
.acct-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
|
|
|
|
|
|
.acct-name { max-width: 16ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.acct-menu { position: relative; }
|
|
|
|
|
|
.acct-pop {
|
|
|
|
|
|
position: absolute; right: 0; top: calc(100% + 6px); z-index: 1100; min-width: 180px;
|
|
|
|
|
|
display: flex; flex-direction: column; padding: 6px;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
|
|
|
|
box-shadow: 0 16px 40px rgba(0, 0, 0, .45);
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-pop[hidden] { display: none; }
|
|
|
|
|
|
.acct-pop-link {
|
|
|
|
|
|
display: block; text-align: left; padding: 10px 12px; border-radius: 8px; min-height: 40px;
|
|
|
|
|
|
background: transparent; border: 0; color: var(--text); cursor: pointer;
|
|
|
|
|
|
font: inherit; font-size: 14px; text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-pop-link:hover { background: var(--surface-2); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Notification bell + dropdown. */
|
|
|
|
|
|
.acct { display: inline-flex; align-items: center; gap: 8px; }
|
|
|
|
|
|
.notif { position: relative; }
|
|
|
|
|
|
.notif-btn { position: relative; padding: 8px; min-width: 40px; justify-content: center; }
|
|
|
|
|
|
.notif-badge {
|
|
|
|
|
|
position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px;
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
|
background: var(--accent); color: #1a1206; font-size: 11px; font-weight: 800;
|
|
|
|
|
|
border-radius: 999px; line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-badge[hidden] { display: none; }
|
|
|
|
|
|
.notif-pop {
|
|
|
|
|
|
position: absolute; right: 0; top: calc(100% + 6px); z-index: 1100;
|
|
|
|
|
|
width: min(360px, calc(100vw - 24px));
|
|
|
|
|
|
display: flex; flex-direction: column;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
|
|
|
|
box-shadow: 0 16px 40px rgba(0, 0, 0, .45); overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-pop[hidden] { display: none; }
|
|
|
|
|
|
.notif-head {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
|
padding: 12px 14px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-readall {
|
|
|
|
|
|
background: none; border: 0; color: var(--accent); cursor: pointer;
|
|
|
|
|
|
font: inherit; font-size: 12px; font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-list { list-style: none; margin: 0; padding: 4px; max-height: 60vh; overflow-y: auto; }
|
|
|
|
|
|
.notif-empty { padding: 20px 14px; text-align: center; font-size: 13px; }
|
|
|
|
|
|
.notif-item { padding: 10px 12px; border-radius: 9px; cursor: default; }
|
|
|
|
|
|
.notif-item.is-unread { cursor: pointer; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
|
|
|
|
.notif-item.is-unread:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
|
|
|
|
|
.notif-item + .notif-item { margin-top: 2px; }
|
|
|
|
|
|
.notif-item-title { font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
|
|
|
|
|
.notif-item.is-unread .notif-item-title::before {
|
|
|
|
|
|
content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-item-body { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
|
|
|
|
|
.notif-item-time { font-size: 11px; margin-top: 3px; }
|
|
|
|
|
|
.notif-manage {
|
|
|
|
|
|
display: block; padding: 10px 14px; border-top: 1px solid var(--border);
|
|
|
|
|
|
font-size: 13px; font-weight: 600; color: var(--accent); text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notif-manage:hover { background: var(--surface-2); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Auth modal reuses .mp-overlay/.mp-modal chrome; this styles its form body. */
|
|
|
|
|
|
.acct-modal { max-width: 400px; }
|
|
|
|
|
|
.acct-form { display: flex; flex-direction: column; gap: 14px; padding: 16px; }
|
|
|
|
|
|
.acct-form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--muted); }
|
|
|
|
|
|
.acct-form input {
|
|
|
|
|
|
padding: 11px 14px; border-radius: 10px; border: 1px solid var(--border);
|
|
|
|
|
|
background: var(--bg); color: var(--text); font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-form input:focus { outline: none; border-color: var(--accent); }
|
|
|
|
|
|
.acct-submit {
|
|
|
|
|
|
margin-top: 2px; padding: 12px 16px; min-height: 44px; border: none; border-radius: 10px;
|
|
|
|
|
|
background: var(--accent); color: #1a1206; font-weight: 700; font-size: 15px; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-submit:hover { filter: brightness(1.05); }
|
|
|
|
|
|
.acct-submit:disabled { opacity: .6; cursor: progress; }
|
|
|
|
|
|
.acct-error {
|
|
|
|
|
|
margin: 0; padding: 9px 12px; border-radius: 8px; font-size: 13px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--rec-hot, #d64545) 20%, transparent);
|
|
|
|
|
|
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-error[hidden] { display: none; }
|
|
|
|
|
|
.acct-switch { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
|
|
|
|
|
|
.acct-switch button {
|
|
|
|
|
|
background: none; border: 0; color: var(--accent); cursor: pointer;
|
|
|
|
|
|
font: inherit; font-weight: 600; padding: 0; text-decoration: underline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 03:57:20 +00:00
|
|
|
|
/* One-off status toast (e.g. "email verified") — neutral for both success and
|
|
|
|
|
|
failure; the text says which, not the color. */
|
|
|
|
|
|
.acct-toast {
|
|
|
|
|
|
position: fixed; left: 50%; bottom: 24px; z-index: 1200;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
max-width: calc(100vw - 32px); padding: 12px 18px; border-radius: 10px;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); color: var(--text);
|
|
|
|
|
|
font-size: 14px; box-shadow: 0 12px 30px rgba(0, 0, 0, .4);
|
|
|
|
|
|
}
|
|
|
|
|
|
.acct-toast[hidden] { display: none; }
|
|
|
|
|
|
|
Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
|
|
|
|
/* --- alerts page (subscriptions) --- */
|
|
|
|
|
|
.alerts-loading { color: var(--muted); }
|
|
|
|
|
|
.alerts-gate {
|
|
|
|
|
|
max-width: 480px; margin: 24px auto; padding: 24px; text-align: center;
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.alerts-gate h2 { margin: 0; }
|
2026-07-16 23:41:24 +00:00
|
|
|
|
.alerts-gate { max-width: 520px; }
|
|
|
|
|
|
.alerts-lede { margin: 0; }
|
|
|
|
|
|
/* The "what are alerts" steps read as a left-aligned list inside the centered card. */
|
|
|
|
|
|
.alerts-explain {
|
|
|
|
|
|
text-align: left; align-self: stretch; margin: 2px 0; padding-left: 24px;
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 10px; line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
.alerts-explain li { padding-left: 4px; }
|
|
|
|
|
|
.alerts-eg { margin: 0; font-style: italic; }
|
|
|
|
|
|
.alerts-gate-note { margin: 0; font-size: 13px; }
|
Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
|
|
|
|
.alerts-gate .acct-submit { max-width: 280px; }
|
|
|
|
|
|
.alerts-toolbar {
|
|
|
|
|
|
display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.alerts-toolbar .find-btn { padding: 11px 18px; min-height: 44px; }
|
|
|
|
|
|
.alerts-note { margin: 0; font-size: 13px; }
|
|
|
|
|
|
|
2026-07-15 23:21:06 +00:00
|
|
|
|
/* Per-device push toggle, above the account-wide alert list. */
|
|
|
|
|
|
.push-bar {
|
|
|
|
|
|
border: 1px solid var(--border); background: var(--surface); border-radius: 14px;
|
|
|
|
|
|
padding: 12px 16px; margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.push-row {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.push-status { font-size: 14px; font-weight: 600; }
|
|
|
|
|
|
.push-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
|
|
|
|
.push-toggle { padding: 9px 16px; min-height: 44px; }
|
|
|
|
|
|
.push-test-btn {
|
|
|
|
|
|
min-height: 44px; padding: 9px 14px; border-radius: 10px; cursor: pointer;
|
|
|
|
|
|
border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.push-test-btn:hover { border-color: var(--accent); }
|
|
|
|
|
|
.push-note { margin: 0; }
|
|
|
|
|
|
|
Account system with weather-notification subscriptions (#89)
* Add account system foundation: email/password auth with cookie sessions
Introduce the app's first authoritative, user-owned data in a separate
data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache
DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and
a session-check endpoint, backed by a database session strategy so logins survive
restarts and are revocable.
- db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL +
foreign keys, create_db_and_tables().
- models.py: User, AccessToken, Subscription, Notification tables.
- users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax,
Secure via env), DatabaseStrategy sessions, current-user dependencies.
- schemas.py: user + subscription + notification Pydantic models.
- app.py: mount auth/register/users routers on v2, create tables at startup.
- Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*.
* Add account header entry and auth modal (frontend)
account.js self-injects a header entry (following the units.js pattern) that
shows a Sign in button when logged out and an account menu when logged in, plus
an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password
sign-in and account creation. A shared apiFetch helper sends the same-origin
cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases.
Imported by every page entry module. On narrow screens the entry collapses to an
icon-only button so it doesn't crowd the title.
Enforce an 8-character minimum password in the user manager.
* Add subscription CRUD API and the alerts management page
Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to
create/list/update/delete subscriptions (and the notification reads used next):
POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate
location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch).
Mounted on the v2 prefix.
Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in
gate when logged out, an add flow that reuses the shared map picker and an editor
modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with
inline threshold/active edits and remove. Reachable from the account menu.
* Add background subscription evaluation engine
notify.py runs a daemon thread that periodically evaluates every active
subscription: it groups them by grid cell, reads history from the parquet cache
only (never spends archive quota) plus the hourly recent/forecast bundle, and
grades candidate days with the existing grading.grade_day. A watched metric that
lands at or beyond the threshold percentile fires a 'high' alert; a two-sided
subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays
one-directional). Observed subscriptions look at the last few recorded days,
forecast subscriptions at the coming week.
Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction,
kind) constraint dedups repeat events, and a per-subscription weekly cap
(last_notified_at) limits each alert to one notification per 7 days. The loop
tolerates a bad cell or an upstream rate limit without aborting the pass. Started
and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER.
* Add in-app notification center (header bell)
Extend account.js with a notification bell beside the account menu: an unread
badge, a dropdown listing recent notifications (title, body, relative time), a
per-item mark-read on click, and a Mark all read action, all through the
cookie-authed notifications API. Unread state refreshes on open and polls every
two minutes while signed in; polling stops on sign-out. Styled to match the app,
responsive down to mobile.
* Harden accounts: expired-session cleanup, engine tests, ops docs
- notify.py sweeps expired login sessions (access tokens past their lifetime)
once per evaluation pass.
- Add hermetic unit tests for the evaluation engine's trigger detection (high/low
tails, precip one-directional, normal = no trigger) and notification wording.
- Document accounts.sqlite (authoritative, back it up), the single-worker
requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
|
|
|
|
.sub-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; }
|
|
|
|
|
|
@media (min-width: 720px) { .sub-list { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } }
|
|
|
|
|
|
.sub-empty { padding: 20px; border: 1px dashed var(--border); border-radius: 12px; text-align: center; }
|
|
|
|
|
|
.sub-card {
|
|
|
|
|
|
border: 1px solid var(--border); background: var(--surface); border-radius: 14px;
|
|
|
|
|
|
padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-card.is-paused { opacity: .62; }
|
|
|
|
|
|
.sub-card.is-busy { pointer-events: none; opacity: .6; }
|
|
|
|
|
|
.sub-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
|
|
|
|
|
.sub-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
|
|
|
|
|
|
.sub-name { font-weight: 700; font-size: 15px; }
|
|
|
|
|
|
.sub-kind {
|
|
|
|
|
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
|
|
|
|
|
padding: 3px 8px; border-radius: 999px; white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-kind-observed { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); }
|
|
|
|
|
|
.sub-kind-forecast { background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); }
|
|
|
|
|
|
.sub-remove {
|
|
|
|
|
|
flex-shrink: 0; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
|
|
|
|
|
cursor: pointer; border-radius: 9px; min-width: 40px; min-height: 40px;
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-remove svg { width: 17px; height: 17px; }
|
|
|
|
|
|
.sub-remove:hover { border-color: var(--rec-hot, #d64545); color: var(--rec-hot, #d64545); }
|
|
|
|
|
|
.sub-metrics { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
|
|
|
|
.sub-metric {
|
|
|
|
|
|
font-size: 12px; padding: 3px 9px; border-radius: 999px;
|
|
|
|
|
|
background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; justify-content: space-between; }
|
|
|
|
|
|
.sub-thr { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); }
|
|
|
|
|
|
.sub-active-lbl { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; cursor: pointer; }
|
|
|
|
|
|
.sub-active-lbl input { width: 18px; height: 18px; accent-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Segmented control shared by the threshold + kind pickers. */
|
|
|
|
|
|
.seg { display: inline-flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 3px; }
|
|
|
|
|
|
.seg button {
|
|
|
|
|
|
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
|
|
|
|
|
font: inherit; font-weight: 700; font-size: 13px; padding: 7px 11px; border-radius: 7px;
|
|
|
|
|
|
min-height: 38px; min-width: 40px; line-height: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.seg button.active { background: var(--accent); color: #1a1206; }
|
|
|
|
|
|
.seg button:hover:not(.active) { color: var(--text); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Alert editor modal (reuses .mp-overlay / .mp-modal chrome). */
|
|
|
|
|
|
.sub-editor { max-width: 460px; }
|
|
|
|
|
|
.sub-form { display: flex; flex-direction: column; gap: 16px; padding: 16px; overflow-y: auto; }
|
|
|
|
|
|
.sub-form-loc { margin: 0; font-weight: 700; font-size: 15px; }
|
|
|
|
|
|
.sub-field { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
|
|
.sub-field legend { padding: 0 6px; font-size: 13px; color: var(--muted); }
|
|
|
|
|
|
.sub-kind-help { margin: 0; font-size: 12px; }
|
|
|
|
|
|
.metric-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
|
|
|
|
|
|
.metric-check { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; min-height: 32px; }
|
|
|
|
|
|
.metric-check input { width: 18px; height: 18px; accent-color: var(--accent); flex-shrink: 0; }
|
|
|
|
|
|
.thr-range { width: 100%; accent-color: var(--accent); height: 28px; }
|
|
|
|
|
|
.two-sided-lbl { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
|
|
|
|
.two-sided-lbl input { width: 18px; height: 18px; accent-color: var(--accent); flex-shrink: 0; }
|
|
|
|
|
|
.sub-form-error {
|
|
|
|
|
|
margin: 0; padding: 9px 12px; border-radius: 8px; font-size: 13px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--rec-hot, #d64545) 20%, transparent);
|
|
|
|
|
|
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
|
|
|
|
|
}
|
|
|
|
|
|
.sub-form-error[hidden] { display: none; }
|
|
|
|
|
|
@media (max-width: 640px) { .metric-checks { grid-template-columns: 1fr; } }
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
/* --- shared header view switcher (Map · Calendar · Day) --- */
|
|
|
|
|
|
/* The bar is split into equal, fully-clickable segments (one per page) with a
|
|
|
|
|
|
clear divider between them, so the whole width is a set of obvious tabs. */
|
|
|
|
|
|
.view-nav {
|
|
|
|
|
|
margin-left: auto; align-self: center; display: inline-flex; gap: 0;
|
|
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 11px; padding: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.view-nav a {
|
|
|
|
|
|
flex: 1; color: var(--muted); text-decoration: none; font-size: 14px; font-weight: 600;
|
|
|
|
|
|
padding: 8px 18px; border-radius: 8px; white-space: nowrap; position: relative;
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center; min-height: 38px;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Divider line between adjacent tabs. Hidden around the highlighted (active) tab
|
|
|
|
|
|
so its pill reads clean against the bar. */
|
|
|
|
|
|
.view-nav a + a::before {
|
|
|
|
|
|
content: ""; position: absolute; left: 0; top: 16%; bottom: 16%;
|
|
|
|
|
|
width: 2px; border-radius: 2px; background: var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.view-nav a.active::before,
|
|
|
|
|
|
.view-nav a.active + a::before { background: transparent; }
|
|
|
|
|
|
.view-nav a:hover { color: var(--text); }
|
|
|
|
|
|
.view-nav a.active { background: var(--accent); color: #fff; }
|
|
|
|
|
|
|
2026-07-16 12:15:00 +00:00
|
|
|
|
/* Hamburger wrapper. On desktop the menu and its panel are transparent
|
|
|
|
|
|
(display:contents), so the nav pills plus the injected °F/°C toggle, bell and
|
|
|
|
|
|
account render inline in the header row and the hamburger summary is hidden;
|
|
|
|
|
|
on phones (the 640px block) the panel becomes a dropdown behind the hamburger. */
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.nav-menu { display: contents; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
.nav-panel { display: contents; }
|
|
|
|
|
|
/* A closed <details> hides its non-summary content; force the panel visible on
|
2026-07-16 05:42:59 +00:00
|
|
|
|
desktop (the 640px block re-hides it behind the hamburger on phones). */
|
2026-07-16 12:15:00 +00:00
|
|
|
|
.nav-menu > .view-nav,
|
|
|
|
|
|
.nav-panel > .view-nav { display: inline-flex; }
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.nav-menu::details-content { display: contents; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
/* The controls are injected into the panel after the nav in DOM order; restore
|
|
|
|
|
|
the header's visual order — °F/°C · account · tabs — on desktop. On phones the
|
|
|
|
|
|
640px block resets these so the dropdown stacks nav → °F/°C → account. */
|
|
|
|
|
|
.nav-panel > .unit-toggle { order: 1; }
|
|
|
|
|
|
.nav-panel > .acct { order: 2; }
|
|
|
|
|
|
.nav-panel > .view-nav { order: 3; }
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.nav-toggle { display: none; list-style: none; cursor: pointer; }
|
|
|
|
|
|
.nav-toggle::-webkit-details-marker { display: none; }
|
|
|
|
|
|
.nav-toggle svg { display: block; }
|
|
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
/* --- calendar subpage --- */
|
|
|
|
|
|
.metric-toggle {
|
|
|
|
|
|
display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.metric-toggle button {
|
|
|
|
|
|
padding: 10px 16px; background: var(--surface); color: var(--text);
|
|
|
|
|
|
border: none; cursor: pointer; font-size: 14px; min-height: 44px; white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.metric-toggle button + button { border-left: 1px solid var(--border); }
|
|
|
|
|
|
.metric-toggle button.active { background: var(--accent); color: #1a1206; font-weight: 600; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Metric selector block — sits below the time filters, labeling the grid it colors. */
|
|
|
|
|
|
.metric-block { margin: 0 0 18px; }
|
|
|
|
|
|
.metric-block .cal-filter-label { display: block; margin: 0 0 8px; }
|
2026-07-11 19:04:30 +00:00
|
|
|
|
/* Beyond phones the selector shrinks to its tabs and sits centered over the
|
|
|
|
|
|
month grid (scoped under .metric-block — the Compare page's basis toggle
|
|
|
|
|
|
shares .metric-toggle and must keep filling its param cell). */
|
|
|
|
|
|
@media (min-width: 641px) {
|
|
|
|
|
|
.metric-block { display: flex; flex-direction: column; align-items: center; }
|
|
|
|
|
|
.metric-block .cal-filter-label { text-align: center; }
|
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
/* Month/year range picker (calendar). 16px inputs avoid iOS zoom-on-focus. */
|
|
|
|
|
|
.cal-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; }
|
|
|
|
|
|
/* Range label + "up to 3 years" hint share one row spanning both input columns. */
|
|
|
|
|
|
.cal-range-head {
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-range label {
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted);
|
|
|
|
|
|
}
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.cal-range input { width: 100%; padding: 8px 10px; border-radius: 8px; min-height: 40px; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cal-range .hint { margin: 0; flex: 0 0 auto; max-width: none; font-size: 12px; color: var(--muted); }
|
|
|
|
|
|
/* Appears under the pickers once the user edits a date; no request is made until
|
|
|
|
|
|
it's tapped. Spans both columns so it reads as a clear confirm step. */
|
|
|
|
|
|
.range-refresh {
|
|
|
|
|
|
grid-column: 1 / -1; margin-top: 2px;
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
padding: 10px 16px; min-height: 44px; border-radius: 9px;
|
|
|
|
|
|
font-size: 15px; font-weight: 700; border: 1px solid var(--accent);
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
.range-refresh:hover { filter: brightness(1.05); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Time-filter panel: month/year range + season + year checklists, stacked in a
|
2026-07-11 18:47:52 +00:00
|
|
|
|
tidy column on phones. */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cal-filters {
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 16px;
|
|
|
|
|
|
max-width: 560px; margin: 0 0 18px; padding: 14px 16px;
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Season + year dropdowns fill the row evenly — no dead space to their right. */
|
|
|
|
|
|
.cal-seasonyear { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Desktop: the panel spans the content width as one row — the date range on the
|
|
|
|
|
|
left, the season/year/weather dropdowns filling the rest — instead of a narrow
|
|
|
|
|
|
stacked box with dead space beside it. */
|
|
|
|
|
|
@media (min-width: 960px) {
|
|
|
|
|
|
.cal-filters { max-width: none; flex-direction: row; align-items: flex-start; gap: 14px 28px; }
|
|
|
|
|
|
.cal-range { flex: 0 1 400px; }
|
|
|
|
|
|
.cal-seasonyear { flex: 1 1 500px; grid-template-columns: 1fr 1fr 1.6fr; align-content: start; }
|
|
|
|
|
|
/* A head row mirroring the Range label, so both halves read as label + controls. */
|
|
|
|
|
|
.cal-seasonyear::before {
|
|
|
|
|
|
content: "Filter by"; grid-column: 1 / -1;
|
|
|
|
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
|
|
|
|
|
color: var(--muted); font-weight: 700; line-height: 1.45;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Weather joins the same row as Seasons + Years (it only spans the full width
|
|
|
|
|
|
in the stacked phone layout — the extra .cal-seasonyear qualifier outranks
|
|
|
|
|
|
that base rule); its panel keeps a usable two-column width, anchored right. */
|
|
|
|
|
|
.cal-seasonyear .cal-weather { grid-column: auto; }
|
|
|
|
|
|
.cal-seasonyear .cal-weather .cal-dd-panel { left: auto; right: 0; min-width: 420px; }
|
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cal-filter-label {
|
|
|
|
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
|
|
|
|
|
color: var(--muted); font-weight: 700; margin-right: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cal-head { margin-bottom: 16px; }
|
|
|
|
|
|
.cal-head h2 { margin: 0 0 2px; font-size: 18px; }
|
|
|
|
|
|
.cal-head .meta { color: var(--muted); font-size: 12.5px; margin: 0; }
|
|
|
|
|
|
/* "loading N more blocks…" note while a long span streams in chunk by chunk. */
|
|
|
|
|
|
.cal-loading { color: var(--accent); font-weight: 600; }
|
|
|
|
|
|
/* Nudge to interact with the grid — wording covers both tap (mobile) and click. */
|
|
|
|
|
|
.cal-hint {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
|
|
|
|
margin: 10px 0 0; padding: 6px 11px; font-size: 13px; font-weight: 600;
|
|
|
|
|
|
color: var(--accent); background: color-mix(in oklab, var(--accent), transparent 88%);
|
|
|
|
|
|
border: 1px solid color-mix(in oklab, var(--accent), transparent 70%);
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Months flow into as many columns as fit (one on phones, several on monitors),
|
|
|
|
|
|
so a multi-year span reads as a year-at-a-glance wall instead of one long
|
|
|
|
|
|
thin strip. Cells stay square via aspect-ratio. */
|
|
|
|
|
|
.calendar {
|
|
|
|
|
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
|
|
|
|
gap: 20px 24px; margin-bottom: 22px; align-items: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-month { width: 100%; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.cal-month h4 { margin: 0 0 6px; font-size: 14px; color: var(--text); font-weight: 700; }
|
|
|
|
|
|
.cal-dows, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; }
|
|
|
|
|
|
.cal-dows { margin-bottom: 3px; }
|
|
|
|
|
|
.cal-dows span { font-size: 11px; color: var(--muted); text-align: center; line-height: 1; }
|
|
|
|
|
|
.cal-cell { position: relative; aspect-ratio: 1; border-radius: 6px; background: var(--surface-2); }
|
|
|
|
|
|
.cal-cell.empty { background: transparent; }
|
|
|
|
|
|
.cal-cell.filled { cursor: pointer; }
|
|
|
|
|
|
/* Day-of-month number, corner-anchored. White with a dark halo so it reads on any
|
|
|
|
|
|
tier color; on transparent (no-record) tiles fall back to a plain muted number. */
|
|
|
|
|
|
.cal-daynum {
|
|
|
|
|
|
position: absolute; top: 2px; left: 4px; font-size: 10px; font-weight: 700;
|
|
|
|
|
|
line-height: 1; color: #fff; pointer-events: none;
|
|
|
|
|
|
text-shadow: 0 0 2px rgba(0, 0, 0, .9), 0 1px 1px rgba(0, 0, 0, .7);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-cell.empty .cal-daynum { color: var(--muted); text-shadow: none; }
|
|
|
|
|
|
.cal-cell.filled:hover { outline: 2px solid var(--text); outline-offset: 1px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Fixed positioning so the tooltip tracks the viewport (clientX/Y) even when the
|
|
|
|
|
|
page is scrolled far down the calendar. */
|
|
|
|
|
|
#cal-tip { position: fixed; max-width: calc(100vw - 20px); }
|
|
|
|
|
|
/* Mini grid of the day's stats: metric name · value(+pct) · category. */
|
|
|
|
|
|
#cal-tip .tt-grid {
|
|
|
|
|
|
display: grid; grid-template-columns: auto auto minmax(0, 1fr);
|
|
|
|
|
|
column-gap: 12px; row-gap: 3px; align-items: baseline;
|
|
|
|
|
|
}
|
|
|
|
|
|
#cal-tip .tt-name { font-weight: 700; }
|
|
|
|
|
|
#cal-tip .tt-val { white-space: nowrap; }
|
|
|
|
|
|
#cal-tip .tt-pct { color: var(--muted); }
|
|
|
|
|
|
/* Dry streak has no percentile, so its text spans the value + category columns. */
|
|
|
|
|
|
#cal-tip .tt-span2 { grid-column: 2 / -1; }
|
|
|
|
|
|
/* The stat currently being viewed is bolded and separated from the rest, so it's
|
|
|
|
|
|
easy to match a cell's color to the stat it represents. The border spans all
|
|
|
|
|
|
three columns because it's set on every cell of the active row. */
|
|
|
|
|
|
#cal-tip .tt-name.tt-r-active, #cal-tip .tt-val.tt-r-active { font-weight: 800; }
|
|
|
|
|
|
#cal-tip .tt-r-active { padding-bottom: 5px; border-bottom: 1px solid var(--border); }
|
|
|
|
|
|
/* Category (grade) text, right-aligned, is tinted by --cat — the color that
|
|
|
|
|
|
represents that day's value for the metric. The active calendar metric gets it
|
|
|
|
|
|
bold and near full saturation; the others are lifted toward --text so they stay
|
|
|
|
|
|
readable and read as secondary. color-mix keeps every tier legible on both. */
|
|
|
|
|
|
#cal-tip .tt-cat {
|
|
|
|
|
|
justify-self: end; text-align: right; font-weight: 600;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 50%);
|
|
|
|
|
|
}
|
|
|
|
|
|
#cal-tip .tt-cat-active {
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 12%);
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Plain-language "what the day was like" summary title, sits just under the date. */
|
|
|
|
|
|
#cal-tip .tt-type {
|
|
|
|
|
|
font-size: 15px; font-weight: 800; margin: 2px 0 6px;
|
|
|
|
|
|
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* --- single-day detail subpage --- */
|
|
|
|
|
|
.day-weather { margin: 2px 0 4px; font-size: 16px; font-weight: 800; }
|
|
|
|
|
|
.day-nav { display: flex; align-items: flex-end; gap: 8px; }
|
|
|
|
|
|
.day-date { font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
|
|
|
|
|
|
.day-step {
|
|
|
|
|
|
min-width: 44px; min-height: 44px; padding: 0 12px; border-radius: 10px;
|
|
|
|
|
|
border: 1px solid var(--border); background: var(--surface-2); color: var(--text);
|
|
|
|
|
|
font-size: 22px; line-height: 1; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.day-step:hover:not(:disabled) { border-color: var(--accent); }
|
|
|
|
|
|
.day-step:disabled { opacity: .4; cursor: default; }
|
|
|
|
|
|
|
2026-07-11 18:47:52 +00:00
|
|
|
|
/* Metric cards: one column on phones, side by side on monitors so the page
|
2026-07-11 21:13:05 +00:00
|
|
|
|
doesn't run as a single long strip of full-width ladders. minmax(0, 1fr)
|
|
|
|
|
|
(not bare 1fr, whose min is the content) so one wide ladder row can't inflate
|
|
|
|
|
|
the shared column track past the viewport and push the whole page off-center. */
|
|
|
|
|
|
#day-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; }
|
|
|
|
|
|
@media (min-width: 1000px) { #day-body { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
|
|
|
|
@media (min-width: 3400px) { #day-body { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
2026-07-11 18:47:52 +00:00
|
|
|
|
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.ladder-card {
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border);
|
2026-07-11 18:47:52 +00:00
|
|
|
|
border-radius: 13px; padding: 16px 16px 12px;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
.ladder-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
|
|
|
|
.ladder-head h3 { margin: 0; font-size: 15px; }
|
|
|
|
|
|
.ladder-summary { display: inline-flex; align-items: baseline; gap: 8px; }
|
|
|
|
|
|
.day-obs {
|
|
|
|
|
|
font-size: 20px; font-weight: 800;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 12%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.day-obs-meta { font-size: 12.5px; color: var(--muted); }
|
|
|
|
|
|
.ladder-note {
|
|
|
|
|
|
display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
|
|
|
|
|
|
margin: 4px 0 12px; font-size: 12px; color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.ladder-note-left { min-width: 0; }
|
|
|
|
|
|
/* Historical range: bold, pinned to the right edge of the card. */
|
|
|
|
|
|
.ladder-range { font-weight: 700; color: var(--text); white-space: nowrap; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Tier ladder: swatch · label · percentile range · value range · observed marker.
|
|
|
|
|
|
Fixed column widths (not auto) so every row shares the same tracks — the
|
|
|
|
|
|
percentile ranges and value ranges line up down the whole ladder. The negative
|
|
|
|
|
|
margin cancels the row's own padding so the swatches sit on the same left edge
|
|
|
|
|
|
as the card title and the subheader note above. */
|
|
|
|
|
|
.ladder { display: flex; flex-direction: column; gap: 3px; }
|
|
|
|
|
|
.ladder-row {
|
|
|
|
|
|
display: grid; grid-template-columns: 14px 128px 64px 1fr 60px;
|
|
|
|
|
|
align-items: center; column-gap: 10px; padding: 6px 8px; margin: 0 -8px;
|
|
|
|
|
|
border-radius: 8px; font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ladder-row.active {
|
|
|
|
|
|
background: color-mix(in oklab, var(--accent), transparent 86%);
|
|
|
|
|
|
outline: 1px solid color-mix(in oklab, var(--accent), transparent 55%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.ladder-sw { width: 14px; height: 14px; border-radius: 4px; }
|
|
|
|
|
|
.ladder-label { font-weight: 700; color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 22%); }
|
|
|
|
|
|
.ladder-pct { color: var(--muted); font-size: 12px; }
|
2026-07-11 21:13:05 +00:00
|
|
|
|
/* Value ranges may wrap (long unit strings like "8.1 g/m³–12.9 g/m³" on narrow
|
|
|
|
|
|
phones) — nowrap here would widen the row past the card and scroll the page. */
|
|
|
|
|
|
.ladder-val { justify-self: end; text-align: right; font-variant-numeric: tabular-nums; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
/* Observed marker: stays in the rightmost column but reads from its left edge,
|
|
|
|
|
|
so the arrow sits right beside the value range it points at. */
|
|
|
|
|
|
.ladder-mk { justify-self: start; min-width: 0; }
|
2026-07-11 21:13:05 +00:00
|
|
|
|
/* Markers are unit-less ("◀ 11.5") so nowrap can't push them past the card —
|
|
|
|
|
|
the widest (precip, ◀ 0.25") bleeds a few px into the row padding at most. */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.ladder-mark { color: var(--accent); font-weight: 800; white-space: nowrap; font-size: 12.5px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Compact "graded days" table: a ROW per metric, a COLUMN per day. Wide runs
|
|
|
|
|
|
scroll horizontally in their own container; the metric-label column is pinned. */
|
|
|
|
|
|
.rd-scroll { position: relative; overflow-x: auto; margin: 0 0 6px; padding-bottom: 4px; }
|
|
|
|
|
|
.rd-grid { display: grid; gap: 3px; align-items: stretch; min-width: min-content; }
|
|
|
|
|
|
/* Simple scroll-orientation cue for the timeline: oldest at left, newest at right. */
|
|
|
|
|
|
.rd-orient {
|
|
|
|
|
|
display: flex; justify-content: space-between; margin: 0 0 8px;
|
|
|
|
|
|
font-size: 11.5px; font-weight: 600; letter-spacing: .03em; color: var(--muted);
|
|
|
|
|
|
}
|
Weekly view: center the daily-graded window on the target day (#28)
* Pin the °F/°C toggle to the header's top-right on all widths
The unit toggle sat inside .brand after a flex-basis:auto title block, so on
narrow (phone) widths the long tagline claimed the first row and bumped the
toggle onto its own line mid-header. Give the title block flex:1 1 0 with
min-width:0 so it contributes ~nothing to the wrap decision and shrinks
instead — keeping the toggle on the logo's row, top-right, with the tagline
wrapping beneath and the view tabs on their own row below.
* Weekly view: center the daily-graded window on the target day
Reframe the "Daily, graded" chart + table on the weekly view around the
selected target day instead of ending at it:
- Show two weeks of history before the target, the target itself, then up
to 7 days after it. Days after the target are real observations when they
are already in the past and the forward forecast when they run into the
future.
- Mark the target day with a solid orange line on the chart (and an accent
edge on its table column); draw the dashed "forecast →" divider only where
the window actually crosses into the future and it is separated from the
target marker.
Backend: _build_grade now grades a [target-days, target+after] window built
from both the archive and the recent+forecast bundle (the bundle wins per
date; the archive fills older gaps), so any past target renders its
surrounding week. api/grade gains an `after` param (default 7) and the cache
key includes it; the /cell prefetch bundle builds the matching slice.
Frontend: the chart consumes the server-built window directly — the separate
forecast fetch and gap-merge are gone. The target is looked up by date for
the comparison cards (the newest row is now a forecast day), and the graded
table opens centered on the target column.
* Weekly view: extend the after-target window to 14 days
Bump the daily-graded window's after-target span from 7 to 14 days. A target
far enough in the past now shows 14 observed days after it; a recent target
shows its observed days plus the 1-7 available forecast days, and the forecast
naturally caps at ~7 days out. The after cap stays at 14.
2026-07-11 14:58:42 +00:00
|
|
|
|
/* Forecast (future) columns: accent-tinted headers. The .rd-today column is the
|
|
|
|
|
|
graded target day (the orange chart marker), flagged with an accent left edge;
|
|
|
|
|
|
forecast columns sit to the right of today. */
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.rd-colh.rd-fc b { color: var(--accent); }
|
|
|
|
|
|
.rd-colh.rd-fc span { color: color-mix(in oklab, var(--accent), var(--muted) 45%); }
|
|
|
|
|
|
.rd-colh.rd-today b { color: var(--accent); }
|
|
|
|
|
|
.rd-c.rd-fc { opacity: .9; }
|
|
|
|
|
|
.rd-colh.rd-today,
|
|
|
|
|
|
.rd-c.rd-today { box-shadow: inset 2px 0 0 var(--accent); }
|
|
|
|
|
|
.rd-corner {
|
|
|
|
|
|
position: sticky; left: 0; z-index: 3; background: var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rd-mlabel {
|
|
|
|
|
|
position: sticky; left: 0; z-index: 2; background: var(--surface);
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
|
display: flex; align-items: center; gap: 4px; padding-right: 6px;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
Follow the unit system for precipitation and wind too (#190)
Defaulting climate pages to the city's temperature convention left every page
half-converted: a Delhi reader got °C next to "9 mph" and "0.00 in". One flag now
drives every measure — picking °C also gives mm and km/h.
Absolute humidity stays g/m³ in both systems; there is no imperial unit for it
anyone would recognise, so converting it would only make the page worse.
units.js becomes the single source for all of it. The formatting logic existed in
three independent copies — shared.js's fmtPrecip/fmtWind/fmtHumid, a second set
inside fmtMetricVal, and a third baked into chart.js's series labels, axis ticks
and tooltip — which is why the units drifted apart in the first place. shared.js
now re-exports from units.js so its importers are unchanged, and chart.js carries
a per-series unit `kind` in place of sniffing for a "°" suffix, which could not
have extended to three unit families.
Server-rendered pages gain the carrier they were missing: precipitation and wind
now render as <span class="precip" data-precip-in> / <span class="wind"
data-wind-mph>, the same contract temperature already had, so climate.js can
repaint them on toggle and the attribute stays imperial as the source of truth.
Precision follows the unit: millimetres get one decimal where inches get two.
0.04 in and 1.0 mm are the same amount, and "1.02 mm" would advertise precision
the source doesn't have. Wind rounds half-up to match Math.round, as temperature
already does.
The weekly table's wind and rain cells are bare numbers to keep the grid narrow,
so their row labels now carry the unit and rebuild per render. Static copy that
names a unit — the legend's "(mph)", "(inches)", and a "(°F)" that had been wrong
for °C readers since the country defaults landed — is marked up with
data-unit-label and painted from the same source.
The chart keeps its imperial domain; only labels convert, so point positions and
the fan geometry are untouched.
Push and email bodies are still imperial-only (notify.py): they are composed
server-side with no client to repaint them and no per-user unit stored, which is
the same limitation temperature already has there. Left for a follow-up.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:03:41 +00:00
|
|
|
|
/* Wind/rain cells are bare numbers to keep the grid narrow, so the row label
|
|
|
|
|
|
carries the unit — quieter than the label itself, and lowercase since "km/h"
|
|
|
|
|
|
and "mm" are unit symbols, not words. */
|
|
|
|
|
|
.rd-unit {
|
|
|
|
|
|
font-weight: 500; text-transform: none; letter-spacing: 0;
|
|
|
|
|
|
font-size: 10px; opacity: .75;
|
|
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.rd-colh {
|
|
|
|
|
|
display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
|
|
|
|
|
|
gap: 0; padding-bottom: 3px; font-size: 10px; color: var(--muted);
|
|
|
|
|
|
line-height: 1.15; white-space: nowrap; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rd-colh b { color: var(--text); font-size: 11.5px; font-weight: 700; }
|
|
|
|
|
|
.rd-c {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
|
|
|
min-height: 30px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
|
|
|
|
|
font-variant-numeric: tabular-nums; cursor: pointer;
|
|
|
|
|
|
background: color-mix(in oklab, var(--c, var(--surface-2)), transparent 74%);
|
|
|
|
|
|
color: color-mix(in oklab, var(--c, var(--text)), var(--text) 28%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rd-c[data-date]:hover { filter: brightness(1.18); }
|
|
|
|
|
|
|
|
|
|
|
|
/* grade colors */
|
|
|
|
|
|
.g-rec-cold { color: var(--rec-cold); } .m-rec-cold { background: var(--rec-cold); }
|
|
|
|
|
|
.g-very-cold { color: var(--very-cold); } .m-very-cold { background: var(--very-cold); }
|
|
|
|
|
|
.g-cold { color: var(--cold); } .m-cold { background: var(--cold); }
|
|
|
|
|
|
.g-cool { color: var(--cool); } .m-cool { background: var(--cool); }
|
|
|
|
|
|
.g-normal { color: var(--normal); } .m-normal { background: var(--normal); }
|
|
|
|
|
|
.g-warm { color: var(--warm); } .m-warm { background: var(--warm); }
|
|
|
|
|
|
.g-hot { color: var(--hot); } .m-hot { background: var(--hot); }
|
|
|
|
|
|
.g-very-hot { color: var(--very-hot); } .m-very-hot { background: var(--very-hot); }
|
|
|
|
|
|
.g-rec-hot { color: var(--rec-hot); } .m-rec-hot { background: var(--rec-hot); }
|
|
|
|
|
|
.g-dry { color: var(--dry); } .m-dry { background: var(--dry); }
|
|
|
|
|
|
.g-wet-1 { color: var(--wet-1); } .m-wet-1 { background: var(--wet-1); }
|
|
|
|
|
|
.g-wet-2 { color: var(--wet-2); } .m-wet-2 { background: var(--wet-2); }
|
|
|
|
|
|
.g-wet-3 { color: var(--wet-3); } .m-wet-3 { background: var(--wet-3); }
|
|
|
|
|
|
.g-wet-4 { color: var(--wet-4); } .m-wet-4 { background: var(--wet-4); }
|
|
|
|
|
|
.g-wet-5 { color: var(--wet-5); } .m-wet-5 { background: var(--wet-5); }
|
|
|
|
|
|
.g-wet-6 { color: var(--wet-6); } .m-wet-6 { background: var(--wet-6); }
|
|
|
|
|
|
.g-wet-7 { color: var(--wet-7); } .m-wet-7 { background: var(--wet-7); }
|
|
|
|
|
|
.g-wet-8 { color: var(--wet-8); } .m-wet-8 { background: var(--wet-8); }
|
|
|
|
|
|
.g-wet-9 { color: var(--wet-9); } .m-wet-9 { background: var(--wet-9); }
|
|
|
|
|
|
|
|
|
|
|
|
/* "Near Record" danger tiers: fill the whole grade cell to make it pop. */
|
|
|
|
|
|
.error { color: #e06a6a; font-size: 14px; }
|
|
|
|
|
|
.spinner { color: var(--muted); font-size: 14px; }
|
|
|
|
|
|
/* --- phones / narrow screens --- */
|
|
|
|
|
|
|
|
|
|
|
|
/* --- Find button + current-place label (opens the modal map picker) --- */
|
|
|
|
|
|
.find-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; flex: 1 1 340px; }
|
|
|
|
|
|
.find-btn {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 8px;
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
padding: 11px 18px; font-weight: 700; font-size: 15px; min-height: 44px;
|
2026-07-11 00:29:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
.find-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
|
|
|
|
|
|
.find-btn:hover { filter: brightness(1.05); }
|
|
|
|
|
|
.loc-label { color: var(--text); font-weight: 600; font-size: 14px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Weekly panel is now full width (the map lives in the picker, not inline). */
|
|
|
|
|
|
|
|
|
|
|
|
/* Link styling for the compact legend notes + guide page. */
|
|
|
|
|
|
.tk-note a, .hint a, .guide-back a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
|
|
|
|
|
.tk-note a:hover, .hint a:hover, .guide-back a:hover { text-decoration: underline; }
|
|
|
|
|
|
|
|
|
|
|
|
/* --- shared modal location picker --- */
|
|
|
|
|
|
.mp-overlay {
|
|
|
|
|
|
position: fixed; inset: 0; z-index: 1000;
|
|
|
|
|
|
background: rgba(0, 0, 0, .55);
|
|
|
|
|
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-overlay[hidden] { display: none; }
|
|
|
|
|
|
.mp-modal {
|
|
|
|
|
|
width: 100%; max-width: 560px; max-height: 92vh;
|
|
|
|
|
|
display: flex; flex-direction: column;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 16px; overflow: hidden; box-shadow: 0 24px 60px rgba(0, 0, 0, .5);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-head {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
|
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-head h2 { margin: 0; font-size: 16px; }
|
|
|
|
|
|
.mp-close {
|
|
|
|
|
|
border: none; background: transparent; color: var(--muted); cursor: pointer;
|
|
|
|
|
|
font-size: 26px; line-height: 1; padding: 0 4px; min-width: 40px; min-height: 40px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-close:hover { color: var(--text); }
|
|
|
|
|
|
.mp-search { position: relative; display: flex; gap: 8px; padding: 12px 16px 8px; }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.mp-search input { flex: 1; min-width: 0; padding: 11px 14px; background: var(--bg); }
|
|
|
|
|
|
.mp-search button { padding: 11px 16px; min-height: 44px; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.mp-sug {
|
|
|
|
|
|
position: absolute; top: 100%; left: 16px; right: 16px; z-index: 5;
|
|
|
|
|
|
list-style: none; margin: 4px 0 0; padding: 4px; max-height: 240px; overflow-y: auto;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
|
|
|
|
box-shadow: 0 12px 30px rgba(0, 0, 0, .4);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-sug li { padding: 10px 12px; border-radius: 7px; cursor: pointer; font-size: 14px; }
|
Typo-tolerant location search suggestions (#33)
Add /api/v2/suggest and wire the location picker's search box to it as a
debounced type-ahead: top-5 place suggestions that tolerate a single-letter
typo (substituted, missing, or extra letter, or two adjacent letters swapped)
anywhere in the query, including the first character.
- backend/places.py: local place index built from a GeoNames cities dump
(downloaded once into data/geonames/, loaded in a background thread; the
app boots and serves without it). Exact-prefix matches rank first by
population, then names one edit away; a token vocabulary respells one
mistyped word against known place-name tokens ("pest seattle" ->
"west seattle") for retry against the upstream geocoder, which covers
neighbourhood-level places the dump lacks. THERMOGRAPH_CITIES picks the
dump (default cities1000).
- /suggest blends local and upstream results by population with an exactness
boost, so "Seatle" (a Cumbrian hamlet) can't outrank Seattle when the
query is one edit from the city, while "munchen" still surfaces Munich via
upstream (the index only knows English names). Upstream lookups are
memoized and skipped entirely when the index answers convincingly.
- mappicker.js: debounced (250ms) live suggestions with abort + sequence
guards against stale responses, arrow-key navigation, Enter-picks-highlight,
Escape dismissing the list before closing the overlay. Submit goes through
the same typo-tolerant endpoint.
- climate.geocode results now carry population (used for ranking).
2026-07-11 15:36:14 +00:00
|
|
|
|
.mp-sug li:hover, .mp-sug li.active { background: var(--surface-2); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.mp-sug .sub { color: var(--muted); font-size: 12px; }
|
|
|
|
|
|
.mp-map {
|
|
|
|
|
|
flex: 1 1 auto; min-height: 300px; margin: 4px 16px; z-index: 0;
|
|
|
|
|
|
border-radius: 12px; overflow: hidden; border: 1px solid var(--border);
|
2026-07-11 08:10:15 +00:00
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25), 0 6px 18px rgba(0, 0, 0, .25);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-map .leaflet-container { background: var(--surface-2); }
|
Picker map: smaller labels, darker roads; location label coords→place (#27)
* Picker map: smaller city labels, darker/bolder roads (split tile layers)
Split the CARTO Voyager basemap into two raster layers so label size and road
weight are independent: a labels-free base upscaled via tileSize 512 + zoomOffset
-1 (bold, prominent roads) plus a labels-only layer at natural size on top (small,
crisp city names). The darken/saturate filter now targets the base layer only
(.mp-base: brightness .92, contrast 1.12), deepening the roads while leaving label
text at full contrast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
* Location label: show coordinates immediately, then resolve to place name
On selecting a location, write the raw coordinates to the location label right
away, so the user sees the picked spot instantly instead of a stale/blank label.
The existing post-fetch render already overwrites it with the reverse-geocoded
"neighborhood, city, region" string (data.place), so the label now reads
coords → place name live.
- app.js / calendar.js / day.js: set coords in the selection handler before the
fetch; the render/initOnce overwrite is unchanged.
- compare.js: show coordinates through the pending + loading states (instead of
"Locating…") so each chip's coords are replaced live by the place name once
scored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Q2EkHHnTUX2BfhV5q1Zc
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:35:43 +00:00
|
|
|
|
/* Base map only (roads/land/water): deepen the roads and push saturation. The
|
|
|
|
|
|
labels layer (.mp-labels) is left unfiltered so city names keep full contrast. */
|
|
|
|
|
|
.mp-map .mp-base { filter: saturate(1.3) contrast(1.12) brightness(0.92); }
|
2026-07-11 08:10:15 +00:00
|
|
|
|
/* Branded accent teardrop pin (an L.divIcon wrapping the Find-button glyph),
|
|
|
|
|
|
replacing Leaflet's default blue raster marker. */
|
|
|
|
|
|
.mp-pin { background: none; border: none; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, .45)); }
|
|
|
|
|
|
.mp-pin svg { width: 32px; height: 32px; display: block; }
|
|
|
|
|
|
.mp-pin path { fill: var(--accent); stroke: rgba(0, 0, 0, .35); stroke-width: 1.25; }
|
|
|
|
|
|
.mp-pin circle { fill: #fff; stroke: none; }
|
|
|
|
|
|
/* Theme the Leaflet zoom buttons + attribution to sit in the app's surfaces
|
|
|
|
|
|
instead of Leaflet's stock white chrome. */
|
|
|
|
|
|
.mp-map .leaflet-bar { border: none; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); }
|
|
|
|
|
|
.mp-map .leaflet-bar a {
|
|
|
|
|
|
background: var(--surface); color: var(--text); border-bottom-color: var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-map .leaflet-bar a:hover { background: var(--surface-2); color: var(--accent); }
|
|
|
|
|
|
.mp-map .leaflet-control-attribution {
|
|
|
|
|
|
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
|
|
|
|
|
color: var(--muted); backdrop-filter: blur(2px); border-top-left-radius: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-map .leaflet-control-attribution a { color: var(--accent); }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.mp-foot {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
|
|
|
|
padding: 12px 16px 16px; flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mp-hint { color: var(--muted); font-size: 12.5px; flex: 1 1 auto; min-width: 0; }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.mp-confirm { padding: 11px 18px; min-height: 44px; font-weight: 700; }
|
2026-07-11 00:29:47 +00:00
|
|
|
|
.mp-confirm:disabled { opacity: .45; cursor: default; }
|
|
|
|
|
|
|
|
|
|
|
|
/* --- calendar Season/Year filters as compact dropdowns (native <details>) --- */
|
|
|
|
|
|
.cal-dd { position: relative; min-width: 0; }
|
|
|
|
|
|
.cal-dd summary {
|
|
|
|
|
|
display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;
|
|
|
|
|
|
list-style: none; padding: 8px 12px; min-height: 40px;
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 9px; background: var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-dd summary::-webkit-details-marker { display: none; }
|
|
|
|
|
|
.cal-dd summary .cal-filter-label { flex: 0 0 auto; }
|
|
|
|
|
|
.cal-dd[open] summary { border-color: var(--accent); }
|
|
|
|
|
|
/* Summary value truncates rather than wrapping to a second line in the narrow cell. */
|
|
|
|
|
|
.cal-dd-sum {
|
|
|
|
|
|
flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
|
|
|
|
font-size: 13px; color: var(--text); font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Push the caret to the right edge so summaries read cleanly at full width. */
|
|
|
|
|
|
.cal-dd-caret { margin-left: auto; color: var(--muted); font-size: 11px; transition: transform .15s ease; }
|
|
|
|
|
|
.cal-dd[open] .cal-dd-caret { transform: rotate(180deg); }
|
|
|
|
|
|
.cal-dd-panel {
|
|
|
|
|
|
position: absolute; top: calc(100% + 6px); left: 0; z-index: 20; min-width: 168px;
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 2px; padding: 6px;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
|
|
|
|
box-shadow: 0 12px 30px rgba(0, 0, 0, .35);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-dd-opt {
|
|
|
|
|
|
display: flex; align-items: center; gap: 9px; cursor: pointer;
|
|
|
|
|
|
font-size: 14px; padding: 8px 10px; border-radius: 7px; min-height: 40px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-dd-opt:hover { background: var(--surface-2); }
|
|
|
|
|
|
.cal-dd-opt input { accent-color: var(--accent); width: 17px; height: 17px; margin: 0; }
|
|
|
|
|
|
/* Group headings inside a dropdown panel (the weather filter's Feel / Sky halves). */
|
|
|
|
|
|
.cal-dd-group {
|
|
|
|
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
|
|
|
|
|
color: var(--muted); font-weight: 700; padding: 8px 10px 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* The weather filter spans the full filter row beneath Seasons + Years, and its
|
|
|
|
|
|
panel is two columns so 13 checkboxes don't make a skyscraper on phones. */
|
|
|
|
|
|
.cal-weather { grid-column: 1 / -1; }
|
|
|
|
|
|
.cal-weather .cal-dd-panel {
|
|
|
|
|
|
left: 0; right: 0; display: grid; grid-template-columns: 1fr 1fr; column-gap: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-weather .cal-dd-group { grid-column: 1 / -1; }
|
|
|
|
|
|
|
2026-07-12 05:33:09 +00:00
|
|
|
|
/* --- season-primary time filter (shared: calendar Seasons dropdown + compare) ---
|
|
|
|
|
|
Seasons are the primary, color-coded selector; each expands to its three months
|
|
|
|
|
|
(the nested suboption). One month Set backs both; a season checkbox is checked when
|
|
|
|
|
|
all three of its months are on, indeterminate (native dash) when one or two are. */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--season-winter: #5b9bd5; --season-spring: #6fbf73;
|
|
|
|
|
|
--season-summer: #e6a52c; --season-fall: #c56a3a;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cal-season-panel { min-width: 212px; max-height: 320px; overflow-y: auto; gap: 4px; }
|
|
|
|
|
|
.season-row { border-radius: 8px; }
|
|
|
|
|
|
.season-row.expanded { background: color-mix(in oklab, var(--season) 12%, transparent); }
|
|
|
|
|
|
.season-head { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
|
.season-check {
|
|
|
|
|
|
display: flex; align-items: center; gap: 9px; flex: 1 1 auto; cursor: pointer;
|
|
|
|
|
|
padding: 8px 10px; border-radius: 7px; min-height: 40px; font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.season-check:hover { background: var(--surface-2); }
|
|
|
|
|
|
.season-check input { accent-color: var(--season); width: 17px; height: 17px; margin: 0; flex: 0 0 auto; }
|
|
|
|
|
|
.season-sw {
|
|
|
|
|
|
width: 12px; height: 12px; border-radius: 3px; background: var(--season); flex: 0 0 auto;
|
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25);
|
|
|
|
|
|
}
|
|
|
|
|
|
.season-name { font-weight: 600; }
|
2026-07-12 07:11:21 +00:00
|
|
|
|
/* "Full year" select-all, above the four seasons — a divider separates it, and its
|
|
|
|
|
|
swatch runs the four season colors so it reads as "everything". */
|
|
|
|
|
|
.season-allyear { --season: var(--accent); border-bottom: 1px solid var(--border); border-radius: 0; margin-bottom: 2px; }
|
|
|
|
|
|
.season-allyear .allyear-sw {
|
|
|
|
|
|
background: linear-gradient(90deg, var(--season-winter), var(--season-spring), var(--season-summer), var(--season-fall));
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
2026-07-12 05:33:09 +00:00
|
|
|
|
.season-expand {
|
|
|
|
|
|
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
|
|
|
|
|
background: transparent; border: 1px solid var(--border); border-radius: 7px;
|
|
|
|
|
|
color: var(--muted); font-size: 12px; padding: 6px 9px; min-height: 34px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.season-expand:hover { color: var(--text); border-color: var(--season); }
|
|
|
|
|
|
.season-caret { font-size: 10px; transition: transform .15s ease; }
|
|
|
|
|
|
.season-expand[aria-expanded="true"] .season-caret { transform: rotate(180deg); }
|
|
|
|
|
|
.season-months {
|
|
|
|
|
|
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 2px;
|
|
|
|
|
|
padding: 2px 4px 6px; margin: 0 0 4px 12px; border-left: 2px solid var(--season);
|
|
|
|
|
|
}
|
|
|
|
|
|
.season-months .month-opt { font-size: 13px; padding: 7px 4px; min-height: 38px; justify-content: flex-start; }
|
|
|
|
|
|
.season-months .month-opt input { accent-color: var(--season); }
|
|
|
|
|
|
.cmp-season-dd .cal-dd { display: block; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Mobile filter sheet — the pill, scrim and grip stay hidden above the phone
|
|
|
|
|
|
breakpoint (switched on inside the max-width:640px block); desktop keeps the filter
|
|
|
|
|
|
panel inline. */
|
|
|
|
|
|
.filter-fab, .filter-scrim, .filter-sheet-grip { display: none; }
|
|
|
|
|
|
|
2026-07-11 01:58:40 +00:00
|
|
|
|
/* Category totals above the grid: a compact distribution strip. Each category is a
|
|
|
|
|
|
column captioned with its name over its value (share by default, raw count when
|
|
|
|
|
|
"Show count" is ticked, in that category's own status color) above a thin status-
|
|
|
|
|
|
colored line rising from a shared baseline (height ∝ share). Every category draws
|
|
|
|
|
|
a line — including the neutral "Normal" center of the temperature scale. */
|
2026-07-11 19:04:30 +00:00
|
|
|
|
.cal-totals { max-width: 960px; margin: 0 auto 18px; }
|
2026-07-11 01:58:40 +00:00
|
|
|
|
/* Header row: the "N days shown — by X" title, with the share/count toggle at the
|
|
|
|
|
|
far end. Wraps to its own line on narrow screens rather than crushing the title. */
|
|
|
|
|
|
.ct-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; }
|
|
|
|
|
|
.ct-head .section-title { margin-bottom: 4px; }
|
|
|
|
|
|
.ct-count-toggle {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
|
|
|
|
|
font-size: 12.5px; color: var(--muted); cursor: pointer; user-select: none;
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ct-count-toggle input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; cursor: pointer; }
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
/* Connected category bar chart: one bar per tier, low→high, sitting on a shared
|
|
|
|
|
|
baseline and nearly touching so they read as one chart. The tier's average value
|
|
|
|
|
|
sits above its bar, the min–max range inside it, and the share + name below.
|
|
|
|
|
|
Bar height encodes frequency (within the metric's scale group). */
|
Show a metric's unit once atop its distribution strip (#195)
The distribution strip printed each bar's average with its full unit inline, so
humidity read "4.0 g/m³" in a ~38px phone column and clipped on every bar —
temperature never did, because it shows a bare "40°". Wind ("5 mph") and precip
were heading the same way.
The unit now appears once, top-right of the strip, and every bar value is bare:
"4.0" under a "g/m³" label, "5" under "mph", "0.00" under "in". It reacts to the
unit toggle like the values do (°F↔°C, mm↔in, mph↔km/h). Temperature keeps its
degree glyph on the value since it is iconic and never clipped.
With the unit no longer on the humidity label, its calendar title drops the
"(g/m³ of water vapor)" parenthetical that would otherwise repeat it — every
metric's title is unitless now, the strip's own label carries it.
Also fixes a pre-existing crash: calendar.js referenced DRY_CAP without importing
it from shared.js, so selecting the dry-streak metric threw and left its legend
unrendered. Exported it.
Verified at 390 and 1440 in light and dark, imperial and metric, across all five
calendar metrics and the compare strips: no label clips, no console errors.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 22:30:31 +00:00
|
|
|
|
/* Wraps the unit label + the scrolling strip. The label sits outside .ct-strip's
|
|
|
|
|
|
overflow so it stays put when a wide (precip) strip scrolls. */
|
|
|
|
|
|
.ct-plot { position: relative; }
|
|
|
|
|
|
/* The metric's unit, shown once at the top-right of the strip — the values on the
|
|
|
|
|
|
bars are bare so they don't clip a narrow phone column. */
|
|
|
|
|
|
.ct-unit {
|
|
|
|
|
|
text-align: right; margin: 6px 2px -2px; height: 1em;
|
|
|
|
|
|
font-size: 11px; font-weight: 700; line-height: 1;
|
|
|
|
|
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
2026-07-11 01:28:24 +00:00
|
|
|
|
.ct-strip {
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
display: flex; align-items: flex-end; gap: 1px; margin: 10px 0 6px; overflow-x: auto;
|
2026-07-11 01:28:24 +00:00
|
|
|
|
}
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
.ct-col {
|
|
|
|
|
|
flex: 1 1 0; min-width: 38px;
|
|
|
|
|
|
display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch;
|
|
|
|
|
|
text-align: center;
|
2026-07-11 01:58:40 +00:00
|
|
|
|
}
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
/* Average value, pinned just above each bar's top. */
|
|
|
|
|
|
.ct-top {
|
|
|
|
|
|
min-height: 1.25em; margin-bottom: 3px; font-size: 10.5px; font-weight: 700; line-height: 1;
|
|
|
|
|
|
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 45%);
|
|
|
|
|
|
white-space: nowrap; overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ct-bar {
|
|
|
|
|
|
width: 100%; background: var(--c); border-radius: 4px 4px 0 0;
|
|
|
|
|
|
display: flex; align-items: center; justify-content: center;
|
2026-07-11 01:58:40 +00:00
|
|
|
|
}
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
.ct-bar-0 { height: 2px; opacity: .35; }
|
Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
|
|
|
|
/* The tier's low–high span, directly under the average and above the bar. It used
|
|
|
|
|
|
to sit inside the bar as a dark pill; a column is ~38px on a phone, so it clipped
|
|
|
|
|
|
there however small the type got. Out here it gets the whole column width, and
|
|
|
|
|
|
needs no scrim to stay legible over nine different tier colours. */
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
.ct-range {
|
Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
|
|
|
|
min-height: 1.1em; margin-bottom: 3px;
|
|
|
|
|
|
font-size: 9px; font-weight: 600; line-height: 1;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
color: var(--muted); opacity: .9;
|
|
|
|
|
|
white-space: nowrap; overflow: hidden;
|
2026-07-16 12:38:26 +00:00
|
|
|
|
}
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
/* Share + category name, below the bar. */
|
|
|
|
|
|
.ct-cap { display: flex; flex-direction: column; align-items: center; gap: 1px; margin-top: 5px; }
|
2026-07-11 01:28:24 +00:00
|
|
|
|
.ct-num {
|
2026-07-11 01:58:40 +00:00
|
|
|
|
font-size: 12px; font-weight: 700; line-height: 1;
|
2026-07-11 01:28:24 +00:00
|
|
|
|
color: color-mix(in oklab, var(--c, var(--muted)), var(--text) 40%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.ct-num-zero { font-weight: 600; opacity: .38; }
|
Category distribution: connected bar chart with per-tier avg + range (#76)
Redesign the shared category distribution (calendar totals + compare page)
from thin percentile lines into a connected bar chart. Each tier is a bar,
low→high, sitting on a shared baseline: the tier's average value sits above
the bar, its min–max range inside it, and the share (or count) with the tier
name below. Bar height still encodes frequency within the metric's scale
group. Works for every measure — High/Low/Feels in °F/°C, humidity g/m³,
wind/gust mph, precip inches, dry-streak days.
- metricBuckets() now returns objects ({label,color,n,group,cls?,unit,values})
and collects each category's actual values, not just a count, so distStrip
can compute the per-tier average and range.
- distStrip() renders the connected bars and formats values in the metric's
unit via new fmtMetricVal/fmtMetricRange helpers (temps follow the active
°C/°F unit; the rest are fixed-unit).
- Because the chart now prints temperatures, it re-renders on the °C/°F
toggle: wired in compare.js's onUnitChange and a new onUnitChange in
calendar.js (the grid itself stays tier-colored, reading temps live).
- Callers updated for the object buckets (b.n instead of b[2]); CSS reworked
from thin lines to connected bars with an in-bar range pill.
2026-07-12 04:31:29 +00:00
|
|
|
|
.ct-label {
|
|
|
|
|
|
min-height: 2.2em; font-size: 10px; line-height: 1.1; font-weight: 600;
|
|
|
|
|
|
color: var(--muted); overflow: hidden;
|
2026-07-20 04:38:30 +00:00
|
|
|
|
/* The strip's columns sit 1px apart, so long neighbouring names (e.g. "Very
|
|
|
|
|
|
Heavy" next to "Extreme") ran into each other and read as one word. */
|
Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
|
|
|
|
padding: 0 2px;
|
2026-07-11 01:28:24 +00:00
|
|
|
|
}
|
2026-07-11 00:29:47 +00:00
|
|
|
|
|
|
|
|
|
|
/* Days filtered out by the weather filter fade back so matches pop. */
|
|
|
|
|
|
.cal-cell.dim { opacity: .15; }
|
|
|
|
|
|
|
|
|
|
|
|
/* --- guide / legend page --- */
|
|
|
|
|
|
.guide { max-width: 760px; }
|
|
|
|
|
|
.guide-block { margin: 0 0 26px; }
|
|
|
|
|
|
.guide-block h2 { font-size: 16px; margin: 0 0 10px; }
|
|
|
|
|
|
.guide-block p { font-size: 14px; margin: 0 0 10px; }
|
|
|
|
|
|
.guide-scale { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
|
|
|
|
|
.guide-seg { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; }
|
|
|
|
|
|
.guide-sw { width: 22px; height: 14px; border-radius: 4px; flex-shrink: 0; }
|
|
|
|
|
|
.guide-lbl { font-weight: 600; }
|
|
|
|
|
|
.guide-rng { color: var(--muted); font-size: 12px; }
|
|
|
|
|
|
.guide-metrics { display: grid; grid-template-columns: max-content 1fr; gap: 8px 18px; margin: 0; }
|
|
|
|
|
|
.guide-metrics dt { font-weight: 700; color: var(--text); }
|
|
|
|
|
|
.guide-metrics dd { margin: 0; color: var(--muted); font-size: 14px; }
|
|
|
|
|
|
.guide-back { margin-top: 8px; }
|
|
|
|
|
|
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
|
|
|
|
|
/* ===== Compare view ===== */
|
|
|
|
|
|
.cmp-locs { flex: 1 1 340px; }
|
|
|
|
|
|
.cmp-loc-list { list-style: none; margin: 8px 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
|
|
|
|
|
|
.cmp-loc-list:empty { margin: 0; }
|
|
|
|
|
|
.cmp-chip {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 999px; padding: 5px 5px 5px 14px; font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-chip.loading { color: var(--muted); }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
.cmp-chip.pending { color: var(--muted); border-style: dashed; }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-chip.error { border-color: var(--very-hot); color: var(--very-hot); }
|
2026-07-11 03:17:17 +00:00
|
|
|
|
|
|
|
|
|
|
/* Add-location + Refresh sit together; Refresh only shows while changes are pending. */
|
|
|
|
|
|
.cmp-loc-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
|
|
|
|
.cmp-refresh-btn {
|
|
|
|
|
|
padding: 11px 18px; border-radius: 10px; cursor: pointer;
|
|
|
|
|
|
font-weight: 700; font-size: 15px; min-height: 44px;
|
|
|
|
|
|
background: var(--accent); color: #1a1206; border: 1px solid var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-refresh-btn[hidden] { display: none; }
|
|
|
|
|
|
.cmp-refresh-btn:hover { filter: brightness(1.05); }
|
2026-07-12 06:32:10 +00:00
|
|
|
|
.cmp-chip-name {
|
|
|
|
|
|
font-weight: 600; max-width: 44vw;
|
|
|
|
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
|
|
|
|
}
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-chip-x {
|
|
|
|
|
|
border: none; background: transparent; color: var(--muted); cursor: pointer;
|
|
|
|
|
|
font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-chip-x:hover { color: var(--text); background: var(--border); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Parameter panel: comfort target, band, judged temperature, date range. */
|
|
|
|
|
|
.cmp-params {
|
|
|
|
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
|
|
|
|
gap: 18px 24px; margin: 0 0 22px; padding: 16px;
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-params[hidden] { display: none; }
|
|
|
|
|
|
.cmp-param > label { display: block; font-size: 13px; color: var(--muted); margin: 0 0 8px; }
|
|
|
|
|
|
.cmp-param > label b { color: var(--text); font-size: 15px; }
|
|
|
|
|
|
.cmp-param input[type="range"] {
|
|
|
|
|
|
width: 100%; height: 44px; margin: 0; accent-color: var(--accent); background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-param .hint { display: block; margin: 4px 0 0; font-size: 12px; color: var(--muted); max-width: none; }
|
|
|
|
|
|
.cmp-basis-block .metric-toggle { margin-top: 2px; }
|
|
|
|
|
|
.cmp-basis-block .metric-toggle button { flex: 1 0 auto; padding: 10px 14px; }
|
|
|
|
|
|
.cmp-range { align-content: start; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Ranked result cards. */
|
|
|
|
|
|
.cmp-results { display: grid; gap: 14px; }
|
|
|
|
|
|
.cmp-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 16px 18px; }
|
|
|
|
|
|
.cmp-card-top { display: flex; align-items: center; gap: 12px; }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
/* Rank badge: a filled disc — accent for the winner, muted for the rest. */
|
|
|
|
|
|
.cmp-rank {
|
|
|
|
|
|
flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%;
|
|
|
|
|
|
display: grid; place-items: center; font-size: 13px; font-weight: 800;
|
|
|
|
|
|
background: var(--surface-2); color: var(--muted);
|
|
|
|
|
|
}
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-card-name { flex: 1 1 auto; min-width: 0; }
|
2026-07-12 06:32:10 +00:00
|
|
|
|
/* Name hierarchy: the lead segment bold, the rest (city · region · country) muted
|
|
|
|
|
|
beneath — both truncate rather than wrap so the card head stays one tidy block. */
|
|
|
|
|
|
.cmp-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
|
|
|
|
.cmp-name b { font-size: 16px; font-weight: 700; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.cmp-name-sub { font-size: 12.5px; color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-daycount { font-size: 12px; color: var(--muted); }
|
|
|
|
|
|
.cmp-big { flex: 0 0 auto; text-align: right; }
|
|
|
|
|
|
.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; }
|
|
|
|
|
|
.cmp-big span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
|
|
|
|
|
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
/* Winner: accent frame + badge, and a "Best match" flag on the subline. */
|
|
|
|
|
|
.cmp-win { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
|
|
|
|
.cmp-win .cmp-rank { background: var(--accent); color: #1a1206; }
|
|
|
|
|
|
.cmp-best { color: var(--accent); font-weight: 700; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Diverging comfort bar: a fixed center marks the comfort zone; colder days grow
|
|
|
|
|
|
left of it, warmer days grow right. Both halves share one 0–100% scale, so a
|
|
|
|
|
|
cold-skewed place and a hot-skewed one mirror each other and compare at a glance. */
|
2026-07-12 01:49:22 +00:00
|
|
|
|
/* Full-width day-distribution bar: five temperature buckets, cold to hot, together
|
|
|
|
|
|
100% of the days, in the site's own temperature colors. min-width keeps a thin
|
|
|
|
|
|
sliver visible; the key below spells out every color and share. */
|
|
|
|
|
|
.cmp-bar { display: flex; height: 20px; border-radius: 6px; overflow: hidden; margin: 14px 0 10px; background: var(--surface-2); }
|
|
|
|
|
|
.cmp-seg { height: 100%; min-width: 2px; }
|
2026-07-12 00:49:06 +00:00
|
|
|
|
.cmp-seg-vcold { background: var(--very-cold); }
|
Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
|
|
|
|
.cmp-seg-cool { background: var(--cool); }
|
2026-07-12 00:49:06 +00:00
|
|
|
|
.cmp-seg-comf { background: var(--normal); }
|
Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
|
|
|
|
.cmp-seg-warm { background: var(--warm); }
|
2026-07-12 00:49:06 +00:00
|
|
|
|
.cmp-seg-vhot { background: var(--very-hot); }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
|
2026-07-12 01:49:22 +00:00
|
|
|
|
/* Key under the bar: swatch + plain name + share for every bucket the place has, in
|
|
|
|
|
|
the same cold-to-hot order as the bar — so a 1% span is named as clearly as a 60% one. */
|
|
|
|
|
|
.cmp-key { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--muted); }
|
|
|
|
|
|
.cmp-key-item { display: inline-flex; align-items: center; gap: 6px; }
|
|
|
|
|
|
.cmp-key-item b { color: var(--text); font-weight: 700; }
|
|
|
|
|
|
.cmp-sw { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
|
2026-07-12 02:23:28 +00:00
|
|
|
|
/* Desktop: spread the labels across the bar's width, each centred under its own
|
2026-07-12 21:05:01 +00:00
|
|
|
|
segment; spreadKeys() then de-collides them and keeps the run inside the edges. */
|
2026-07-12 02:23:28 +00:00
|
|
|
|
@media (min-width: 641px) {
|
|
|
|
|
|
.cmp-key { position: relative; display: block; height: 18px; }
|
|
|
|
|
|
.cmp-key-item { position: absolute; transform: translateX(-50%); white-space: nowrap; }
|
|
|
|
|
|
}
|
2026-07-12 01:49:22 +00:00
|
|
|
|
|
Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
|
|
|
|
.cmp-bands { margin: 8px 0 0; font-size: 12.5px; color: var(--muted); }
|
|
|
|
|
|
.cmp-bands b { color: var(--text); }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
/* Baseline strip: every place's typical basis temperature on one shared °F axis,
|
|
|
|
|
|
with the comfort range shaded, so the places' baselines line up side by side. */
|
|
|
|
|
|
.cmp-baseline { margin: 0 0 18px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
|
|
|
|
|
.cmp-baseline[hidden] { display: none; }
|
|
|
|
|
|
.cmp-bl-head { font-size: 14px; font-weight: 700; }
|
|
|
|
|
|
.cmp-bl-head .hint { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); margin: 2px 0 12px; max-width: none; }
|
|
|
|
|
|
.cmp-bl-rows { display: grid; gap: 10px; }
|
|
|
|
|
|
.cmp-bl-row { display: grid; grid-template-columns: 120px 1fr; align-items: center; gap: 12px; }
|
|
|
|
|
|
.cmp-bl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.cmp-bl-track { position: relative; height: 26px; }
|
Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
|
|
|
|
.cmp-bl-tol { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: color-mix(in srgb, var(--normal) 9%, transparent); }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
.cmp-bl-band {
|
|
|
|
|
|
position: absolute; top: 0; bottom: 0; border-radius: 3px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--normal) 20%, transparent);
|
|
|
|
|
|
outline: 1px solid color-mix(in srgb, var(--normal) 50%, transparent); outline-offset: -1px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-bl-spread { position: absolute; top: 17px; height: 4px; transform: translateY(-50%); border-radius: 2px; opacity: .6; }
|
|
|
|
|
|
.cmp-bl-dot { position: absolute; top: 17px; width: 12px; height: 12px; border-radius: 50%; transform: translate(-50%, -50%); border: 2px solid var(--surface); }
|
|
|
|
|
|
.cmp-bl-val { position: absolute; top: 1px; transform: translateX(-50%); font-size: 10.5px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
|
|
|
|
|
.cmp-bl-spread.cmp-below, .cmp-bl-dot.cmp-below { background: var(--cold); }
|
|
|
|
|
|
.cmp-bl-spread.cmp-comfort, .cmp-bl-dot.cmp-comfort { background: var(--normal); }
|
|
|
|
|
|
.cmp-bl-spread.cmp-above, .cmp-bl-dot.cmp-above { background: var(--hot); }
|
|
|
|
|
|
.cmp-bl-axis { display: flex; justify-content: space-between; margin: 8px 0 0 132px; font-size: 11px; color: var(--muted); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Loading affordances: a spinning ring (chips + header) and shimmering skeleton
|
|
|
|
|
|
cards, so a still-loading place is obvious and the results don't pop in cold. */
|
|
|
|
|
|
@keyframes cmp-spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
@keyframes cmp-shimmer { 100% { background-position: -200% 0; } }
|
|
|
|
|
|
.cmp-spinner {
|
|
|
|
|
|
display: inline-block; width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
|
|
|
|
|
|
border: 2px solid var(--border); border-top-color: var(--accent);
|
|
|
|
|
|
animation: cmp-spin .7s linear infinite; vertical-align: -2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-spinner-chip { width: 12px; height: 12px; }
|
|
|
|
|
|
.cmp-chip-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .55; }
|
|
|
|
|
|
.cmp-loading { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; color: var(--muted); font-size: 14px; }
|
|
|
|
|
|
.cmp-loading-inline { display: inline-flex; align-items: center; gap: 6px; }
|
|
|
|
|
|
.cmp-loading-inline .cmp-spinner { width: 12px; height: 12px; }
|
|
|
|
|
|
|
|
|
|
|
|
.cmp-skel-top { display: flex; align-items: center; gap: 12px; }
|
|
|
|
|
|
.cmp-skel-badge, .cmp-skel-name, .cmp-skel-big, .cmp-skel-bar {
|
|
|
|
|
|
display: block; border-radius: 6px;
|
|
|
|
|
|
background: linear-gradient(90deg, var(--surface-2) 25%, var(--border) 37%, var(--surface-2) 63%);
|
|
|
|
|
|
background-size: 200% 100%; animation: cmp-shimmer 1.3s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-skel-badge { width: 26px; height: 26px; border-radius: 50%; flex: 0 0 auto; }
|
|
|
|
|
|
.cmp-skel-name { flex: 1 1 auto; height: 16px; }
|
|
|
|
|
|
.cmp-skel-big { width: 56px; height: 22px; flex: 0 0 auto; }
|
|
|
|
|
|
.cmp-skel-bar { height: 16px; margin-top: 16px; }
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
.cmp-spinner { animation-duration: 1.4s; }
|
|
|
|
|
|
.cmp-skel-badge, .cmp-skel-name, .cmp-skel-big, .cmp-skel-bar { animation: none; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 02:23:28 +00:00
|
|
|
|
/* Comfort vs tolerance shown as two separate labels, and a temperature marker above
|
|
|
|
|
|
each of the four handles (the band borders). */
|
2026-07-12 07:11:21 +00:00
|
|
|
|
/* Comfort/tolerance panel — pulled out of the params grid so it stays inline above
|
|
|
|
|
|
the results (and out of the mobile filter sheet); a full-width bordered card. */
|
|
|
|
|
|
.cmp-comfort-wrap {
|
|
|
|
|
|
margin: 0 0 18px; padding: 14px 16px 8px;
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-comfort-wrap[hidden] { display: none; }
|
2026-07-12 07:45:12 +00:00
|
|
|
|
/* The basis toggle now rides the comfort card (a scoring control, above the results). */
|
|
|
|
|
|
.cmp-comfort-wrap .cmp-basis-block { margin-top: 12px; }
|
|
|
|
|
|
/* Sheet-only duplicate metric selectors: hidden on desktop (the inline copy shows),
|
|
|
|
|
|
revealed inside the filter sheet on phones (see the max-width:640px block). */
|
|
|
|
|
|
.cmp-dist-metric-block, .cal-metric-sheet { display: none; }
|
|
|
|
|
|
.cal-metric-sheet .cal-filter-label { display: block; margin: 0 0 8px; }
|
2026-07-12 02:23:28 +00:00
|
|
|
|
.cmp-range-labels { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 0 0 6px; font-size: 13px; color: var(--muted); }
|
|
|
|
|
|
.cmp-rl b { color: var(--text); font-size: 15px; margin-left: 2px; }
|
|
|
|
|
|
.cmp-rl-comfort b { color: var(--accent); }
|
|
|
|
|
|
.cmp-ticks { position: relative; height: 17px; }
|
|
|
|
|
|
.cmp-tick { position: absolute; transform: translateX(-50%); font-size: 11px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
|
|
|
|
|
.cmp-tick.tol { color: var(--muted); font-weight: 600; }
|
|
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
/* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a
|
|
|
|
|
|
fill marking the selected span. Only the thumbs take pointer events, so both
|
|
|
|
|
|
handles stay grabbable even where the inputs overlap. */
|
|
|
|
|
|
.dual-range { position: relative; height: 44px; }
|
|
|
|
|
|
.dual-range .dual-track {
|
|
|
|
|
|
position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
|
|
|
|
|
|
height: 6px; border-radius: 3px; background: var(--surface-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
.dual-range .dual-fill { position: absolute; top: 0; bottom: 0; border-radius: 3px; background: var(--accent); }
|
|
|
|
|
|
.dual-range input[type="range"] {
|
|
|
|
|
|
position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0;
|
|
|
|
|
|
-webkit-appearance: none; appearance: none; background: transparent; pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dual-range input[type="range"]::-webkit-slider-thumb {
|
|
|
|
|
|
-webkit-appearance: none; appearance: none; pointer-events: auto;
|
|
|
|
|
|
width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
|
|
|
|
|
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dual-range input[type="range"]::-moz-range-thumb {
|
|
|
|
|
|
pointer-events: auto; width: 22px; height: 22px; border-radius: 50%; background: var(--accent);
|
|
|
|
|
|
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, .35); cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.dual-range input[type="range"]::-webkit-slider-runnable-track { background: transparent; border: none; }
|
|
|
|
|
|
.dual-range input[type="range"]::-moz-range-track { background: transparent; border: none; }
|
|
|
|
|
|
|
Compare: match-score ranking, tolerance range, fix Feels basis (#69)
Ranking by raw comfort% tied extreme places (Death Valley ranked #2 just
because 26% of days happened to land in-band). Replace it with a weighted
score that folds in tolerance days and miss magnitude, add an outer
tolerance range, and fix the Feels basis.
- Match score (0–100): each day scores full credit inside comfort [lo,hi],
partial credit inside the tolerance range (ramped 1→0 by how far past the
comfort edge it lands), zero beyond tolerance. Rank by the mean; tie-break
by smaller typical miss. Death Valley's hot days now score ~0 so it drops
to last. The score is the card's headline number.
- Tolerance range [tlo, thi] as an outer dual-thumb pair on the same track
(four handles, tlo ≤ lo ≤ hi ≤ thi), persisted + in the share hash.
Defaults to comfort widened 10° each side. The diverging bar now splits
each side into a within-tolerance band (lighter, near centre) and a
beyond-tolerance band (saturated), and the baseline strip shades the
tolerance range faintly around the comfort band.
- Feels basis now judges the felt daytime high (fmax) instead of the
combined `feels` metric, which reports whichever apparent extreme is
furthest from 65°F — in mild climates that flips to the cold overnight
low in winter and made comfortable days nearly vanish.
2026-07-12 00:25:36 +00:00
|
|
|
|
/* Quad-handle variant: an outer tolerance range around the inner comfort range on
|
|
|
|
|
|
the same track. The tolerance fill sits behind the comfort fill; tolerance thumbs
|
|
|
|
|
|
are hollow (a ring) so they read as secondary, and the comfort thumbs stack above
|
|
|
|
|
|
them so the inner handles always win where two thumbs overlap. */
|
|
|
|
|
|
.quad-range .dual-fill-tol { background: color-mix(in srgb, var(--accent) 28%, transparent); }
|
|
|
|
|
|
.quad-range #cmp-lo, .quad-range #cmp-hi { z-index: 2; }
|
|
|
|
|
|
.quad-range input.thumb-tol::-webkit-slider-thumb { width: 18px; height: 18px; background: var(--surface); border: 2px solid var(--accent); }
|
|
|
|
|
|
.quad-range input.thumb-tol::-moz-range-thumb { width: 18px; height: 18px; background: var(--surface); border: 2px solid var(--accent); }
|
|
|
|
|
|
|
2026-07-11 22:33:38 +00:00
|
|
|
|
/* Climate distribution below the ranked cards: a metric toggle + share/count toggle,
|
|
|
|
|
|
then one calendar-style distribution strip (.ct-strip, shared) per location. */
|
|
|
|
|
|
.cmp-dist { margin: 24px 0 8px; }
|
|
|
|
|
|
.cmp-dist[hidden] { display: none; }
|
|
|
|
|
|
.cmp-dist .ct-head { align-items: center; }
|
|
|
|
|
|
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
|
|
|
|
|
|
.cmp-dist-controls .cal-filter-label { margin: 0; }
|
|
|
|
|
|
.cmp-dist .metric-toggle { margin: 0; }
|
2026-07-12 06:32:10 +00:00
|
|
|
|
/* minmax(0,1fr) + the row's min-width:0 let the track shrink below the strip's
|
|
|
|
|
|
min-content, so the connected bar chart scrolls inside its own .ct-strip
|
|
|
|
|
|
(overflow-x:auto) instead of widening the whole page on a phone. */
|
|
|
|
|
|
.cmp-dist-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; margin-top: 14px; }
|
2026-07-11 22:33:38 +00:00
|
|
|
|
.cmp-dist-row {
|
2026-07-12 06:32:10 +00:00
|
|
|
|
min-width: 0;
|
2026-07-11 22:33:38 +00:00
|
|
|
|
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
|
|
|
|
|
|
.cmp-dist-days { font-size: 12px; font-weight: 400; color: var(--muted); }
|
|
|
|
|
|
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
/* --- phones / narrow screens — every mobile override lives in this one
|
|
|
|
|
|
block (they used to be scattered across four) --- */
|
2026-07-11 02:58:56 +00:00
|
|
|
|
@media (max-width: 640px) {
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.calendar { gap: 16px; }
|
|
|
|
|
|
.metric-toggle { width: 100%; }
|
|
|
|
|
|
/* 7 metrics wrap to ~4 per row on a phone instead of overflowing one line. */
|
|
|
|
|
|
.metric-toggle button { flex: 1 0 25%; padding: 11px 4px; font-size: 12px; }
|
|
|
|
|
|
/* Compress the day tooltip so it fits narrow phone screens without cutting off. */
|
|
|
|
|
|
#cal-tip { padding: 7px 9px; min-width: 0; font-size: 11.5px; line-height: 1.4; }
|
|
|
|
|
|
#cal-tip .tt-type { font-size: 13.5px; margin: 1px 0 5px; padding-bottom: 5px; }
|
|
|
|
|
|
#cal-tip .tt-grid { column-gap: 8px; row-gap: 2px; }
|
|
|
|
|
|
/* Day ladder on mobile: tighten the fixed tracks but keep every column (including
|
2026-07-11 21:13:05 +00:00
|
|
|
|
the percentile range) so the ranges still line up down the ladder. Slack moved
|
|
|
|
|
|
from the pct/marker tracks to the value column keeps the longest range
|
|
|
|
|
|
("17.3–19.9 g/m³", ~88px) on one line down to ~375px screens; narrower than
|
|
|
|
|
|
that it wraps at the unit. The label track can't give ("Above Normal" ≈ 89px). */
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.day-nav { width: 100%; }
|
|
|
|
|
|
.day-date { flex: 1; }
|
2026-07-11 21:13:05 +00:00
|
|
|
|
.ladder-row { grid-template-columns: 13px 90px 46px 1fr 42px; column-gap: 8px; font-size: 12.5px; }
|
2026-07-16 23:51:55 +00:00
|
|
|
|
}
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
|
2026-07-16 23:51:55 +00:00
|
|
|
|
/* The full inline header (logo + name + tagline + °F/°C + Sign in + six nav tabs)
|
|
|
|
|
|
only fits comfortably on a wide screen; squeeze it and the tagline crushes the
|
|
|
|
|
|
title into a one-word-per-line sliver. So: hide the tagline below 1200px, and
|
|
|
|
|
|
below 1080px fold the nav, °F/°C and account into a hamburger dropdown, leaving a
|
|
|
|
|
|
clean compact header (logo + name + hamburger). The body keeps its ≤640px rules. */
|
|
|
|
|
|
@media (max-width: 1199px) { .tag { display: none; } }
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1080px) {
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
header { padding: 14px 16px; }
|
|
|
|
|
|
h1 { font-size: 19px; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
|
|
|
|
|
|
/* One compact row: logo + name on the left, the hamburger flush-right. The nav,
|
|
|
|
|
|
°F/°C toggle, bell and account all fold into the hamburger dropdown below, so
|
|
|
|
|
|
the header never grows a second control-bar row. */
|
2026-07-18 06:09:59 +00:00
|
|
|
|
/* The hamburger is fixed (out of flow) — reserve its corner so the lockup
|
|
|
|
|
|
can't slide beneath it. */
|
|
|
|
|
|
.brand { flex-wrap: nowrap; align-items: center; gap: 10px; padding-right: 56px; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
.brand > div:not(.unit-toggle):not(.acct) { flex: 1 1 auto; min-width: 0; }
|
|
|
|
|
|
.logo { align-self: center; margin-top: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
/* A real box again (not display:contents) so it anchors the absolute dropdown
|
2026-07-18 06:09:59 +00:00
|
|
|
|
and holds the hamburger — fixed to the viewport's top-right so nav follows
|
|
|
|
|
|
the user down the page. Below the filter-sheet scrim (950) and the map
|
|
|
|
|
|
modal (1000) so overlays still cover it. */
|
|
|
|
|
|
.nav-menu { display: inline-flex; position: fixed; top: 10px; right: 14px; z-index: 940; }
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.nav-toggle {
|
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
2026-07-16 12:15:00 +00:00
|
|
|
|
width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 12px;
|
2026-07-16 05:42:59 +00:00
|
|
|
|
background: var(--surface-2); color: var(--text);
|
2026-07-18 06:09:59 +00:00
|
|
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
|
2026-07-16 05:42:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
.nav-menu[open] .nav-toggle { border-color: var(--accent); color: var(--accent); }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
|
|
|
|
|
|
/* The panel is the dropdown: right-anchored and clamped to the viewport so it
|
|
|
|
|
|
never runs off-screen (this also fixes the SEO/city pages, whose only panel
|
|
|
|
|
|
item is the nav). Contents stack nav → °F/°C → account top-to-bottom. */
|
|
|
|
|
|
.nav-panel {
|
2026-07-16 12:34:04 +00:00
|
|
|
|
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
|
2026-07-16 12:15:00 +00:00
|
|
|
|
position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
|
2026-07-16 12:34:04 +00:00
|
|
|
|
margin: 0; padding: 8px; width: min(208px, calc(100vw - 32px));
|
2026-07-16 12:15:00 +00:00
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
|
|
|
|
|
box-shadow: 0 16px 40px rgba(0, 0, 0, .45);
|
|
|
|
|
|
}
|
|
|
|
|
|
.nav-menu:not([open]) .nav-panel { display: none; }
|
|
|
|
|
|
/* Reset the desktop cluster order — the dropdown wants plain DOM order. */
|
|
|
|
|
|
.nav-panel > .unit-toggle, .nav-panel > .acct, .nav-panel > .view-nav { order: 0; }
|
|
|
|
|
|
|
2026-07-16 12:34:04 +00:00
|
|
|
|
/* Nav links: a compact, left-aligned list with an inset hairline between each
|
|
|
|
|
|
entry (suppressed around the active pill so nothing crosses it). The whole
|
|
|
|
|
|
group is hairline-separated from the controls below — the :not(:last-child)
|
|
|
|
|
|
keeps SEO pages, which have nav only, divider-free. */
|
2026-07-16 05:42:59 +00:00
|
|
|
|
.view-nav {
|
2026-07-16 12:34:04 +00:00
|
|
|
|
display: flex; flex-direction: column; align-items: stretch; align-self: stretch;
|
|
|
|
|
|
gap: 0; margin: 0; width: auto;
|
2026-07-16 12:15:00 +00:00
|
|
|
|
background: transparent; border: 0; border-radius: 0; padding: 0;
|
2026-07-16 05:42:59 +00:00
|
|
|
|
}
|
2026-07-16 12:15:00 +00:00
|
|
|
|
.nav-panel > .view-nav:not(:last-child) {
|
2026-07-16 12:34:04 +00:00
|
|
|
|
padding-bottom: 6px; margin-bottom: 4px; border-bottom: 1px solid var(--border);
|
2026-07-16 12:15:00 +00:00
|
|
|
|
}
|
2026-07-16 12:34:04 +00:00
|
|
|
|
.nav-panel .view-nav a {
|
|
|
|
|
|
min-height: 40px; padding: 8px 12px; justify-content: flex-start;
|
|
|
|
|
|
border-radius: 8px; position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
.nav-panel .view-nav a + a::before { display: none; }
|
|
|
|
|
|
.nav-panel .view-nav a:not(:last-child)::after {
|
|
|
|
|
|
content: ""; position: absolute; left: 12px; right: 12px; bottom: 0;
|
|
|
|
|
|
height: 1px; background: var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.nav-panel .view-nav a.active::after,
|
|
|
|
|
|
.nav-panel .view-nav a:has(+ a.active)::after { display: none; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
|
|
|
|
|
|
/* °F/°C toggle: full-width segmented control inside the dropdown. */
|
|
|
|
|
|
.nav-panel > .unit-toggle { margin: 0; width: 100%; align-self: stretch; }
|
|
|
|
|
|
.nav-panel > .unit-toggle button { flex: 1; }
|
|
|
|
|
|
|
2026-07-16 15:45:43 +00:00
|
|
|
|
/* Account row (bell + account/Sign in): a compact chip that hugs its label and
|
|
|
|
|
|
sits beside the bell, centered in the dropdown, rather than a full-width bar.
|
|
|
|
|
|
The 40px min-height stays for the tap target; padding and truncation tighten
|
|
|
|
|
|
so the long email no longer dominates. Popovers open below, clamped to the
|
|
|
|
|
|
viewport so the notification list never overflows. */
|
|
|
|
|
|
.nav-panel > .acct { margin: 0; width: 100%; align-self: stretch; justify-content: center; }
|
|
|
|
|
|
.nav-panel > .acct .acct-btn:not(.notif-btn) { padding: 8px 10px; }
|
|
|
|
|
|
.nav-panel > .acct .acct-name { max-width: 12ch; }
|
2026-07-16 12:15:00 +00:00
|
|
|
|
/* The bell's notification list would anchor to the small bell button and spill
|
|
|
|
|
|
off the left edge; drop .notif's positioning context so it anchors to the
|
|
|
|
|
|
panel instead — dropping below the menu, right-aligned and within the
|
|
|
|
|
|
viewport. The account menu already fits; just clamp its width. */
|
|
|
|
|
|
.nav-panel .notif { position: static; }
|
|
|
|
|
|
.nav-panel .notif-pop { right: 0; width: min(340px, calc(100vw - 24px)); }
|
|
|
|
|
|
.acct-pop { max-width: calc(100vw - 40px); }
|
2026-07-16 23:51:55 +00:00
|
|
|
|
}
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
|
2026-07-16 23:51:55 +00:00
|
|
|
|
@media (max-width: 640px) {
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
main { padding: 14px 14px 40px; }
|
|
|
|
|
|
.controls { gap: 12px; margin-bottom: 14px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Map + panel stack (from the 900px rule); shrink the map so results are
|
|
|
|
|
|
reachable with less scrolling on a phone. */
|
|
|
|
|
|
.panel { padding: 16px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Give buttons a comfortable touch target (~44px). */
|
|
|
|
|
|
.date-row button { padding: 12px 16px; min-height: 44px; }
|
|
|
|
|
|
.share button { padding: 9px 12px; font-size: 12.5px; min-height: 38px; }
|
|
|
|
|
|
|
|
|
|
|
|
.date-row { gap: 10px; width: 100%; }
|
|
|
|
|
|
.hint { max-width: none; flex-basis: 100%; }
|
|
|
|
|
|
|
|
|
|
|
|
.loc-head { flex-wrap: wrap; }
|
|
|
|
|
|
.share { flex-wrap: wrap; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Keep the normal cards legible when very narrow. */
|
|
|
|
|
|
.normals { gap: 8px; }
|
|
|
|
|
|
.normal-card { padding: 10px 11px; }
|
|
|
|
|
|
.normal-card .big { font-size: 19px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Compact table tuned for phone width. */
|
|
|
|
|
|
/* Chart metric toggle: force an even 4-per-row grid so the buttons line up
|
|
|
|
|
|
(without this the flex-wrap sizes each to its label and the rows go ragged). */
|
|
|
|
|
|
.chart-toggle { width: 100%; }
|
|
|
|
|
|
.chart-toggle button { flex: 1 0 25%; padding: 9px 4px; font-size: 12px; }
|
|
|
|
|
|
.rd-c { min-height: 30px; font-size: 11px; border-radius: 5px; }
|
|
|
|
|
|
.rd-mlabel { font-size: 10px; padding-right: 4px; }
|
|
|
|
|
|
.rd-colh { font-size: 9px; }
|
|
|
|
|
|
.rd-colh b { font-size: 10.5px; }
|
|
|
|
|
|
|
|
|
|
|
|
.mp-overlay { padding: 0; }
|
2026-07-11 21:17:04 +00:00
|
|
|
|
/* Full-screen sheet. Size to the *dynamic* viewport (dvh) — plain 100vh on
|
|
|
|
|
|
iOS Safari is the large-viewport height (toolbars retracted), so with the
|
|
|
|
|
|
chrome showing the footer's "Use this location" button falls behind it and
|
|
|
|
|
|
gets cut off. dvh tracks the currently-visible height, and the flex map
|
|
|
|
|
|
gives up the space so the footer stays on-screen. vh kept as a fallback. */
|
|
|
|
|
|
.mp-modal {
|
|
|
|
|
|
max-width: none; border-radius: 0; border: none;
|
|
|
|
|
|
max-height: 100vh; height: 100vh;
|
|
|
|
|
|
max-height: 100dvh; height: 100dvh;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Clear the home-indicator inset once the bottom toolbar retracts. */
|
|
|
|
|
|
.mp-foot { padding-bottom: calc(16px + env(safe-area-inset-bottom)); }
|
CSS: delete dead selectors, one mobile block, shared control recipes (#50)
- Deleted the rules for elements that no longer exist (verified by
auditing every style.css class selector against all class usage in
HTML/JS, including dynamically-built names): the pre-modal inline
search/suggestions, the old two-column .layout + inline #map, the
.panel fixed-height scroller (+ its now-pointless .panel-full
neutralizer and 900px escape), .share-btn, the pre-dropdown calendar
filter chips, .series-toggle, the .grade.danger/.d-rec-* set and the
bare .legend block. Leaflet's classes stay (styled runtime DOM).
- The four scattered max-width:640px blocks are now one block at the end
of the sheet, where mobile overrides belong.
- The accent-button recipe (5 copies) and the 16px text-input recipe
(4 copies) are each one grouped rule; components keep only their
deltas. The iOS zoom-on-focus rule now has a single documented home.
(The consolidation also restored the .date-row button rule that the
dead-search deletion would otherwise have taken with it.)
993 -> 919 lines. Verified pixel-identical: full-page screenshots of all
five pages at 390/800/1280px and the opened picker modal are
byte-for-byte equal before/after (headless Chromium).
2026-07-11 20:42:41 +00:00
|
|
|
|
.find-bar { width: 100%; }
|
|
|
|
|
|
.guide-metrics { grid-template-columns: 1fr; gap: 2px 0; }
|
|
|
|
|
|
.guide-metrics dt { margin-top: 10px; }
|
|
|
|
|
|
|
2026-07-12 06:32:10 +00:00
|
|
|
|
/* Tighter distribution columns so a 9-tier temp strip fits a phone without
|
Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
|
|
|
|
scrolling (precip's 10th column still scrolls inside its own .ct-strip).
|
|
|
|
|
|
The low–high line shrinks to match: at 34px a column has room for "17–62"
|
|
|
|
|
|
comfortably, and for precip's ".04–.46" once the leading zero is dropped. */
|
2026-07-12 06:32:10 +00:00
|
|
|
|
.ct-col { min-width: 34px; }
|
Report rain in whole millimetres; lift tier spans out of the bars (#192)
Two fixes to how measurements read.
Rain goes back to millimetres, rendered as whole numbers — that is how rainfall
is reported, and a tenth of a millimetre is below what the source resolves.
Inches keep two decimals, since a whole inch of rain is a lot to round to.
The distribution strip's low-high span moves from inside each bar to just under
the average, above it. A column is ~38px on a phone, so the old "Max 62° / Min
17°" pill clipped — it had already been shrunk to 8px to cope, and on a
high-rainfall city with longer values it lost both ends of the label, because the
text is centred and overflow-hidden. Above the bar it has the full column width
and needs no scrim to stay legible over nine tier colours.
The span is now bare numbers ("17-62"), since the average directly above it
carries the unit; repeating " g/m³" on both ends is what made it too wide in the
first place. It uses the same precision as that average, or the two lines
disagree ("52mm" over "12.7-136.91"). Sub-1 inch values drop their leading zero
so precip's ten columns still fit a phone.
With nothing inside the bars, the height floor drops from 30px to 14px: it only
has to keep a non-empty bucket visible now, so the heights encode frequency more
honestly. Category names get 2px of side padding, which stops long neighbours
("Light-Mod" beside "Moderate") reading as one word.
Verified at 390/412/1920 in both themes across all four calendar metrics and the
compare strips, in imperial and metric: nothing clips.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-19 19:28:58 +00:00
|
|
|
|
.ct-range { font-size: 8px; }
|
2026-07-12 06:32:10 +00:00
|
|
|
|
|
2026-07-12 05:33:09 +00:00
|
|
|
|
.cmp-params { grid-template-columns: 1fr; }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }
|
2026-07-12 07:45:12 +00:00
|
|
|
|
/* Reveal the synced metric selectors inside the filter sheet on phones. */
|
|
|
|
|
|
.cmp-dist-metric-block, .cal-metric-sheet { display: block; }
|
2026-07-12 05:33:09 +00:00
|
|
|
|
|
|
|
|
|
|
/* --- filters reachable from anywhere: a floating pill slides the page's filter
|
|
|
|
|
|
panel up as a bottom sheet (compare #cmp-params, calendar #cal-filters) --- */
|
|
|
|
|
|
.filter-fab {
|
|
|
|
|
|
display: none; position: fixed; z-index: 900;
|
|
|
|
|
|
left: 50%; transform: translateX(-50%);
|
|
|
|
|
|
bottom: calc(14px + env(safe-area-inset-bottom));
|
|
|
|
|
|
align-items: center; gap: 7px; padding: 9px 16px; min-height: 40px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
|
|
|
|
|
color: var(--text); border: 1px solid var(--border); border-radius: 999px;
|
|
|
|
|
|
box-shadow: 0 6px 20px rgba(0, 0, 0, .38); backdrop-filter: blur(6px);
|
|
|
|
|
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.filter-fab.ready { display: inline-flex; }
|
|
|
|
|
|
.filter-fab .ff-ic { width: 16px; height: 16px; }
|
|
|
|
|
|
.filter-fab .ff-dot {
|
|
|
|
|
|
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
|
|
|
|
|
|
box-shadow: 0 0 0 2px var(--surface);
|
|
|
|
|
|
}
|
|
|
|
|
|
.filter-scrim { display: block; position: fixed; inset: 0; z-index: 950; background: rgba(0, 0, 0, .5); }
|
|
|
|
|
|
.filter-scrim[hidden] { display: none; }
|
|
|
|
|
|
|
|
|
|
|
|
/* The filter panel becomes a bottom sheet, off-screen until the pill opens it.
|
|
|
|
|
|
id selectors keep these above the pages' own class-based mobile rules. */
|
|
|
|
|
|
#cmp-params, #cal-filters {
|
|
|
|
|
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 960;
|
|
|
|
|
|
max-width: none; margin: 0; border-radius: 16px 16px 0 0;
|
|
|
|
|
|
max-height: 85dvh; overflow-y: auto; transform: translateY(110%);
|
|
|
|
|
|
padding: 16px 16px calc(18px + env(safe-area-inset-bottom));
|
|
|
|
|
|
box-shadow: 0 -12px 40px rgba(0, 0, 0, .5);
|
|
|
|
|
|
}
|
|
|
|
|
|
#cmp-params.open, #cal-filters.open { transform: translateY(0); }
|
|
|
|
|
|
|
|
|
|
|
|
.filter-sheet-grip {
|
|
|
|
|
|
display: flex; align-items: center; gap: 10px; position: relative;
|
|
|
|
|
|
padding: 6px 2px 10px; margin-bottom: 10px; border-bottom: 1px solid var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.filter-sheet-grip .fsg-bar {
|
|
|
|
|
|
position: absolute; top: -6px; left: 50%; transform: translateX(-50%);
|
|
|
|
|
|
width: 36px; height: 4px; border-radius: 2px; background: var(--border);
|
|
|
|
|
|
}
|
|
|
|
|
|
.filter-sheet-grip .fsg-title { font-weight: 700; font-size: 15px; }
|
|
|
|
|
|
.filter-sheet-grip .fsg-close {
|
|
|
|
|
|
margin-left: auto; background: transparent; border: none; color: var(--muted);
|
|
|
|
|
|
font-size: 20px; line-height: 1; min-width: 40px; min-height: 40px; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Room so the pill never covers the last bit of content (only on pages with it). */
|
|
|
|
|
|
body.has-filter-sheet main { padding-bottom: 76px; }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
.cmp-big b { font-size: 20px; }
|
Compare: diverging comfort bars, baseline strip, real loading state (#68)
Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.
- Diverging comfort bar per place: colder days grow left of a fixed
centre comfort marker, warmer days grow right, both on one 0–100%
scale — a cold-skewed place and a hot-skewed one mirror each other and
compare directly. Replaces the left-anchored stacked bar + 3-up stat
grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
one shared °F axis with the comfort range shaded, plus a middle-80%
(p10–p90) spread, so baselines line up side by side. computeStats now
also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
"Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
dot on pending chips, an inline spinner in the header, and shimmering
skeleton cards for still-loading places (previously only muted text —
the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
2026-07-11 23:49:00 +00:00
|
|
|
|
/* Baseline strip: tighten the name gutter and shrink text so the shared axis
|
|
|
|
|
|
still fits a phone; keep the axis ticks aligned to the narrower track. */
|
|
|
|
|
|
.cmp-bl-row { grid-template-columns: 84px 1fr; gap: 8px; }
|
|
|
|
|
|
.cmp-bl-name { font-size: 12px; }
|
|
|
|
|
|
.cmp-bl-axis { margin-left: 92px; }
|
2026-07-12 01:49:22 +00:00
|
|
|
|
.cmp-key { font-size: 11.5px; gap: 5px 12px; }
|
2026-07-11 02:58:56 +00:00
|
|
|
|
}
|
2026-07-12 05:33:09 +00:00
|
|
|
|
|
|
|
|
|
|
/* Slide the filter sheet only when motion is welcome (phones). */
|
|
|
|
|
|
@media (max-width: 640px) and (prefers-reduced-motion: no-preference) {
|
|
|
|
|
|
#cmp-params, #cal-filters { transition: transform .28s ease; }
|
|
|
|
|
|
}
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
|
|
|
|
|
|
/* ============================================================
|
|
|
|
|
|
Server-rendered content pages (climate hub / city / month /
|
|
|
|
|
|
records / glossary / about). Text-first, minimal JS.
|
|
|
|
|
|
============================================================ */
|
Make the brand lockup a home link on every page (#182)
The mark and wordmark were already a home link on the Jinja pages (homepage,
about, climate, glossary, privacy), but the five static tool pages — calendar,
day, compare, legend, alerts — built their header separately and left the brand
as a bare <h1>, so clicking it did nothing.
Wrap the lockup in `<a href="./">` there, matching the relative form the nav
already uses: it resolves to the app root whether the app is served at the
domain root or under a base path.
The link wraps the whole lockup, so the mark is clickable too, not just the
word. Its colour/decoration moves from `.site-name a` up to the shared
`.brand h1 a, .brand .site-name a` rule so it covers both the static pages'
<h1> form and the homepage's <p>, with an accent hover so the affordance is
visible.
Adds a parametrized test over every page: the two header implementations are
easy to change independently, so this asserts each one carries a home link that
wraps the mark.
2026-07-18 08:06:14 +00:00
|
|
|
|
/* Link colour/decoration for the brand now lives with the lockup rule at the top
|
|
|
|
|
|
of this file, so it covers the static pages' <h1> form too. */
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
.site-name { font-size: 20px; margin: 0; }
|
|
|
|
|
|
main.content { max-width: 880px; margin: 0 auto; padding: 8px 20px 56px; line-height: 1.6; }
|
|
|
|
|
|
main.content h1 { font-size: clamp(26px, 4vw, 36px); margin: 8px 0 4px; }
|
|
|
|
|
|
main.content h2 { font-size: 22px; margin: 32px 0 10px; }
|
|
|
|
|
|
main.content p { margin: 10px 0; }
|
|
|
|
|
|
main.content a { color: var(--accent); }
|
|
|
|
|
|
.content .lede { font-size: 18px; color: var(--text); }
|
|
|
|
|
|
.breadcrumb { font-size: 13px; color: var(--muted); margin: 4px 0 12px; }
|
|
|
|
|
|
.breadcrumb a { color: var(--muted); text-decoration: none; }
|
|
|
|
|
|
.breadcrumb a:hover { color: var(--accent); }
|
|
|
|
|
|
.breadcrumb .sep { opacity: .5; }
|
|
|
|
|
|
|
|
|
|
|
|
.content .cta {
|
|
|
|
|
|
display: inline-block; margin-top: 6px; padding: 11px 18px; border-radius: 10px;
|
|
|
|
|
|
background: var(--accent); color: #1a1206; font-weight: 700; text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.content .cta:hover { filter: brightness(1.05); }
|
|
|
|
|
|
|
|
|
|
|
|
.today-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 12px 0; }
|
|
|
|
|
|
.today-card {
|
|
|
|
|
|
display: flex; flex-direction: column; gap: 3px; padding: 12px 14px;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-left: 5px solid var(--border);
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tc-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
|
|
|
|
|
.tc-value { font-size: 17px; font-weight: 700; }
|
|
|
|
|
|
.tc-grade { font-size: 12px; color: var(--muted); }
|
|
|
|
|
|
|
|
|
|
|
|
.table-wrap { overflow-x: auto; }
|
|
|
|
|
|
table.normals-table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 15px; table-layout: fixed; }
|
2026-07-16 03:37:51 +00:00
|
|
|
|
/* Records as color-accented cards: a grid on desktop, single-column (high/low
|
|
|
|
|
|
stacked vertically) on mobile — no horizontal scroll. */
|
|
|
|
|
|
.records-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin: 14px 0; }
|
|
|
|
|
|
.record-card { border: 1px solid var(--border); background: var(--surface); border-radius: 12px; padding: 12px 14px; }
|
|
|
|
|
|
.rc-metric { margin: 0 0 8px; font-size: 16px; }
|
|
|
|
|
|
.rc-row { display: flex; align-items: baseline; gap: 8px; padding: 6px 0 6px 10px; border-left: 4px solid var(--border); }
|
|
|
|
|
|
.rc-row + .rc-row { margin-top: 4px; }
|
|
|
|
|
|
.rc-high { border-left-color: var(--very-hot); }
|
|
|
|
|
|
.rc-low { border-left-color: var(--very-cold); }
|
|
|
|
|
|
.rc-tag { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; min-width: 62px; flex-shrink: 0; }
|
|
|
|
|
|
.rc-high .rc-tag { color: var(--very-hot); }
|
|
|
|
|
|
.rc-low .rc-tag { color: var(--very-cold); }
|
|
|
|
|
|
.rc-val { font-weight: 700; font-size: 15px; }
|
|
|
|
|
|
.rc-date { color: var(--muted); font-size: 13px; margin-left: auto; white-space: nowrap; padding-left: 6px; }
|
|
|
|
|
|
.records-note { font-size: 13px; margin-top: 12px; }
|
|
|
|
|
|
@media (max-width: 640px) { .records-grid { grid-template-columns: 1fr; } }
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
table.normals-table th:first-child, table.normals-table td:first-child { width: 26%; }
|
|
|
|
|
|
table.normals-table th, table.normals-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
|
|
|
|
table.normals-table thead th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
|
|
|
|
|
table.normals-table td a { text-decoration: none; }
|
|
|
|
|
|
table.normals-table tbody tr:hover { background: var(--surface-2); }
|
2026-07-16 04:14:19 +00:00
|
|
|
|
/* On phones, tighten the climate tables so wide values (3-digit °F with the °C in
|
|
|
|
|
|
parens) never collide — the records tables are already 3 columns (date folded
|
|
|
|
|
|
under each value), and this keeps the city's 4-column normals table roomy too. */
|
2026-07-16 04:23:22 +00:00
|
|
|
|
/* Month/season records tables: value+date (and season name+span) stack within a
|
|
|
|
|
|
cell, so top-align keeps the two lines tidy instead of vertically centred. */
|
|
|
|
|
|
table.records-table td { vertical-align: top; }
|
|
|
|
|
|
table.records-table td:first-child { white-space: normal; }
|
|
|
|
|
|
|
2026-07-16 04:14:19 +00:00
|
|
|
|
@media (max-width: 560px) {
|
|
|
|
|
|
table.normals-table { font-size: 13px; }
|
|
|
|
|
|
table.normals-table th, table.normals-table td { padding: 7px 8px; }
|
|
|
|
|
|
.rec-on { font-size: 10.5px; }
|
2026-07-16 04:23:22 +00:00
|
|
|
|
/* Stack the "(Dec–Feb)" span under the season name so it can't overflow the
|
|
|
|
|
|
narrow first column and slide under the tinted value cell. */
|
|
|
|
|
|
.season-span { display: block; }
|
2026-07-16 04:14:19 +00:00
|
|
|
|
}
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
|
|
|
|
|
|
.climate-records ul { margin: 8px 0; padding-left: 20px; }
|
|
|
|
|
|
.climate-records li { margin: 4px 0; }
|
2026-07-16 03:50:59 +00:00
|
|
|
|
.season-span { color: var(--muted); font-weight: 400; font-size: 13px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Temperature heat-map tints — the interactive tool's diverging cold→hot palette
|
|
|
|
|
|
brought onto the static tables. The value itself stays in the ink token; the tint
|
|
|
|
|
|
is a wash behind it, so a record high glows red and a record low reads cold-blue.
|
|
|
|
|
|
Mix percentages are tuned per tier (saturated darks need less) to keep the number
|
|
|
|
|
|
legible in both themes; the date column stays muted and untinted. */
|
|
|
|
|
|
td.t-cell { font-weight: 600; }
|
|
|
|
|
|
td.rec-date { color: var(--muted); font-size: 13px; }
|
2026-07-16 04:14:19 +00:00
|
|
|
|
/* The date each record occurred, folded under its value so the month/season tables
|
|
|
|
|
|
stay 3 columns and fit a phone without horizontal scroll. */
|
|
|
|
|
|
.rec-on { display: block; font-size: 11px; font-weight: 400; color: var(--muted); margin-top: 1px; }
|
2026-07-16 04:57:13 +00:00
|
|
|
|
/* Month/season records: each metric cell carries two extremes — ▲ warmest and
|
|
|
|
|
|
▼ coldest it has ever been — as tinted chips, each with the date it occurred. */
|
|
|
|
|
|
.rec-ext { margin: 4px 0; }
|
|
|
|
|
|
.rec-arrow { display: inline-block; width: 12px; font-size: 10px; }
|
|
|
|
|
|
.rec-arrow.up { color: var(--very-hot); }
|
|
|
|
|
|
.rec-arrow.down { color: var(--very-cold); }
|
|
|
|
|
|
.records-ext .t-inline { font-weight: 700; }
|
|
|
|
|
|
.records-ext .rec-on { margin: 1px 0 2px 12px; font-size: 10.5px; }
|
2026-07-16 03:50:59 +00:00
|
|
|
|
.t-rec-cold { background: color-mix(in srgb, var(--rec-cold) 26%, var(--surface)); }
|
|
|
|
|
|
.t-very-cold { background: color-mix(in srgb, var(--very-cold) 26%, var(--surface)); }
|
|
|
|
|
|
.t-cold { background: color-mix(in srgb, var(--cold) 28%, var(--surface)); }
|
|
|
|
|
|
.t-cool { background: color-mix(in srgb, var(--cool) 34%, var(--surface)); }
|
|
|
|
|
|
.t-normal { background: color-mix(in srgb, var(--normal) 26%, var(--surface)); }
|
|
|
|
|
|
.t-warm { background: color-mix(in srgb, var(--warm) 40%, var(--surface)); }
|
|
|
|
|
|
.t-hot { background: color-mix(in srgb, var(--hot) 40%, var(--surface)); }
|
|
|
|
|
|
.t-very-hot { background: color-mix(in srgb, var(--very-hot) 40%, var(--surface)); }
|
|
|
|
|
|
.t-rec-hot { background: color-mix(in srgb, var(--rec-hot) 30%, var(--surface)); }
|
|
|
|
|
|
.t-none { background: transparent; }
|
|
|
|
|
|
/* Inline tinted value (month-page hero numbers, record list) — same washes, padded
|
|
|
|
|
|
so the colour reads as a chip around the number. */
|
|
|
|
|
|
.t-inline { padding: 1px 7px; border-radius: 7px; }
|
|
|
|
|
|
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
/* ---- Climate score page ---------------------------------------------------
|
|
|
|
|
|
Hero (overall score), per-metric cards reuse .normal-card, a by-season chip
|
|
|
|
|
|
grid, and a button-revealed summary with tinted score tables. All color comes
|
|
|
|
|
|
from the shared temperature scale via --cat / the .t-* washes. */
|
|
|
|
|
|
.score-hero {
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
|
|
|
|
|
padding: 18px 20px; margin: 4px 0 18px;
|
|
|
|
|
|
}
|
2026-07-20 04:02:40 +00:00
|
|
|
|
.score-eyebrow { margin: 0 0 8px; } /* else styled by .section-title */
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
.score-hero-row { display: flex; align-items: center; gap: 16px; }
|
|
|
|
|
|
.score-num {
|
|
|
|
|
|
font-size: 52px; font-weight: 800; line-height: 1;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 15%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.score-hero-label { display: flex; flex-direction: column; gap: 4px; }
|
|
|
|
|
|
.score-grade {
|
|
|
|
|
|
font-size: 18px; font-weight: 700;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)), var(--text) 22%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.score-sub { font-size: 13px; color: var(--muted); }
|
|
|
|
|
|
.score-note { font-size: 12px; color: var(--muted); margin: 12px 0 0; line-height: 1.4; }
|
|
|
|
|
|
|
|
|
|
|
|
.score-section { margin: 20px 0; }
|
|
|
|
|
|
|
2026-07-20 00:22:38 +00:00
|
|
|
|
/* Score metric cards: name · score · tier · detail, each on its own line so a
|
|
|
|
|
|
long grade never crams into the corner on a narrow (2-up) phone card. */
|
|
|
|
|
|
.score-card h3 { margin-bottom: 6px; }
|
|
|
|
|
|
.score-card .nc-tier {
|
|
|
|
|
|
font-size: 12px; font-weight: 700; margin-top: 3px; line-height: 1.25;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--muted)), var(--text) 22%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.score-card .range { margin-top: 1px; }
|
|
|
|
|
|
|
2026-07-19 23:37:42 +00:00
|
|
|
|
/* Wet-bulb explainer + heat-stress-day share. */
|
|
|
|
|
|
.score-callout {
|
|
|
|
|
|
background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px;
|
|
|
|
|
|
padding: 12px 14px; margin: 14px 0 4px; font-size: 13px; line-height: 1.5; color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 23:57:55 +00:00
|
|
|
|
/* By-season scores: seasons as rows, one uniform column per metric so every
|
|
|
|
|
|
metric lines up vertically across the seasons. Fixed layout keeps the columns
|
|
|
|
|
|
equal width; the min-width lets it scroll (not squash) on a phone. */
|
|
|
|
|
|
.season-table { table-layout: fixed; width: 100%; min-width: 620px; max-width: 740px; }
|
|
|
|
|
|
.season-table th:first-child, .season-table td:first-child { width: 62px; text-align: left; }
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
|
|
|
|
|
|
/* Summary reveal — pill button + caret, mirrors the season-expand pattern. */
|
|
|
|
|
|
.summary-toggle {
|
|
|
|
|
|
display: inline-flex; align-items: center; gap: 6px; min-height: 44px;
|
|
|
|
|
|
margin: 8px 0 4px; padding: 8px 16px; border: 1px solid var(--border);
|
|
|
|
|
|
border-radius: 10px; background: var(--surface); color: var(--text);
|
|
|
|
|
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.summary-toggle:hover { border-color: var(--accent); }
|
|
|
|
|
|
.summary-caret { transition: transform .15s ease; }
|
|
|
|
|
|
.summary-toggle[aria-expanded="true"] { border-color: var(--accent); }
|
|
|
|
|
|
.summary-toggle[aria-expanded="true"] .summary-caret { transform: rotate(180deg); }
|
|
|
|
|
|
|
|
|
|
|
|
#score-summary { margin-top: 14px; }
|
|
|
|
|
|
.score-summary-lead { font-size: 14px; color: var(--text); margin: 6px 0; }
|
|
|
|
|
|
.score-sentences { margin: 8px 0 18px; padding-left: 20px; }
|
|
|
|
|
|
.score-sentences li { font-size: 14px; line-height: 1.5; margin-bottom: 8px; color: var(--text); }
|
|
|
|
|
|
|
2026-07-20 04:02:40 +00:00
|
|
|
|
#score-body .table-wrap { margin: 8px 0 20px; } /* .table-wrap supplies overflow-x */
|
Add a climate-score page from recent-vs-baseline percentile divergence (#196)
Score how far a location's last 6 years have drifted from its full 45-year
record. For each metric and percentile category (p10/p25/p50/p75/p90), the
recent-years value is placed on the baseline distribution and the gap from the
expected percentile is the divergence — unit-free, so metrics compare directly.
Scored per meteorological season plus annual, weighted into per-metric and
overall scores (temps, humidity and feels-like weighted heaviest).
- backend/scoring.py: divergence math, seasonal slicing, precip zero-inflation
split (wet-day frequency + amount), tier mapping onto the existing temp scale.
- climate.py: derive a wet-bulb column (Stull 2011) at the read boundary, before
the humidity column is converted to absolute — via a shared _derive_metrics
wrapper at all four read sites.
- api/v2/score endpoint + build_score payload, cached on the history token with
a scoring-version key.
- frontend score page: overall hero, per-metric cards, by-season chips, and a
button-revealed summary (sentences + metrics×season table + per-percentile
detail). Score nav link across all headers.
- Tests for the scoring math, wet-bulb formula, payload shape and route.
2026-07-19 23:02:33 +00:00
|
|
|
|
.score-table { border-collapse: collapse; font-size: 13px; }
|
|
|
|
|
|
.score-table th, .score-table td {
|
|
|
|
|
|
padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.score-table thead th { color: var(--muted); font-weight: 600; }
|
|
|
|
|
|
.score-table tbody th { text-align: left; font-weight: 600; color: var(--text); }
|
|
|
|
|
|
.score-table-overall th, .score-table-overall td {
|
|
|
|
|
|
border-top: 2px solid var(--border); font-weight: 800;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pq-table td { text-align: left; font-size: 12px; color: var(--muted); }
|
|
|
|
|
|
.pq-d { font-weight: 700; }
|
|
|
|
|
|
.pq-up { color: color-mix(in oklab, var(--hot), var(--text) 30%); }
|
|
|
|
|
|
.pq-down { color: color-mix(in oklab, var(--cool), var(--text) 30%); }
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
|
.score-num { font-size: 40px; }
|
|
|
|
|
|
.score-hero { padding: 14px 15px; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 03:50:59 +00:00
|
|
|
|
/* Monthly temperature-range strip: one gradient bar per month, low→high on a shared
|
|
|
|
|
|
axis. The signature climate visual — colourful and unmistakably Thermograph. */
|
|
|
|
|
|
.range-strip { display: grid; gap: 6px; margin: 16px 0 8px; max-width: 620px; }
|
|
|
|
|
|
.range-row { display: grid; grid-template-columns: 34px 1fr 92px; align-items: center; gap: 10px; }
|
|
|
|
|
|
.range-month { font-size: 13px; font-weight: 600; color: var(--muted); }
|
|
|
|
|
|
.range-track { position: relative; height: 14px; background: var(--surface-2); border-radius: 999px; }
|
|
|
|
|
|
.range-fill {
|
|
|
|
|
|
position: absolute; top: 0; bottom: 0; border-radius: 999px;
|
|
|
|
|
|
background: linear-gradient(90deg, var(--c1), var(--c2));
|
|
|
|
|
|
}
|
|
|
|
|
|
.range-vals { font-size: 13px; color: var(--text); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
|
|
|
|
|
.range-note { font-size: 13px; margin-top: 6px; }
|
2026-07-16 04:23:22 +00:00
|
|
|
|
/* Phone: cap the strip so bars + values pack into a tight group on the left
|
|
|
|
|
|
instead of stretching edge-to-edge, and shrink the values to fit (no reserved
|
|
|
|
|
|
92px column). Placed after the base rules above so it actually wins. */
|
|
|
|
|
|
@media (max-width: 560px) {
|
|
|
|
|
|
.range-strip { max-width: 300px; }
|
|
|
|
|
|
.range-row { grid-template-columns: 26px 1fr auto; gap: 8px; }
|
|
|
|
|
|
.range-month { font-size: 12px; }
|
|
|
|
|
|
.range-vals { font-size: 12px; }
|
|
|
|
|
|
}
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
.climate-foot { font-size: 14px; margin-top: 28px; border-top: 1px solid var(--border); padding-top: 16px; }
|
2026-07-16 00:39:47 +00:00
|
|
|
|
.city-blurb { font-size: 15px; margin: 4px 0 14px; }
|
|
|
|
|
|
.blurb-src { color: var(--muted); text-decoration: none; font-size: 13px; white-space: nowrap; }
|
|
|
|
|
|
.blurb-src:hover { color: var(--accent); }
|
2026-07-16 01:23:45 +00:00
|
|
|
|
.city-event { border-left: 4px solid var(--accent); background: var(--surface); border-radius: 0 10px 10px 0; padding: 12px 18px; margin: 22px 0; }
|
|
|
|
|
|
.city-event h2 { margin: 2px 0 6px; font-size: 18px; }
|
|
|
|
|
|
.city-event p { margin: 0; }
|
2026-07-16 00:39:47 +00:00
|
|
|
|
.city-travel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; margin: 28px 0; }
|
|
|
|
|
|
.city-travel h2 { margin-top: 4px; }
|
2026-07-16 05:42:59 +00:00
|
|
|
|
/* Month page: the "visiting in <month>?" prompt as a compact callout — text on
|
|
|
|
|
|
the left, a CTA button on the right (stacks on narrow screens). */
|
|
|
|
|
|
.travel-note {
|
|
|
|
|
|
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px;
|
|
|
|
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
|
|
|
|
padding: 14px 18px; margin: 22px 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.travel-note p { margin: 0; flex: 1 1 240px; }
|
|
|
|
|
|
.travel-note .cta { flex-shrink: 0; margin-top: 0; }
|
2026-07-16 00:39:47 +00:00
|
|
|
|
.travel-note { font-size: 15px; }
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
|
|
|
|
|
|
/* Hub / directory of cities */
|
2026-07-16 03:17:00 +00:00
|
|
|
|
/* Collapsible country sections (native <details>). */
|
2026-07-16 04:44:18 +00:00
|
|
|
|
/* Hub search box + flat alphabetical results. */
|
|
|
|
|
|
.hub-search { margin: 16px 0 4px; }
|
|
|
|
|
|
.hub-search input {
|
|
|
|
|
|
width: 100%; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
|
|
|
|
|
background: var(--bg); color: var(--text); font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hub-search input:focus { outline: none; border-color: var(--accent); }
|
|
|
|
|
|
.hub-results { list-style: none; margin: 8px 0; padding: 0; }
|
|
|
|
|
|
.hub-results[hidden] { display: none; }
|
|
|
|
|
|
.hub-results li { padding: 11px 4px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; gap: 12px; }
|
|
|
|
|
|
.hub-results li a { text-decoration: none; font-weight: 600; }
|
|
|
|
|
|
.hub-r-country { color: var(--muted); font-size: 13px; margin-left: auto; text-align: right; white-space: nowrap; }
|
|
|
|
|
|
.hub-results .hub-empty { color: var(--muted); }
|
|
|
|
|
|
#hub-directory[hidden] { display: none; }
|
|
|
|
|
|
|
2026-07-16 03:17:00 +00:00
|
|
|
|
details.hub-country { border-bottom: 1px solid var(--border); }
|
|
|
|
|
|
details.hub-country > summary {
|
|
|
|
|
|
cursor: pointer; padding: 12px 4px; font-weight: 700; font-size: 16px;
|
|
|
|
|
|
list-style: none; display: flex; align-items: center; gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
details.hub-country > summary::-webkit-details-marker { display: none; }
|
|
|
|
|
|
details.hub-country > summary::before {
|
|
|
|
|
|
content: "\25B8"; color: var(--muted); transition: transform .15s ease; display: inline-block;
|
|
|
|
|
|
}
|
|
|
|
|
|
details.hub-country[open] > summary::before { transform: rotate(90deg); }
|
|
|
|
|
|
details.hub-country > summary:hover { color: var(--accent); }
|
|
|
|
|
|
.hub-count { margin-left: auto; color: var(--muted); font-weight: 500; font-size: 13px; }
|
|
|
|
|
|
.city-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; padding: 2px 4px 16px 22px; list-style: none; }
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
.city-links a { text-decoration: none; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Glossary / about editorial */
|
|
|
|
|
|
.glossary-term { border-bottom: 1px solid var(--border); padding: 16px 0; }
|
|
|
|
|
|
.glossary-term h2 { margin: 0 0 6px; }
|
|
|
|
|
|
|
|
|
|
|
|
.site-footer {
|
|
|
|
|
|
max-width: 880px; margin: 0 auto; padding: 24px 20px 48px; border-top: 1px solid var(--border);
|
|
|
|
|
|
color: var(--muted); font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.site-footer nav { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-bottom: 10px; }
|
|
|
|
|
|
.site-footer a { color: var(--accent); text-decoration: none; }
|
|
|
|
|
|
.site-footer p { margin: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
|
main.content { padding: 8px 14px 40px; }
|
|
|
|
|
|
.today-cards { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
|
|
|
|
|
|
}
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
|
|
|
|
|
|
/* ============================================================
|
|
|
|
|
|
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); }
|
Fix the hero's "Use my location" doing nothing (#179)
The button was dead on plain HTTP and failed silently everywhere.
Browsers only expose geolocation on secure origins, but `navigator.geolocation`
still EXISTS on http:// — getCurrentPosition just fails with a permission error
("Only secure origins are allowed"). The guard was `if (!navigator.geolocation)
return;`, which passes on http://, so the call went ahead, errored, and landed
in an empty error callback. Nothing happened at all.
`make lan-run` serves plain HTTP on 0.0.0.0:8137 so phones can reach it, so the
button could never work in the normal dev-and-phone workflow, and gave no hint
why. It would have worked on thermograph.org, which is HTTPS.
- Gate on `window.isSecureContext`, not just the API's presence. Where the
browser will refuse, hide the locate button and promote "Pick on the map" to
primary rather than offering a control that cannot work.
- Report failures: a declined prompt, a timeout, and an unavailable fix each get
their own message in an aria-live slot. A silent failure is indistinguishable
from a broken button, which is exactly how this went unnoticed.
- Show a "Locating…" state while the fix is pending; it can take seconds.
To exercise geolocation against the LAN dev server, serve it over TLS:
`make lan-run TLS=1` (self-signed cert into certs/).
Verified by driving the button in a real browser across all three cases:
insecure origin (button hidden, map promoted), permission granted (hero
re-points at the located place), permission denied (message shown, button
restored).
2026-07-18 07:48:17 +00:00
|
|
|
|
.hero-locate-msg { margin: 0; font-size: 13px; color: var(--muted); max-width: 42ch; }
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
.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 {
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
|
display: grid; gap: 2px; min-width: 190px; padding: 12px 14px;
|
|
|
|
|
|
/* A wash of the band colour, so the card reads as its grade at a glance and
|
|
|
|
|
|
the strip isn't a row of identical grey boxes. */
|
|
|
|
|
|
background: color-mix(in oklab, var(--cat, var(--surface)) 10%, var(--surface));
|
|
|
|
|
|
border: 1px solid color-mix(in oklab, var(--cat, var(--border)) 25%, var(--border));
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
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; }
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
|
/* The reading itself leads: a percentile with no value attached says nothing
|
|
|
|
|
|
about what the weather actually is. */
|
|
|
|
|
|
/* 50% is the most band colour these can carry and still clear 4.5:1 against the
|
|
|
|
|
|
tinted card in BOTH schemes — measured across the ramp, worst case being the
|
|
|
|
|
|
deliberately-dark --rec-cold/--rec-hot "Near Record" tiers. Raising it fails
|
|
|
|
|
|
dark mode; the band word carries the meaning anyway, colour only reinforces. */
|
|
|
|
|
|
.unusual-value { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.15;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)) 50%, var(--text)); }
|
|
|
|
|
|
.unusual-band { font-size: 13px; font-weight: 600;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)) 50%, var(--text)); }
|
|
|
|
|
|
.unusual-meta { font-size: 12px; color: var(--muted); }
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
.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;
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
|
/* Each card is tinted by its own token so the row carries the product's
|
|
|
|
|
|
palette instead of reading as four identical grey boxes. */
|
|
|
|
|
|
background: color-mix(in oklab, var(--cat, var(--surface)) 9%, var(--surface));
|
|
|
|
|
|
border: 1px solid color-mix(in oklab, var(--cat, var(--border)) 28%, var(--border));
|
|
|
|
|
|
border-left: 4px solid var(--cat, var(--border));
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
border-radius: 11px; text-decoration: none; color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
.explore-card:hover { border-color: var(--accent); }
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
|
.explore-name { font-weight: 600;
|
|
|
|
|
|
color: color-mix(in oklab, var(--cat, var(--text)) 45%, var(--text)); }
|
|
|
|
|
|
/* The nine grade tiers in order — a miniature of the calendar itself. */
|
|
|
|
|
|
.explore-ramp { display: flex; gap: 2px; margin: 2px 0 1px; }
|
|
|
|
|
|
.explore-ramp i { flex: 1 1 0; height: 6px; border-radius: 2px; }
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
Give the records strip real context, and colour the explore cards (#183)
Every card in "Unusual right now" read the same: a city, "100th", "Near Record".
Nothing said what was unusual, by how much, or even whether it was hot or cold.
Cards now lead with the reading and say what it is measured against:
Tehran, Iran
108°F
Near Record hot
High temp · 99th pct · normal 97°F
- "Near Record" is the band label at BOTH ends of the scale, so a cold extreme
and a hot one rendered identically and colour was the only thing telling them
apart. Qualify that tier with its direction; the other tiers already read
directionally ("Very High", "Low") and are left alone.
- Carry the ±7-day median through to the card. A percentile means little without
the value it describes and the normal it departs from.
- Floor the percentile label into 1-99. An empirical percentile can round to 100,
and "100th percentile" reads as a measurement error.
- Shorten the city label to "City, Country". display_name keeps admin1 when it
differs from the city, which gave "Dar es Salaam, Dar es Salaam Region,
Tanzania" — truncated to nonsense in a one-line card.
- Temperatures render as .temp spans and the homepage now loads climate.js, so
the strip follows the °F/°C toggle like every other server-rendered page.
Colour: the strip cards and the explore cards take a wash and a border from
their token instead of being uniform grey, and Calendar carries the nine grade
tiers in order — it is a miniature of the calendar itself.
Text colours mix 50% band colour with the foreground. That is the most colour
they can carry and still clear 4.5:1 against the tinted card in both schemes;
the worst case is the deliberately-dark --rec-cold/--rec-hot tiers, which at the
previous 78% measured 2.33:1 in dark mode. Measured, not eyeballed: worst text
contrast is now 4.79 (light) and 4.81 (dark).
2026-07-18 08:19:27 +00:00
|
|
|
|
.city-chips a:hover {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
background: color-mix(in oklab, var(--accent) 12%, var(--surface));
|
|
|
|
|
|
}
|
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.
2026-07-18 07:39:47 +00:00
|
|
|
|
.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); }
|
2026-07-20 00:40:04 +00:00
|
|
|
|
|
|
|
|
|
|
/* iOS "Add to Home Screen" hint (frontend/ios-install.js) — a slim dismissible
|
|
|
|
|
|
card pinned above the home indicator, shown only to iOS Safari visitors who
|
|
|
|
|
|
haven't installed yet. Themed like the rest of the surface chrome. */
|
|
|
|
|
|
.ios-a2hs {
|
|
|
|
|
|
position: fixed; z-index: 950;
|
|
|
|
|
|
left: 50%; transform: translateX(-50%);
|
|
|
|
|
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
|
|
|
|
|
width: min(440px, calc(100vw - 24px));
|
|
|
|
|
|
display: flex; align-items: center; gap: 12px;
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
background: var(--surface); color: var(--text);
|
|
|
|
|
|
border: 1px solid var(--border); border-radius: 14px;
|
|
|
|
|
|
box-shadow: 0 8px 30px rgba(0, 0, 0, .35);
|
|
|
|
|
|
animation: ios-a2hs-in .25s ease-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes ios-a2hs-in { from { opacity: 0; transform: translate(-50%, 12px); } }
|
|
|
|
|
|
.ios-a2hs-share { width: 26px; height: 26px; flex: 0 0 auto; color: var(--accent); }
|
|
|
|
|
|
.ios-a2hs-text { display: flex; flex-direction: column; gap: 2px; font-size: 13px; line-height: 1.3; }
|
|
|
|
|
|
.ios-a2hs-text b { font-weight: 700; }
|
|
|
|
|
|
.ios-a2hs-text span { color: var(--muted); }
|
|
|
|
|
|
.ios-a2hs-x {
|
|
|
|
|
|
flex: 0 0 auto; margin-left: auto;
|
|
|
|
|
|
width: 32px; height: 32px; border-radius: 8px;
|
|
|
|
|
|
background: none; border: none; color: var(--muted);
|
|
|
|
|
|
font-size: 22px; line-height: 1; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.ios-a2hs-x:hover { color: var(--text); background: var(--surface-2); }
|