more fluent design

This commit is contained in:
Vincent van der Wal
2026-07-25 11:10:14 +02:00
parent 562d545c7a
commit d78ac84a12
8 changed files with 90 additions and 73 deletions
@@ -101,11 +101,11 @@
<style> <style>
.chart-bleed { .chart-bleed {
/* Bleed exactly into the page padding on mobile (main has p-5 = /* Bleed exactly into the page padding on mobile (main has p-3 =
1.25rem) for edge-to-edge charts, and a bit past the content 0.75rem) for edge-to-edge charts, and a bit past the content
column on md+ (main has 2rem padding) for extra readability. */ column on md+ (main has 2rem padding) for extra readability. */
margin-left: -1.25rem; margin-left: -0.75rem;
margin-right: -1.25rem; margin-right: -0.75rem;
overflow-x: auto; overflow-x: auto;
} }
+1 -1
View File
@@ -92,7 +92,7 @@ export interface ChartPanel {
export const defaultChartLayout: ChartPanel[] = [ export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] }, { id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
{ id: 'panel-3', variables: ['wind', 'humidity'] } { id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] }
]; ];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout); export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
+1 -1
View File
@@ -83,7 +83,7 @@
<Header onMenuToggle={toggleMobileMenu} /> <Header onMenuToggle={toggleMobileMenu} />
<main <main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'} class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 md:px-8 md:py-6'}
> >
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
@@ -281,22 +281,68 @@
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── --> <!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<div class="mb-5 flex min-w-0 items-center gap-3"> <div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<img <div class="flex min-w-0 items-center gap-3">
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border" <img
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg" class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
alt={location.country ?? ''} src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
/> alt={location.country ?? ''}
<div class="min-w-0"> />
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl"> <div class="min-w-0">
{location.name} <h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
</h1> {location.name}
<p class="truncate text-sm text-muted-foreground"> </h1>
<span class="lg:hidden" <p class="truncate text-sm text-muted-foreground">
>{#if location.admin1}{location.admin1}, <span class="lg:hidden"
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span >{#if location.admin1}{location.admin1},
>Model comparison {/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
</p> >Model comparison
</p>
</div>
</div>
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
>
+ scroll to zoom
</span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div> </div>
</div> </div>
@@ -310,49 +356,6 @@
</div> </div>
{/if} {/if}
<!-- Range / zoom controls (same as the 7-day meteograms) -->
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
<span class="hidden text-xs text-muted-foreground md:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd>
+ scroll to zoom
</span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div>
<!-- chart count derives from the selected variables (not the fetched data), <!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives --> so the reserved height is right even before the response arrives -->
<ChartContainer <ChartContainer
@@ -119,7 +119,7 @@
</defs> </defs>
</svg> </svg>
<div in:fade out:fade class="mb-6 min-h-[260px]"> <div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]">
<!-- negative margin + matching padding: the scroll box gains room so a <!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge --> lines up with the page content edge -->
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings'; import { storedVariablePrefs } from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
@@ -236,10 +238,11 @@
{#if cellData.length > 0} {#if cellData.length > 0}
{@const hourly = data.hourly} {@const hourly = data.hourly}
{@const iconPx = is3h ? 38 : 33} {@const iconPx = is3h ? 38 : 33}
<!-- Full-bleed to the viewport edges on mobile (main has p-5 = 1.25rem); <!-- Full-bleed to the viewport edges on mobile (main has p-3 = 0.75rem);
a contained rounded card on md+ --> a contained rounded card on md+ -->
<section <section
class="-mx-5 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border" transition: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"
> >
<!-- Card toolbar --> <!-- Card toolbar -->
<div <div
@@ -153,7 +153,7 @@
); );
</script> </script>
<section class="mt-8" in:fade={{ duration: 200 }}> <section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold"> <h3 class="text-lg font-bold">
Meteograms Meteograms
@@ -239,7 +239,7 @@
{:else} {:else}
<!-- one full-bleed card on mobile / contained card on md+, graphs stacked <!-- one full-bleed card on mobile / contained card on md+, graphs stacked
tightly so they read as one fluent meteogram --> tightly so they read as one fluent meteogram -->
<div class="-mx-5 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"> <div class="-mx-3 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border">
{#each renderPanels as panel, i (panel.id)} {#each renderPanels as panel, i (panel.id)}
<div <div
class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i === class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i ===
@@ -201,8 +201,19 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: '#26a69a', color: '#26a69a',
width: 2, width: 2,
fill: true, fill: true,
fillOpacity: 0.15, fillOpacity: 0.15
windArrows: true },
{
key: 'wind_direction',
label: 'Wind direction',
short: 'Dir',
field: 'winddirection_10m',
api: 'wind_direction_10m',
type: 'line',
kind: 'wind',
color: '#14b8a6',
windArrows: true,
marker: true
}, },
{ {
key: 'wind_gusts', key: 'wind_gusts',
@@ -325,7 +336,7 @@ export function neededHourlyApiVars(
if (!def) continue; if (!def) continue;
s.add(apiNameOf(def)); s.add(apiNameOf(def));
if (def.pictograms) s.add('weather_code'); if (def.pictograms) s.add('weather_code');
if (def.key === 'wind') s.add('wind_direction_10m'); if (def.windArrows || def.key === 'wind') s.add('wind_direction_10m');
} }
return [...s]; return [...s];