finish animations and add elevation

This commit is contained in:
Vincent van der Wal
2026-08-06 22:19:17 +02:00
parent d0f94094ca
commit 83b0e46e64
21 changed files with 328 additions and 112 deletions
+11 -3
View File
@@ -45,8 +45,16 @@
// separators spelled out in the template lose their spacing to Svelte's
// whitespace trimming ("Canton of Schwyz,Switzerland").
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
// elevation rides along in the pill's muted part ("· Canton of Schwyz,
// Switzerland · 465m"); a 0 m coastal town is a real reading, only a
// missing value is dropped
let locationDetail = $derived(
[locationRegion, location?.elevation != null ? `${Math.round(location.elevation)}m` : null]
.filter(Boolean)
.join(' · ')
);
// the pill ellipses, so the full name still has to be readable somewhere
let locationLine = $derived([location?.name, locationRegion].filter(Boolean).join(' · '));
let locationLine = $derived([location?.name, locationDetail].filter(Boolean).join(' · '));
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, () => string> = {
@@ -120,8 +128,8 @@
<!-- full location (desktop); the page hero carries it on smaller screens -->
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
{location.name}
{#if locationRegion}
<span class="font-normal text-muted-foreground">· {locationRegion}</span>
{#if locationDetail}
<span class="font-normal text-muted-foreground">· {locationDetail}</span>
{/if}
</span>
</div>
+12
View File
@@ -10,6 +10,18 @@ import { writable } from 'svelte/store';
*/
export const pageContentReady = writable(true);
/**
* Raised by the layout just before it captures a navigation away from the maps
* page. The map is a cross-origin iframe, which browsers do not paint into a
* view transition snapshot - captured bare, the outgoing page would carry a
* hole where the map was. The maps page answers by laying an opaque
* same-origin cover over the iframe (the same one that hides the map while it
* boots), so the snapshot shows a clean panel and the cross-fade has something
* real to fade from. The layout lowers it again once the navigation is
* through.
*/
export const mapTransitionCover = writable(false);
/**
* Called by the layout before it swaps to a route that fetches its own data.
*
+42 -10
View File
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
import {
canStartViewTransition,
prefersReducedMotion,
skipActiveViewTransition,
startViewTransition,
supportsViewTransitions
} from './view-transition';
@@ -21,22 +22,53 @@ import {
* applies: starting a rival transition would skip the running one and flash the
* page (see view-transition.ts).
*/
/** Guards the shared cleanup below against a switch superseding a switch. */
let dayTransitionToken = 0;
export async function runDayTransition(update: () => void): Promise<void> {
if (!canStartViewTransition()) {
update();
return;
}
// Svelte applies the change on the next tick; the transition has to wait for
// that before it snapshots the new state. `day-switch` scopes which regions
// take part (see routes/layout.css) and is cleared for us when it ends.
await startViewTransition(
async () => {
update();
await tick();
},
{ rootClass: 'day-switch' }
);
const token = ++dayTransitionToken;
const root = document.documentElement;
// The region snapshots include the part of the table normally scrolled up
// behind the sticky strip; the transition overlay is clipped at the bar's
// bottom edge so they cannot paint over the (live, clickable) strip.
// Measured per switch because the bar's height follows the scroll collapse.
const bar = document.querySelector('.daystrip .strip-row');
if (bar) {
const clip = Math.max(0, bar.getBoundingClientRect().bottom);
root.style.setProperty('--day-switch-clip', `${clip}px`);
}
// The page stays scrollable during the fade, but the snapshots and the clip
// line above are anchored to where things were at capture - so the first
// sign of scrolling finishes the fade on the spot instead of animating
// against a moving page.
const skip = () => skipActiveViewTransition();
window.addEventListener('wheel', skip, { passive: true });
window.addEventListener('touchmove', skip, { passive: true });
try {
// Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state. `day-switch` scopes which
// regions take part (see routes/layout.css) and is cleared when it ends.
await startViewTransition(
async () => {
update();
await tick();
},
{ rootClass: 'day-switch' }
);
} finally {
window.removeEventListener('wheel', skip);
window.removeEventListener('touchmove', skip);
// a newer switch owns the clip var now; only the last one may clear it
if (token === dayTransitionToken) root.style.removeProperty('--day-switch-clip');
}
}
/**
+37 -2
View File
@@ -42,6 +42,29 @@ interface Options {
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
let capturing: Promise<void> | null = null;
/**
* How many running transitions hold each scoping class. A superseded
* transition's cleanup fires while its successor is mid-capture (skipping
* rejects `finished` on a microtask, which runs before the next render);
* without the count it would strip the class out from under the successor,
* and a day switch captured without `day-switch` falls back to the full-page
* fade it exists to prevent.
*/
const rootClassHolds = new Map<string, number>();
/** The most recently started transition, while it is capturing or animating. */
let active: ViewTransition | null = null;
/**
* Finishes the running transition's animation on the spot (the DOM is already
* in its final state, so this is always safe). Used to hand the screen back
* the moment the user starts scrolling under a day switch, rather than
* animating against a moving target.
*/
export function skipActiveViewTransition(): void {
active?.skipTransition();
}
export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
@@ -76,9 +99,13 @@ export function startViewTransition(update: UpdateCallback, options: Options = {
}
const root = document.documentElement;
if (rootClass) root.classList.add(rootClass);
if (rootClass) {
rootClassHolds.set(rootClass, (rootClassHolds.get(rootClass) ?? 0) + 1);
root.classList.add(rootClass);
}
const transition = document.startViewTransition(update);
active = transition;
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is
@@ -102,6 +129,14 @@ export function startViewTransition(update: UpdateCallback, options: Options = {
() => {}
)
.finally(() => {
if (rootClass) root.classList.remove(rootClass);
if (active === transition) active = null;
if (!rootClass) return;
const holds = (rootClassHolds.get(rootClass) ?? 1) - 1;
if (holds > 0) {
rootClassHolds.set(rootClass, holds);
} else {
rootClassHolds.delete(rootClass);
root.classList.remove(rootClass);
}
});
}
+26 -9
View File
@@ -7,6 +7,7 @@
import { page } from '$app/stores';
import {
mapTransitionCover,
markPageLoading,
markPageReady,
pageContentReady
@@ -104,10 +105,14 @@
const OVERLAY_CEILING_MS = 15000;
// The map is a cross-origin iframe, and a browser does not paint one into a
// view transition snapshot. Any transition with the maps page on either side
// animates a hole where the map is: leaving it, the map drops out at frame one
// and that blank sits under the incoming page for the whole run. Swapping
// outright is the honest answer - there is nothing here to cross-fade.
// view transition snapshot - captured bare, any transition with the maps page
// on either side would animate a hole where the map is. The way out is to
// make sure the map is never what gets captured: the maps page keeps an
// opaque cover over the iframe while the map boots (so an arrival fades into
// a clean panel, and the map fades up once ready), and raises the same cover
// again just before a departure is captured (see mapTransitionCover). Both
// snapshots then hold real pixels and the maps page transitions like any
// other route.
const MAPS_ROUTE = '/weather/maps';
const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE);
@@ -187,7 +192,7 @@
/**
* True when the navigation lands on the route and params the page is already
* showing - the sidebar's home link from the 7-day page it points at, or a
* showing - the sidebar's home link from the week page it points at, or a
* link that differs only in the query string.
*
* SvelteKit keeps the page component mounted for those, and nothing it holds
@@ -206,7 +211,7 @@
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
}
onNavigate((navigation) => {
onNavigate(async (navigation) => {
const pending =
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
// Either way the flag is set explicitly: leaving a page that never resolved
@@ -217,9 +222,16 @@
// `startViewTransition` decides whether a transition is possible at all
// (support, reduced motion, one already capturing) and runs the update
// inline when it is not - so there is exactly one path from here down.
const touchesMap =
navigation.from?.route?.id === MAPS_ROUTE || navigation.to?.route?.id === MAPS_ROUTE;
const underTransition = canStartViewTransition() && !touchesMap;
const underTransition = canStartViewTransition();
// Leaving the maps page: cover the iframe before the outgoing state is
// captured, so the snapshot holds a clean panel instead of a hole where
// the map was. The tick makes sure the cover is actually in the DOM by
// the time the capture reads it.
if (underTransition && navigation.from?.route?.id === MAPS_ROUTE) {
mapTransitionCover.set(true);
await tick();
}
return new Promise<void>((swap) => {
void startViewTransition(
@@ -242,6 +254,11 @@
let mainEl = $state<HTMLElement | null>(null);
afterNavigate((navigation) => {
// The departure snapshot (if any) is taken by now, so the maps cover has
// done its job; lowering it here also means a later visit to the maps page
// starts from its own boot cover rather than a stuck one.
mapTransitionCover.set(false);
// The page scrolls inside <main>, not the window, so SvelteKit's own scroll
// handling never touches it and a new page would open half way down.
// Back/forward and in-page anchors keep their position.
+39 -36
View File
@@ -135,9 +135,18 @@
/* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch`
class cancels the root animation, so the strip, header and page chrome do
not so much as flicker while the table, summary and charts swap over. */
in the cross-fade - and nothing else is captured at all. A captured
element is neither painted nor hit-testable in the live page for the
length of the animation (per spec: as if it had visibility:hidden and
pointer-events:none), and the root is captured by default, which captures
the whole page. That is what made the strip unclickable and the page
unscrollable while a switch played - no amount of pointer-events on the
overlay could fix it, because the page underneath was gone too. With the
root left un-named the strip, the chrome and the scroller keep their live
pixels and stay fully interactive. */
:root.day-switch {
view-transition-name: none;
}
:root.day-switch .day-region-table {
view-transition-name: day-table;
}
@@ -148,23 +157,20 @@
view-transition-name: day-charts;
}
/* The sticky day strip never changes during a day switch, but it has to be
captured too: view-transition snapshots all paint in one layer above the
page, so without its own group (and a higher z-index in that layer) the
fading table and charts would slide over the top of it. */
:root.day-switch .daystrip {
view-transition-name: daystrip;
}
::view-transition-group(daystrip) {
z-index: 20;
/* Region snapshots paint in a viewport-fixed layer above the live page, at
their full layout size - including the part of the table normally
scrolled up behind the sticky strip and the topbar. The strip used to be
captured purely to stay on top of that, but a captured strip is dead to
input; instead the whole overlay is clipped at the strip bar's bottom
edge (measured per switch by runDayTransition), so the snapshots stay
out of the chrome and the chrome stays live. */
:root.day-switch::view-transition {
clip-path: inset(var(--day-switch-clip, 0px) 0 0 0);
}
/* Same for the topbar and the sidebar: a captured region's snapshot is
painted at its layout position, including the part normally scrolled up
behind the chrome. Both are also identical either side of a *page* swap,
so they are captured there too and pinned below - a region that does not
change should not be animated at all. */
:root.day-switch .topbar,
/* Page swaps capture the topbar and sidebar: a region that is identical on
both sides should not be animated at all, so they are pinned here and
swapped outright below. */
:root.page-switch .topbar {
view-transition-name: topbar;
}
@@ -178,24 +184,27 @@
z-index: 25;
}
/* The root fade is the page swap's own; during a day switch the chrome must
not so much as flicker, so it is cancelled outright. */
:root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.day-region-table,
.day-region-summary,
.day-region-charts,
.daystrip,
.topbar,
.sidebar-region {
view-transition-name: none;
}
}
/* The snapshot layer is a fixed overlay across the whole viewport and it
hit-tests, so for the length of an animation every click would land on it
and die - a day tapped while the previous switch is still fading simply
went dead. Let input fall through to the live page instead: by the time
the pseudo elements exist the DOM is already in its final state, and a
click that starts a new transition supersedes the running one cleanly
(see view-transition.ts). */
::view-transition {
pointer-events: none;
}
/* ── Cross-fades that do not dip ──────────────────────────────────────────
The default cross-fade is NOT opacity-neutral: coverage of two stacked
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
@@ -240,21 +249,15 @@
mix-blend-mode: plus-lighter;
}
/* Chrome captured only so the fading regions cannot paint over it. The strip
does change - the selected day moves - but that change belongs to the
regions' cross-fade, not to the strip, so it swaps outright: the outgoing
snapshot is dropped rather than held, because the selected cell is a
translucent `bg-primary/10` tint and anything left underneath shows
through it as a second, doubled label. */
/* Pinned chrome swaps outright: identical on both sides, so any animation
would only risk a flicker. */
::view-transition-old(topbar),
::view-transition-old(sidebar),
::view-transition-old(daystrip) {
::view-transition-old(sidebar) {
animation: none;
opacity: 0;
}
::view-transition-new(topbar),
::view-transition-new(sidebar),
::view-transition-new(daystrip) {
::view-transition-new(sidebar) {
animation: none;
opacity: 1;
}
+16 -6
View File
@@ -45,8 +45,18 @@
// 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(', '));
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
// the whole line is hidden, because the topbar pill carries the region and
// elevation there.
let region = $derived(
[
location?.admin1,
location?.country,
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
]
.filter(Boolean)
.join(', ')
);
</script>
{#if subtitle && location}
@@ -60,11 +70,11 @@
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
<span class="font-medium text-muted-foreground">· {subtitle}</span>
</h1>
<p class="truncate text-sm text-muted-foreground">
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
>{/if}{subtitle}
</p>
{#if region}
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
{/if}
</div>
</div>
@@ -298,7 +298,7 @@
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<div class="flex w-full items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
groups={ensembleModelGroups}
@@ -143,7 +143,7 @@
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => 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 @@
});
</script>
<!-- the zoom controls ride in the layout's location row (see weather/+layout) -->
<!-- the models button and zoom controls ride in the layout's location row -->
{#snippet heroActions()}
<!-- Range / zoom controls, aligned with the title like the other pages -->
<!-- Hero models control: same footprint and look as the model selector on
the other pages, so the title row lines up everywhere. Model selection
here is a multi-select grid further down, so this hands over to it. -->
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<button
type="button"
class="group flex h-auto min-h-12 w-full min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 pe-3 text-left shadow-sm transition-colors hover:border-primary/70 hover:shadow-md sm:min-h-14 sm:w-80 sm:flex-none sm:gap-3 sm:py-2"
onclick={() =>
document.getElementById('models')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
>
<!-- layered-globe icon, matching the model selector -->
<svg
class="size-4.5 sm:size-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
/>
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{m.compare_models_heading()}
</span>
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm">
{params.models?.length || 0} / {models.flat().length}
</span>
<span
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
>
{m.compare_models_choose()}
</span>
</div>
<svg
class="size-4 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<!-- Range / zoom controls, floated over the chart area on lg -->
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
{m.meteograms_zoom_hint()}
@@ -225,7 +225,7 @@
<!-- the reanalysis picker rides in the layout's location row -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
@@ -31,7 +31,7 @@
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => 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)))
);
+25 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
import { mapsDomainForModel } from '$lib/utils/maps-domain';
@@ -27,6 +28,14 @@
// backstop, so a map that fails to boot still releases the page.
reportPageReady(() => mapReady || frameLoaded);
// The map stays under an opaque cover until it is ready, so it fades up from
// a clean panel instead of flashing the iframe's white boot document (worst
// in dark mode). The same cover is what makes navigation transitions work at
// all here: a cross-origin iframe is never painted into a view transition
// snapshot, so the cover is raised again just before a departure is captured
// (see mapTransitionCover) - both snapshots then hold real pixels.
let revealed = $derived(mapReady || frameLoaded);
const postToMap = (message: Record<string, unknown>) => {
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
};
@@ -93,7 +102,7 @@
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<div class="h-full w-full bg-background">
<div class="relative h-full w-full bg-background">
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
map; it only takes effect when this site itself is served with
COOP/COEP headers (see README, Deployment) -->
@@ -107,6 +116,20 @@
referrerpolicy="no-referrer"
onload={() => (frameLoaded = true)}
class="block h-full w-full border-0"
class:invisible={$mapTransitionCover}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
<!-- No `in:` transition on purpose: when the cover comes back for a
departure it has to be at full opacity by the time the snapshot is
taken, not fading towards it. The iframe is additionally made
invisible then (above), so the capture never has to paint cross-origin
content at all - some engines degrade the whole transition over it. -->
{#if !revealed || $mapTransitionCover}
<div
class="absolute inset-0 bg-background"
out:fade={{ duration: 300 }}
aria-hidden="true"
></div>
{/if}
</div>
@@ -196,7 +196,7 @@
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
and archive pages: the controls then cannot move the heading when they
change size. -->
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
forecast is on its way they stay mounted but invisible, because
mounting them on arrival re-flowed the row and nudged the heading.
@@ -346,12 +346,12 @@
<svelte:head>
<title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
<meta name="description" content="Weekly weather forecast with detailed hourly data" />
</svelte:head>
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
{#snippet heroActions()}
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -322,6 +322,10 @@
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
// Measured so the centred NOW badge can be clamped fully inside the row:
// centred on a time near midnight it would poke past the last column and
// hand the scroller a sliver of phantom overflow.
let nowBadgeWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
@@ -608,14 +612,17 @@
{#if nowPercent != null}
<div
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
style="left:{nowPercent}%"
style="left:clamp(1px, {nowPercent}%, calc(100% - 1px))"
></div>
{/if}
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
<!-- "Now" label, aligned with the sunrise/sunset labels along the
bottom; its centre is clamped so the pill never leaves the row -->
{#if isTodaySelected && nowPercent != null}
<span
bind:clientWidth={nowBadgeWidth}
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
style="left:{nowPercent}%"
style="left:clamp({nowBadgeWidth /
2}px, {nowPercent}%, calc(100% - {nowBadgeWidth / 2}px))"
>
{m.table_now()}
</span>
@@ -904,24 +911,31 @@
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
<!-- Hovered-column highlight, mirroring the meteogram crosshair.
max-width caps the box at the wrapper's true (fractional) right
edge: the widths here derive from rounded clientWidth bindings, so
on the last column left+width can land a fraction of a pixel past
the edge - enough scrollable overflow for a phantom scrollbar. -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const colLeft = headerColWidth + hoveredCol * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
<!-- "Now" column highlight. The current-time line itself is painted per
cell (.now-cell) so it stays under the values and icons. -->
cell (.now-cell) so it stays under the values and icons. Same
max-width cap as the hover highlight above. -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
{@const colLeft = headerColWidth + nowIdx * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
{/if}
@@ -64,9 +64,13 @@
if (val) onModelChange(val);
}}
>
<!-- Fixed width from sm up (mobile stays full-width): the trigger used to hug
its content, so its size changed with every model name and differed
between pages. One constant footprint, sized for the longest label in
the catalogue; anything longer truncates. -->
<Select.Trigger
aria-label={m.model_selector_aria({ label })}
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:w-80 sm:gap-3 sm:py-2 sm:flex-none"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"