Large-monitor layouts + compact CTA, comfort-slider fix, month-picker on click (#37)

- Widen the content column in steps at 1680/2400/3400px so 1440p-4K monitors
  get real layout instead of a 1200px strip; header brand aligns with it.
- Calendar: months flow as a responsive multi-column grid (year-at-a-glance
  on desktop, single column on phones); the time-filter panel spans the
  content width as one row (Range | Seasons/Years/Weather) on desktop;
  category-totals strip widened to 720px.
- Day: metric ladder cards lay out 2-up from 1000px (3-up at 4K).
- Weekly: the calendar CTA is a compact button (sub-line removed); the trend
  chart's drawing width now tracks its container (~1.5 CSS px per SVG unit,
  redrawn on resize) so wide screens gain plot room instead of magnification.
- Compare: scope the rank-bar segment colors to .cmp-seg — the bare
  .cmp-comfort rule was painting a green box behind the comfort slider.
- Calendar/Compare From-To month inputs open the native month picker on
  click (input.showPicker, ignored where unsupported).
- CLAUDE.md: require validating design changes at mobile/tablet/1080p/2K/4K.
This commit is contained in:
Emi Griffith 2026-07-11 11:47:52 -07:00 committed by GitHub
parent 85e4e08f96
commit c12b5953c0
4 changed files with 105 additions and 31 deletions

View file

@ -170,10 +170,7 @@ function render(data) {
</div> </div>
<a class="cta-cal" href="calendar#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}"> <a class="cta-cal" href="calendar#lat=${selected.lat.toFixed(5)}&lon=${selected.lon.toFixed(5)}">
<span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span> <span class="cta-cal-icon" aria-hidden="true">${IC.calendar}</span>
<span class="cta-cal-text"> <span>Check historical data</span>
<span class="cta-cal-title">Check historical data</span>
<span class="cta-cal-sub">The last 2 years, graded day by day</span>
</span>
<span class="cta-cal-arrow" aria-hidden="true"></span> <span class="cta-cal-arrow" aria-hidden="true"></span>
</a> </a>
<div class="section-title">${data.target_date} vs a typical day tap a metric for the full breakdown</div> <div class="section-title">${data.target_date} vs a typical day tap a metric for the full breakdown</div>
@ -263,6 +260,14 @@ window.Thermograph.onUnitChange(() => {
if (recentData) render(recentData); if (recentData) render(recentData);
}); });
// Rebuild the chart when the window is resized so its drawing width keeps
// tracking the container (debounced — resize fires continuously while dragging).
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { if (recentData) renderSeries(); }, 160);
});
function renderSeries() { function renderSeries() {
const host = document.getElementById("series"); const host = document.getElementById("series");
if (!host || !recentData) return; if (!host || !recentData) return;
@ -369,9 +374,13 @@ function chartPalette() {
}; };
} }
const W = 720, H = 340; // W/PW are recomputed per build from the container width (see buildChart), so a
// wide monitor gets a genuinely wider drawing — more room between days — instead
// of the same 720-unit canvas magnified.
let W = 720;
const H = 340;
const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308; const PL = 46, PR = 62, plotTop = 48, plotBot = 288, xLabY = 308;
const PW = W - PL - PR; let PW = W - PL - PR;
const ord = (n) => { const ord = (n) => {
const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100; const r = Math.round(n), s = ["th", "st", "nd", "rd"], v = r % 100;
return r + (s[(v - 20) % 10] || s[v] || s[0]); return r + (s[(v - 20) % 10] || s[v] || s[0]);
@ -397,6 +406,11 @@ let chartMetric = localStorage.getItem("thermograph:chartMetric") || "tmax";
function buildChart(data) { function buildChart(data) {
const wrap = document.getElementById("chart-wrap"); const wrap = document.getElementById("chart-wrap");
const C = chartPalette(); const C = chartPalette();
// Drawing width tracks the on-screen box at a steady ~1.5 CSS px per SVG unit,
// so text renders the same size everywhere while wide monitors gain plot room.
// Phones keep the 720-unit floor (the SVG just scales down).
W = Math.max(720, Math.min(1400, Math.round((wrap.clientWidth || 720) / 1.5)));
PW = W - PL - PR;
lastGraded = data; // the series currently drawn (for PNG export) lastGraded = data; // the series currently drawn (for PNG export)
chartDays = [...data.recent].reverse(); // oldest -> newest chartDays = [...data.recent].reverse(); // oldest -> newest
const days = chartDays, n = days.length; const days = chartDays, n = days.length;

View file

@ -412,6 +412,13 @@ function markRangeDirty() { refreshBtn.hidden = false; }
startInput.addEventListener("change", markRangeDirty); startInput.addEventListener("change", markRangeDirty);
endInput.addEventListener("change", markRangeDirty); endInput.addEventListener("change", markRangeDirty);
// Clicking anywhere in a From/To field opens the native month/year picker.
// Desktop Chrome otherwise only opens it from the tiny calendar glyph and just
// focuses a segment. showPicker isn't everywhere (Safari/Firefox) — ignore there.
for (const el of [startInput, endInput]) {
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
}
// Confirm: read the pickers into the active range and (re)load. // Confirm: read the pickers into the active range and (re)load.
function applyRange() { function applyRange() {
let s = startInput.value ? monthStart(startInput.value) : null; let s = startInput.value ? monthStart(startInput.value) : null;

View file

@ -383,6 +383,11 @@ basisToggle.addEventListener("click", (e) => {
startInput.max = endInput.max = `${new Date().getFullYear()}-12`; startInput.max = endInput.max = `${new Date().getFullYear()}-12`;
startInput.addEventListener("change", renderAll); startInput.addEventListener("change", renderAll);
endInput.addEventListener("change", renderAll); endInput.addEventListener("change", renderAll);
// Clicking anywhere in a From/To field opens the native month/year picker
// (mirrors the Calendar range; showPicker is missing in some browsers — ignore).
for (const el of [startInput, endInput]) {
el.addEventListener("click", () => { try { el.showPicker(); } catch (e) {} });
}
// ---- init ---- // ---- init ----
(function restore() { (function restore() {

View file

@ -58,7 +58,10 @@ header {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
.brand { display: flex; align-items: center; gap: 14px; } /* 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; }
/* The title block (logo's neighbour) grows and may shrink, so the unit toggle is /* 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 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. */ instead of bumping the toggle onto its own line. */
@ -79,6 +82,22 @@ header.compact h1 { font-size: 18px; }
main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
/* 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; }
}
.controls { margin-bottom: 18px; display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; } .controls { margin-bottom: 18px; display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
.search { position: relative; flex: 1 1 340px; display: flex; gap: 8px; } .search { position: relative; flex: 1 1 340px; display: flex; gap: 8px; }
.search input { .search input {
@ -159,24 +178,23 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.wx { width: 1.15em; height: 1.15em; vertical-align: -0.2em; } .wx { width: 1.15em; height: 1.15em; vertical-align: -0.2em; }
.share button:hover, .share .share-btn:hover { border-color: var(--accent); } .share button:hover, .share .share-btn:hover { border-color: var(--accent); }
/* Prominent call-to-action into the 2-year calendar the primary next step, /* Call-to-action into the 2-year calendar a compact button-sized link, so the
so it reads unmistakably as a tappable button (full width, big touch target). */ graded results stay the page's focal point. */
.cta-cal { .cta-cal {
display: flex; align-items: center; gap: 13px; display: inline-flex; align-items: center; gap: 9px;
width: 100%; margin: 0 0 20px; padding: 14px 16px; min-height: 60px; margin: 0 0 20px; padding: 10px 16px; min-height: 44px;
border: none; border-radius: 13px; text-decoration: none; border: none; border-radius: 10px; text-decoration: none;
font-size: 14px; font-weight: 700;
color: #1a1206; background: linear-gradient(135deg, #f79556, #ea722c); color: #1a1206; background: linear-gradient(135deg, #f79556, #ea722c);
box-shadow: 0 4px 16px rgba(240, 128, 60, .3); box-shadow: 0 2px 8px rgba(240, 128, 60, .25);
transition: transform .12s ease, box-shadow .12s ease, filter .12s ease; transition: transform .12s ease, box-shadow .12s ease, filter .12s ease;
} }
.cta-cal:hover { transform: translateY(-1px); box-shadow: 0 7px 22px rgba(240, 128, 60, .42); filter: brightness(1.04); } .cta-cal .ic-lg { width: 18px; height: 18px; }
.cta-cal:active { transform: translateY(0); box-shadow: 0 3px 10px rgba(240, 128, 60, .34); } .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); }
.cta-cal:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; } .cta-cal:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; }
.cta-cal-icon { font-size: 26px; line-height: 1; flex-shrink: 0; } .cta-cal-icon { line-height: 1; flex-shrink: 0; display: inline-flex; }
.cta-cal-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .cta-cal-arrow { font-size: 16px; font-weight: 800; flex-shrink: 0; }
.cta-cal-title { font-size: 16px; font-weight: 800; letter-spacing: .01em; }
.cta-cal-sub { font-size: 12.5px; font-weight: 600; opacity: .8; }
.cta-cal-arrow { margin-left: auto; font-size: 22px; font-weight: 800; flex-shrink: 0; }
#chart-wrap { position: relative; margin-bottom: 22px; } #chart-wrap { position: relative; margin-bottom: 22px; }
#chart-wrap svg { width: 100%; height: auto; display: block; border-radius: 10px; touch-action: pan-y; } #chart-wrap svg { width: 100%; height: auto; display: block; border-radius: 10px; touch-action: pan-y; }
@ -332,7 +350,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.range-refresh:hover { filter: brightness(1.05); } .range-refresh:hover { filter: brightness(1.05); }
/* Time-filter panel: month/year range + season + year checklists, stacked in a /* Time-filter panel: month/year range + season + year checklists, stacked in a
tidy column so nothing balloons on wide screens or leaves gaps on phones. */ tidy column on phones. */
.cal-filters { .cal-filters {
display: flex; flex-direction: column; gap: 16px; display: flex; flex-direction: column; gap: 16px;
max-width: 560px; margin: 0 0 18px; padding: 14px 16px; max-width: 560px; margin: 0 0 18px; padding: 14px 16px;
@ -340,6 +358,25 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
} }
/* Season + year dropdowns fill the row evenly — no dead space to their right. */ /* Season + year dropdowns fill the row evenly — no dead space to their right. */
.cal-seasonyear { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .cal-seasonyear { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* 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; }
}
.cal-filter-group { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .cal-filter-group { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cal-filter-label { .cal-filter-label {
font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
@ -370,11 +407,14 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
border-radius: 999px; border-radius: 999px;
} }
/* Months stacked vertically (one per row), with roomier cells. */ /* Months flow into as many columns as fit (one on phones, several on monitors),
.calendar { display: flex; flex-direction: column; gap: 18px; margin-bottom: 22px; align-items: stretch; } so a multi-year span reads as a year-at-a-glance wall instead of one long
/* Each month fills the available width (capped on wide desktops so the squares thin strip. Cells stay square via aspect-ratio. */
don't balloon); the 7 columns split it evenly and cells stay square. */ .calendar {
.cal-month { width: 100%; max-width: 560px; } display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px 24px; margin-bottom: 22px; align-items: start;
}
.cal-month { width: 100%; }
.cal-month h4 { margin: 0 0 6px; font-size: 14px; color: var(--text); font-weight: 700; } .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, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; }
.cal-dows { margin-bottom: 3px; } .cal-dows { margin-bottom: 3px; }
@ -444,9 +484,15 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.day-step:hover:not(:disabled) { border-color: var(--accent); } .day-step:hover:not(:disabled) { border-color: var(--accent); }
.day-step:disabled { opacity: .4; cursor: default; } .day-step:disabled { opacity: .4; cursor: default; }
/* Metric cards: one column on phones, side by side on monitors so the page
doesn't run as a single long strip of full-width ladders. */
#day-body { display: grid; grid-template-columns: 1fr; gap: 16px; align-items: start; }
@media (min-width: 1000px) { #day-body { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 3400px) { #day-body { grid-template-columns: repeat(3, 1fr); } }
.ladder-card { .ladder-card {
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
border-radius: 13px; padding: 16px 16px 12px; margin-bottom: 16px; border-radius: 13px; padding: 16px 16px 12px;
} }
.ladder-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .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-head h3 { margin: 0; font-size: 15px; }
@ -787,7 +833,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
"Show count" is ticked, in that category's own status color) above a thin status- "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 colored line rising from a shared baseline (height share). Every category draws
a line including the neutral "Normal" center of the temperature scale. */ a line including the neutral "Normal" center of the temperature scale. */
.cal-totals { max-width: 560px; margin: 0 0 18px; } .cal-totals { max-width: 720px; margin: 0 0 18px; }
/* Header row: the "N days shown — by X" title, with the share/count toggle at the /* 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. */ 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 { display: flex; align-items: baseline; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; }
@ -911,9 +957,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; margin: 12px 0 10px; background: var(--surface-2); } .cmp-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; margin: 12px 0 10px; background: var(--surface-2); }
.cmp-seg { display: block; height: 100%; } .cmp-seg { display: block; height: 100%; }
.cmp-below { background: var(--cold); } /* Scoped to the bar segments .cmp-comfort is also the comfort-slider param's
.cmp-comfort { background: var(--normal); } class, which must not inherit the green fill. */
.cmp-above { background: var(--hot); } .cmp-seg.cmp-below { background: var(--cold); }
.cmp-seg.cmp-comfort { background: var(--normal); }
.cmp-seg.cmp-above { background: var(--hot); }
.cmp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } .cmp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.cmp-stat { text-align: center; padding: 8px 4px; border-radius: 8px; background: var(--surface-2); } .cmp-stat { text-align: center; padding: 8px 4px; border-radius: 8px; background: var(--surface-2); }