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
@@ -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>