From f66e259e3251a46dc70843317e95d5036120e842 Mon Sep 17 00:00:00 2001
From: Vincent van der Wal
Date: Mon, 3 Aug 2026 09:57:33 +0200
Subject: [PATCH 01/12] add seasonal
---
src/routes/about/content/de.svelte | 3 ++-
src/routes/about/content/en.svelte | 4 ++--
src/routes/about/content/es.svelte | 3 ++-
src/routes/about/content/fr.svelte | 3 ++-
src/routes/about/content/it.svelte | 3 ++-
src/routes/legal/terms/content/de.svelte | 5 +++--
src/routes/legal/terms/content/en.svelte | 4 ++--
src/routes/legal/terms/content/es.svelte | 5 +++--
src/routes/legal/terms/content/fr.svelte | 4 ++--
src/routes/legal/terms/content/it.svelte | 5 +++--
10 files changed, 23 insertions(+), 16 deletions(-)
diff --git a/src/routes/about/content/de.svelte b/src/routes/about/content/de.svelte
index 12177d8..fa57f69 100644
--- a/src/routes/about/content/de.svelte
+++ b/src/routes/about/content/de.svelte
@@ -29,7 +29,8 @@
Drizz.li ist kostenlos nutzbar. Wenn es Ihnen nützt, können Sie es mit einem kleinen monatlichen
Beitrag unterstützen (ab 3 € / Monat) - das hilft, Domain, Hosting und Entwicklungszeit zu
decken. Als Dankeschön schalten Unterstützerinnen und Unterstützer die Extras frei: historisches
- Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie erscheinen.
+ Wetter mit Vergleich zu den Klimanormalen, die saisonalen Aussichten für die kommenden Monate
+ sowie neue Funktionen, sobald sie erscheinen.
Beiträge laufen über eine einfache Banküberweisung - kein Zahlungsdienstleister, keine
diff --git a/src/routes/about/content/en.svelte b/src/routes/about/content/en.svelte
index 12c8fa9..e7b3fd6 100644
--- a/src/routes/about/content/en.svelte
+++ b/src/routes/about/content/en.svelte
@@ -25,8 +25,8 @@
Drizz.li is free to use. If you find it useful, you can support it with a small monthly
contribution (from €3 / month) - it helps cover the domain, hosting and development time. As a
- thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, and
- new supporter features as they land.
+ thank-you, supporters unlock the extras: historical weather with climate-normal comparisons, the
+ seasonal outlook for the months ahead, and new supporter features as they land.
Contributions are handled by a simple bank transfer - no payment processor, no stored card
diff --git a/src/routes/about/content/es.svelte b/src/routes/about/content/es.svelte
index 2a034d7..4a9098e 100644
--- a/src/routes/about/content/es.svelte
+++ b/src/routes/about/content/es.svelte
@@ -29,7 +29,8 @@
Drizz.li es gratuito. Si te resulta útil, puedes apoyarlo con una pequeña aportación mensual
(desde 3 € al mes): ayuda a cubrir el dominio, el alojamiento y el tiempo de desarrollo. Como
agradecimiento, quienes colaboran desbloquean los extras: clima histórico con comparación frente
- a las normales climáticas y las nuevas funciones que vayan llegando.
+ a las normales climáticas, la perspectiva estacional de los próximos meses y las nuevas
+ funciones que vayan llegando.
Las aportaciones se gestionan mediante una simple transferencia bancaria: sin pasarela de pago y
diff --git a/src/routes/about/content/fr.svelte b/src/routes/about/content/fr.svelte
index 85e7b11..845f679 100644
--- a/src/routes/about/content/fr.svelte
+++ b/src/routes/about/content/fr.svelte
@@ -31,7 +31,8 @@
Drizz.li est gratuit. S'il vous est utile, vous pouvez le soutenir par une petite contribution
mensuelle (à partir de 3 € par mois) : elle aide à couvrir le nom de domaine, l'hébergement et
le temps de développement. En remerciement, les contributeurs débloquent les bonus : la météo
- historique avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir.
+ historique avec comparaison aux normales climatiques, l'aperçu saisonnier des mois à venir,
+ ainsi que les nouvelles fonctions à venir.
Les contributions passent par un simple virement bancaire : aucun prestataire de paiement,
diff --git a/src/routes/about/content/it.svelte b/src/routes/about/content/it.svelte
index 94ecfe6..261f879 100644
--- a/src/routes/about/content/it.svelte
+++ b/src/routes/about/content/it.svelte
@@ -29,7 +29,8 @@
Drizz.li è gratuito. Se lo trovi utile, puoi sostenerlo con un piccolo contributo mensile (da 3
€ al mese): aiuta a coprire dominio, hosting e tempo di sviluppo. Come ringraziamento, chi
sostiene il progetto sblocca gli extra: meteo storico con confronto rispetto alle normali
- climatiche e le nuove funzioni man mano che arrivano.
+ climatiche, le prospettive stagionali per i mesi a venire e le nuove funzioni man mano che
+ arrivano.
I contributi avvengono con un semplice bonifico bancario: nessun gestore di pagamenti, nessun
diff --git a/src/routes/legal/terms/content/de.svelte b/src/routes/legal/terms/content/de.svelte
index d991bbf..2328068 100644
--- a/src/routes/legal/terms/content/de.svelte
+++ b/src/routes/legal/terms/content/de.svelte
@@ -15,8 +15,9 @@
1. Was Sie erhalten
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
- historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie
- erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei.
+ historisches Wetter mit Vergleich zu den Klimanormalen und die saisonalen Aussichten für die
+ kommenden Monate sowie neue Funktionen, sobald sie erscheinen. Die kostenlosen Teile von
+ Drizz.li bleiben für alle frei.
2. Ablauf
diff --git a/src/routes/legal/terms/content/en.svelte b/src/routes/legal/terms/content/en.svelte
index 1b3e869..c624ccf 100644
--- a/src/routes/legal/terms/content/en.svelte
+++ b/src/routes/legal/terms/content/en.svelte
@@ -14,8 +14,8 @@
1. What you get
A supporter contribution unlocks the supporter extras for the paid period: currently historical
- weather with climate-normal comparisons, plus new supporter features as they land. The free
- parts of Drizz.li stay free for everyone.
+ weather with climate-normal comparisons and the seasonal outlook for the months ahead, plus new
+ supporter features as they land. The free parts of Drizz.li stay free for everyone.
2. How it works
diff --git a/src/routes/legal/terms/content/es.svelte b/src/routes/legal/terms/content/es.svelte
index d9e355d..1946dce 100644
--- a/src/routes/legal/terms/content/es.svelte
+++ b/src/routes/legal/terms/content/es.svelte
@@ -17,8 +17,9 @@
1. Qué obtienes
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
- con comparación frente a las normales climáticas, además de las nuevas funciones que vayan
- llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos.
+ con comparación frente a las normales climáticas y la perspectiva estacional de los próximos
+ meses, además de las nuevas funciones que vayan llegando. Las partes gratuitas de Drizz.li
+ siguen siendo gratuitas para todos.
2. Cómo funciona
diff --git a/src/routes/legal/terms/content/fr.svelte b/src/routes/legal/terms/content/fr.svelte
index 2f1ec5f..39544ec 100644
--- a/src/routes/legal/terms/content/fr.svelte
+++ b/src/routes/legal/terms/content/fr.svelte
@@ -14,8 +14,8 @@
1. Ce que vous obtenez
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
- avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les
- parties gratuites de Drizz.li le restent pour tout le monde.
+ avec comparaison aux normales climatiques et l'aperçu saisonnier des mois à venir, ainsi que les
+ nouvelles fonctions à venir. Les parties gratuites de Drizz.li le restent pour tout le monde.
2. Fonctionnement
diff --git a/src/routes/legal/terms/content/it.svelte b/src/routes/legal/terms/content/it.svelte
index 4b4111c..281f280 100644
--- a/src/routes/legal/terms/content/it.svelte
+++ b/src/routes/legal/terms/content/it.svelte
@@ -14,8 +14,9 @@
1. Cosa ottieni
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
- confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le
- parti gratuite di Drizz.li restano gratuite per tutti.
+ confronto rispetto alle normali climatiche e le prospettive stagionali per i mesi a venire,
+ oltre alle nuove funzioni man mano che arrivano. Le parti gratuite di Drizz.li restano gratuite
+ per tutti.
2. Come funziona
From 9fc17e74174772c6024e7df53e8868cad29eb107 Mon Sep 17 00:00:00 2001
From: Vincent van der Wal
Date: Mon, 3 Aug 2026 11:05:34 +0200
Subject: [PATCH 02/12] fix incons
---
messages/de.json | 30 +++-
messages/en.json | 30 +++-
messages/es.json | 30 +++-
messages/fr.json | 30 +++-
messages/it.json | 30 +++-
.../[location]/ModelPictogramTimeline.svelte | 11 +-
.../[location]/HistoricalDaily.svelte | 5 +-
.../weather/utils/weather-codes.spec.ts | 129 ++++++++++++++++++
src/routes/weather/utils/weather-codes.ts | 106 ++++++++++----
.../weather/week/[location]/DailyCards.svelte | 4 +-
.../week/[location]/DailyStripSticky.svelte | 4 +-
.../week/[location]/HourlyTable.svelte | 11 +-
.../week/[location]/NearbyCities.svelte | 3 +-
13 files changed, 384 insertions(+), 39 deletions(-)
create mode 100644 src/routes/weather/utils/weather-codes.spec.ts
diff --git a/messages/de.json b/messages/de.json
index 84e1fb1..8ae4807 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -335,5 +335,33 @@
"cadence_varies": "variiert",
"model_group_automatic": "Automatisch",
"model_group_reanalysis": "ECMWF-Reanalyse",
- "model_group_regional_reanalysis": "Regionale Reanalyse"
+ "model_group_regional_reanalysis": "Regionale Reanalyse",
+ "wmo_0": "Klarer Himmel",
+ "wmo_1": "Überwiegend klar",
+ "wmo_2": "Teils bewölkt",
+ "wmo_3": "Bedeckt",
+ "wmo_45": "Nebel",
+ "wmo_48": "Gefrierender Nebel",
+ "wmo_51": "Leichter Nieselregen",
+ "wmo_53": "Mäßiger Nieselregen",
+ "wmo_55": "Starker Nieselregen",
+ "wmo_56": "Leichter gefrierender Nieselregen",
+ "wmo_57": "Starker gefrierender Nieselregen",
+ "wmo_61": "Leichter Regen",
+ "wmo_63": "Mäßiger Regen",
+ "wmo_65": "Starker Regen",
+ "wmo_66": "Leichter gefrierender Regen",
+ "wmo_67": "Starker gefrierender Regen",
+ "wmo_71": "Leichter Schneefall",
+ "wmo_73": "Mäßiger Schneefall",
+ "wmo_75": "Starker Schneefall",
+ "wmo_77": "Schneegriesel",
+ "wmo_80": "Leichte Regenschauer",
+ "wmo_81": "Mäßige Regenschauer",
+ "wmo_82": "Heftige Regenschauer",
+ "wmo_85": "Leichte Schneeschauer",
+ "wmo_86": "Starke Schneeschauer",
+ "wmo_95": "Gewitter",
+ "wmo_96": "Gewitter mit leichtem Hagel",
+ "wmo_99": "Gewitter mit starkem Hagel"
}
diff --git a/messages/en.json b/messages/en.json
index a8b8526..536c18c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -335,5 +335,33 @@
"cadence_varies": "varies",
"model_group_automatic": "Automatic",
"model_group_reanalysis": "ECMWF reanalysis",
- "model_group_regional_reanalysis": "Regional reanalysis"
+ "model_group_regional_reanalysis": "Regional reanalysis",
+ "wmo_0": "Clear sky",
+ "wmo_1": "Mainly clear",
+ "wmo_2": "Partly cloudy",
+ "wmo_3": "Overcast",
+ "wmo_45": "Fog",
+ "wmo_48": "Depositing rime fog",
+ "wmo_51": "Light drizzle",
+ "wmo_53": "Moderate drizzle",
+ "wmo_55": "Dense drizzle",
+ "wmo_56": "Light freezing drizzle",
+ "wmo_57": "Dense freezing drizzle",
+ "wmo_61": "Slight rain",
+ "wmo_63": "Moderate rain",
+ "wmo_65": "Heavy rain",
+ "wmo_66": "Light freezing rain",
+ "wmo_67": "Heavy freezing rain",
+ "wmo_71": "Slight snowfall",
+ "wmo_73": "Moderate snowfall",
+ "wmo_75": "Heavy snowfall",
+ "wmo_77": "Snow grains",
+ "wmo_80": "Slight rain showers",
+ "wmo_81": "Moderate rain showers",
+ "wmo_82": "Violent rain showers",
+ "wmo_85": "Slight snow showers",
+ "wmo_86": "Heavy snow showers",
+ "wmo_95": "Thunderstorm",
+ "wmo_96": "Thunderstorm with slight hail",
+ "wmo_99": "Thunderstorm with heavy hail"
}
diff --git a/messages/es.json b/messages/es.json
index 7b4924c..fc4aff5 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -335,5 +335,33 @@
"cadence_varies": "variable",
"model_group_automatic": "Automático",
"model_group_reanalysis": "Reanálisis del ECMWF",
- "model_group_regional_reanalysis": "Reanálisis regional"
+ "model_group_regional_reanalysis": "Reanálisis regional",
+ "wmo_0": "Cielo despejado",
+ "wmo_1": "Mayormente despejado",
+ "wmo_2": "Parcialmente nublado",
+ "wmo_3": "Cubierto",
+ "wmo_45": "Niebla",
+ "wmo_48": "Niebla engelante",
+ "wmo_51": "Llovizna débil",
+ "wmo_53": "Llovizna moderada",
+ "wmo_55": "Llovizna intensa",
+ "wmo_56": "Llovizna engelante débil",
+ "wmo_57": "Llovizna engelante intensa",
+ "wmo_61": "Lluvia débil",
+ "wmo_63": "Lluvia moderada",
+ "wmo_65": "Lluvia intensa",
+ "wmo_66": "Lluvia engelante débil",
+ "wmo_67": "Lluvia engelante intensa",
+ "wmo_71": "Nevada débil",
+ "wmo_73": "Nevada moderada",
+ "wmo_75": "Nevada intensa",
+ "wmo_77": "Granos de nieve",
+ "wmo_80": "Chubascos débiles",
+ "wmo_81": "Chubascos moderados",
+ "wmo_82": "Chubascos violentos",
+ "wmo_85": "Chubascos de nieve débiles",
+ "wmo_86": "Chubascos de nieve intensos",
+ "wmo_95": "Tormenta",
+ "wmo_96": "Tormenta con granizo ligero",
+ "wmo_99": "Tormenta con granizo fuerte"
}
diff --git a/messages/fr.json b/messages/fr.json
index 30c7e72..1b7a88f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -335,5 +335,33 @@
"cadence_varies": "variable",
"model_group_automatic": "Automatique",
"model_group_reanalysis": "Réanalyse ECMWF",
- "model_group_regional_reanalysis": "Réanalyse régionale"
+ "model_group_regional_reanalysis": "Réanalyse régionale",
+ "wmo_0": "Ciel dégagé",
+ "wmo_1": "Plutôt dégagé",
+ "wmo_2": "Partiellement nuageux",
+ "wmo_3": "Couvert",
+ "wmo_45": "Brouillard",
+ "wmo_48": "Brouillard givrant",
+ "wmo_51": "Bruine faible",
+ "wmo_53": "Bruine modérée",
+ "wmo_55": "Bruine dense",
+ "wmo_56": "Bruine verglaçante faible",
+ "wmo_57": "Bruine verglaçante dense",
+ "wmo_61": "Pluie faible",
+ "wmo_63": "Pluie modérée",
+ "wmo_65": "Pluie forte",
+ "wmo_66": "Pluie verglaçante faible",
+ "wmo_67": "Pluie verglaçante forte",
+ "wmo_71": "Neige faible",
+ "wmo_73": "Neige modérée",
+ "wmo_75": "Neige forte",
+ "wmo_77": "Grésil",
+ "wmo_80": "Averses faibles",
+ "wmo_81": "Averses modérées",
+ "wmo_82": "Averses violentes",
+ "wmo_85": "Averses de neige faibles",
+ "wmo_86": "Averses de neige fortes",
+ "wmo_95": "Orage",
+ "wmo_96": "Orage avec grêle légère",
+ "wmo_99": "Orage avec forte grêle"
}
diff --git a/messages/it.json b/messages/it.json
index ad8ece2..c43c69c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -335,5 +335,33 @@
"cadence_varies": "variabile",
"model_group_automatic": "Automatico",
"model_group_reanalysis": "Rianalisi ECMWF",
- "model_group_regional_reanalysis": "Rianalisi regionale"
+ "model_group_regional_reanalysis": "Rianalisi regionale",
+ "wmo_0": "Cielo sereno",
+ "wmo_1": "Prevalentemente sereno",
+ "wmo_2": "Parzialmente nuvoloso",
+ "wmo_3": "Coperto",
+ "wmo_45": "Nebbia",
+ "wmo_48": "Nebbia gelata",
+ "wmo_51": "Pioviggine debole",
+ "wmo_53": "Pioviggine moderata",
+ "wmo_55": "Pioviggine intensa",
+ "wmo_56": "Pioviggine congelantesi debole",
+ "wmo_57": "Pioviggine congelantesi intensa",
+ "wmo_61": "Pioggia debole",
+ "wmo_63": "Pioggia moderata",
+ "wmo_65": "Pioggia forte",
+ "wmo_66": "Pioggia congelantesi debole",
+ "wmo_67": "Pioggia congelantesi forte",
+ "wmo_71": "Nevicata debole",
+ "wmo_73": "Nevicata moderata",
+ "wmo_75": "Nevicata forte",
+ "wmo_77": "Granuli di neve",
+ "wmo_80": "Rovesci deboli",
+ "wmo_81": "Rovesci moderati",
+ "wmo_82": "Rovesci violenti",
+ "wmo_85": "Rovesci di neve deboli",
+ "wmo_86": "Rovesci di neve forti",
+ "wmo_95": "Temporale",
+ "wmo_96": "Temporale con grandine leggera",
+ "wmo_99": "Temporale con grandine forte"
}
diff --git a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte
index ef64421..88aaa20 100644
--- a/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte
+++ b/src/routes/weather/compare/[location]/ModelPictogramTimeline.svelte
@@ -3,7 +3,7 @@
import * as m from '$lib/paraglide/messages';
- import { getWeatherIconName } from '../../utils/weather-codes';
+ import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
interface Props {
timestamps: number[];
@@ -62,8 +62,9 @@
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
-{#snippet weatherIcon(name: string, size: number = 24)}
+{#snippet weatherIcon(name: string, size: number = 24, description: string = '')}
+ {#if description}{description} {/if}
{/snippet}
@@ -135,7 +136,11 @@
? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
>
- {@render weatherIcon(getWeatherIconName(code, day))}
+ {@render weatherIcon(
+ getWeatherIconName(code, day),
+ 24,
+ getWeatherDescription(code)
+ )}
{/each}
diff --git a/src/routes/weather/historical/[location]/HistoricalDaily.svelte b/src/routes/weather/historical/[location]/HistoricalDaily.svelte
index 1d5f989..3c7d27e 100644
--- a/src/routes/weather/historical/[location]/HistoricalDaily.svelte
+++ b/src/routes/weather/historical/[location]/HistoricalDaily.svelte
@@ -9,7 +9,7 @@
} from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors';
- import { getWeatherIconName } from '../../utils/weather-codes';
+ import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props {
@@ -202,7 +202,8 @@
>{formatZoned(date, timezone, 'd MMM')}
-
+
+ {getWeatherDescription(daily.weather_code[i])}
string> = {
+ 0: m.wmo_0,
+ 1: m.wmo_1,
+ 2: m.wmo_2,
+ 3: m.wmo_3,
+ 45: m.wmo_45,
+ 48: m.wmo_48,
+ 51: m.wmo_51,
+ 53: m.wmo_53,
+ 55: m.wmo_55,
+ 56: m.wmo_56,
+ 57: m.wmo_57,
+ 61: m.wmo_61,
+ 63: m.wmo_63,
+ 65: m.wmo_65,
+ 66: m.wmo_66,
+ 67: m.wmo_67,
+ 71: m.wmo_71,
+ 73: m.wmo_73,
+ 75: m.wmo_75,
+ 77: m.wmo_77,
+ 80: m.wmo_80,
+ 81: m.wmo_81,
+ 82: m.wmo_82,
+ 85: m.wmo_85,
+ 86: m.wmo_86,
+ 95: m.wmo_95,
+ 96: m.wmo_96,
+ 99: m.wmo_99
+};
+
+/** Localized condition text for a weather code; '' for codes we have no name for. */
+export function getWeatherDescription(code: number | null | undefined): string {
+ if (code == null || !Number.isFinite(code)) return '';
+ return WMO_DESCRIPTIONS[code]?.() ?? '';
+}
+
// ─── Local day/night weather codes ──────────────────────────────────────────
// Open-meteo's daily weather_code is a plain numeric max over all 24 hourly
// codes (VariableDaily.swift: `.max(.weathercode)`) — there is no day/night
@@ -113,8 +165,17 @@ const FOG = new Set([45, 48]);
// counterparts when breaking frequency ties (fixes 80 "outranking" 65).
const INTENSITY_EQUIV: Record = { 80: 61, 81: 63, 82: 65, 77: 71, 85: 71, 86: 75 };
-/** Most frequent code; ties go to the more intense (then higher) code. */
-function modeWithHighTiebreak(codes: number[]): number {
+/**
+ * Most frequent code; ties go to the more intense (then higher) code, or with
+ * `preferLower` to the least intense one.
+ *
+ * Thunder is the one group that ties downwards. Within it the code only
+ * describes how much hail comes with the storm, and letting the worst of them
+ * win a coin-flip tie is how a single hour of 99 used to brand a whole day as
+ * the most extreme thing on the scale. Elsewhere the heavier code winning a tie
+ * is the point (heavy rain over slight showers).
+ */
+function modeWithHighTiebreak(codes: number[], preferLower = false): number {
const counts = new Map();
for (const c of codes) counts.set(c, (counts.get(c) ?? 0) + 1);
let best = codes[0];
@@ -122,11 +183,10 @@ function modeWithHighTiebreak(codes: number[]): number {
for (const [code, count] of counts) {
const intensity = INTENSITY_EQUIV[code] ?? code;
const bestIntensity = INTENSITY_EQUIV[best] ?? best;
- if (
- count > bestCount ||
- (count === bestCount &&
- (intensity > bestIntensity || (intensity === bestIntensity && code > best)))
- ) {
+ const winsTie = preferLower
+ ? intensity < bestIntensity || (intensity === bestIntensity && code < best)
+ : intensity > bestIntensity || (intensity === bestIntensity && code > best);
+ if (count > bestCount || (count === bestCount && winsTie)) {
best = code;
bestCount = count;
}
@@ -144,7 +204,7 @@ function daypartCode(codes: number[]): number | null {
// (open-meteo's "don't hide hazards" philosophy, kept per-group).
for (const group of [THUNDER, FREEZING, SNOW, RAIN, DRIZZLE]) {
const hits = hours.filter((c) => group.has(c));
- if (hits.length > 0) return modeWithHighTiebreak(hits);
+ if (hits.length > 0) return modeWithHighTiebreak(hits, group === THUNDER);
}
// Fog needs persistence (≥2h and ≥¼ of the daypart) so one misty hour at
diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte
index debe2fd..5c3e440 100644
--- a/src/routes/weather/week/[location]/DailyCards.svelte
+++ b/src/routes/weather/week/[location]/DailyCards.svelte
@@ -6,7 +6,7 @@
import * as m from '$lib/paraglide/messages';
import { getTempStyle } from '../../utils/colors';
- import { getWeatherIconName } from '../../utils/weather-codes';
+ import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
@@ -196,6 +196,7 @@
height="100px"
style="filter: url(#thin-day-icon)"
>
+ {getWeatherDescription(dayCode)}
+ {getWeatherDescription(nightCode)}
+ {getWeatherDescription(dayCode)}
+ {getWeatherDescription(nightCode)}
+ {#if description}{description} {/if}
{/snippet}
@@ -678,7 +679,11 @@
!cellData[i + 1].isDaytime}
>
{#if finite(wCode)}
- {@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
+ {@render weatherIcon(
+ getWeatherIconName(wCode, cell.isDaytime),
+ iconPx,
+ getWeatherDescription(wCode)
+ )}
{/if}
{/each}
diff --git a/src/routes/weather/week/[location]/NearbyCities.svelte b/src/routes/weather/week/[location]/NearbyCities.svelte
index 8c5bb53..442d87c 100644
--- a/src/routes/weather/week/[location]/NearbyCities.svelte
+++ b/src/routes/weather/week/[location]/NearbyCities.svelte
@@ -9,7 +9,7 @@
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
- import { getWeatherIconName } from '../../utils/weather-codes';
+ import { getWeatherDescription, getWeatherIconName } from '../../utils/weather-codes';
import type { UnitPrefs } from '$lib/stores/settings';
@@ -113,6 +113,7 @@
>
{#if day}
+ {getWeatherDescription(day.weatherCode)}
p.variables.length > 0).length);
- // The hourly table is a header row plus one row per enabled variable, so the
- // placeholder below reserves exactly that and nothing under it jumps when the
- // real table arrives.
- const TABLE_ROW_PX = 57;
+ // The hourly table is a header row, the time/daylight row, then one row per
+ // enabled variable, so the placeholder below reserves exactly that and
+ // nothing under it jumps when the real table arrives. Rows are shorter below
+ // lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
+ // and the time row is 48px at every width.
+ let compactRows = $state(false);
+ onMount(() => {
+ const mq = window.matchMedia('(max-width: 1023px)');
+ const apply = () => (compactRows = mq.matches);
+ apply();
+ mq.addEventListener('change', apply);
+ return () => mq.removeEventListener('change', apply);
+ });
+ let tableRowPx = $derived(compactRows ? 36 : 55);
+ const TABLE_TIME_ROW_PX = 48;
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
// Request only the hourly variables the table rows and meteograms actually
@@ -490,8 +501,13 @@
+
+
{#each { length: enabledTableRows } as _, i (i)}
-
{:else}
-
+
{/if}
@@ -526,14 +548,27 @@
-
+
+
-
+
+
{/if}
diff --git a/src/routes/weather/week/[location]/DaySummary.svelte b/src/routes/weather/week/[location]/DaySummary.svelte
index 538131d..100cac1 100644
--- a/src/routes/weather/week/[location]/DaySummary.svelte
+++ b/src/routes/weather/week/[location]/DaySummary.svelte
@@ -96,6 +96,35 @@
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
});
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
+
+ // ─── Narrative clamp (phones only) ──────────────────────────────────────────
+ // The narrative runs anywhere from two to nine lines depending on the day,
+ // which on a phone means the whole page below it moves as soon as the data
+ // lands. Below md the text is clamped to five lines and gets a toggle, and
+ // the block reserves those five lines plus the toggle row whatever the
+ // length - so the card is the same height before and after the fetch, and the
+ // skeleton can match it exactly. From md up nothing is clamped.
+
+ let expanded = $state(false);
+ let textEl = $state
();
+ let overflows = $state(false);
+
+ $effect(() => {
+ sentences; // re-measure when the day (and so the text) changes
+ const el = textEl;
+ if (!el || expanded) return; // measuring while open would always say "fits"
+
+ const measure = () => {
+ // only clamped below md, where the toggle is the only way to see the rest
+ const clamped = window.matchMedia('(max-width: 767px)').matches;
+ overflows = clamped && el.scrollHeight - el.clientHeight > 1;
+ };
+ measure();
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(el);
+ return () => observer.disconnect();
+ });
- {#if sentences.length > 0}
-
- {sentences.join(' ')}
-
- {:else}
-
- {m.summary_no_data()}
-
- {/if}
+
+ {#if sentences.length > 0}
+
+ {sentences.join(' ')}
+
+ {:else}
+
+ {m.summary_no_data()}
+
+ {/if}
+
+
+
(expanded = !expanded)}
+ >
+ {expanded ? m.summary_read_less() : m.summary_read_more()}
+
+
+
+
From 025623aaa67497a3cfa6e512448952f3b0853b15 Mon Sep 17 00:00:00 2001
From: Vincent van der Wal
Date: Mon, 3 Aug 2026 13:26:30 +0200
Subject: [PATCH 04/12] rm unused urlparam
---
src/lib/utils/url-state.ts | 24 +++++++++++++++++++
.../weather/14-day/[location]/+page.svelte | 10 ++++----
.../weather/compare/[location]/+page.svelte | 19 +++++++++++----
.../weather/week/[location]/+page.svelte | 4 ++--
4 files changed, 46 insertions(+), 11 deletions(-)
diff --git a/src/lib/utils/url-state.ts b/src/lib/utils/url-state.ts
index 52397be..09ecd1f 100644
--- a/src/lib/utils/url-state.ts
+++ b/src/lib/utils/url-state.ts
@@ -39,6 +39,30 @@ export function syncSearchParams(url: URL, updates: Record {
const model = params.models?.[0];
if (!mounted || !model) return;
- syncSearchParams(get(page).url, { model });
+ syncSearchParams(get(page).url, { model: unlessDefault(model, DEFAULT_MODEL) });
});
// components persist across refetches; entries are null while unmounted
@@ -296,7 +298,7 @@
{#snippet heroActions()}
{
diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte
index bf115ec..e6cf6f7 100644
--- a/src/routes/weather/compare/[location]/+page.svelte
+++ b/src/routes/weather/compare/[location]/+page.svelte
@@ -9,7 +9,7 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
- import { readList, syncSearchParams } from '$lib/utils/url-state';
+ import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -71,10 +71,19 @@
storedLocation.set(data.location);
});
+ const DEFAULT_VARS = ['temperature_2m', 'rain', 'wind_speed_10m'];
+ const DEFAULT_MODELS = [
+ 'ecmwf_ifs',
+ 'meteofrance_seamless',
+ 'ukmo_seamless',
+ 'icon_seamless',
+ 'gfs_seamless'
+ ];
+
let params = $state({
...defaultParameters,
- hourly: ['temperature_2m', 'rain', 'wind_speed_10m'],
- models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
+ hourly: [...DEFAULT_VARS],
+ models: [...DEFAULT_MODELS]
});
// units live in a persisted store; mirror them into params so a change
@@ -125,8 +134,8 @@
const vars = params.hourly;
if (!mounted) return;
syncSearchParams(get(page).url, {
- models: models?.length ? models.join(',') : null,
- vars: vars?.length ? vars.join(',') : null
+ models: listUnlessDefault(models, DEFAULT_MODELS),
+ vars: listUnlessDefault(vars, DEFAULT_VARS)
});
});
diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte
index 06573f8..352fbbd 100644
--- a/src/routes/weather/week/[location]/+page.svelte
+++ b/src/routes/weather/week/[location]/+page.svelte
@@ -18,7 +18,7 @@
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
- import { syncSearchParams } from '$lib/utils/url-state';
+ import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -269,7 +269,7 @@
: false;
syncSearchParams(get(page).url, {
day: isToday ? null : dayKey,
- model: model && model !== 'best_match' ? model : null
+ model: unlessDefault(model, 'best_match')
});
});
From f4a02a208cdb524b4b9692d3ca65ebe59e29d023 Mon Sep 17 00:00:00 2001
From: Vincent van der Wal
Date: Thu, 6 Aug 2026 19:08:00 +0200
Subject: [PATCH 05/12] overlay
---
.gitignore | 13 +++
README.md | 11 +-
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
src/app.html | 5 +
src/lib/components/navigation/header.svelte | 20 ++--
src/lib/utils/date.ts | 54 +++++++--
src/lib/utils/day-swap.ts | 12 +-
src/lib/utils/location.ts | 24 ++++
src/routes/+layout.svelte | 108 +++++++++++++++---
src/routes/weather/+layout.svelte | 11 +-
.../weather/14-day/[location]/+page.svelte | 2 +-
.../weather/compare/[location]/+page.svelte | 2 +-
.../historical/[location]/+page.svelte | 10 +-
.../weather/seasonal/[location]/+page.svelte | 66 ++++++-----
.../weather/week/[location]/+page.svelte | 9 +-
static/favicon.svg | 46 ++++++++
svelte.config.js | 16 ++-
21 files changed, 336 insertions(+), 78 deletions(-)
create mode 100644 static/favicon.svg
diff --git a/.gitignore b/.gitignore
index 3b87454..39548b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,16 @@ AGENTS.md
# Claude Code scratch: throwaway probe scripts, never committed
/.scratch
+
+# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
+# runs. They are not project files and keep sneaking into commits via `git add -A`.
+/.bash_profile
+/.bashrc
+/.profile
+/.zprofile
+/.zshrc
+/.gitconfig
+/.gitmodules
+/.ripgreprc
+/.idea
+/.mcp.json
diff --git a/README.md b/README.md
index 6e7136a..0790d6e 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
and resolves the location client-side. Configure the server to serve
`404.html` for unknown paths.
+Serve it as an **internal rewrite (200)**, not as an error page. `error_page
+404 /404.html` sends the right body with a 404 status: the page works, but
+every hard reload of an unprerendered URL logs a 404 in the network panel and
+tells crawlers the page does not exist. `try_files` with a URI as its last
+argument does an internal redirect instead, and answers 200.
+
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
@@ -83,11 +89,12 @@ drizzli.example.com {
server {
server_name drizzli.example.com;
root /srv/drizzli;
- error_page 404 /404.html;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
location / {
- try_files $uri $uri/ =404;
+ # the trailing /404.html is a URI, so nginx rewrites internally and
+ # answers 200 - `error_page 404 /404.html` would answer 404 instead
+ try_files $uri $uri/index.html /404.html;
}
}
```
diff --git a/messages/de.json b/messages/de.json
index 3c55855..ac30e78 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -260,6 +260,7 @@
"table_interval_aria": "Stundenintervall",
"table_now": "Jetzt",
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
+ "page_loading": "Wird geladen…",
"charts_loading": "Diagramme werden geladen…",
"chart_download": "Meteogramm als PNG-Bild herunterladen",
"chart_credit_viz": "Visualisierung von",
diff --git a/messages/en.json b/messages/en.json
index 60958e8..1475299 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -260,6 +260,7 @@
"table_interval_aria": "Hourly interval",
"table_now": "Now",
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
+ "page_loading": "Loading…",
"charts_loading": "Loading charts...",
"chart_download": "Download meteogram as PNG image",
"chart_credit_viz": "visualisation by",
diff --git a/messages/es.json b/messages/es.json
index c6e0efe..5186393 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -260,6 +260,7 @@
"table_interval_aria": "Intervalo horario",
"table_now": "Ahora",
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
+ "page_loading": "Cargando…",
"charts_loading": "Cargando gráficos…",
"chart_download": "Descargar el meteograma como imagen PNG",
"chart_credit_viz": "visualización de",
diff --git a/messages/fr.json b/messages/fr.json
index e057603..830dd9d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -260,6 +260,7 @@
"table_interval_aria": "Intervalle horaire",
"table_now": "Maintenant",
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
+ "page_loading": "Chargement…",
"charts_loading": "Chargement des graphiques…",
"chart_download": "Télécharger le météogramme en PNG",
"chart_credit_viz": "visualisation par",
diff --git a/messages/it.json b/messages/it.json
index fe570ba..3990888 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -260,6 +260,7 @@
"table_interval_aria": "Intervallo orario",
"table_now": "Ora",
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
+ "page_loading": "Caricamento…",
"charts_loading": "Caricamento dei grafici…",
"chart_download": "Scarica il meteogramma come immagine PNG",
"chart_credit_viz": "visualizzazione di",
diff --git a/src/app.html b/src/app.html
index cb618ef..c833eb1 100644
--- a/src/app.html
+++ b/src/app.html
@@ -3,6 +3,11 @@
+
+
-
+
+
+{#if loadingOverlay}
+
+
+
+
+
+
{m.page_loading()}
+
+
+{/if}
+
diff --git a/src/routes/weather/+layout.svelte b/src/routes/weather/+layout.svelte
index 2ac586c..518ae36 100644
--- a/src/routes/weather/+layout.svelte
+++ b/src/routes/weather/+layout.svelte
@@ -42,6 +42,11 @@
let subtitle = $derived(
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
);
+
+ // Joined here rather than in the markup: Svelte trims the whitespace around a
+ // line break, so a separator written as "{admin1},\n{country}" renders as
+ // "Canton of Schwyz,Switzerland".
+ let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
{#if subtitle && location}
@@ -57,10 +62,8 @@
{location.name}
- {#if location.admin1}{location.admin1},
- {/if}{location.country ?? ''}· {subtitle}
+ {#if region}{region}· {/if}{subtitle}
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte
index ebe9a9a..e440a6f 100644
--- a/src/routes/weather/14-day/[location]/+page.svelte
+++ b/src/routes/weather/14-day/[location]/+page.svelte
@@ -44,7 +44,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
- reportPageReady(() => fetchedData != null);
+ reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte
index e6cf6f7..6e4d55f 100644
--- a/src/routes/weather/compare/[location]/+page.svelte
+++ b/src/routes/weather/compare/[location]/+page.svelte
@@ -59,7 +59,7 @@
let { data }: { data: PageData } = $props();
// the page cross-fade waits for this before revealing the new page
- reportPageReady(() => fetchedData != null);
+ reportPageReady(() => fetchedData != null || loadError != null);
useHeroActions(heroActions);
diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte
index ff359c7..a106540 100644
--- a/src/routes/weather/historical/[location]/+page.svelte
+++ b/src/routes/weather/historical/[location]/+page.svelte
@@ -39,8 +39,10 @@
let { data }: { data: PageData } = $props();
- // the page cross-fade waits for this before revealing the new page
- reportPageReady(() => result != null);
+ // The page cross-fade waits for this before revealing the new page - and so
+ // does the loading overlay, so a visitor without a key has to count as ready:
+ // the paywall is the finished page here, nothing is on its way.
+ reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -213,7 +215,9 @@
);
function switchDay(date: Date) {
- selectedDay.setTime(date.getTime());
+ // see the week page: an unformattable selected day breaks every consumer
+ const time = date?.getTime();
+ if (Number.isFinite(time)) selectedDay.setTime(time);
}
diff --git a/src/routes/weather/seasonal/[location]/+page.svelte b/src/routes/weather/seasonal/[location]/+page.svelte
index f57368b..80f76b6 100644
--- a/src/routes/weather/seasonal/[location]/+page.svelte
+++ b/src/routes/weather/seasonal/[location]/+page.svelte
@@ -33,8 +33,10 @@
let { data }: { data: PageData } = $props();
- // the page cross-fade waits for this before revealing the new page
- reportPageReady(() => result != null);
+ // The page cross-fade waits for this before revealing the new page - and so
+ // does the loading overlay, so a visitor without a key has to count as ready:
+ // the paywall is the finished page here, nothing is on its way.
+ reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -189,33 +191,39 @@
{#snippet heroActions()}
-
- {#if result}
-
-
- {#each RANGES as range, i (range.label)}
- {@const disabled = range.days !== Infinity && range.days > horizonDays}
- (rangeIndex = i)}
- >
- {range.label}
-
- {/each}
-
- {/if}
+
+
+
+
+ {#each RANGES as range, i (range.label)}
+ {@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
+ (rangeIndex = i)}
+ >
+ {range.label}
+
+ {/each}
+
fetchedDaily != null && fetchedHourly != null);
+ reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
useHeroActions(heroActions);
@@ -211,8 +211,13 @@
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
+ // A model that answers with a broken timestamp must not be able to park an
+ // unreadable date in `selectedDay`: everything downstream formats it, and a
+ // date that cannot be formatted takes the page with it.
const switchDay = (date: Date) => {
- runDayTransition(() => selectedDay.setTime(date.getTime()));
+ const time = date?.getTime();
+ if (!Number.isFinite(time)) return;
+ runDayTransition(() => selectedDay.setTime(time));
};
onMount(() => {
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..3a99f1f
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/svelte.config.js b/svelte.config.js
index d615f22..3bdead3 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -45,12 +45,26 @@ const config = {
];
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
+ // Every per-location route, not just the week page: an unprerendered
+ // path is served by the SPA fallback, which the host answers with a
+ // 404 status. The page still works, but it costs a bogus 404 on every
+ // hard reload (and tells crawlers the page does not exist).
+ const cityRoutes = [
+ '/weather/week',
+ '/weather/compare',
+ '/weather/14-day',
+ '/weather/seasonal',
+ '/weather/historical'
+ ];
+
try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
const raw = fs.readFileSync(citiesPath, 'utf-8');
const cities = JSON.parse(raw);
if (Array.isArray(cities)) {
- const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
+ const cityEntries = cities.flatMap((c) =>
+ cityRoutes.map((route) => `/en${route}/${c}`)
+ );
// Keep the default wildcard to include other routes
return ['*', ...localized, ...cityEntries];
}
From 1c5ea2b52eeeaa26324f71ca9406f9da2e7c5f91 Mon Sep 17 00:00:00 2001
From: Vincent van der Wal
Date: Thu, 6 Aug 2026 19:24:22 +0200
Subject: [PATCH 06/12] beter crossfade
---
src/lib/utils/skeleton-fade.ts | 44 ++++
.../weather/14-day/[location]/+page.svelte | 82 ++++----
.../weather/compare/[location]/+page.svelte | 80 ++++----
.../historical/[location]/+page.svelte | 6 +-
.../weather/seasonal/[location]/+page.svelte | 74 +++----
.../weather/week/[location]/+page.svelte | 189 +++++++++---------
6 files changed, 276 insertions(+), 199 deletions(-)
create mode 100644 src/lib/utils/skeleton-fade.ts
diff --git a/src/lib/utils/skeleton-fade.ts b/src/lib/utils/skeleton-fade.ts
new file mode 100644
index 0000000..851aede
--- /dev/null
+++ b/src/lib/utils/skeleton-fade.ts
@@ -0,0 +1,44 @@
+import type { TransitionConfig } from 'svelte/transition';
+
+const prefersReducedMotion = () =>
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+/**
+ * Fades a loading placeholder out *over* the content that replaces it.
+ *
+ * A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
+ * layout for the length of the transition, so the real content stacks below the
+ * placeholder and the whole page jumps up the moment the placeholder finally
+ * unmounts. That is why these placeholders only ever faded in - the swap was
+ * abrupt, but at least nothing moved.
+ *
+ * This pins the placeholder to the box it already occupies and takes it out of
+ * flow for the fade instead. The content settles into its final position
+ * immediately and the skeleton dissolves on top of it.
+ *
+ * Requirements at the call site: the placeholder's parent must be positioned
+ * (`class="relative"`) and must be the same element that renders the real
+ * content, or the overlay lands somewhere else on the page.
+ */
+export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
+ if (prefersReducedMotion()) return { duration: 0 };
+
+ // measured while the node is still in flow, which is when Svelte builds the
+ // transition - the values below are what freeze it in place
+ const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
+
+ return {
+ duration,
+ css: (t) => `
+ opacity: ${t};
+ position: absolute;
+ top: ${offsetTop}px;
+ left: ${offsetLeft}px;
+ width: ${offsetWidth}px;
+ height: ${offsetHeight}px;
+ margin: 0;
+ pointer-events: none;
+ z-index: 5;
+ `
+ };
+}
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte
index e440a6f..743053a 100644
--- a/src/routes/weather/14-day/[location]/+page.svelte
+++ b/src/routes/weather/14-day/[location]/+page.svelte
@@ -1,12 +1,14 @@
{#if subtitle && location}
@@ -60,11 +70,11 @@
{location.name}
+ · {subtitle}
-
- {#if region}{region}· {/if}{subtitle}
-
+ {#if region}
+
{region}
+ {/if}
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte
index 3235980..a4be3c6 100644
--- a/src/routes/weather/14-day/[location]/+page.svelte
+++ b/src/routes/weather/14-day/[location]/+page.svelte
@@ -298,7 +298,7 @@
{#snippet heroActions()}
-
+
chart != null));
- // ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
+ // ─── Zoom range controls (mirrors the week-page meteograms) ─────────────────────
const SECONDS_PER_DAY = 24 * 3600;
@@ -321,9 +321,62 @@
});
-
+
{#snippet heroActions()}
-
+
+
+
+ document.getElementById('models')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
+ >
+
+
+
+ {m.compare_models_heading()}
+
+
+ {params.models?.length || 0} / {models.flat().length}
+
+
+ {m.compare_models_choose()}
+
+
+
+
+
+
+
+
+
{m.meteograms_zoom_hint()}
diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte
index 5fe1c6b..aaeee1c 100644
--- a/src/routes/weather/historical/[location]/+page.svelte
+++ b/src/routes/weather/historical/[location]/+page.svelte
@@ -225,7 +225,7 @@
{#snippet heroActions()}
-
+
c != null));
- // Same customizable layout as the 7-day meteograms, so the two pages match.
+ // Same customizable layout as the week-page meteograms, so the two pages match.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
diff --git a/src/routes/weather/maps/+page.svelte b/src/routes/weather/maps/+page.svelte
index 504f0d8..6aef597 100644
--- a/src/routes/weather/maps/+page.svelte
+++ b/src/routes/weather/maps/+page.svelte
@@ -1,7 +1,8 @@