read more text

This commit is contained in:
Vincent van der Wal
2026-08-03 11:39:56 +02:00
parent 9fc17e7417
commit dbfa38f6c5
7 changed files with 151 additions and 24 deletions
+45 -10
View File
@@ -85,10 +85,21 @@
// arrives (no layout shift)
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
// The hourly table is a header row plus one row per enabled variable, so the
// placeholder below reserves exactly that and nothing under it jumps when the
// real table arrives.
const TABLE_ROW_PX = 57;
// The hourly table is a header row, the time/daylight row, then one row per
// enabled variable, so the placeholder below reserves exactly that and
// nothing under it jumps when the real table arrives. Rows are shorter below
// lg, where the cells sit tighter - measured, not guessed: 36px against 55px,
// and the time row is 48px at every width.
let compactRows = $state(false);
onMount(() => {
const mq = window.matchMedia('(max-width: 1023px)');
const apply = () => (compactRows = mq.matches);
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
let tableRowPx = $derived(compactRows ? 36 : 55);
const TABLE_TIME_ROW_PX = 48;
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
// Request only the hourly variables the table rows and meteograms actually
@@ -490,8 +501,13 @@
<!-- one placeholder per row the real table will render, so the body
reads as a loading table rather than a blank panel -->
<div class="divide-y divide-border/50">
<!-- the time + daylight row, which is taller than the variable rows -->
<div class="flex items-center gap-4 px-4" style="height: {TABLE_TIME_ROW_PX}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
{#each { length: enabledTableRows } as _, i (i)}
<div class="flex items-center gap-4 px-4" style="height: {TABLE_ROW_PX}px">
<div class="flex items-center gap-4 px-4" style="height: {tableRowPx}px">
<div class="h-3.5 w-14 shrink-0 animate-pulse rounded bg-muted"></div>
<div class="h-3.5 flex-1 animate-pulse rounded bg-muted/70"></div>
</div>
@@ -505,10 +521,16 @@
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
</div>
{:else}
<!-- same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives -->
<!-- Same footprint as the written forecast, so it doesn't shove the
meteograms down when it arrives. The heights are measured from the
real card at each breakpoint: on phones the narrative is clamped to
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }}>
<div class="h-52 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-40"></div>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
@@ -526,14 +548,27 @@
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<!-- The real header wraps to two rows until the controls fit beside
the title, which happens at different widths than you would
expect because the sidebar takes its share from md up. These
min-heights follow the measured wrap points. -->
<div
class="mb-3 flex min-h-27 flex-wrap items-center justify-between gap-2 sm:min-h-16.5 md:min-h-27 lg:min-h-16.5 xl:min-h-7.5"
>
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
<div class="flex items-center gap-3">
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
</div>
</div>
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
<!-- Each meteogram is its plot plus a title row and axis labels, so
the reserved box needs that chrome on top of the plot height or
everything below lands ~50px per chart too high. -->
<ChartContainer
loading
chartCount={enabledChartCount || 1}
chartHeight={chartHeight + (narrowViewport ? 43 : 62)}
/>
</section>
{/if}
@@ -96,6 +96,35 @@
return `M 0 ${-R} A ${R} ${R} 0 0 ${outerSweep} 0 ${R} A ${rx} ${R} 0 0 ${innerSweep} 0 ${-R} Z`;
});
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
// ─── Narrative clamp (phones only) ──────────────────────────────────────────
// The narrative runs anywhere from two to nine lines depending on the day,
// which on a phone means the whole page below it moves as soon as the data
// lands. Below md the text is clamped to five lines and gets a toggle, and
// the block reserves those five lines plus the toggle row whatever the
// length - so the card is the same height before and after the fetch, and the
// skeleton can match it exactly. From md up nothing is clamped.
let expanded = $state(false);
let textEl = $state<HTMLParagraphElement>();
let overflows = $state(false);
$effect(() => {
sentences; // re-measure when the day (and so the text) changes
const el = textEl;
if (!el || expanded) return; // measuring while open would always say "fits"
const measure = () => {
// only clamped below md, where the toggle is the only way to see the rest
const clamped = window.matchMedia('(max-width: 767px)').matches;
overflows = clamped && el.scrollHeight - el.clientHeight > 1;
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
});
</script>
<section class="mt-6" aria-label={m.summary_heading()}>
@@ -115,15 +144,35 @@
</div>
<div class="grid gap-4 px-4 py-3.5 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-6">
{#if sentences.length > 0}
<p class="text-[15px] leading-relaxed text-foreground">
{sentences.join(' ')}
</p>
{:else}
<p class="text-[15px] leading-relaxed text-muted-foreground">
{m.summary_no_data()}
</p>
{/if}
<div class="narrative">
{#if sentences.length > 0}
<p
bind:this={textEl}
class="text-[15px] leading-relaxed text-foreground"
class:clamped={!expanded}
>
{sentences.join(' ')}
</p>
{:else}
<p class="text-[15px] leading-relaxed text-muted-foreground">
{m.summary_no_data()}
</p>
{/if}
<!-- Always occupies its row on phones, even when the text fits: a
toggle that appears only sometimes is itself a layout shift. -->
<button
type="button"
class="toggle mt-1 cursor-pointer text-[13px] font-semibold text-primary hover:underline"
class:invisible={!overflows && !expanded}
aria-hidden={!overflows && !expanded}
tabindex={!overflows && !expanded ? -1 : 0}
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
{expanded ? m.summary_read_less() : m.summary_read_more()}
</button>
</div>
<!-- Sun, moon and UV: the numbers the sentence above deliberately leaves out -->
<dl
@@ -218,3 +267,36 @@
</div>
</div>
</section>
<style>
/* Phones: exactly five lines, reserved whether the text is long or short, so
the card's height never depends on the forecast that lands. 15px text at
leading-relaxed (1.625) → 5 lines = 8.125em. */
.clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
}
.narrative p {
min-height: 8.125em;
}
/* md and up: no clamp, no reserved height, no toggle. */
@media (min-width: 768px) {
.clamped {
display: block;
overflow: visible;
}
.narrative p {
min-height: 0;
}
.toggle {
display: none;
}
}
</style>