more fluent texts

This commit is contained in:
Vincent van der Wal
2026-08-01 15:27:26 +02:00
parent 774afa6c65
commit 0d5dbbc61f
18 changed files with 694 additions and 201 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import { page } from '$app/stores';
import LanguageOptions from '$lib/components/language-options.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as m from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { page } from '$app/stores';
// the URL decides the locale, so re-read it on navigation
let current = $derived.by(() => {
void $page.url.pathname;
+23
View File
@@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
/**
* Whether the current page has enough data on screen to be worth revealing.
*
* The weather pages fetch their forecast *after* the route swap, so a plain
* navigation transition would cross-fade one skeleton into another and then cut
* hard to the real content. The layout waits on this instead, so the fade-in
* lines up with the page actually being loaded.
*/
export const pageContentReady = writable(true);
/**
* Declare a page's readiness. Pass a getter for "my data has arrived"; the flag
* is released when the page unmounts, so a page that never reports readiness
* (static content) is revealed immediately.
*/
export function reportPageReady(isReady: () => boolean): void {
$effect(() => {
pageContentReady.set(isReady());
return () => pageContentReady.set(true);
});
}
+45 -14
View File
@@ -1,24 +1,55 @@
import { tick } from 'svelte';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a block back in whenever the value passed to it changes - used to make
* a day switch visible in the hourly table, the written forecast and the
* meteograms.
* Runs a day change inside a view transition, so the outgoing day is still on
* screen while the incoming one fades in - a real cross-fade rather than the
* old content vanishing and the new one fading up from nothing.
*
* It animates the existing node instead of remounting it: the meteograms hold
* canvas charts with their own zoom state, and rebuilding those on every day
* change would be both expensive and lossy.
* Only the regions that actually change carry a `view-transition-name` (see
* `.day-region-*` in routes/layout.css); everything else - the strip, the
* header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still.
*/
export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) {
update();
return;
}
const root = document.documentElement;
root.classList.add('day-switch');
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state.
await document.startViewTransition(async () => {
update();
await tick();
}).finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
} finally {
root.classList.remove('day-switch');
}
}
/**
* Fallback for browsers without view transitions: fade the block back in when
* the value passed to it changes. Animates the existing node rather than
* remounting it, so the canvas charts keep their zoom state.
*/
export function daySwap(node: HTMLElement, key: unknown) {
let current = key;
const play = () => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
node.animate(
[
{ opacity: 0.15, transform: 'translateY(4px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 280, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }
);
// view transitions handle it properly where they exist
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
});
};
return {
+46
View File
@@ -0,0 +1,46 @@
/**
* Mirrors UI state into the query string so a view can be linked and reloaded
* exactly as it was: which day is open, which model is plotted, which variables
* are compared.
*
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
* history entry or re-runs a load - the back button still means "the page
* before", not "the previous day I clicked".
*/
import { browser } from '$app/environment';
import { replaceState } from '$app/navigation';
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
if (!browser) return;
const next = new URL(url);
for (const [key, value] of Object.entries(updates)) {
if (value == null || value === '') next.searchParams.delete(key);
else next.searchParams.set(key, value);
}
if (next.href === url.href) return;
try {
replaceState(next, {});
} catch {
// A page whose state settles during mount can get here before the router
// has taken over. The URL is cosmetic, so retry on the next frame rather
// than letting it break the page.
requestAnimationFrame(() => {
try {
replaceState(next, {});
} catch {
/* give up: the view still works, it just isn't linkable yet */
}
});
}
}
/** Reads a comma-separated list, dropping empties. */
export function readList(url: URL, key: string): string[] | null {
const raw = url.searchParams.get(key);
if (!raw) return null;
const list = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return list.length > 0 ? list : null;
}