diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 77dafe5..d746fb3 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -1086,7 +1086,10 @@ for (let i = 0; i < xs.length; i++) { const pos = Math.max(0, Math.min(1, (xs[i] - first) / span)); const cover = smoothed[i]; - fill.addColorStop(pos, withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve))); + fill.addColorStop( + pos, + withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve)) + ); if (edge) { const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR)); edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6))); diff --git a/src/lib/components/location/location-search.svelte b/src/lib/components/location/location-search.svelte index f757b68..8f7f686 100644 --- a/src/lib/components/location/location-search.svelte +++ b/src/lib/components/location/location-search.svelte @@ -48,6 +48,12 @@ ); } + /** Drop a single entry from the recent list (favourites are unaffected). */ + function removeRecent(loc: GeoLocation) { + const key = locationKey(loc); + storedRecentLocations.update((list) => list.filter((l) => locationKey(l) !== key)); + } + function toggleFavorite(loc: GeoLocation) { const key = locationKey(loc); storedFavoriteLocations.update((list) => @@ -126,7 +132,7 @@ })(); -{#snippet locationRow(location: GeoLocation)} +{#snippet locationRow(location: GeoLocation, removable: boolean)} {@const fav = favKeys.has(locationKey(location))}
+ {#if removable} + + {/if}
{/snippet} @@ -262,7 +280,7 @@
{#each $storedFavoriteLocations as loc (locationKey(loc))} - {@render locationRow(loc)} + {@render locationRow(loc, false)} {/each}
{/if} @@ -276,7 +294,7 @@
{#each recentToShow as loc (locationKey(loc))} - {@render locationRow(loc)} + {@render locationRow(loc, true)} {/each}
{/if} @@ -305,7 +323,7 @@ {:else if results.results && results.results.length > 0}
{#each results.results as location, i (i)} - {@render locationRow(location)} + {@render locationRow(location, false)} {/each}
{:else if results.results} diff --git a/src/lib/components/navigation/footer.svelte b/src/lib/components/navigation/footer.svelte index 4afa526..ab15480 100644 --- a/src/lib/components/navigation/footer.svelte +++ b/src/lib/components/navigation/footer.svelte @@ -1,6 +1,8 @@ + + + + + + + + diff --git a/src/lib/components/navigation/weather-nav.svelte b/src/lib/components/navigation/weather-nav.svelte index 720faf9..0f5078a 100644 --- a/src/lib/components/navigation/weather-nav.svelte +++ b/src/lib/components/navigation/weather-nav.svelte @@ -2,6 +2,8 @@ import { resolve } from '$app/paths'; import { page } from '$app/stores'; + import LogoMark from './logo-mark.svelte'; + interface Props { collapsed?: boolean; onToggle?: () => void; @@ -78,24 +80,7 @@
- - - - - - - - +
{#if !collapsed} diff --git a/src/lib/paywall/PaywallGate.svelte b/src/lib/paywall/PaywallGate.svelte index 397aae8..5c67ef2 100644 --- a/src/lib/paywall/PaywallGate.svelte +++ b/src/lib/paywall/PaywallGate.svelte @@ -2,8 +2,8 @@ import { onMount } from 'svelte'; import UnlockDialog from './UnlockDialog.svelte'; - import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config'; - import { isSupporter, supporterState, refreshSupporter } from './supporter'; + import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config'; + import { isSupporter, refreshSupporter, supporterState } from './supporter'; interface Props { /** Short feature name shown in the locked panel headline. */ diff --git a/src/lib/paywall/UnlockDialog.svelte b/src/lib/paywall/UnlockDialog.svelte index 3c4f854..a1b15fa 100644 --- a/src/lib/paywall/UnlockDialog.svelte +++ b/src/lib/paywall/UnlockDialog.svelte @@ -7,7 +7,13 @@ import { Label } from '$lib/components/ui/label'; import { SIGNUP_URL, getSupporterPrice } from './config'; - import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter'; + import { + clearLicense, + isSupporter, + storedLicenseKey, + supporterState, + verifyKey + } from './supporter'; interface Props { open?: boolean; diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 410014c..baed673 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -138,6 +138,15 @@ export const defaultChartLayout: ChartPanel[] = [ export const storedChartLayout = persisted('chart_layout_v1', defaultChartLayout); +/** + * Time range the meteograms open on. 'auto' narrows to three days on a phone, + * where a full week of hours is too dense to read, and shows everything on + * roomier screens. + */ +export type ChartRangePref = 'auto' | 'today' | '3d' | '5d' | 'all'; + +export const storedChartRange = persisted('chart_range_v1', 'auto'); + /** Selected ensemble model for the 14-day spread forecast. */ export const storedEnsembleModel = persisted('ensemble_model', 'ncep_gefs_seamless'); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 208e20d..9b4b039 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,12 +17,22 @@ // keep the .dark class in sync with the persisted theme; in 'system' mode // follow the OS preference live + let themeSettled = false; + let themeTimer = 0; $effect(() => { const theme = $storedTheme; const mq = window.matchMedia('(prefers-color-scheme: dark)'); const apply = () => { - const dark = theme === 'dark' || (theme === 'system' && mq.matches); - document.documentElement.classList.toggle('dark', dark); + const root = document.documentElement; + // The very first application is just painting the stored theme - only + // an actual switch afterwards is worth cross-fading. + if (themeSettled) { + root.classList.add('theme-transition'); + clearTimeout(themeTimer); + themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400); + } + themeSettled = true; + root.classList.toggle('dark', theme === 'dark' || (theme === 'system' && mq.matches)); }; apply(); mq.addEventListener('change', apply); @@ -83,15 +93,22 @@
+
{#if fullBleed} {@render children()} {:else} -
+
{@render children()}
- -
-
- {location.country -
-

- {location.name} -

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''}·14-day ensemble forecast -

-
-
- + +{#snippet heroActions()}
-
+{/snippet} diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index 8e09e2e..c328477 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -28,6 +28,7 @@ fetchModelComparison } from '$lib/services/weather'; + import { useHeroActions } from '../../hero.svelte'; import { findModel, hourly, modelGroups } from '../../options'; import { defaultParameters } from '../../options'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; @@ -52,6 +53,8 @@ let { data }: { data: PageData } = $props(); + useHeroActions(heroActions); + // the URL is the source of truth: location comes from the load function, // which is also correct on hydrated prerendered pages. The persisted store // only mirrors it so the header and bare /weather/* redirects follow along. @@ -280,28 +283,8 @@ }); - - -
-
- {location.country -
-

- {location.name} -

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''}·Model comparison -

-
-
- + +{#snippet heroActions()}
-
+{/snippet} diff --git a/src/routes/weather/hero.svelte.ts b/src/routes/weather/hero.svelte.ts new file mode 100644 index 0000000..cf90360 --- /dev/null +++ b/src/routes/weather/hero.svelte.ts @@ -0,0 +1,20 @@ +import { getContext } from 'svelte'; + +import type { Snippet } from 'svelte'; + +interface HeroContext { + setActions: (snippet: Snippet | null) => void; +} + +/** + * Renders a page's own controls into the shared location row that lives in + * `weather/+layout.svelte`. The row itself stays mounted across navigation; + * only the controls swap, and they are cleared when the page unmounts. + */ +export function useHeroActions(actions: Snippet): void { + const hero = getContext('weather-hero'); + $effect(() => { + hero.setActions(actions); + return () => hero.setActions(null); + }); +} diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte index 5557bcc..e11188f 100644 --- a/src/routes/weather/historical/[location]/+page.svelte +++ b/src/routes/weather/historical/[location]/+page.svelte @@ -204,28 +204,6 @@ - -
-
- {location.country -
-

- {location.name} -

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''}·Historical weather -

-
-
-
- - -
-
- {location.country -
-

- {location.name} -

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''}·Seasonal outlook -

-
-
- + +{#snippet heroActions()} {#if result}
{/if} -
+{/snippet} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index f3324d5..e616a66 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -23,6 +23,7 @@ humanizeWeatherError } from '$lib/services/weather'; + import { useHeroActions } from '../../hero.svelte'; import { defaultParameters } from '../../options'; import { computeDayNightWeatherCodes } from '../../utils/weather-codes'; import DailyStripSticky from './DailyStripSticky.svelte'; @@ -37,6 +38,8 @@ let { data }: { data: PageData } = $props(); + useHeroActions(heroActions); + let params = $state({ models: ['best_match'], ...defaultParameters @@ -56,6 +59,14 @@ // arrives (no layout shift) let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length); + // Height the hourly table will occupy once it renders, so its placeholder + // reserves exactly that and nothing below it jumps. The table is a header row + // plus one row per enabled variable, so the count drives the estimate. + const TABLE_HEADER_PX = 96; + const TABLE_ROW_PX = 57; + let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length); + let tableSkeletonHeight = $derived(TABLE_HEADER_PX + enabledTableRows * TABLE_ROW_PX); + // Request only the hourly variables the table rows and meteograms actually // show, so unused variables are never fetched. weather_code is always // included: the day cards / strip derive their day- and night-period icons @@ -200,45 +211,24 @@ + +{#snippet heroActions()} +
+ { + params.models = [model]; + storedModel.set(model); + // a new model may not support the extended / past range + forecastDays = 7; + pastDays = 0; + }} + /> +
+{/snippet} +
- -
-
- {location.country -
-

- {location.name} -

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''}·7-day forecast -

-
-
- -
- { - params.models = [model]; - storedModel.set(model); - // a new model may not support the extended / past range - forecastDays = 7; - pastDays = 0; - }} - /> -
-
- (variableSidebarOpen = false)} /> {#if loadError} @@ -314,19 +304,20 @@ the strip's sentinel view-timeline so the sticky strip (a sibling of the sentinel) can scrub its collapse from it. -->
- {#if fetchedDaily} - (forecastDays = 15)} - canExtendPast={pastDays < 3} - onExtendPast={() => (pastDays = 3)} - {locationRoute} - /> - {/if} + + (forecastDays = 15)} + canExtendPast={pastDays < 3} + onExtendPast={() => (pastDays = 3)} + {locationRoute} + /> {#if fetchedHourly && fetchedDaily} (variableSidebarOpen = true)} /> {:else} - +
+ in:fade={{ duration: 200 }} + class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border" + > +
+
+
+
+
+
+
+
+
{/if} {#if fetchedHourly} {:else} - -
+ +
+
+
+
+
+
+
+
{/if} diff --git a/src/routes/weather/week/[location]/ChartCustomizer.svelte b/src/routes/weather/week/[location]/ChartCustomizer.svelte index 9170cf2..4c42210 100644 --- a/src/routes/weather/week/[location]/ChartCustomizer.svelte +++ b/src/routes/weather/week/[location]/ChartCustomizer.svelte @@ -1,7 +1,13 @@