diff --git a/website/app/page.ts b/website/app/page.ts index c7510707f..016ba304d 100644 --- a/website/app/page.ts +++ b/website/app/page.ts @@ -246,6 +246,92 @@ function sourceWindow(title: string, sample: string) { `; } +/** + * The landing page's intro video, self-hosted rather than embedded. + * + * The player is the browser's own, so it works with scripting disabled. The + * YouTube iframe this replaced could not, because its player needs JS inside + * the frame, so the section used to hide itself from a JS-off reader rather + * than show a broken embed. + * + * REPLACING THE VIDEO, for whoever does it next. + * + * The bytes live in the Cloudflare R2 bucket `webjs-videos`, served from + * videos.webjs.dev. The key carries no version, so a new cut overwrites + * `intro.mp4` in place and nothing in this file changes: + * + * env -u CLOUDFLARE_API_TOKEN npx wrangler r2 object put \ + * webjs-videos/intro.mp4 --file \ + * --content-type video/mp4 \ + * --cache-control "public, max-age=86400, s-maxage=31536000" \ + * --remote + * + * Then purge that URL at the edge (Caching, then Purge Cache, then the custom + * single-file purge), or the old bytes keep serving for the `s-maxage` year. + * + * Three things about this are easy to get wrong. + * + * 1. R2 object metadata cannot be edited after upload. `Cache-Control` is set + * at write time or not at all, so every upload has to pass it again. There + * is no bucket-level setting, and the dashboard uploader has no field for + * it, which is why an upload made there serves no header of its own. + * + * 2. The `env -u` is load bearing. A `CLOUDFLARE_API_TOKEN` in the environment + * overrides wrangler's OAuth credentials, and that token carries no R2 + * permission, so the upload fails with an authentication error that names + * nothing. Unsetting it for the one call falls back to the OAuth login. + * + * 3. A purge clears the edge and never a browser. That is why `max-age` is a + * day rather than a year, and why the header is deliberately not + * `immutable`: anyone holding the old file needs a way to pick the new one + * up, and 24 hours is that way. `s-maxage` keeps the edge copy long lived + * regardless, since the edge can be purged and a browser cannot. + * + * A versioned key (`intro-2026-08-21.mp4`) is the other valid shape. Take it + * and the tradeoff inverts: put `immutable` back, raise `max-age` to a year, + * and update the src below on every cut. + * + * The thumbnail at `intro-thumbnail.webp` is uploaded the same way, with + * `--content-type image/webp`. It is 1920 wide because the box is capped at + * max-w-3xl (about 718 CSS px), so that already covers a 2x display and a + * wider encode buys nothing a viewport can show. + */ +const INTRO_VIDEO = html` +
+
+ +
+ +
+
+
+`; + + export default function LandingPage() { return html` - - + ${INTRO_VIDEO}
diff --git a/website/test/ssr/intro-video-ssr.test.ts b/website/test/ssr/intro-video-ssr.test.ts index eac856803..092619270 100644 --- a/website/test/ssr/intro-video-ssr.test.ts +++ b/website/test/ssr/intro-video-ssr.test.ts @@ -1,14 +1,16 @@ /** - * The landing page's intro video is hidden until its frame fires load. + * The landing page's intro video is self-hosted, not embedded. * - * A cross-origin iframe paints its own canvas before the embedded stylesheet - * applies, and on some engines that canvas is opaque white, which no - * background on the iframe element can cover. Hiding the frame until load - * removes the question: what it paints early is off screen, and the box under - * it is already black. + * It replaced a YouTube iframe whose every property was a workaround for + * being cross-origin: a frame hidden until load (an iframe paints its own + * canvas, opaque white on some engines, before the embedded stylesheet + * lands), a plain onload attribute to reveal it (this page never hydrates, so + * an event hole would be dropped at SSR), and a noscript rule that deleted + * the whole section for a JS-off reader (their player needs JS inside the + * frame, so revealing it would have shown their own error). * - * Each assertion here is a piece that silently breaks the whole thing if it - * goes missing, which is why they are pinned rather than left to review. + * A native player needs none of that, and the assertions below pin the pieces + * that silently break it if they go missing. */ import test from 'node:test'; import assert from 'node:assert/strict'; @@ -17,26 +19,48 @@ import LandingPage from '#app/page.ts'; const render = () => renderToString(LandingPage()); -test('the intro frame ships hidden, over a black box', async () => { +test('the intro video is a native player pointed at our own host', async () => { const out = await render(); - assert.match(out, /class="intro-video-frame [^"]*\binvisible\b/, 'the frame must start hidden'); - assert.match(out, /aspect-video[^"]*\bbg-black\b/, 'the box under it must be black'); + assert.match(out, /]*\ssrc="https:\/\/videos\.webjs\.dev\/intro\.mp4"/); + assert.match(out, /]*\scontrols/, 'the controls are the whole no-JS story'); }); -test('the frame reveals itself with a plain onload attribute', async () => { +test('the player keeps the accessible name the embed had', async () => { const out = await render(); - // A plain HTML attribute, not an @event hole: this page never hydrates, so - // a template event binding would be dropped at SSR and the frame would stay - // hidden forever. - assert.match(out, /onload="this\.classList\.remove\('invisible'\)"/); + // The iframe this replaced carried title="WebJs introduction video". Without + // a replacement the player is an unnamed media element to a screen reader. + assert.match(out, /]*\saria-label="WebJs introduction video"/); }); -test('a JS-off reader gets no embed at all', async () => { +test('the first paint shows a poster rather than a black box', async () => { const out = await render(); - // Without JS the load handler never runs AND YouTube's player cannot run - // inside the frame either, so revealing it would show their own noscript - // error rather than a video. Hide the whole section instead. The rule must - // live in noscript, which a browser with scripting on parses as inert text. - assert.match(out, /