strip more seamless and margin improvements

This commit is contained in:
Vincent van der Wal
2026-08-01 11:38:02 +02:00
parent 99c1a89537
commit a0379a1fd6
13 changed files with 706 additions and 188 deletions
+44 -10
View File
@@ -152,6 +152,10 @@
group?: string; group?: string;
/** Fixed left-axis minimum (otherwise derived from data, including 0) */ /** Fixed left-axis minimum (otherwise derived from data, including 0) */
yMin?: number; yMin?: number;
/** Minimum breathing room (axis units) above the left-axis data range. */
yPadTop?: number;
/** Minimum breathing room (axis units) below the left-axis data range. */
yPadBottom?: number;
/** Fixed left-axis maximum */ /** Fixed left-axis maximum */
yMax?: number; yMax?: number;
/** Force the derived left axis to include zero (default true) */ /** Force the derived left axis to include zero (default true) */
@@ -196,6 +200,8 @@
group, group,
yMin, yMin,
yMax, yMax,
yPadTop,
yPadBottom,
zeroBaseLeft = true, zeroBaseLeft = true,
yMinRight, yMinRight,
yMaxRight, yMaxRight,
@@ -302,9 +308,11 @@
// and the right-axis labels are drawn overlaid on top instead (see below). // and the right-axis labels are drawn overlaid on top instead (see below).
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20); let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps // Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
// a stacked row of charts the same height even if some have fewer icon rows. // a stacked row of charts the same height even if some have fewer icon rows
// but only on wide screens: on mobile that uniform band wastes precious
// vertical space, so each chart reserves just its own rows there.
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0)); let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows)); let iconRows = $derived(isNarrow ? ownIconRows : Math.max(ownIconRows, reserveTopRows));
// tighter top/bottom gutters on mobile so charts don't waste vertical space // tighter top/bottom gutters on mobile so charts don't waste vertical space
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
@@ -353,16 +361,38 @@
return [lo, hi]; return [lo, hi];
} }
function buildScale(lo: number, hi: number, loFixed: boolean, hiFixed: boolean): Scale { function buildScale(
lo: number,
hi: number,
loFixed: boolean,
hiFixed: boolean,
halfStepBounds = false
): Scale {
const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true); const step = niceNum(niceNum(Math.max(hi - lo, 1e-9), false) / 4, true);
const min = loFixed ? lo : Math.floor(lo / step) * step; // Padded axes (e.g. temperature) may end on HALF steps — 5° when ticks
const max = hiFixed ? hi : Math.ceil(hi / step) * step; // are every 10° — so the requested margin isn't inflated to a whole step.
// Tick drawing starts at the first full-step multiple, so a half-step
// bound gets no label or gridline of its own.
const snap = halfStepBounds ? step / 2 : step;
const min = loFixed ? lo : Math.floor(lo / snap) * snap;
const max = hiFixed ? hi : Math.ceil(hi / snap) * snap;
return { min, max: max > min ? max : min + step, step }; return { min, max: max > min ? max : min + step, step };
} }
/** First tick at or above the scale minimum (bounds may sit on half steps). */
function firstTick(scale: Scale): number {
return Math.ceil((scale.min - 1e-9) / scale.step) * scale.step;
}
let leftScale = $derived.by((): Scale => { let leftScale = $derived.by((): Scale => {
const [dLo, dHi] = dataExtent('left', zeroBaseLeft); let [dLo, dHi] = dataExtent('left', zeroBaseLeft);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined); // requested breathing room around the data (e.g. temperature): at least
// the given units, growing with wide ranges so it stays proportionate
const span = dHi - dLo;
const padded = yPadTop != null || yPadBottom != null;
if (yMax === undefined && yPadTop) dHi += Math.max(yPadTop, span * 0.08);
if (yMin === undefined && yPadBottom) dLo -= Math.max(yPadBottom, span * 0.12);
return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined, padded);
}); });
let rightScale = $derived.by((): Scale => { let rightScale = $derived.by((): Scale => {
@@ -839,7 +869,11 @@
ctx.font = font; ctx.font = font;
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (let v = leftScale.min; v <= leftScale.max + leftScale.step / 2; v += leftScale.step) { for (
let v = firstTick(leftScale);
v <= leftScale.max + leftScale.step / 2;
v += leftScale.step
) {
const y = yPix(v, 'left'); const y = yPix(v, 'left');
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -858,7 +892,7 @@
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
for ( for (
let v = rightScale.min; let v = firstTick(rightScale);
v <= rightScale.max + rightScale.step / 2; v <= rightScale.max + rightScale.step / 2;
v += rightScale.step v += rightScale.step
) { ) {
@@ -1153,7 +1187,7 @@
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
for ( for (
let v = rightScale.min; let v = firstTick(rightScale);
v <= rightScale.max + rightScale.step / 2; v <= rightScale.max + rightScale.step / 2;
v += rightScale.step v += rightScale.step
) { ) {
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { resolve } from '$app/paths';
// A hand-picked set of the prerendered city pages (see
// routes/weather/locations/city-names100.json) — instant navigation targets
// that double as SEO entry points.
const popularCities: { slug: string; label: string }[] = [
{ slug: 'london', label: 'London' },
{ slug: 'tokyo', label: 'Tokyo' },
{ slug: 'berlin', label: 'Berlin' },
{ slug: 'sydney', label: 'Sydney' },
{ slug: 'singapore', label: 'Singapore' },
{ slug: 'dubai', label: 'Dubai' },
{ slug: 'los-angeles', label: 'Los Angeles' },
{ slug: 'hong-kong', label: 'Hong Kong' },
{ slug: 'istanbul', label: 'Istanbul' },
{ slug: 'seoul', label: 'Seoul' },
{ slug: 'mexico-city', label: 'Mexico City' },
{ slug: 'sao-paulo', label: 'São Paulo' }
];
const forecastLinks = [
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
];
const year = new Date().getFullYear();
</script>
<footer class="mt-16 border-t border-border bg-card/60">
<div class="mx-auto w-full max-w-[1536px] px-4 py-10 lg:px-8">
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<!-- Brand -->
<div class="max-w-xs">
<div class="flex items-center gap-2">
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-lg"
></span
>
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
Fast, no-nonsense weather forecasts built on open data.
</p>
<p class="mt-3 text-xs text-muted-foreground">
Weather data by
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>
</p>
</div>
<!-- Forecasts -->
<nav aria-label="Forecasts">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Forecasts
</h3>
<ul class="mt-3 flex flex-col gap-2 text-sm">
{#each forecastLinks as link (link.href)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={link.href}>{link.label}</a
>
</li>
{/each}
</ul>
</nav>
<!-- Popular locations (spans two columns of links) -->
<nav aria-label="Popular locations" class="lg:col-span-2">
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
Popular locations
</h3>
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
{#each popularCities as city (city.slug)}
<li>
<a
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/weather/week/[location]', { location: city.slug })}
>{city.label} weather</a
>
</li>
{/each}
</ul>
</nav>
</div>
<!-- Bottom bar: legal -->
<div
class="mt-10 flex flex-col items-start justify-between gap-3 border-t border-border/70 pt-5 text-xs text-muted-foreground sm:flex-row sm:items-center"
>
<span>© {year} Drizz.li</span>
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1">
<a class="underline-offset-2 hover:text-foreground hover:underline" href={resolve('/about')}
>About</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/imprint')}>Imprint</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/privacy')}>Privacy</a
>
<a
class="underline-offset-2 hover:text-foreground hover:underline"
href={resolve('/legal/terms')}>Terms</a
>
</nav>
</div>
</div>
</footer>
@@ -26,6 +26,16 @@
location = value; location = value;
}); });
// Prerendered pages bake the DEFAULT location's flag into the HTML, and
// Svelte's hydration repairs text but not attributes — so on pages that
// never update the store (legal pages etc.) the stale flag would stick
// around next to the correct location name. Re-sync the src after mount.
let flagEl = $state<HTMLImageElement>();
$effect(() => {
const src = `/images/country-flags/${(location.country_code || 'united_nations').toLowerCase()}.svg`;
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
});
const themeCycle: Theme[] = ['system', 'light', 'dark']; const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, string> = { const themeTitles: Record<Theme, string> = {
system: 'Theme: follow system', system: 'Theme: follow system',
@@ -76,6 +86,7 @@
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex" class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
> >
<img <img
bind:this={flagEl}
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border" class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg" src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country} alt={location.country}
@@ -131,35 +131,7 @@
{/each} {/each}
</nav> </nav>
<!-- About / legal links (hidden when collapsed; the pages stay reachable <!-- About / legal links moved to the page footer -->
via any expanded sidebar and the signup page footer) -->
{#if !collapsed}
<div class="px-4 py-3 text-[11px] leading-relaxed text-sidebar-foreground/60">
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/about')}
onclick={onMobileClose}>About</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/imprint')}
onclick={onMobileClose}>Imprint</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/privacy')}
onclick={onMobileClose}>Privacy</a
>
<span aria-hidden="true"> · </span>
<a
class="hover:text-sidebar-foreground hover:underline"
href={resolve('/legal/terms')}
onclick={onMobileClose}>Terms</a
>
</div>
{/if}
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) --> <!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle} {#if onToggle}
+59
View File
@@ -273,6 +273,65 @@ export interface EnsembleForecastResult {
hourlyUnitsFlat: Record<string, string>; hourlyUnitsFlat: Record<string, string>;
} }
// ─── Error Humanizing ───────────────────────────────────────────────────────────
export interface FriendlyWeatherError {
/** Short, plain-language headline. */
title: string;
/** What the user can actually do about it. */
hint?: string;
/** The raw underlying message, for a collapsed "technical details" block. */
detail?: string;
}
/**
* Turns a fetch/API error into something a person can act on. The raw message
* (often API-speak like "No data is available for this location") is kept as
* `detail` so it can be shown collapsed.
*/
export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
const raw = err instanceof Error ? err.message : String(err);
const msg = raw.toLowerCase();
if (
err instanceof TypeError ||
msg.includes('failed to fetch') ||
msg.includes('networkerror') ||
msg.includes('load failed') ||
msg.includes('network request failed')
) {
return {
title: "Couldn't reach the weather service",
hint: 'Check your internet connection and try again.',
detail: raw
};
}
if (
msg.includes('no data is available') ||
msg.includes('not available for this location') ||
msg.includes('out of allowed range') ||
msg.includes('coordinates')
) {
return {
title: 'No data for this location with the selected model',
hint: 'Regional weather models only cover their own area — "Best match" picks a suitable model automatically.',
detail: raw
};
}
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
return {
title: 'The weather service rejected the request',
hint: 'Try different settings, or switch the model back to "Best match".',
detail: raw
};
}
return {
title: 'Loading the weather data failed',
hint: 'Try again in a moment. If it keeps happening, switch the model to "Best match".',
detail: raw
};
}
// ─── Week Forecast Fetch ──────────────────────────────────────────────────────── // ─── Week Forecast Fetch ────────────────────────────────────────────────────────
// Fallback set when the caller does not specify which hourly variables it // Fallback set when the caller does not specify which hourly variables it
+9 -3
View File
@@ -5,6 +5,7 @@
import { storedTheme } from '$lib/stores/settings'; import { storedTheme } from '$lib/stores/settings';
import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -88,11 +89,16 @@
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
{:else} {:else}
<!-- cap the content width on very large screens; generous bottom room <!-- cap the content width on very large screens; the footer below
so the last chart/table never sits flush against the viewport edge --> gives the page its ending, so only modest bottom room is needed -->
<div class="mx-auto w-full max-w-[1536px] pb-80"> <div class="mx-auto w-full max-w-[1536px] pb-24">
{@render children()} {@render children()}
</div> </div>
<!-- full-bleed footer inside the scroll area (its own inner max-w),
cancelling main's padding so it sits flush with the edges -->
<div class="-mx-3 -mb-3 lg:-mx-8 lg:-mb-6">
<Footer />
</div>
{/if} {/if}
</main> </main>
</div> </div>
@@ -195,6 +195,8 @@
unit={panel.def.unit} unit={panel.def.unit}
unitRight={panel.def.unitRight} unitRight={panel.def.unitRight}
yMin={panel.def.yMin} yMin={panel.def.yMin}
yPadTop={panel.def.yPadTop}
yPadBottom={panel.def.yPadBottom}
zeroBaseLeft={panel.def.zeroBaseLeft} zeroBaseLeft={panel.def.zeroBaseLeft}
yMinRight={panel.def.yMinRight} yMinRight={panel.def.yMinRight}
yMaxRight={panel.def.yMaxRight} yMaxRight={panel.def.yMaxRight}
+96 -26
View File
@@ -14,11 +14,15 @@
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather'; import {
type FriendlyWeatherError,
type WeekForecastResult,
fetchWeekForecast,
humanizeWeatherError
} from '$lib/services/weather';
import { defaultParameters } from '../../options'; import { defaultParameters } from '../../options';
import { computeDayNightWeatherCodes } from '../../utils/weather-codes'; import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
import DailyCards from './DailyCards.svelte';
import DailyStripSticky from './DailyStripSticky.svelte'; import DailyStripSticky from './DailyStripSticky.svelte';
import HourlyTable from './HourlyTable.svelte'; import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte'; import MeteogramCharts from './MeteogramCharts.svelte';
@@ -74,8 +78,29 @@
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null); let loadError = $state<FriendlyWeatherError | null>(null);
let requestVersion = 0; let requestVersion = 0;
// bumped by the "Try again" button to re-run the fetch effect
let retryNonce = $state(0);
/** Back to the model that always has data (also what ModelSelector does). */
function resetToBestMatch() {
params.models = ['best_match'];
storedModel.set('best_match');
forecastDays = 7;
pastDays = 0;
}
// A request can succeed yet contain nothing usable: regional models return
// all-NaN outside their coverage area. Detect that so the page can say so
// instead of silently rendering an empty strip.
let noData = $derived.by((): boolean => {
const fd = fetchedDaily;
if (loading || !fd) return false;
return !fd.daily.temperature_2m_max.some(
(v, i) => v != null && !isNaN(v) && !(v === 0 && fd.daily.temperature_2m_min[i] === 0)
);
});
// 7 by default; the user can extend to the model's longer range (up to 16 days) // 7 by default; the user can extend to the model's longer range (up to 16 days)
let forecastDays = $state(7); let forecastDays = $state(7);
@@ -103,6 +128,7 @@
const loc = location; const loc = location;
const modelList = params.models; const modelList = params.models;
const requestVars = hourlyVars; const requestVars = hourlyVars;
void retryNonce; // re-run on "Try again"
if (!mounted || !loc || !modelList?.length) return; if (!mounted || !loc || !modelList?.length) return;
@@ -156,7 +182,7 @@
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (version !== requestVersion) return; if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err); loadError = humanizeWeatherError(err);
loading = false; loading = false;
}); });
}); });
@@ -210,33 +236,77 @@
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} /> <VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
{#if loadError} {#if loadError}
<div <div class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive" <p class="font-semibold text-destructive">{loadError.title}</p>
> {#if loadError.hint}
Failed to load weather data: {loadError} <p class="mt-0.5 text-destructive/90">{loadError.hint}</p>
{/if}
<div class="mt-2.5 flex flex-wrap gap-2">
<button
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
onclick={() => retryNonce++}
>
Try again
</button>
{#if params.models?.[0] !== 'best_match'}
<button
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
onclick={resetToBestMatch}
>
Switch to Best match
</button>
{/if}
</div>
{#if loadError.detail}
<details class="mt-2 text-xs text-destructive/70">
<summary class="cursor-pointer select-none">Technical details</summary>
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
</details>
{/if}
</div> </div>
{/if} {/if}
<!-- Desktop: the full day cards --> {#if noData && !loadError}
<div class="hidden md:block"> <!-- the request succeeded but every value is NaN: the selected (regional)
<DailyCards model doesn't cover this location -->
daily={fetchedDaily} <div
{selectedDay} class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
units={params} >
onSelectDay={switchDay} <svg
canExtend={forecastDays < 15} class="h-4 w-4 shrink-0"
onExtend={() => (forecastDays = 15)} fill="none"
canExtendPast={pastDays < 3} stroke="currentColor"
onExtendPast={() => (pastDays = 3)} viewBox="0 0 24 24"
/> stroke-width="2"
</div> >
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v4m0 4h.01M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="font-semibold">No forecast data for this model here</p>
<p class="text-[13px] opacity-90">
The selected weather model doesn't cover {location.name} — regional models only provide data
inside their own area.
</p>
</div>
<button
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
onclick={resetToBestMatch}
>
Switch to Best match
</button>
</div>
{/if}
<!-- The sticky day strip, the hourly table AND the meteograms share this <!-- The sticky day strip, the hourly table AND the meteograms share this
wrapper, so the strip stays stuck for the entire page (it collapses wrapper, so the strip stays stuck for the entire page: the full day
as it sticks on mobile; on md+ it's a slim always-compact bar under cards collapse into the compact strip as it sticks (on md+ the bar
the topbar, complementing the full cards above). timeline-scope docks under the topbar at its exact height). timeline-scope hoists
hoists the strip's sentinel view-timeline so the sticky strip (a the strip's sentinel view-timeline so the sticky strip (a sibling of
sibling of the sentinel) can scrub its collapse from it. --> the sentinel) can scrub its collapse from it. -->
<div style="timeline-scope: --daystrip-sentinel"> <div style="timeline-scope: --daystrip-sentinel">
{#if fetchedDaily} {#if fetchedDaily}
<DailyStripSticky <DailyStripSticky
@@ -116,7 +116,7 @@
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
type="button" type="button"
class="day-side mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground" class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load recent past days" aria-label="Load recent past days"
> >
@@ -347,7 +347,7 @@
{#if daily && canExtend && onExtend} {#if daily && canExtend && onExtend}
<button <button
type="button" type="button"
class="day-side mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24" class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend} onclick={onExtend}
aria-label="Load the longer-range forecast" aria-label="Load the longer-range forecast"
> >
@@ -409,18 +409,10 @@
} }
} }
/* Keep the exact same layout at every size, just scale it: small screens get /* Mobile: keep the exact desktop layout, just scale the whole card down. */
the compact scale, and mid-size desktops (anything below ~1440p) a gentler
one so the cards don't look oversized on 1080p displays. */
@media (max-width: 768px) { @media (max-width: 768px) {
.day-card { .day-card {
zoom: 0.72; zoom: 0.72;
} }
} }
@media (min-width: 768px) and (max-width: 2200px) {
.day-card,
.day-side {
zoom: 0.85;
}
}
</style> </style>
@@ -5,8 +5,14 @@
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, windIsSignificant } from './significance'; import {
import { type FetchedDaily, type WeatherUnits } from './types'; getSunshineColor,
getSunshinePercent,
precipIsSignificant,
sunIsSignificant,
windIsSignificant
} from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props { interface Props {
daily: FetchedDaily | null; daily: FetchedDaily | null;
@@ -30,9 +36,11 @@
onExtendPast onExtendPast
}: Props = $props(); }: Props = $props();
// ─── Scroll-driven collapse (full → compact, mobile only) ─────────────────── // ─── Scroll-driven collapse (full cards → compact strip) ───────────────────
// All sizing derives from a single registered custom property `--strip-p` // All sizing derives from a single registered custom property `--strip-p`
// (0 = full cards, 1 = compact strip): // (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
// same mechanics — desktop just uses larger "full" tunables and collapses
// into a bar that matches the topbar height.
// //
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+) // * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
// scrub `--strip-p` natively from a view-timeline on the sentinel below — // scrub `--strip-p` natively from a view-timeline on the sentinel below —
@@ -40,36 +48,37 @@
// * Everything else (Firefox, older Safari) snaps between the two states // * Everything else (Firefox, older Safari) snaps between the two states
// with a short CSS transition instead: an IntersectionObserver on the same // with a short CSS transition instead: an IntersectionObserver on the same
// sentinel toggles `.compact`. No per-frame scroll handler anywhere. // sentinel toggles `.compact`. No per-frame scroll handler anywhere.
//
// On md+ the strip is pinned to the compact state (no animation) and docks
// under the topbar as a slim always-sticky day picker.
let sentinelEl = $state<HTMLDivElement>(); let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>(); let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>(); let daysWrapEl = $state<HTMLDivElement>();
let needsSnapFallback = $state(false); let needsSnapFallback = $state(false);
let compact = $state(false); let compact = $state(false);
let stuck = $state(false);
onMount(() => { onMount(() => {
const scrubSupported = const scrubSupported =
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none'); CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
if (scrubSupported || !sentinelEl) return; if (scrubSupported || !sentinelEl) return;
needsSnapFallback = true; needsSnapFallback = true;
// Collapse once more than half of the sentinel band has scrolled past the // `stuck` fires the moment the strip pins (turns the bar opaque before any
// top; expands again on the same boundary (the transition smooths both). // content can slide under it); `compact` snaps the cells once more than
// half of the sentinel band has scrolled past. The transitions smooth both.
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
const e = entries[entries.length - 1]; const e = entries[entries.length - 1];
stuck = e.intersectionRatio < 0.97;
compact = e.intersectionRatio < 0.5; compact = e.intersectionRatio < 0.5;
}, },
{ threshold: [0.25, 0.5, 0.75] } { threshold: [0.25, 0.5, 0.75, 0.97] }
); );
io.observe(sentinelEl); io.observe(sentinelEl);
return () => io.disconnect(); return () => io.disconnect();
}); });
// Start scrolled so the "Past" button sits just off the left edge (revealed by // Start scrolled so the "Past" button sits just off the left edge (revealed by
// scrolling left), with the first day flush to the content edge — same as the // scrolling left), with the first day flush to the content edge. The days
// desktop cards. Runs once per dataset. // group has min-width:100%, so the row always overflows by exactly the past
// button — this works at every viewport size. Runs once per dataset.
let scrolledForRef: FetchedDaily | null = null; let scrolledForRef: FetchedDaily | null = null;
$effect(() => { $effect(() => {
const d = daily; const d = daily;
@@ -77,10 +86,18 @@
scrolledForRef = d; scrolledForRef = d;
const scroll = stripScrollEl; const scroll = stripScrollEl;
const wrap = daysWrapEl; const wrap = daysWrapEl;
requestAnimationFrame(() => { // the delta form is self-correcting (a no-op once right), so apply after
// 12px = the strip's px-3 left padding, so the first day lands on the edge // layout and once more after late-loading CSS/fonts settle — otherwise a
// sliver of the past button can stay visible on first paint
const apply = () => {
const padLeft = parseFloat(getComputedStyle(scroll).paddingLeft) || 0;
const firstCell = wrap.querySelector('.strip-cell') ?? wrap;
scroll.scrollLeft += scroll.scrollLeft +=
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12; firstCell.getBoundingClientRect().left - scroll.getBoundingClientRect().left - padLeft;
};
requestAnimationFrame(() => {
apply();
setTimeout(apply, 250);
}); });
}); });
</script> </script>
@@ -96,26 +113,33 @@
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8" class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
class:js-snap={needsSnapFallback} class:js-snap={needsSnapFallback}
class:compact class:compact
class:stuck
> >
<div class="strip-row flex overflow-x-auto px-3 py-2 md:py-1 lg:px-8" bind:this={stripScrollEl}> <div class="strip-row flex overflow-x-auto px-3 lg:px-8" bind:this={stripScrollEl}>
{#if daily} {#if daily}
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<!-- starts scrolled out of view (revealed by scrolling left); styled to
match the 15-days button on the other end -->
<button <button
type="button" type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground" class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load recent past days" aria-label="Load the past 3 days"
title="Load the past 3 days"
> >
<svg <span class="inline-flex items-baseline gap-0.5">
class="h-5 w-5 md:h-4 md:w-4" <svg
fill="none" class="h-3 w-3 translate-y-px"
stroke="currentColor" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
stroke-width="1.75" viewBox="0 0 24 24"
> stroke-width="2.5"
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" /> >
</svg> <path stroke-linecap="round" stroke-linejoin="round" d="M15 5l-7 7 7 7" />
<span class="text-[9px] leading-tight font-semibold">Past</span> </svg>
<span class="text-sm leading-none font-extrabold">3</span>
</span>
<span class="text-[9px] leading-tight font-semibold">past</span>
</button> </button>
{/if} {/if}
@@ -129,13 +153,23 @@
{@const nightCode = daily.nightCodes?.[index] ?? wCode} {@const nightCode = daily.nightCodes?.[index] ?? wCode}
{@const precipSum = daily.daily.precipitation_sum[index]} {@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]} {@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = Math.max(
0,
(daily.daily.sunset[index] ?? 0) - (daily.daily.sunrise[index] ?? 0)
)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))} {@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))} {@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, null, String(units.wind_speed_unit))} {@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} {#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button <button
type="button" type="button"
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 md:gap-0 md:pb-0 {selected class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50' ? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}" : 'border-border/60 bg-card'}"
aria-pressed={selected} aria-pressed={selected}
@@ -144,7 +178,7 @@
<!-- weekday drifts to the left edge and the date fades in at the <!-- weekday drifts to the left edge and the date fades in at the
right edge as the cards collapse (flex spacers driven by --rel) --> right edge as the cards collapse (flex spacers driven by --rel) -->
<span <span
class="dow-row flex w-full items-baseline px-0.5 text-[11px] font-semibold tracking-wide whitespace-nowrap md:text-[10px] {selected class="dow-row flex w-full items-baseline px-0.5 font-semibold tracking-wide whitespace-nowrap {selected
? 'text-primary' ? 'text-primary'
: ''}" : ''}"
> >
@@ -158,24 +192,27 @@
</span> </span>
<span <span
class="rel-label overflow-hidden text-[9px] leading-[1.25] whitespace-nowrap text-muted-foreground" class="rel-label overflow-hidden leading-[1.25] whitespace-nowrap text-muted-foreground"
> >
{getRelativeDayLabel(time, daily.timezone)} {getRelativeDayLabel(time, daily.timezone)}
</span> </span>
<div class="icon-wrap relative"> <!-- Two centered rows: day + night icons, then day + night temps.
<svg class="day-icon fill-foreground"> Both stay perfectly centered when compact (the night icon melts
<use away and the day icon re-centers on its own); when full, small
xlink:href="/images/weather-icons/{getWeatherIconName( k-driven nudges line each temp up under its icon. -->
dayCode, <div class="flex w-full items-center justify-center">
true <div class="icon-wrap">
)}.svg#Layer_1" <svg class="day-icon fill-foreground">
></use> <use
</svg> xlink:href="/images/weather-icons/{getWeatherIconName(
<!-- night companion icon, present in the full view, fades as it collapses --> dayCode,
<svg true
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60" )}.svg#Layer_1"
> ></use>
</svg>
</div>
<svg class="night-icon self-end fill-foreground/60">
<use <use
xlink:href="/images/weather-icons/{getWeatherIconName( xlink:href="/images/weather-icons/{getWeatherIconName(
nightCode, nightCode,
@@ -185,9 +222,9 @@
</svg> </svg>
</div> </div>
<div class="flex items-baseline gap-1 leading-none"> <div class="flex w-full items-baseline justify-center gap-0.5 leading-none">
<span <span
class="temp-max rounded-md py-0.5 font-extrabold tabular-nums md:py-px" class="temp-max rounded-md font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}" style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
> >
{tempMax.toFixed(0)}° {tempMax.toFixed(0)}°
@@ -200,40 +237,90 @@
<div <div
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground" class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
> >
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}"> <!-- sunshine bar (desktop full cards only, like the old day cards) -->
<svg class="fill-sky-500" width="13" height="13"> <div
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use> class="hidden w-full items-center gap-1 px-0.5 md:my-1 md:flex {lowSun
? 'opacity-45'
: ''}"
>
<svg class="shrink-0" width="14" height="14" style="fill:{sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg> </svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)} <div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
</span> <div
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}"> class="h-full rounded-full"
<svg class="fill-muted-foreground" width="13" height="13"> style="width:{sunPct}%;background-color:{sunColor}"
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use> ></div>
</svg> </div>
{windMax?.toFixed(0) ?? '-'} <span class="font-medium">
</span> {Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- precip + wind: stacked on mobile, one row on md+ (old card style) -->
<div
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
>
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
{#if windDir != null && !isNaN(windDir)}
<span
class="hidden shrink-0 md:inline-flex md:-mr-1"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg class="fill-muted-foreground" width="20" height="20">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</span>
<svg class="fill-muted-foreground md:hidden" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{:else}
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
<span class="whitespace-nowrap"
>{windMax?.toFixed(0) ?? '-'}<span class="hidden opacity-70 md:inline"
>-{gustMax?.toFixed(0) ?? '-'}</span
></span
>
</span>
</div>
</div> </div>
</button> </button>
{/if} {/if}
{/each} {/each}
{#if canExtend && onExtend} {#if canExtend && onExtend}
<!-- shows the RESULTING range, not an increment: tapping switches the
whole forecast from 7 to 15 days -->
<button <button
type="button" type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground" class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
onclick={onExtend} onclick={onExtend}
aria-label="Load the longer-range forecast" aria-label="Show the full 15-day forecast"
title="Show the full 15-day forecast"
> >
<svg <span class="inline-flex items-baseline gap-0.5">
class="h-5 w-5 md:h-4 md:w-4" <span class="text-sm leading-none font-extrabold">15</span>
fill="none" <svg
stroke="currentColor" class="h-3 w-3 translate-y-px"
viewBox="0 0 24 24" fill="none"
stroke-width="1.75" stroke="currentColor"
> viewBox="0 0 24 24"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" /> stroke-width="2.5"
</svg> >
<span class="text-[9px] leading-tight font-semibold">15d</span> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
<span class="text-[9px] leading-tight font-semibold">days</span>
</button> </button>
{/if} {/if}
</div> </div>
@@ -253,21 +340,90 @@
initial-value: 0; initial-value: 0;
} }
/* 1 as soon as the strip pins — drives the bar chrome independently of the
collapse progress so nothing ever shows through a still-expanding bar. */
@property --stuck {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Tunables, shared by the strip and its sentinel: the collapse plays out /* Tunables, shared by the strip and its sentinel: the collapse plays out
over exactly the cell-height difference (see .daystrip height below). */ over exactly the bar-height difference (see .daystrip height below). */
.sentinel, .sentinel,
.daystrip { .daystrip {
--cell-w-full: 76px; --cell-w-full: 76px;
--cell-w-min: 56px; --cell-w-min: 58px;
--cell-h-full: 140px; --cell-h-full: 140px;
--cell-h-min: 62px; --cell-h-min: 64px;
--icon-full: 44px; --icon-full: 44px;
--icon-min: 21px; --icon-min: 21px;
--pt-full: 7px; --pt-full: 7px;
--pt-min: 2px; --pt-min: 2px;
--gap-full: 8px; --gap-full: 8px;
--gap-min: 4px; --gap-min: 4px;
--collapse: calc(var(--cell-h-full) - var(--cell-h-min)); /* bar (strip-row) vertical padding */
--pad-full: 8px;
--pad-min: 8px;
/* fonts */
--dow-font-full: 11px;
--dow-font-min: 11px;
--rel-font-full: 9px;
--rel-font-min: 9px;
--tmax-font-full: 12px;
--tmax-font-min: 11px;
--tmin-font-full: 11px;
--tmin-font-min: 10px;
--tmax-padx-full: 6px;
--tmax-padx-min: 4px;
/* full state only: nudge each temp to sit under "its" icon */
--tmax-nudge: 0px;
--tmin-nudge: 5px;
/* cell bottom padding (roomy when full, tight when compact) */
--pb-full: 6px;
--pb-min: 6px;
/* extra scrub distance beyond the height difference — slows the collapse
down; the surplus just slides content under the (opaque) bar */
--collapse-extra: 0px;
--collapse: calc(
var(--cell-h-full) + 2 * var(--pad-full) - var(--cell-h-min) - 2 * var(--pad-min) +
var(--collapse-extra)
);
}
/* md+: same collapse, but the full state keeps the old desktop day-card
proportions, plays out over a longer scroll distance, and the compact
bar docks under the topbar (slightly taller than its 56px). */
@media (min-width: 768px) {
.sentinel,
.daystrip {
--cell-w-full: 120px;
--cell-h-full: 208px;
--cell-w-min: 64px;
--cell-h-min: 56px;
--icon-full: 80px;
--icon-min: 22px;
--pt-full: 10px;
--gap-full: 10px;
--gap-min: 6px;
--pad-min: 4px;
--dow-font-full: 13px;
--dow-font-min: 10px;
--rel-font-full: 11px;
--tmax-font-full: 17px;
--tmin-font-full: 15px;
--tmax-padx-full: 12px;
--tmax-nudge: 0px;
--tmin-nudge: 8px;
--pb-full: 10px;
--pb-min: 2px;
--collapse-extra: 60px;
}
.daystrip {
/* breathing room between the full cards and the table */
margin-bottom: 14px;
}
} }
/* Invisible collapse band: the scroll distance over which the collapse /* Invisible collapse band: the scroll distance over which the collapse
@@ -280,6 +436,7 @@
.daystrip { .daystrip {
--strip-p: 0; --strip-p: 0;
--stuck: 0;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */ /* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--strip-p)); --k: calc(1 - var(--strip-p));
@@ -288,11 +445,13 @@
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k)); --icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k)); --pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k)); --gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
--pad: calc(var(--pad-min) + (var(--pad-full) - var(--pad-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1); --rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1); --detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
/* bar background turns opaque almost as soon as the strip sticks, so /* bar background turns opaque the moment the strip sticks (via --stuck),
content never shows through it */ so content never shows through it — even while the cells are still
--chrome: clamp(0, calc(var(--strip-p) * 6), 1); large and the collapse has barely started */
--chrome: clamp(0, calc(var(--strip-p) * 6 + var(--stuck)), 1);
/* The sticky box keeps a CONSTANT height — only its contents shrink. /* The sticky box keeps a CONSTANT height — only its contents shrink.
The collapse therefore never resizes the document (the large table The collapse therefore never resizes the document (the large table
@@ -300,7 +459,7 @@
of scroll jank on mobile Chromium), and because the collapse distance of scroll jank on mobile Chromium), and because the collapse distance
equals the height difference, the table slides up under the shrinking equals the height difference, the table slides up under the shrinking
bar in exact sync. The empty lower part is click-through. */ bar in exact sync. The empty lower part is click-through. */
height: calc(var(--cell-h-full) + 16px); height: calc(var(--cell-h-full) + 2 * var(--pad-full));
pointer-events: none; pointer-events: none;
contain: layout style; contain: layout style;
/* own compositor layer: per-frame repaints stay isolated to the strip */ /* own compositor layer: per-frame repaints stay isolated to the strip */
@@ -313,6 +472,7 @@
.strip-row { .strip-row {
pointer-events: auto; pointer-events: auto;
gap: var(--gap); gap: var(--gap);
padding-block: var(--pad);
background: color-mix( background: color-mix(
in oklab, in oklab,
var(--color-background) calc(var(--chrome) * 100%), var(--color-background) calc(var(--chrome) * 100%),
@@ -330,9 +490,13 @@
view-timeline: --daystrip-sentinel block; view-timeline: --daystrip-sentinel block;
} }
.daystrip { .daystrip {
animation: strip-collapse linear both; animation:
animation-timeline: --daystrip-sentinel; strip-collapse linear both,
animation-range: exit 0% exit 100%; strip-stuck linear both;
animation-timeline: --daystrip-sentinel, --daystrip-sentinel;
animation-range:
exit 0% exit 100%,
exit 0% exit 3%;
} }
} }
@keyframes strip-collapse { @keyframes strip-collapse {
@@ -340,39 +504,57 @@
--strip-p: 1; --strip-p: 1;
} }
} }
@keyframes strip-stuck {
to {
--stuck: 1;
}
}
/* Snap fallback: ease between the two end states instead of scrubbing. /* Snap fallback: ease between the two end states instead of scrubbing.
(Browsers too old to register --strip-p simply switch instantly.) */ (Browsers too old to register --strip-p simply switch instantly.) */
.daystrip.js-snap { .daystrip.js-snap {
transition: --strip-p 0.28s ease; transition:
--strip-p 0.28s ease,
--stuck 0.15s ease;
} }
.daystrip.js-snap.compact { .daystrip.js-snap.compact {
--strip-p: 1; --strip-p: 1;
} }
.daystrip.js-snap.stuck {
--stuck: 1;
}
@media (min-width: 768px) {
/* larger cards need a touch longer to feel smooth */
.daystrip.js-snap {
transition:
--strip-p 0.55s ease,
--stuck 0.15s ease;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.daystrip.js-snap { .daystrip.js-snap {
transition: none; transition: none;
} }
} }
/* md+: no collapse — the strip is a slim, always-compact day picker that
docks under the topbar, matching its h-14 (56px) height exactly. */
@media (min-width: 768px) {
.sentinel,
.daystrip {
--cell-h-min: 48px;
--icon-min: 20px;
--gap-min: 6px;
}
.daystrip {
--strip-p: 1;
animation: none;
height: 56px;
}
}
.strip-days { .strip-days {
gap: var(--gap); gap: var(--gap);
/* fill the row so it overflows by exactly the past button, which starts
scrolled out of view and is revealed by scrolling left — even when the
day cells alone wouldn't overflow (wide desktop viewports) */
min-width: 100%;
}
/* Scroll containers clip at the PADDING edge, so a parked past button would
always leak a sliver across the row's left padding. Extending the days
group's left edge (only while the past button exists) moves max-scroll so
the button parks fully beyond the clip edge. */
.strip-side + .strip-days {
padding-left: calc(12px - var(--gap-min));
}
@media (min-width: 1024px) {
.strip-side + .strip-days {
padding-left: calc(32px - var(--gap-min));
}
} }
.strip-cell, .strip-cell,
.strip-side { .strip-side {
@@ -380,11 +562,32 @@
/* size tracks the collapse exactly; only the tap highlight eases */ /* size tracks the collapse exactly; only the tap highlight eases */
transition: transition:
border-color 0.15s, border-color 0.15s,
background-color 0.15s; background-color 0.15s,
translate 0.2s ease-out,
scale 0.2s ease-out,
box-shadow 0.2s ease-out;
}
/* The old day-card lift: hover raises the card slightly, the selected day a
touch more. Scaled by --k so the effect melts away as the strip compacts
(and never disturbs the slim bar); hover only where hover exists. */
.strip-cell[aria-pressed='true'] {
z-index: 10;
translate: 0 calc(-4px * var(--k));
scale: calc(1 + 0.04 * var(--k));
box-shadow: 0 5px 14px -4px rgba(0, 0, 0, calc(0.4 * var(--k)));
}
@media (hover: hover) {
.strip-cell:hover:not([aria-pressed='true']) {
z-index: 10;
translate: 0 calc(-3px * var(--k));
scale: calc(1 + 0.02 * var(--k));
box-shadow: 0 4px 10px -4px rgba(0, 0, 0, calc(0.3 * var(--k)));
}
} }
.strip-cell { .strip-cell {
width: var(--cell-w); width: var(--cell-w);
padding-top: var(--pt); padding-top: var(--pt);
padding-bottom: calc(var(--pb-min) + (var(--pb-full) - var(--pb-min)) * var(--k));
} }
/* Side buttons keep the compact width in BOTH states, so almost nothing to /* Side buttons keep the compact width in BOTH states, so almost nothing to
the left of the first day changes size during the collapse — the first the left of the first day changes size during the collapse — the first
@@ -402,14 +605,24 @@
width: var(--icon); width: var(--icon);
height: var(--icon); height: var(--icon);
} }
.night-badge { /* The night icon sits beside the day icon (slightly low, like a companion)
width: calc(var(--icon) * 0.44); and melts away completely when compact so the day icon re-centers. */
height: calc(var(--icon) * 0.44); .night-icon {
display: block;
width: calc(var(--icon) * 0.42 * var(--rel));
height: calc(var(--icon) * 0.42 * var(--rel));
opacity: var(--rel); opacity: var(--rel);
margin-left: calc(-4px * var(--rel));
margin-bottom: calc(6px * var(--rel));
} }
/* weekday centered when full, pushed to the edges when compact */ /* weekday centered when full, pushed to the edges when compact */
.dow-row {
font-size: calc(var(--dow-font-min) + (var(--dow-font-full) - var(--dow-font-min)) * var(--k));
}
.dow-spacer { .dow-spacer {
flex-grow: var(--rel); /* keep some outer share when compact so weekday + date sit near-centered
with a modest gap instead of being pushed to the cell edges */
flex-grow: calc(0.6 + 0.4 * var(--rel));
} }
.dow-mid { .dow-mid {
flex-grow: calc(1 - var(--rel)); flex-grow: calc(1 - var(--rel));
@@ -421,19 +634,31 @@
opacity: calc(1 - var(--rel)); opacity: calc(1 - var(--rel));
} }
.rel-label { .rel-label {
font-size: calc(var(--rel-font-min) + (var(--rel-font-full) - var(--rel-font-min)) * var(--k));
opacity: var(--rel); opacity: var(--rel);
max-height: calc(14px * var(--rel)); max-height: calc(14px * var(--rel));
} }
.detail-row { .detail-row {
opacity: var(--detail); opacity: var(--detail);
max-height: calc(42px * var(--detail)); max-height: calc(52px * var(--detail));
} }
.temp-max { .temp-max {
font-size: calc(11px + 1px * var(--k)); font-size: calc(
padding-inline: calc(3px + 3px * var(--k)); var(--tmax-font-min) + (var(--tmax-font-full) - var(--tmax-font-min)) * var(--k)
);
padding-inline: calc(
var(--tmax-padx-min) + (var(--tmax-padx-full) - var(--tmax-padx-min)) * var(--k)
);
padding-block: calc(2px + 1px * var(--k));
/* full: line up under the day icon */
transform: translateX(calc(var(--tmax-nudge) * var(--k)));
} }
.temp-min { .temp-min {
font-size: calc(10px + 1px * var(--k)); font-size: calc(
var(--tmin-font-min) + (var(--tmin-font-full) - var(--tmin-font-min)) * var(--k)
);
/* full: line up under the night icon */
transform: translateX(calc(var(--tmin-nudge) * var(--k)));
} }
.daystrip :global(.overflow-x-auto) { .daystrip :global(.overflow-x-auto) {
@@ -300,8 +300,9 @@
? 'border-t border-border/50' ? 'border-t border-border/50'
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}" : 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
> >
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0"> <!-- same title treatment as the compare / 14-day pages -->
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase"> <div class="mb-1 flex items-center justify-between px-3 lg:px-0">
<h4 class="truncate text-sm font-bold tracking-tight">
<span class="hidden lg:inline">{panel.title}</span> <span class="hidden lg:inline">{panel.title}</span>
<span class="lg:hidden">{panel.titleShort}</span> <span class="lg:hidden">{panel.titleShort}</span>
</h4> </h4>
@@ -327,6 +328,8 @@
unit={panel.def.unit} unit={panel.def.unit}
unitRight={panel.def.unitRight} unitRight={panel.def.unitRight}
yMin={panel.def.yMin} yMin={panel.def.yMin}
yPadTop={panel.def.yPadTop}
yPadBottom={panel.def.yPadBottom}
zeroBaseLeft={panel.def.zeroBaseLeft} zeroBaseLeft={panel.def.zeroBaseLeft}
yMinRight={panel.def.yMinRight} yMinRight={panel.def.yMinRight}
yMaxRight={panel.def.yMaxRight} yMaxRight={panel.def.yMaxRight}
@@ -13,6 +13,25 @@ export function precipIsSignificant(sum: number | null, unit: string): boolean {
return (sum ?? 0) >= min; return (sum ?? 0) >= min;
} }
/** Sunshine as a share of daylight, for the sun progress bar (0-100). */
export function getSunshinePercent(
sunshineSeconds: number | null,
daylightSeconds: number
): number {
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
}
/** Sun icon / bar colour by sunshine ratio: grey → pale gold → amber. */
export function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
if (daylightSeconds <= 0) return '#d1d5db';
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.1) return '#fcd34d';
return '#d1d5db';
}
export function windIsSignificant( export function windIsSignificant(
speed: number | null, speed: number | null,
gust: number | null, gust: number | null,
@@ -419,6 +419,9 @@ export interface PanelDef {
yMin?: number; yMin?: number;
yMinRight?: number; yMinRight?: number;
yMaxRight?: number; yMaxRight?: number;
/** Breathing room (in axis units) above / below the left-axis data range. */
yPadTop?: number;
yPadBottom?: number;
/** Whether the left axis should include zero (false for pressure) */ /** Whether the left axis should include zero (false for pressure) */
zeroBaseLeft: boolean; zeroBaseLeft: boolean;
hasPictograms: boolean; hasPictograms: boolean;
@@ -496,6 +499,10 @@ export function buildPanelDef(
unit: leftKind ? unitForKind(leftKind, units) : '', unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined, unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined, yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// temperature curves shouldn't touch the frame: guarantee headroom above
// the max and extra space below the min (extrema labels live there too)
yPadTop: leftKind === 'temp' ? 3 : undefined,
yPadBottom: leftKind === 'temp' ? 5 : undefined,
// temperature and pressure sit far from zero, so their axis is derived from // temperature and pressure sit far from zero, so their axis is derived from
// the data range (a forced 0 baseline just wastes vertical space) // the data range (a forced 0 baseline just wastes vertical space)
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true, zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,