test
This commit is contained in:
1 parent
0c2b4e7df2
commit
e652b54319
11 files changed
+202
-12
No files matched your search
@@ -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
|
||||||
|
|||||||
Generated
+9
@@ -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.
Executable
+13
@@ -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"
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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))} />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
|||||||
Reference in new issue
Block a user