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

+24 -1
View File
@@ -23,7 +23,7 @@ npm run check # Astro + TypeScript check
| `src/data/team.ts` | Team members, roles, portraits, links. | | `src/data/team.ts` | Team members, roles, portraits, links. |
| `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. | | `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. |
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. | | `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. |
| `src/components/home/GridObject.astro` | The four wireframe objects on the stage cards (sphere, cube, plane, wave). Geometry is computed at build time; single stroke in `--ink`. | | `src/components/home/GridObject.astro` | The wireframe objects on the stage and product cards (sphere, cube, plane, wave, steps, nested, stack). Geometry is computed at build time; single stroke in `--ink`. Each drawing is laid out by hand, so the `viewBox` is derived from the points as they are emitted, not assumed: a square centred on the drawing, padded for the stroke. A new kind can therefore never be clipped, and the card gives the drawing the flexible row so it sits centred between title and body. |
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. | | `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. | | `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. | | `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
@@ -32,6 +32,7 @@ npm run check # Astro + TypeScript check
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. | | `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. | | `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). | | `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). |
| `public/video/` | The home hero's background loop, MP4 and WebM. Shipped as-is; Astro does not process video. |
## Routes ## Routes
@@ -52,6 +53,28 @@ Create `src/content/insights/<slug>.md` with the front matter used in `xg-aloe-u
Put the cover image in `src/assets/images/` and reference it with a relative path. Put the cover image in `src/assets/images/` and reference it with a relative path.
The three newest posts appear on the home page automatically. The three newest posts appear on the home page automatically.
## The hero video
The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the
`plant-at-dusk.jpg` still. The still is the video's own first frame, so it carries the hero until
the video is ready and stays put for anyone who never gets one.
The video is only fetched when it will be seen: not under `prefers-reduced-motion`, not on
`saveData`, and not below 48rem, where a 21:9 frame would be cropped to a sliver for the most
expensive bytes. The sources are attached by script rather than in the markup, so nothing is
requested otherwise. A blocked autoplay also just leaves the still in place.
The source clip was generated with Magnific (Kling 2.5, 10 s, from the hero still as its start
frame, https://www.magnific.com/app/creation/5jPaI3qKxe). `scripts/encode-hero-video.sh` turns a
clip into the looping file: it plays forward, then backward, so the loop point cannot be seen, and
encodes 1920-wide H.264 and VP9. A 20-second loop lands at 862 KB and 472 KB. To replace it:
```sh
FFMPEG=/path/to/ffmpeg ./scripts/encode-hero-video.sh source.mp4 public/video/hero-plant 1920
```
Keep the first frame of any replacement equal to `plant-at-dusk.jpg`, or replace both together.
## Imagery ## Imagery
The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it
+9
View File
@@ -699,6 +699,7 @@
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.3", "@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
@@ -710,6 +711,7 @@
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -2095,6 +2097,7 @@
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -2232,6 +2235,7 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2864,6 +2868,7 @@
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3912,6 +3917,7 @@
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4388,6 +4394,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4679,6 +4686,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
@@ -5111,6 +5119,7 @@
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Turns a generated clip into a seamless looping hero background:
# the clip plays forward, then backward, so the loop point cannot be seen.
# Usage: encode-hero-video.sh <source.mp4> <out-basename> [width]
set -euo pipefail
FFMPEG="${FFMPEG:-ffmpeg}"
SRC="$1"; OUT="$2"; W="${3:-1920}"
FILTER="[0:v]scale=${W}:-2:flags=lanczos,fps=25,setpts=PTS-STARTPTS,split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1[v]"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libx264 -profile:v high -crf 30 -preset slow -pix_fmt yuv420p -movflags +faststart "${OUT}.mp4"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libvpx-vp9 -crf 40 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 "${OUT}.webm"
ls -lh "${OUT}.mp4" "${OUT}.webm"
+39
View File
@@ -0,0 +1,39 @@
import { chromium } from 'playwright';
/* Checks the hero video: does it load and play, is it gated off on phones and under reduced
motion, and does the hero fill the viewport. */
const [,, url, prefix] = process.argv;
const browser = await chromium.launch();
async function probe(label, opts, shots = []) {
const ctx = await browser.newContext(opts);
const page = await ctx.newPage();
const requested = [];
page.on('request', (r) => { if (/\.(mp4|webm)$/.test(r.url())) requested.push(r.url().split('/').pop()); });
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(2500);
const info = await page.evaluate(() => {
const v = document.querySelector('video');
const hero = document.querySelector('.hero');
return {
hasVideo: !!v,
ready: v?.hasAttribute('data-ready') ?? null,
paused: v?.paused ?? null,
currentTime: v ? Math.round(v.currentTime * 10) / 10 : null,
videoSize: v ? `${v.videoWidth}x${v.videoHeight}` : null,
heroHeight: Math.round(hero.getBoundingClientRect().height),
viewport: window.innerHeight,
headerH: Math.round(document.querySelector('header').getBoundingClientRect().height),
};
});
for (const t of shots) {
await page.waitForTimeout(t);
await page.screenshot({ path: `${prefix}-${label}-${t}.png` });
}
console.log(label, JSON.stringify({ ...info, requested }));
await ctx.close();
}
await probe('desktop', { viewport: { width: 1440, height: 900 } }, [0, 2500]);
await probe('mobile', { viewport: { width: 390, height: 844 } });
await probe('reduced', { viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
await browser.close();
+31
View File
@@ -0,0 +1,31 @@
import { chromium } from 'playwright';
/* For every grid object: does the drawn geometry fit inside its viewBox, and is the drawing
centred in the space its card gives it. */
const [,, url, selector, width = '1440'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 1000 } });
await page.goto(url, { waitUntil: 'networkidle' });
const rows = await page.evaluate((sel) => {
return Array.from(document.querySelectorAll(sel)).map((svg, i) => {
const vb = svg.viewBox.baseVal;
const b = svg.querySelector('g').getBBox();
const overflow = {
left: +(vb.x - b.x).toFixed(1),
right: +((b.x + b.width) - (vb.x + vb.width)).toFixed(1),
top: +(vb.y - b.y).toFixed(1),
bottom: +((b.y + b.height) - (vb.y + vb.height)).toFixed(1),
};
const clipped = Object.values(overflow).some((v) => v > 0.5);
/* Vertical centring: the gap above the drawing vs the gap below it, inside the card. */
const card = svg.closest('li');
const title = card.querySelector('h2, h3');
const body = card.querySelector('p');
const r = svg.getBoundingClientRect();
const above = +(r.top - title.getBoundingClientRect().bottom).toFixed(1);
const below = +(body.getBoundingClientRect().top - r.bottom).toFixed(1);
return { i, viewBox: `${vb.x} ${vb.y} ${vb.width} ${vb.height}`, clipped, overflow, above, below, offBy: +(above - below).toFixed(1) };
});
}, selector);
console.table(rows);
console.log('any clipped:', rows.some((r) => r.clipped));
await browser.close();
+26 -5
View File
@@ -13,7 +13,21 @@ const { kind, class: className } = Astro.props;
const S = 400; const S = 400;
const f = (n: number) => Math.round(n * 10) / 10; 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). */ /* 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) }; 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(poly(pts));
} }
lines.push(`circle:${R}`); lines.push(`circle:${R}`);
track(ISO.cx - R, ISO.cy - R);
track(ISO.cx + R, ISO.cy + R);
} }
if (kind === 'cube') { 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)); 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]]; 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)]))); 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"> <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:') {lines.map((l) => l.startsWith('circle:')
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} /> ? <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"> <section class="hero night" aria-labelledby="hero-title">
<div class="hero__media"> <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 <Picture
src={plant} src={plant}
alt={ui.hero.imageAlt} alt={ui.hero.imageAlt}
@@ -22,6 +24,18 @@ const ui = t(lang);
class="hero__img img-cover img-doc" class="hero__img img-cover img-doc"
pictureAttributes={{ class: 'hero__picture' }} 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 class="hero__overlay"></div>
</div> </div>
@@ -39,7 +53,8 @@ const ui = t(lang);
position: relative; position: relative;
display: flex; display: flex;
align-items: flex-end; 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; overflow: hidden;
} }
.hero__media { position: absolute; inset: 0; } .hero__media { position: absolute; inset: 0; }
@@ -53,6 +68,18 @@ const ui = t(lang);
transform-origin: 50% 60%; transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both; 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 { .hero__overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -82,5 +109,32 @@ const ui = t(lang);
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; } .hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
.hero__video { display: none; }
} }
</style> </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 { .stages__item {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr; /* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
align-items: start; grid-template-rows: auto 1fr auto;
gap: var(--space-6); gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7); padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep); background: var(--paper-deep);
+3 -3
View File
@@ -25,8 +25,8 @@ const objects = ['steps', 'nested', 'stack'] as const;
.vcards__item { .vcards__item {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr; /* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
align-items: start; grid-template-rows: auto 1fr auto;
gap: var(--space-6); gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7); padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep); background: var(--paper-deep);
@@ -40,7 +40,7 @@ const objects = ['steps', 'nested', 'stack'] as const;
text-wrap: balance; text-wrap: balance;
hyphens: auto; 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; } .vcards__body { max-width: 26em; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.vcards__list { grid-template-columns: repeat(3, minmax(0, 1fr)); } .vcards__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }