This commit is contained in:
Felix Klement committed 2026-09-18 19:25:48 +02:00
1 parent 0c2b4e7df2
commit e652b54319
11 files changed
+202 -12

No files matched your search

+26 -5
View File
@@ -13,7 +13,21 @@ const { kind, class: className } = Astro.props;
const S = 400;
const f = (n: number) => Math.round(n * 10) / 10;
const poly = (pts: [number, number][]) => pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
/* Every drawing is laid out by hand, so the box is derived from the geometry rather than
assumed: each point is recorded as it is emitted and the viewBox follows at the end. That
way a drawing can never be clipped, whatever a new kind does. */
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const track = (x: number, y: number) => {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
};
const poly = (pts: [number, number][]) => {
for (const [x, y] of pts) track(x, y);
return pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
};
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
@@ -65,6 +79,8 @@ if (kind === 'sphere') {
lines.push(poly(pts));
}
lines.push(`circle:${R}`);
track(ISO.cx - R, ISO.cy - R);
track(ISO.cx + R, ISO.cy + R);
}
if (kind === 'cube') {
@@ -138,12 +154,17 @@ if (kind === 'stack') {
for (let k = 0; k < layers; k++) plane(n, u, k * gapY, cy).forEach((l) => lines.push(l));
const corners: [number, number][] = [[0, 0], [L, 0], [L, L], [0, L]];
corners.forEach(([x, z]) => lines.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)])));
// shift the whole drawing so the isometric footprint sits centred
const dx = -((L - L) * ISO.c30) / 2;
void dx;
}
/* A square box centred on the drawing: wide drawings still fill the width, and every object
keeps the same footprint, so the four cards of a row stay even. The padding covers the
stroke, which sits half outside the geometry. */
const PAD = 8;
const width = maxX - minX;
const height = maxY - minY;
const side = Math.max(width, height) + PAD * 2;
const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side, side].map(f).join(' ');
---
<svg class:list={['grid-object', className]} viewBox={`0 0 ${S} ${S}`} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<svg class:list={['grid-object', className]} viewBox={viewBox} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
{lines.map((l) => l.startsWith('circle:')
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
+55 -1
View File
@@ -9,6 +9,8 @@ const ui = t(lang);
---
<section class="hero night" aria-labelledby="hero-title">
<div class="hero__media">
<!-- The still is the video's first frame, so it carries the hero until the video is ready
and stays put for anyone who never gets one: no script, reduced motion, saved data. -->
<Picture
src={plant}
alt={ui.hero.imageAlt}
@@ -22,6 +24,18 @@ const ui = t(lang);
class="hero__img img-cover img-doc"
pictureAttributes={{ class: 'hero__picture' }}
/>
<video
class="hero__video img-cover img-doc"
muted
loop
playsinline
disablepictureinpicture
preload="none"
aria-hidden="true"
data-hero-video
data-mp4="/video/hero-plant.mp4"
data-webm="/video/hero-plant.webm"
></video>
<div class="hero__overlay"></div>
</div>
@@ -39,7 +53,8 @@ const ui = t(lang);
position: relative;
display: flex;
align-items: flex-end;
min-height: clamp(30rem, calc(100svh - var(--header-height)), 56rem);
/* Fills the screen under the header, and never less than the headline needs. */
min-height: max(28rem, calc(100svh - var(--header-height)));
overflow: hidden;
}
.hero__media { position: absolute; inset: 0; }
@@ -53,6 +68,18 @@ const ui = t(lang);
transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both;
}
.hero__video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 50%;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
}
/* Set once the video can actually play, so the still never cuts to black. */
.hero__video[data-ready] { opacity: 1; }
.hero__overlay {
position: absolute;
inset: 0;
@@ -82,5 +109,32 @@ const ui = t(lang);
@media (prefers-reduced-motion: reduce) {
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
.hero__video { display: none; }
}
</style>
<script>
/* The sources are attached here rather than in the markup, so the file is only fetched for
someone who will actually see it move: not under reduced motion, not on saved data, and not
on a phone, where a 21:9 frame is cropped to a sliver and the bytes cost the most. */
const video = document.querySelector<HTMLVideoElement>('[data-hero-video]');
const wants = window.matchMedia('(min-width: 48rem)').matches
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
if (video && wants && !connection?.saveData) {
const add = (src: string | null, type: string) => {
if (!src) return;
const source = document.createElement('source');
source.src = src;
source.type = type;
video.append(source);
};
add(video.dataset.webm ?? null, 'video/webm');
add(video.dataset.mp4 ?? null, 'video/mp4');
video.addEventListener('canplay', () => video.setAttribute('data-ready', ''), { once: true });
video.preload = 'auto';
video.load();
/* A blocked autoplay leaves the still in place, which is a fine hero on its own. */
video.play().catch(() => {});
}
</script>
+2 -2
View File
@@ -30,8 +30,8 @@ const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
.stages__item {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr;
align-items: start;
/* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
grid-template-rows: auto 1fr auto;
gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep);
+3 -3
View File
@@ -25,8 +25,8 @@ const objects = ['steps', 'nested', 'stack'] as const;
.vcards__item {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr;
align-items: start;
/* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
grid-template-rows: auto 1fr auto;
gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep);
@@ -40,7 +40,7 @@ const objects = ['steps', 'nested', 'stack'] as const;
text-wrap: balance;
hyphens: auto;
}
.vcards__item :global(.vcards__object) { width: 100%; max-width: 14rem; justify-self: center; }
.vcards__item :global(.vcards__object) { width: 100%; max-width: 14rem; justify-self: center; align-self: center; }
.vcards__body { max-width: 26em; }
@media (min-width: 48rem) {
.vcards__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }