829 lines
30 KiB
Svelte
829 lines
30 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
|
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
|
|
|
import { href } from '$lib/i18n';
|
|
import * as m from '$lib/paraglide/messages';
|
|
|
|
import { getTempStyle } from '../../utils/colors';
|
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
|
import {
|
|
getSunshineColor,
|
|
getSunshinePercent,
|
|
precipIsSignificant,
|
|
sunIsSignificant,
|
|
windIsSignificant
|
|
} from './significance';
|
|
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
|
|
|
interface Props {
|
|
daily: FetchedDaily | null;
|
|
selectedDay: Date;
|
|
units: WeatherUnits;
|
|
onSelectDay: (date: Date, index: number) => void;
|
|
canExtend?: boolean;
|
|
onExtend?: () => void;
|
|
canExtendPast?: boolean;
|
|
onExtendPast?: () => void;
|
|
/**
|
|
* Location segment for the hand-off links that replace the side buttons
|
|
* once their range is exhausted. Omit to leave the slots empty.
|
|
*/
|
|
locationRoute?: string;
|
|
}
|
|
|
|
let {
|
|
daily,
|
|
selectedDay,
|
|
units,
|
|
onSelectDay,
|
|
canExtend = false,
|
|
onExtend,
|
|
canExtendPast = false,
|
|
onExtendPast,
|
|
locationRoute
|
|
}: Props = $props();
|
|
|
|
// enough placeholder tiles to fill a wide viewport; the row scrolls, so a
|
|
// couple too many costs nothing
|
|
const SKELETON_CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
|
|
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
|
// All sizing derives from a single registered custom property `--strip-p`
|
|
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
|
|
// same mechanics — desktop just uses larger "full" tunables and collapses
|
|
// into a bar that matches the topbar height.
|
|
//
|
|
// * Browsers with CSS scroll-driven animations (Chrome/Edge 115+, Safari 26+)
|
|
// scrub `--strip-p` natively from a view-timeline on the sentinel below —
|
|
// no JS runs during scroll at all, so there is no rAF frame-lag or jitter.
|
|
// * Everything else (Firefox, older Safari) snaps between the two states
|
|
// with a short CSS transition instead: an IntersectionObserver on the same
|
|
// sentinel toggles `.compact`. No per-frame scroll handler anywhere.
|
|
let sentinelEl = $state<HTMLDivElement>();
|
|
let stripEl = $state<HTMLDivElement>();
|
|
let stripScrollEl = $state<HTMLDivElement>();
|
|
let daysWrapEl = $state<HTMLDivElement>();
|
|
let needsSnapFallback = $state(false);
|
|
let compact = $state(false);
|
|
let stuck = $state(false);
|
|
|
|
onMount(() => {
|
|
const scrubSupported =
|
|
CSS.supports('animation-timeline: view()') && CSS.supports('timeline-scope: none');
|
|
if (scrubSupported || !sentinelEl || !stripEl) return;
|
|
|
|
// Without scroll-driven animations (Firefox, older Safari) the collapse used
|
|
// to snap between the two states at a threshold, which reads as a jump next
|
|
// to the smooth scrub everywhere else. Drive `--strip-p` from the scroll
|
|
// position instead, so the cells shrink with the scroll exactly as they do
|
|
// natively. One rAF-coalesced write per frame, no layout thrash.
|
|
const sentinel = sentinelEl;
|
|
const strip = stripEl;
|
|
const scroller = sentinel.closest('main');
|
|
|
|
let frame = 0;
|
|
const update = () => {
|
|
frame = 0;
|
|
const band = sentinel.getBoundingClientRect();
|
|
const top = scroller ? scroller.getBoundingClientRect().top : 0;
|
|
const height = band.height || 1;
|
|
const progress = Math.min(1, Math.max(0, (top - band.top) / height));
|
|
strip.style.setProperty('--strip-p', String(progress));
|
|
strip.style.setProperty('--stuck', progress > 0.02 ? '1' : '0');
|
|
};
|
|
const schedule = () => {
|
|
if (!frame) frame = requestAnimationFrame(update);
|
|
};
|
|
|
|
update();
|
|
const target: EventTarget = scroller ?? window;
|
|
target.addEventListener('scroll', schedule, { passive: true });
|
|
window.addEventListener('resize', schedule);
|
|
return () => {
|
|
if (frame) cancelAnimationFrame(frame);
|
|
target.removeEventListener('scroll', schedule);
|
|
window.removeEventListener('resize', schedule);
|
|
};
|
|
});
|
|
|
|
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
|
// scrolling left), with the first day flush to the content edge. The days
|
|
// group has min-width:100%, so the row always overflows by exactly the past
|
|
// button — this works at every viewport size. Runs once per dataset.
|
|
let scrolledForRef: FetchedDaily | null = null;
|
|
$effect(() => {
|
|
const d = daily;
|
|
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
|
scrolledForRef = d;
|
|
const scroll = stripScrollEl;
|
|
const wrap = daysWrapEl;
|
|
if (!canExtendPast) {
|
|
// the past button has been replaced by the history link, which is a
|
|
// destination rather than a control: it stays in view instead of parked
|
|
scroll.scrollLeft = 0;
|
|
return;
|
|
}
|
|
// the delta form is self-correcting (a no-op once right), so apply after
|
|
// layout and once more after late-loading CSS/fonts settle — otherwise a
|
|
// sliver of the past button can stay visible on first paint
|
|
const apply = () => {
|
|
const padLeft = parseFloat(getComputedStyle(scroll).paddingLeft) || 0;
|
|
const firstCell = wrap.querySelector('.strip-cell') ?? wrap;
|
|
scroll.scrollLeft +=
|
|
firstCell.getBoundingClientRect().left - scroll.getBoundingClientRect().left - padLeft;
|
|
};
|
|
requestAnimationFrame(() => {
|
|
apply();
|
|
setTimeout(apply, 250);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<!-- progress sentinel: an invisible band (taking no layout space) just above
|
|
the sticky strip. Its exit across the scrollport top drives the collapse —
|
|
via view-timeline where supported, IntersectionObserver otherwise. It sits
|
|
above the strip so the strip shrinking never feeds back into the
|
|
measurement. -->
|
|
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
|
|
|
<div
|
|
bind:this={stripEl}
|
|
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
|
|
class:js-snap={needsSnapFallback}
|
|
class:compact
|
|
class:stuck
|
|
>
|
|
<div class="strip-row flex overflow-x-auto px-3 lg:px-8" bind:this={stripScrollEl}>
|
|
{#if daily}
|
|
{#if canExtendPast && onExtendPast}
|
|
<!-- starts scrolled out of view (revealed by scrolling left); styled to
|
|
match the 15-days button on the other end -->
|
|
<button
|
|
type="button"
|
|
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
|
onclick={onExtendPast}
|
|
aria-label={m.strip_past_aria()}
|
|
title={m.strip_past_aria()}
|
|
>
|
|
<span class="inline-flex items-baseline gap-0.5">
|
|
<svg
|
|
class="h-3 w-3 translate-y-px"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 5l-7 7 7 7" />
|
|
</svg>
|
|
<span class="text-sm leading-none font-extrabold">3</span>
|
|
</span>
|
|
<span class="text-[9px] leading-tight font-semibold">{m.strip_past_label()}</span>
|
|
</button>
|
|
{:else if locationRoute}
|
|
<!-- the past days are already in the strip: the only way further back
|
|
is the archive, so the button hands over to it -->
|
|
<a
|
|
href={href('/weather/historical/[location]', { location: locationRoute })}
|
|
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
|
aria-label={m.strip_history_aria()}
|
|
title={m.strip_history_title()}
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2" />
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
|
|
/>
|
|
</svg>
|
|
<span class="text-[9px] leading-tight font-semibold">{m.strip_history_label()}</span>
|
|
</a>
|
|
{/if}
|
|
|
|
<div class="strip-days flex" bind:this={daysWrapEl}>
|
|
{#each daily.dailyDates as time, index (index)}
|
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
|
{@const wCode = daily.daily.weather_code[index]}
|
|
{@const dayCode = daily.dayCodes?.[index] ?? wCode}
|
|
{@const nightCode = daily.nightCodes?.[index] ?? wCode}
|
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
|
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
|
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
|
{@const daylightSec = Math.max(
|
|
0,
|
|
(daily.daily.sunset[index] ?? 0) - (daily.daily.sunrise[index] ?? 0)
|
|
)}
|
|
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
|
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
|
|
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
|
|
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
|
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
|
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
|
|
{@const relLabel = getRelativeDayLabel(time, daily.timezone)}
|
|
{@const isToday = relLabel === 'Today'}
|
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
|
<button
|
|
type="button"
|
|
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 {selected
|
|
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
|
|
: isToday
|
|
? 'today-cell border-primary/35 bg-primary/4'
|
|
: 'border-border/60 bg-card'}"
|
|
aria-pressed={selected}
|
|
aria-current={isToday ? 'date' : undefined}
|
|
onclick={() => onSelectDay(time, index)}
|
|
>
|
|
<!-- weekday drifts to the left edge and the date fades in at the
|
|
right edge as the cards collapse (flex spacers driven by --rel) -->
|
|
<span
|
|
class="dow-row flex w-full items-baseline px-0.5 font-semibold tracking-wide whitespace-nowrap {selected
|
|
? 'text-primary'
|
|
: ''}"
|
|
>
|
|
<span class="dow-spacer"></span>
|
|
<span>{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}</span>
|
|
<span class="dow-mid"></span>
|
|
<span class="date-inline text-right font-medium tabular-nums text-muted-foreground"
|
|
>{formatZoned(time, daily.timezone, 'd')}</span
|
|
>
|
|
<span class="dow-spacer"></span>
|
|
</span>
|
|
|
|
<span
|
|
class="rel-label leading-[1.25] whitespace-nowrap {isToday
|
|
? 'font-semibold text-primary/80'
|
|
: 'text-muted-foreground'}"
|
|
>
|
|
{relLabel}
|
|
</span>
|
|
|
|
<!-- Two centered rows: day + night icons, then day + night temps.
|
|
Both stay perfectly centered when compact (the night icon melts
|
|
away and the day icon re-centers on its own); when full, small
|
|
k-driven nudges line each temp up under its icon. -->
|
|
<div class="icon-row flex w-full items-center justify-center">
|
|
<div class="icon-wrap">
|
|
<svg class="day-icon fill-foreground">
|
|
<use
|
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
|
dayCode,
|
|
true
|
|
)}.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</div>
|
|
<svg class="night-icon self-end fill-foreground/60">
|
|
<use
|
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
|
nightCode,
|
|
false
|
|
)}.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="flex w-full items-baseline justify-center gap-0.5 leading-none">
|
|
<span
|
|
class="temp-max rounded-md font-extrabold tabular-nums"
|
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
|
>
|
|
{tempMax.toFixed(0)}°
|
|
</span>
|
|
<span class="temp-min font-semibold tabular-nums text-muted-foreground">
|
|
{tempMin.toFixed(0)}°
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
|
>
|
|
<!-- sunshine bar (desktop full cards only, like the old day cards) -->
|
|
<div
|
|
class="hidden w-full items-center gap-1 px-0.5 md:my-1 md:flex {lowSun
|
|
? 'opacity-45'
|
|
: ''}"
|
|
>
|
|
<svg class="shrink-0" width="14" height="14" style="fill:{sunColor}">
|
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
|
</svg>
|
|
<div class="h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
class="h-full rounded-full"
|
|
style="width:{sunPct}%;background-color:{sunColor}"
|
|
></div>
|
|
</div>
|
|
<span class="font-medium">
|
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
|
</span>
|
|
</div>
|
|
|
|
<!-- precip + wind: stacked on mobile, one row on md+ (old card style) -->
|
|
<div
|
|
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
|
|
>
|
|
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
|
<svg class="fill-sky-500" width="16" height="16">
|
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
|
</svg>
|
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
|
</span>
|
|
<span class="inline-flex items-center gap-1 {lowWind ? 'opacity-40' : ''}">
|
|
{#if windDir != null && !isNaN(windDir)}
|
|
<span
|
|
class="hidden shrink-0 md:inline-flex md:-mr-1"
|
|
style="transform: {getWindArrowRotation(windDir)}"
|
|
>
|
|
<svg class="fill-muted-foreground" width="25" height="25">
|
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
|
></use>
|
|
</svg>
|
|
</span>
|
|
<svg class="fill-muted-foreground md:hidden" width="16" height="16">
|
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
|
</svg>
|
|
{:else}
|
|
<svg class="fill-muted-foreground" width="16" height="16">
|
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
|
</svg>
|
|
{/if}
|
|
<span class="whitespace-nowrap"
|
|
>{windMax?.toFixed(0) ?? '-'}<span class="hidden opacity-70 md:inline"
|
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
|
></span
|
|
>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if canExtend && onExtend}
|
|
<!-- shows the RESULTING range, not an increment: tapping switches the
|
|
whole forecast from 7 to 15 days -->
|
|
<button
|
|
type="button"
|
|
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
|
onclick={onExtend}
|
|
aria-label={m.strip_extend_aria()}
|
|
title={m.strip_extend_aria()}
|
|
>
|
|
<span class="inline-flex items-baseline gap-0.5">
|
|
<span class="text-sm leading-none font-extrabold">15</span>
|
|
<svg
|
|
class="h-3 w-3 translate-y-px"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</span>
|
|
<span class="text-[9px] leading-tight font-semibold">{m.strip_days_label()}</span>
|
|
</button>
|
|
{:else if locationRoute}
|
|
<!-- the strip is at the model's limit: anything further out is a
|
|
seasonal outlook, so the button hands over to it -->
|
|
<a
|
|
href={href('/weather/seasonal/[location]', { location: locationRoute })}
|
|
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
|
aria-label={m.strip_seasonal_aria()}
|
|
title={m.strip_seasonal_title()}
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
|
|
</svg>
|
|
<span class="text-[9px] leading-tight font-semibold">{m.strip_seasonal_label()}</span>
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- same cell metrics as the real strip, so the bar looks alive while
|
|
the forecast lands and nothing moves when it does -->
|
|
<div class="strip-days flex">
|
|
{#each SKELETON_CELLS as i (i)}
|
|
<div
|
|
class="strip-cell shrink-0 animate-pulse rounded-xl border border-border/60 bg-muted"
|
|
></div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* `--strip-p` is registered so it interpolates: scroll-driven keyframes scrub
|
|
it continuously, and the snap fallback's `transition` eases it 0 ↔ 1.
|
|
Everything below derives from it via calc(), so a scroll frame is one
|
|
property update resolved natively by the browser — no JS, no inline-style
|
|
patches across cells. */
|
|
@property --strip-p {
|
|
syntax: '<number>';
|
|
inherits: true;
|
|
initial-value: 0;
|
|
}
|
|
|
|
/* 1 as soon as the strip pins — drives the bar chrome independently of the
|
|
collapse progress so nothing ever shows through a still-expanding bar. */
|
|
@property --stuck {
|
|
syntax: '<number>';
|
|
inherits: true;
|
|
initial-value: 0;
|
|
}
|
|
|
|
/* Tunables, shared by the strip and its sentinel: the collapse plays out
|
|
over exactly the bar-height difference (see .daystrip height below). */
|
|
.sentinel,
|
|
.daystrip {
|
|
--cell-w-full: 76px;
|
|
--cell-h-full: 140px;
|
|
--cell-h-min: 64px;
|
|
/* collapsed cells are square: the width follows the compact height, so the
|
|
docked bar reads as a row of tiles instead of narrow slivers */
|
|
--cell-w-min: var(--cell-h-min);
|
|
--icon-full: 44px;
|
|
/* the compact icon carries the square tile: sized so the content fills it
|
|
and the leftover slack under the temps stays small */
|
|
--icon-min: 31px;
|
|
--pt-full: 7px;
|
|
--pt-min: 2px;
|
|
--gap-full: 8px;
|
|
--gap-min: 4px;
|
|
/* bar (strip-row) vertical padding */
|
|
--pad-full: 8px;
|
|
--pad-min: 5px;
|
|
/* fonts */
|
|
--dow-font-full: 11px;
|
|
--dow-font-min: 11px;
|
|
--rel-font-full: 9px;
|
|
--rel-font-min: 9px;
|
|
--tmax-font-full: 12px;
|
|
--tmax-font-min: 11px;
|
|
--tmin-font-full: 11px;
|
|
--tmin-font-min: 10px;
|
|
--tmax-padx-full: 6px;
|
|
--tmax-padx-min: 4px;
|
|
/* full state only: nudge each temp to sit under "its" icon */
|
|
--tmax-nudge: 0px;
|
|
--tmin-nudge: 5px;
|
|
/* full state only: gap above the icons, and the day / night icon offsets
|
|
that tighten the pair around the cell centre */
|
|
--icon-gap-full: 2px;
|
|
--day-icon-nudge: 3px;
|
|
--night-icon-nudge: 0px;
|
|
/* compact only: signed shift that lands the lone icon dead centre in the
|
|
square tile (measured against the tile's mid-line) */
|
|
--icon-lift: 1px;
|
|
/* cell bottom padding (roomy when full, a little breathing room under the
|
|
temps when compact so they don't sit on the tile edge). The compact
|
|
tile is taller than its content here, so the leftover slack already
|
|
supplies that gap and the padding stays at zero. */
|
|
--pb-full: 6px;
|
|
--pb-min: 0px;
|
|
/* extra scrub distance beyond the height difference — slows the collapse
|
|
down; the surplus just slides content under the (opaque) bar */
|
|
--collapse-extra: 0px;
|
|
|
|
--collapse: calc(
|
|
var(--cell-h-full) + 2 * var(--pad-full) - var(--cell-h-min) - 2 * var(--pad-min) +
|
|
var(--collapse-extra)
|
|
);
|
|
}
|
|
|
|
/* md+: same collapse, but the full state keeps the old desktop day-card
|
|
proportions, plays out over a longer scroll distance, and the compact
|
|
bar docks under the topbar (slightly taller than its 56px). */
|
|
@media (min-width: 768px) {
|
|
.sentinel,
|
|
.daystrip {
|
|
--cell-w-full: 120px;
|
|
--cell-h-full: 208px;
|
|
--cell-h-min: 56px;
|
|
--icon-full: 80px;
|
|
--icon-min: 24px;
|
|
--pt-full: 10px;
|
|
--gap-full: 10px;
|
|
--gap-min: 6px;
|
|
--pad-min: 3px;
|
|
--dow-font-full: 13px;
|
|
--dow-font-min: 10px;
|
|
--rel-font-full: 11px;
|
|
--tmax-font-full: 17px;
|
|
--tmin-font-full: 15px;
|
|
--tmax-padx-full: 12px;
|
|
--tmax-nudge: 0px;
|
|
--tmin-nudge: 8px;
|
|
--icon-gap-full: 6px;
|
|
--day-icon-nudge: 4px;
|
|
--night-icon-nudge: 6px;
|
|
--pb-full: 10px;
|
|
--pb-min: 3px;
|
|
--icon-lift: 0px;
|
|
--collapse-extra: 0px;
|
|
}
|
|
.daystrip {
|
|
/* breathing room between the full cards and the table */
|
|
margin-bottom: 14px;
|
|
}
|
|
}
|
|
|
|
/* Desktop only: the docked tiles get roughly 1.75x bigger, with the icon and
|
|
type scaled to match so they stay in proportion. Phones and tablets keep
|
|
the compact bar - there the screen width is the scarce resource. */
|
|
@media (min-width: 1024px) {
|
|
.sentinel,
|
|
.daystrip {
|
|
--cell-h-min: 86px;
|
|
--icon-min: 47px;
|
|
--dow-font-min: 11px;
|
|
--tmax-font-min: 14px;
|
|
--tmin-font-min: 12px;
|
|
--tmax-padx-min: 6px;
|
|
--pt-min: 5px;
|
|
--pb-min: 6px;
|
|
--icon-lift: 1px;
|
|
}
|
|
}
|
|
|
|
/* Invisible collapse band: the scroll distance over which the collapse
|
|
plays. The negative margin removes it from layout so nothing shifts. */
|
|
.sentinel {
|
|
height: var(--collapse);
|
|
margin-bottom: calc(-1 * var(--collapse));
|
|
pointer-events: none;
|
|
}
|
|
|
|
.daystrip {
|
|
--strip-p: 0;
|
|
--stuck: 0;
|
|
|
|
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
|
--k: calc(1 - var(--strip-p));
|
|
--cell-w: calc(var(--cell-w-min) + (var(--cell-w-full) - var(--cell-w-min)) * var(--k));
|
|
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
|
|
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
|
|
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
|
--gap: calc(var(--gap-min) + (var(--gap-full) - var(--gap-min)) * var(--k));
|
|
--pad: calc(var(--pad-min) + (var(--pad-full) - var(--pad-min)) * var(--k));
|
|
--rel: clamp(0, calc(1 - var(--strip-p) * 2), 1);
|
|
--detail: clamp(0, calc(1 - var(--strip-p) * 1.6), 1);
|
|
/* bar background turns opaque the moment the strip sticks (via --stuck),
|
|
so content never shows through it — even while the cells are still
|
|
large and the collapse has barely started */
|
|
--chrome: clamp(0, calc(var(--strip-p) * 6 + var(--stuck)), 1);
|
|
|
|
/* The sticky box keeps a CONSTANT height — only its contents shrink.
|
|
The collapse therefore never resizes the document (the large table
|
|
below would otherwise reflow on every scroll frame: the main source
|
|
of scroll jank on mobile Chromium), and because the collapse distance
|
|
equals the height difference, the table slides up under the shrinking
|
|
bar in exact sync. The empty lower part is click-through. */
|
|
height: calc(var(--cell-h-full) + 2 * var(--pad-full));
|
|
pointer-events: none;
|
|
contain: layout style;
|
|
/* own compositor layer: per-frame repaints stay isolated to the strip */
|
|
transform: translateZ(0);
|
|
}
|
|
|
|
/* The visible bar: hugs the (shrinking) content and carries the chrome.
|
|
No backdrop-filter blur or animated box-shadow — both are very expensive
|
|
to repaint every scroll frame on mobile. */
|
|
.strip-row {
|
|
pointer-events: auto;
|
|
gap: var(--gap);
|
|
padding-block: var(--pad);
|
|
background: color-mix(
|
|
in oklab,
|
|
var(--color-background) calc(var(--chrome) * 100%),
|
|
transparent
|
|
);
|
|
border-bottom: 1px solid
|
|
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
|
}
|
|
|
|
/* Scrub path: the sentinel's exit across the scrollport top maps directly to
|
|
--strip-p 0→1 (the page wrapper hoists the timeline name via
|
|
`timeline-scope` so this sibling can reference it). */
|
|
@supports (animation-timeline: view()) and (timeline-scope: none) {
|
|
.sentinel {
|
|
view-timeline: --daystrip-sentinel block;
|
|
}
|
|
.daystrip {
|
|
animation:
|
|
strip-collapse linear both,
|
|
strip-stuck linear both;
|
|
animation-timeline: --daystrip-sentinel, --daystrip-sentinel;
|
|
animation-range:
|
|
exit 0% exit 100%,
|
|
exit 0% exit 3%;
|
|
}
|
|
}
|
|
@keyframes strip-collapse {
|
|
to {
|
|
--strip-p: 1;
|
|
}
|
|
}
|
|
@keyframes strip-stuck {
|
|
to {
|
|
--stuck: 1;
|
|
}
|
|
}
|
|
|
|
/* Snap fallback: ease between the two end states instead of scrubbing.
|
|
(Browsers too old to register --strip-p simply switch instantly.) */
|
|
.daystrip.js-snap {
|
|
transition:
|
|
--strip-p 0.32s ease,
|
|
--stuck 0.15s ease;
|
|
}
|
|
.daystrip.js-snap.compact {
|
|
--strip-p: 1;
|
|
}
|
|
.daystrip.js-snap.stuck {
|
|
--stuck: 1;
|
|
}
|
|
@media (min-width: 768px) {
|
|
/* larger cards need a touch longer to feel smooth */
|
|
.daystrip.js-snap {
|
|
transition:
|
|
--strip-p 0.42s ease,
|
|
--stuck 0.15s ease;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.daystrip.js-snap {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
.strip-days {
|
|
gap: var(--gap);
|
|
/* fill the row so it overflows by exactly the past button, which starts
|
|
scrolled out of view and is revealed by scrolling left — even when the
|
|
day cells alone wouldn't overflow (wide desktop viewports). content-box
|
|
so the parking padding below ADDS to the 100% instead of being absorbed
|
|
into it. */
|
|
box-sizing: content-box;
|
|
min-width: 100%;
|
|
}
|
|
/* Scroll containers clip at the PADDING edge, so a parked past button would
|
|
always leak a sliver across the row's left padding. Extending the days
|
|
group's left edge (only while the parked past button exists) moves
|
|
max-scroll so the button parks fully beyond the clip edge. The history
|
|
link that replaces it is never parked, so it keeps the normal gap. */
|
|
.strip-park + .strip-days {
|
|
padding-left: calc(12px - var(--gap-min));
|
|
}
|
|
@media (min-width: 1024px) {
|
|
.strip-park + .strip-days {
|
|
padding-left: calc(32px - var(--gap-min));
|
|
}
|
|
}
|
|
.strip-cell,
|
|
.strip-side {
|
|
height: var(--cell-h);
|
|
/* size tracks the collapse exactly; only the tap highlight eases */
|
|
transition:
|
|
border-color 0.15s,
|
|
background-color 0.15s,
|
|
translate 0.2s ease-out,
|
|
scale 0.2s ease-out,
|
|
box-shadow 0.2s ease-out;
|
|
}
|
|
/* The old day-card lift: hover raises the card slightly, the selected day a
|
|
touch more. Scaled by --k so the effect melts away as the strip compacts
|
|
(and never disturbs the slim bar); hover only where hover exists. */
|
|
.strip-cell[aria-pressed='true'] {
|
|
z-index: 10;
|
|
translate: 0 calc(-4px * var(--k));
|
|
scale: calc(1 + 0.04 * var(--k));
|
|
box-shadow: 0 5px 14px -4px rgba(0, 0, 0, calc(0.4 * var(--k)));
|
|
}
|
|
@media (hover: hover) {
|
|
.strip-cell:hover:not([aria-pressed='true']) {
|
|
z-index: 10;
|
|
translate: 0 calc(-3px * var(--k));
|
|
scale: calc(1 + 0.02 * var(--k));
|
|
box-shadow: 0 4px 10px -4px rgba(0, 0, 0, calc(0.3 * var(--k)));
|
|
}
|
|
}
|
|
.strip-cell {
|
|
width: var(--cell-w);
|
|
padding-top: var(--pt);
|
|
padding-bottom: calc(var(--pb-min) + (var(--pb-full) - var(--pb-min)) * var(--k));
|
|
/* The collapsed rows that shrink to zero height still paid for their flex
|
|
gap, which pushed the temps onto the tile's bottom edge. Fading the gap
|
|
out with the collapse gives that space back as real bottom padding. */
|
|
row-gap: calc(2px * var(--k));
|
|
}
|
|
/* Side buttons keep the compact width in BOTH states, so almost nothing to
|
|
the left of the first day changes size during the collapse — the first
|
|
day stays put instead of drifting off-screen. */
|
|
.strip-side {
|
|
width: var(--cell-w-min);
|
|
}
|
|
.icon-row {
|
|
/* full: keep the icons off the weekday / date block above them.
|
|
compact: the icon is the only thing between the two text rows, so lift
|
|
it to sit optically dead centre in the square tile. */
|
|
margin-top: calc(var(--icon-gap-full) * var(--k));
|
|
transform: translateY(calc(-1 * var(--icon-lift) * (1 - var(--k))));
|
|
/* the compact icon overlaps the temperature badge below it, and reads far
|
|
better over that colour than under it */
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.icon-wrap {
|
|
/* tuck the icon into its line box: the glyphs carry generous built-in
|
|
padding, so pulling the neighbours in keeps the cells tight */
|
|
margin-block: -2px -3px;
|
|
}
|
|
.day-icon {
|
|
display: block;
|
|
width: var(--icon);
|
|
height: var(--icon);
|
|
/* full only: settle the pair a touch right of centre */
|
|
transform: translateX(calc(var(--day-icon-nudge) * var(--k)));
|
|
}
|
|
/* The night icon sits beside the day icon (slightly low, like a companion)
|
|
and melts away completely when compact so the day icon re-centers. */
|
|
.night-icon {
|
|
display: block;
|
|
width: calc(var(--icon) * 0.55 * var(--rel));
|
|
height: calc(var(--icon) * 0.55 * var(--rel));
|
|
opacity: var(--rel);
|
|
margin-left: calc(-4px * var(--rel));
|
|
margin-bottom: calc(-4px * var(--rel));
|
|
transform: translateX(calc(-1 * var(--night-icon-nudge) * var(--k)));
|
|
}
|
|
/* weekday centered when full, pushed to the edges when compact */
|
|
.dow-row {
|
|
font-size: calc(var(--dow-font-min) + (var(--dow-font-full) - var(--dow-font-min)) * var(--k));
|
|
}
|
|
.dow-spacer {
|
|
/* keep some outer share when compact so weekday + date sit near-centered
|
|
with a modest gap instead of being pushed to the cell edges */
|
|
flex-grow: calc(0.6 + 0.4 * var(--rel));
|
|
}
|
|
.dow-mid {
|
|
flex-grow: calc(1 - var(--rel));
|
|
}
|
|
.date-inline {
|
|
overflow: hidden;
|
|
/* width 0 while the cards are full so the weekday stays exactly centered */
|
|
width: calc(14px * (1 - var(--rel)));
|
|
opacity: calc(1 - var(--rel));
|
|
}
|
|
.rel-label {
|
|
font-size: calc(var(--rel-font-min) + (var(--rel-font-full) - var(--rel-font-min)) * var(--k));
|
|
opacity: var(--rel);
|
|
max-height: calc(14px * var(--rel));
|
|
}
|
|
.detail-row {
|
|
opacity: var(--detail);
|
|
max-height: calc(52px * var(--detail));
|
|
}
|
|
.temp-max {
|
|
font-size: calc(
|
|
var(--tmax-font-min) + (var(--tmax-font-full) - var(--tmax-font-min)) * var(--k)
|
|
);
|
|
padding-inline: calc(
|
|
var(--tmax-padx-min) + (var(--tmax-padx-full) - var(--tmax-padx-min)) * var(--k)
|
|
);
|
|
padding-block: calc(2px + 1px * var(--k));
|
|
/* full: line up under the day icon */
|
|
transform: translateX(calc(var(--tmax-nudge) * var(--k)));
|
|
}
|
|
.temp-min {
|
|
font-size: calc(
|
|
var(--tmin-font-min) + (var(--tmin-font-full) - var(--tmin-font-min)) * var(--k)
|
|
);
|
|
/* full: line up under the night icon */
|
|
transform: translateX(calc(var(--tmin-nudge) * var(--k)));
|
|
}
|
|
|
|
.daystrip :global(.overflow-x-auto) {
|
|
scrollbar-width: none;
|
|
}
|
|
</style>
|