diff --git a/README.md b/README.md index 82ff926..d972d64 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ npm run check # Astro + TypeScript check | `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/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/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. | @@ -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/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/video/` | The home hero's background loop, MP4 and WebM. Shipped as-is; Astro does not process video. | ## Routes @@ -52,6 +53,28 @@ Create `src/content/insights/.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. 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 The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it diff --git a/package-lock.json b/package-lock.json index 4e064f2..a41a19d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -699,6 +699,7 @@ "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -710,6 +711,7 @@ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2095,6 +2097,7 @@ "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2232,6 +2235,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2864,6 +2868,7 @@ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3912,6 +3917,7 @@ "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4388,6 +4394,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4679,6 +4686,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -5111,6 +5119,7 @@ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "devOptional": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/public/video/hero-plant.mp4 b/public/video/hero-plant.mp4 new file mode 100644 index 0000000..9bf09ca Binary files /dev/null and b/public/video/hero-plant.mp4 differ diff --git a/public/video/hero-plant.webm b/public/video/hero-plant.webm new file mode 100644 index 0000000..e5546ab Binary files /dev/null and b/public/video/hero-plant.webm differ diff --git a/scripts/encode-hero-video.sh b/scripts/encode-hero-video.sh new file mode 100755 index 0000000..bf857e6 --- /dev/null +++ b/scripts/encode-hero-video.sh @@ -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 [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" diff --git a/scripts/hero-video-test.mjs b/scripts/hero-video-test.mjs new file mode 100644 index 0000000..5c605fd --- /dev/null +++ b/scripts/hero-video-test.mjs @@ -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(); diff --git a/scripts/object-fit-test.mjs b/scripts/object-fit-test.mjs new file mode 100644 index 0000000..dcba374 --- /dev/null +++ b/scripts/object-fit-test.mjs @@ -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(); diff --git a/src/components/home/GridObject.astro b/src/components/home/GridObject.astro index 744d018..6405f74 100644 --- a/src/components/home/GridObject.astro +++ b/src/components/home/GridObject.astro @@ -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(' '); --- -
+ +
@@ -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; } } + + diff --git a/src/components/home/Stages.astro b/src/components/home/Stages.astro index b75ba7c..ae1f003 100644 --- a/src/components/home/Stages.astro +++ b/src/components/home/Stages.astro @@ -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); diff --git a/src/components/verifier/Cards.astro b/src/components/verifier/Cards.astro index 3a28130..1adf124 100644 --- a/src/components/verifier/Cards.astro +++ b/src/components/verifier/Cards.astro @@ -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)); }