From 9229ce5a51cbeddef5adecfe4e036b4ebc7e97c7 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 3 Oct 2026 02:19:52 +0200 Subject: [PATCH 01/15] feat(website): show a live demo in the hero --- website/i18n/code.source.json | 14 +- website/i18n/de/code.json | 30 +-- website/i18n/es/code.json | 30 +-- website/i18n/fr/code.json | 30 +-- website/i18n/ja/code.json | 30 +-- website/i18n/pt-BR/code.json | 30 +-- website/i18n/zh-CN/code.json | 30 +-- website/i18n/zh-TW/code.json | 30 +-- website/src/components/Editor/index.tsx | 3 +- .../src/components/Editor/styles.module.css | 6 +- .../components/Recreation/ART-DIRECTION.md | 23 ++- website/src/components/Recreation/driver.ts | 155 ++++++++++++--- website/src/components/Recreation/index.tsx | 61 ++++-- .../components/Recreation/playback.test.ts | 77 ++++++++ website/src/components/Recreation/playback.ts | 43 +++++ website/src/components/Recreation/scene.ts | 9 +- .../components/Recreation/styles.module.css | 178 ++++++++++++++++-- website/src/pages/index.module.css | 165 ++++++---------- website/src/pages/index.tsx | 122 +++++------- 19 files changed, 714 insertions(+), 352 deletions(-) create mode 100644 website/src/components/Recreation/playback.test.ts create mode 100644 website/src/components/Recreation/playback.ts diff --git a/website/i18n/code.source.json b/website/i18n/code.source.json index 5d3e9b4fd..fe41750db 100644 --- a/website/i18n/code.source.json +++ b/website/i18n/code.source.json @@ -38,14 +38,11 @@ "download.preRelease.body": "Release candidates ship between stable versions, alongside older releases, checksums, and full release notes.", "download.preRelease.cta": "Browse all releases", "home.meta.title": "Free open-source screen recorder & video editor", - "home.meta.description": "OpenScreen is a free, open-source screen recorder and video editor for Windows, macOS, and Linux — native capture, on-device captions, no watermarks.", + "home.meta.description": "Turn screen recordings into polished demos with automatic zooms, styled backgrounds and an editable cursor. Free and open source for Windows, macOS and Linux. No watermark.", "home.hero.badge.new": "NEW", "home.hero.badge.text": "1.11 exports faster on macOS and Linux", - "home.hero.titleTagline": "A free, open-source screen recorder and video editor", - "home.hero.tagline": "Screen recording with native capture, local AI and no paywall.", + "home.hero.tagline": "Automatic zooms, smooth cursor movement, and a frame that looks finished. Record, adjust, share.", "home.hero.download": "Download", - "home.hero.readDocs": "Read the docs", - "home.hero.scrollHint": "Scroll down", "home.features.kicker": "Also true", "home.features.title": "Free, local, cross-platform: three things a screenshot can't show.", "home.features.summary": "OpenScreen is a free, open-source screen recorder and video editor for Windows, macOS, and Linux: a raw capture goes in and a finished demo comes out, in the category {screenStudio} defined. It is MIT licensed, with no watermark and no account, and it continues the {originalProject}, which its creator archived after v1.5.0.", @@ -225,5 +222,10 @@ "footer.community.license": "License (MIT)", "footer.bottom.license": "OpenScreen is released under the MIT license. Built by the community — free, forever.", "footer.bottom.lineage": "The official spin-off of the {originalProject} — 39k stars, now archived.", - "footer.bottom.lineage.originalProject": "original OpenScreen project" + "footer.bottom.lineage.originalProject": "original OpenScreen project", + "home.hero.title": "Record your screen. Your video already looks polished.", + "home.hero.reassurance": "Free and open source. No watermark. No account.", + "recreation.pause": "Pause animation", + "recreation.play": "Play animation", + "home.hero.explore": "Scroll to explore the editor" } diff --git a/website/i18n/de/code.json b/website/i18n/de/code.json index b27e742e0..df60fc40e 100644 --- a/website/i18n/de/code.json +++ b/website/i18n/de/code.json @@ -122,30 +122,21 @@ "message": "Kostenloser Open-Source-Bildschirmrekorder und Videoeditor" }, "home.meta.description": { - "message": "OpenScreen: kostenloser Open-Source-Bildschirmrekorder & Videoeditor für Windows, macOS und Linux. Native Aufnahme, lokale Untertitel, kein Wasserzeichen." + "message": "Machen Sie aus Bildschirmaufnahmen ansprechende Demos mit automatischen Zooms, gestalteten Hintergründen und einem bearbeitbaren Cursor. Kostenlos und Open Source für Windows, macOS und Linux. Ohne Wasserzeichen." }, "home.hero.badge.new": { "message": "NEU" }, "home.hero.badge.text": { "message": "1.11 exportiert schneller (Mac, Linux)", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "Ein kostenloser Open-Source-Bildschirmrekorder und Videoeditor" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "Bildschirmaufnahme mit nativer Erfassung, lokaler KI und ohne Bezahlschranke." + "message": "Automatische Zooms, flüssige Cursorbewegungen und ein ansprechender Rahmen. Aufnehmen, anpassen, teilen." }, "home.hero.download": { "message": "Download" }, - "home.hero.readDocs": { - "message": "Doku lesen" - }, - "home.hero.scrollHint": { - "message": "Nach unten scrollen" - }, "home.features.kicker": { "message": "Auch das stimmt" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "Den Agenten des Editors um eine Änderung bitten", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "Nehmen Sie Ihren Bildschirm auf. Ihr Video ist bereits ansprechend gestaltet." + }, + "home.hero.reassurance": { + "message": "Kostenlos und Open Source. Ohne Wasserzeichen. Ohne Konto." + }, + "home.hero.explore": { + "message": "Scrollen, um den Editor zu entdecken" + }, + "recreation.pause": { + "message": "Animation pausieren" + }, + "recreation.play": { + "message": "Animation abspielen" } } diff --git a/website/i18n/es/code.json b/website/i18n/es/code.json index 14b2bbdc4..6d84203ce 100644 --- a/website/i18n/es/code.json +++ b/website/i18n/es/code.json @@ -109,30 +109,21 @@ "message": "Grabador de pantalla y editor de video gratis y de código abierto" }, "home.meta.description": { - "message": "OpenScreen, grabador de pantalla y editor de video gratis y de código abierto (Windows, macOS, Linux): captura nativa, subtítulos locales, sin marca de agua." + "message": "Convierte tus grabaciones de pantalla en demos cuidadas con zooms automáticos, fondos personalizados y un cursor editable. Gratis y de código abierto para Windows, macOS y Linux. Sin marca de agua." }, "home.hero.badge.new": { "message": "NUEVO" }, "home.hero.badge.text": { "message": "1.11 exporta más rápido en macOS y Linux", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "Un grabador de pantalla y editor de video gratis y de código abierto" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "Grabación de pantalla con captura nativa, IA local y sin muro de pago." + "message": "Zooms automáticos, un cursor fluido y un marco cuidado. Graba, ajusta y comparte." }, "home.hero.download": { "message": "Descargar" }, - "home.hero.readDocs": { - "message": "Leer la documentación" - }, - "home.hero.scrollHint": { - "message": "Desplázate hacia abajo" - }, "home.features.kicker": { "message": "Además" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "Pedir una edición al agente del editor", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "Graba tu pantalla. Tu vídeo ya tiene un acabado cuidado." + }, + "home.hero.reassurance": { + "message": "Gratis y de código abierto. Sin marca de agua. Sin cuenta." + }, + "home.hero.explore": { + "message": "Desplázate para explorar el editor" + }, + "recreation.pause": { + "message": "Pausar la animación" + }, + "recreation.play": { + "message": "Reproducir la animación" } } diff --git a/website/i18n/fr/code.json b/website/i18n/fr/code.json index 10edea176..c6072f44e 100644 --- a/website/i18n/fr/code.json +++ b/website/i18n/fr/code.json @@ -109,30 +109,21 @@ "message": "Enregistreur d'écran gratuit et open source avec éditeur vidéo" }, "home.meta.description": { - "message": "OpenScreen, enregistreur d'écran et éditeur vidéo gratuit et open source pour Windows, macOS et Linux : capture native, sous-titres en local, sans filigrane." + "message": "Transformez vos enregistrements en démos soignées avec des zooms automatiques, des fonds personnalisés et un curseur modifiable. Gratuit et open source pour Windows, macOS et Linux. Sans filigrane." }, "home.hero.badge.new": { "message": "NOUVEAU" }, "home.hero.badge.text": { "message": "La 1.11 exporte plus vite (Mac, Linux)", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "Un enregistreur d'écran et éditeur vidéo gratuit et open source" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "Enregistrement d'écran avec capture native et IA locale, sans fonction payante." + "message": "Des zooms automatiques, un curseur fluide et un cadre soigné. Enregistrez, ajustez, partagez." }, "home.hero.download": { "message": "Télécharger" }, - "home.hero.readDocs": { - "message": "Lire la documentation" - }, - "home.hero.scrollHint": { - "message": "Faites défiler" - }, "home.features.kicker": { "message": "Tout aussi vrai" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "Demander une modification à l'agent de l'éditeur", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "Enregistrez votre écran. Votre vidéo est déjà soignée." + }, + "home.hero.reassurance": { + "message": "Gratuit et open source. Sans filigrane. Sans compte." + }, + "home.hero.explore": { + "message": "Faites défiler pour découvrir l’éditeur" + }, + "recreation.pause": { + "message": "Mettre l’animation en pause" + }, + "recreation.play": { + "message": "Lire l’animation" } } diff --git a/website/i18n/ja/code.json b/website/i18n/ja/code.json index 04e120608..27e413835 100644 --- a/website/i18n/ja/code.json +++ b/website/i18n/ja/code.json @@ -109,30 +109,21 @@ "message": "無料・オープンソースの画面録画・動画編集ソフト" }, "home.meta.description": { - "message": "OpenScreen は Windows・macOS・Linux に対応した、無料・オープンソースの画面録画・動画編集ソフトです。OS ネイティブのキャプチャ、端末上で生成する字幕、透かしなし。" + "message": "画面録画を、見栄えのよいデモ動画に。自動ズーム、背景のカスタマイズ、カーソル編集に対応。Windows、macOS、Linuxで使える無料のオープンソース。透かしなし。" }, "home.hero.badge.new": { "message": "NEW" }, "home.hero.badge.text": { "message": "1.11:macOS・Linux でエクスポート高速化", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "無料・オープンソースの画面録画・動画編集ソフト" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "ネイティブキャプチャ、ローカル AI、課金不要の画面録画。" + "message": "自動ズーム、なめらかなカーソル、見栄えのよいフレーム。録画して、調整して、共有しましょう。" }, "home.hero.download": { "message": "ダウンロード" }, - "home.hero.readDocs": { - "message": "ドキュメントを読む" - }, - "home.hero.scrollHint": { - "message": "下にスクロール" - }, "home.features.kicker": { "message": "さらに" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "エディターのエージェントに編集を依頼", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "画面を録画。動画はすでに洗練された仕上がり。" + }, + "home.hero.reassurance": { + "message": "無料でオープンソース。透かしなし。アカウント不要。" + }, + "home.hero.explore": { + "message": "スクロールしてエディターを見る" + }, + "recreation.pause": { + "message": "アニメーションを一時停止" + }, + "recreation.play": { + "message": "アニメーションを再生" } } diff --git a/website/i18n/pt-BR/code.json b/website/i18n/pt-BR/code.json index 794350748..d3f0ded5c 100644 --- a/website/i18n/pt-BR/code.json +++ b/website/i18n/pt-BR/code.json @@ -109,30 +109,21 @@ "message": "Gravador de tela e editor de vídeo grátis e de código aberto" }, "home.meta.description": { - "message": "OpenScreen: gravador de tela e editor de vídeo grátis e de código aberto para Windows, macOS e Linux. Captura nativa, legendas locais, sem marca d'água." + "message": "Transforme gravações de tela em demos bem-acabadas com zooms automáticos, fundos personalizados e um cursor editável. Grátis e de código aberto para Windows, macOS e Linux. Sem marca-d’água." }, "home.hero.badge.new": { "message": "NOVO" }, "home.hero.badge.text": { "message": "1.11 exporta mais rápido (macOS, Linux)", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "Gravador de tela e editor de vídeo grátis e de código aberto" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "Gravação de tela com captura nativa, IA local e sem paywall." + "message": "Zooms automáticos, um cursor suave e uma moldura bem-acabada. Grave, ajuste e compartilhe." }, "home.hero.download": { "message": "Baixar" }, - "home.hero.readDocs": { - "message": "Ler a documentação" - }, - "home.hero.scrollHint": { - "message": "Role para baixo" - }, "home.features.kicker": { "message": "Também é verdade" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "Pedir uma edição ao agente do editor", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "Grave sua tela. Seu vídeo já tem um acabamento caprichado." + }, + "home.hero.reassurance": { + "message": "Grátis e de código aberto. Sem marca-d’água. Sem conta." + }, + "home.hero.explore": { + "message": "Role para explorar o editor" + }, + "recreation.pause": { + "message": "Pausar animação" + }, + "recreation.play": { + "message": "Reproduzir animação" } } diff --git a/website/i18n/zh-CN/code.json b/website/i18n/zh-CN/code.json index c10117b85..e918e72d2 100644 --- a/website/i18n/zh-CN/code.json +++ b/website/i18n/zh-CN/code.json @@ -122,30 +122,21 @@ "message": "免费开源的录屏与视频剪辑软件" }, "home.meta.description": { - "message": "OpenScreen 是一款免费、开源的录屏与视频剪辑软件,支持 Windows、macOS 和 Linux:采用系统原生屏幕采集,字幕在本机生成,导出视频无水印。" + "message": "把屏幕录制变成精美的演示视频:自动缩放、自定义背景、可编辑光标。适用于 Windows、macOS 和 Linux,免费开源,无水印。" }, "home.hero.badge.new": { "message": "新" }, "home.hero.badge.text": { "message": "1.11:macOS 和 Linux 导出更快", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "免费、开源的录屏与视频剪辑软件" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "原生采集、本地 AI、没有付费墙的屏幕录制。" + "message": "自动缩放、流畅光标和精美边框。录制、调整、分享。" }, "home.hero.download": { "message": "下载" }, - "home.hero.readDocs": { - "message": "阅读文档" - }, - "home.hero.scrollHint": { - "message": "向下滚动" - }, "home.features.kicker": { "message": "此外" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "请编辑器的代理完成修改", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "录制屏幕。视频已呈现精美效果。" + }, + "home.hero.reassurance": { + "message": "免费开源。无水印。无需账号。" + }, + "home.hero.explore": { + "message": "滚动以探索编辑器" + }, + "recreation.pause": { + "message": "暂停动画" + }, + "recreation.play": { + "message": "播放动画" } } diff --git a/website/i18n/zh-TW/code.json b/website/i18n/zh-TW/code.json index d5e508073..868514033 100644 --- a/website/i18n/zh-TW/code.json +++ b/website/i18n/zh-TW/code.json @@ -122,30 +122,21 @@ "message": "免費開源的螢幕錄影與影片剪輯軟體" }, "home.meta.description": { - "message": "OpenScreen 是一款免費、開源的螢幕錄影與影片剪輯軟體,適用於 Windows、macOS 與 Linux 電腦:使用系統原生的螢幕擷取,字幕直接在你的裝置上產生,匯出的影片也沒有浮水印。" + "message": "把螢幕錄製變成精美的展示影片:自動縮放、自訂背景、可編輯游標。適用於 Windows、macOS 和 Linux,免費開源,無浮水印。" }, "home.hero.badge.new": { "message": "最新" }, "home.hero.badge.text": { "message": "1.11 在 macOS 與 Linux 上匯出更快", - "description": "Links to an English-only blog post. Must fit on one line on a 375px phone." - }, - "home.hero.titleTagline": { - "message": "免費開源的螢幕錄影與影片剪輯軟體" + "description": "Links to an English-only blog post." }, "home.hero.tagline": { - "message": "原生擷取、本機 AI、沒有付費牆的螢幕錄影。" + "message": "自動縮放、流暢游標和精美外框。錄製、調整、分享。" }, "home.hero.download": { "message": "下載" }, - "home.hero.readDocs": { - "message": "閱讀文件" - }, - "home.hero.scrollHint": { - "message": "向下捲動" - }, "home.features.kicker": { "message": "此外" }, @@ -1060,5 +1051,20 @@ "demoLoop.title.ask-the-agent": { "message": "請編輯器的代理完成修改", "description": "The name of a short silent video of the app, given to search engines (schema.org VideoObject)." + }, + "home.hero.title": { + "message": "錄製螢幕。影片已呈現精緻效果。" + }, + "home.hero.reassurance": { + "message": "免費開源。無浮水印。無須帳號。" + }, + "home.hero.explore": { + "message": "捲動以探索編輯器" + }, + "recreation.pause": { + "message": "暫停動畫" + }, + "recreation.play": { + "message": "播放動畫" } } diff --git a/website/src/components/Editor/index.tsx b/website/src/components/Editor/index.tsx index 516410d7d..19740d6d5 100644 --- a/website/src/components/Editor/index.tsx +++ b/website/src/components/Editor/index.tsx @@ -1,5 +1,6 @@ /** - * The page's centre of gravity: nine viewports of editor, and nothing above it. + * The page's centre of gravity: a live preview that docks into nine viewports + * of editor as the reader scrolls. * * It sits immediately under the hero because it is the argument. Everything * after it is either a claim the editor cannot make on its own (the recorder, diff --git a/website/src/components/Editor/styles.module.css b/website/src/components/Editor/styles.module.css index 2e4213aec..b2288eb3a 100644 --- a/website/src/components/Editor/styles.module.css +++ b/website/src/components/Editor/styles.module.css @@ -11,10 +11,8 @@ viewport — so a section without a background sits on the wrong grey. */ background: var(--os-bg); color: var(--os-fg); - /* Down from 104px, which was the air a section heading needed. With the - heading gone the hero should hand over to the editor the way the design - has it: the demo begins as the hero leaves. */ - padding: 24px 0 8px; + /* The full-width live preview begins directly beneath the promise. */ + padding: 0 0 8px; } .head { diff --git a/website/src/components/Recreation/ART-DIRECTION.md b/website/src/components/Recreation/ART-DIRECTION.md index 18a9b3a1c..409ed1832 100644 --- a/website/src/components/Recreation/ART-DIRECTION.md +++ b/website/src/components/Recreation/ART-DIRECTION.md @@ -5,6 +5,15 @@ that drew the whole editor at 1:1 in a 1920px scene and flew a camera over it; this note records what was wrong with that and what the rules are now, so the next pass does not rediscover either. +The picture now starts as the hero's full-width, real-time loop. As soon as +the reader scrolls, 0.28 viewport of scroll docks it into the editor and +rewinds the displayed scene time to zero. The five beats then run as before. +Scrolling back reverses the same movement and resumes the loop. The pause +button controls the preview; reduced motion gets a still and stacked captions. +The initial response eases out, with a short catch-up for wheel increments. +The picture moves by translation and scale rather than reflowing its DOM at +every intermediate width. + ## What the previous cut got wrong 1. **The scene was bigger than the screen.** Authored at 1920px and shown in a @@ -36,10 +45,10 @@ the right, the floor at the bottom in act two. The composite gets a hairline and a seated shadow, not a glow — a halo reads as a selection state on something that is not selected. -**One clock.** Scroll position becomes scene seconds, scene seconds become -document seconds in act two, and the playhead, the pill under it, the composite's +**One score.** The preview clock, then scroll position, becomes scene seconds; +scene seconds become document seconds in act two, and the playhead, the pill under it, the composite's magnification and the transcript's cue are four readings of that one number. No -media element, no second timebase, nothing that can drift. +independent playback clock for the picture or its pointers, nothing that can drift. **The beats touch.** A gap between beats is a stretch with no caption, no panel and no palette — which looks like breathing room on paper and like the left half @@ -100,7 +109,9 @@ exactly what it did for as long as lint-staged was formatting it. ## Verifying -The in-app browser pane returns black frames for this page. Drive headless -Chrome over CDP instead, scroll to a scene time, and look at the result — every -defect in the list at the top of this file was visible in a screenshot and +Check the preview, the midpoint of docking, and each of the five beats in a +browser, at desktop and portrait sizes. Stop scrolling during docking: the +picture should stay still. Scroll back: it should widen, undo the rewind and +resume playing. Also check pause, reduced motion and the static no-JavaScript +fallback. Every defect in the list above was visible in a screenshot and invisible in the source. diff --git a/website/src/components/Recreation/driver.ts b/website/src/components/Recreation/driver.ts index b356bebd2..7e994bba3 100644 --- a/website/src/components/Recreation/driver.ts +++ b/website/src/components/Recreation/driver.ts @@ -1,5 +1,5 @@ /** - * Scroll in, custom properties out — plus one video seek and a handful of class + * Preview clock or scroll in, custom properties out — plus one video seek and a handful of class * changes. This is the only thing that runs per frame. * * Three rules keep it cheap. @@ -23,6 +23,7 @@ */ import { CURSORS } from "./generated"; +import { createPlayback, DOCK_VIEWPORTS, followDock } from "./playback"; import { BEATS, CUT_INDEX, @@ -41,6 +42,7 @@ export interface DriverRefs { cam: HTMLVideoElement; padValue: HTMLElement; flow: HTMLElement; + pause: HTMLButtonElement; } export interface DriverClasses { @@ -99,6 +101,9 @@ const WRITTEN = [ "--wand", "--comment", "--k", + "--dock", + "--approach", + "--card-scale", ]; /** Piecewise-linear read of `[[t, ...values]]`, clamped at both ends. */ @@ -154,6 +159,9 @@ function release(refs: DriverRefs, cls: DriverClasses): void { for (const name of WRITTEN) root.style.removeProperty(name); delete root.dataset.beat; delete root.dataset.cur; + delete root.dataset.phase; + delete refs.band.dataset.driven; + refs.band.style.removeProperty("--hero-offset"); root.dataset.bg = String(frameAt(1).bg); for (const el of Array.from(root.querySelectorAll("[data-trim]"))) { el.style.removeProperty("opacity"); @@ -171,8 +179,20 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { }; } - const { band, root, cam, padValue, flow } = refs; + const { band, root, cam, padValue, flow, pause } = refs; + const hero = document.querySelector("[data-home-hero]"); + const card = root.querySelector("[data-composite]")!; let raf = 0; + const sample = createPlayback(T_TOTAL); + let paused = false; + let visible = false; + let offset = 0; + let span = 1; + let dockDistance = window.innerHeight * DOCK_VIEWPORTS; + let heroTop = 0; + let previewScale = 1; + let smoothOffset = 0; + let lastTick: number | undefined; /* ── the target cache ─────────────────────────────────────────────────── */ @@ -185,6 +205,8 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { * a `transform`, a `height` or an `inset`, it belongs here. */ const GEOMETRY = [ + { css: "--dock", of: () => 1 }, + { css: "--card-scale", of: () => 1 }, { css: "--tl", of: (f: Frame) => f.tl }, { css: "--panel", of: (f: Frame) => f.panel }, { css: "--palette", of: (f: Frame) => f.palette }, @@ -377,6 +399,12 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { // for every beat, including the three that play in the opening one. const f = frameAt((b.from + b.to) / 2 / T_TOTAL); for (const g of GEOMETRY) root.style.setProperty(g.css, g.of(f).toFixed(3)); + if (b.id === "style") { + const heroWidth = + window.innerWidth * (window.innerWidth > 900 ? 0.91 : 1) - + (window.innerWidth > 900 ? 0 : 36); + previewScale = heroWidth / card.getBoundingClientRect().width; + } measureVisible(claimed); } if (had === undefined) delete root.dataset.beat; @@ -480,12 +508,6 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { let camPending: number | undefined; const camSrc = "/video/webcam.mp4"; - // Set at attach, not in the markup and not in primeCam: in the markup every - // reader who never reaches the band pays for it, and in primeCam it would - // race the clip it exists to stand in for. Here it has the whole approach to - // the band to arrive — and on a phone, where the scene does run (the gate is - // 360px), it is still only fetched by a reader who scrolls into it. - cam.poster = "/img/walkthrough/webcam-poster.jpg"; const primeCam = () => { if (cam.getAttribute("src")) return; cam.setAttribute("src", camSrc); @@ -556,7 +578,14 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { const num = (n: string, v: number, dp = 4) => root.style.setProperty(n, v.toFixed(dp)); - const apply = (f: Frame) => { + const apply = (f: Frame, dock: number, phase: string) => { + num("--dock", dock); + num("--card-scale", previewScale + (1 - previewScale) * dock); + if (hero) { + hero.style.opacity = String((1 - dock) ** 2); + hero.inert = dock === 1; + } + root.dataset.phase = phase; num("--t", f.t, 3); num("--tf", f.tf, 3); // Three decimals, not zero. This was written at `toFixed(0)` from when --tl @@ -614,7 +643,7 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { const [ux, uy] = kf(Math.min(f.t, 22.36), path(f)); num("--ui-x", ux, 2); num("--ui-y", uy, 2); - num("--ui-on", f.t > 1.1 && f.t < 22.36 ? 1 : 0, 0); + num("--ui-on", phase === "editor" && f.t > 1.1 && f.t < 22.36 ? 1 : 0, 0); if (f.beat !== lastBeat) { lastBeat = f.beat; @@ -641,24 +670,79 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { /* ── the scroll ───────────────────────────────────────────────────────── */ + // Scroll geometry is cached by events. The autonomous preview reads only a + // clock, so its animation never measures the page per frame. + const readScroll = () => { + let rect = band.getBoundingClientRect(); + const top = rect.top + window.scrollY; + if (top !== heroTop) { + heroTop = top; + band.style.setProperty("--hero-offset", `${heroTop}px`); + rect = band.getBoundingClientRect(); + } + span = heroTop + rect.height + window.innerHeight * 0.04; + dockDistance = window.innerHeight * DOCK_VIEWPORTS; + offset = Math.min(span, Math.max(0, window.scrollY)); + root.style.setProperty("--approach", `${Math.max(0, rect.top)}px`); + }; + const tick = (now: number) => { + raf = 0; + const target = Math.min(offset, dockDistance); + smoothOffset = + lastTick === undefined ? target : followDock(smoothOffset, target, now - lastTick); + lastTick = now; + const following = smoothOffset !== target; + const sceneOffset = smoothOffset === dockDistance ? offset : smoothOffset; + const playing = visible && !paused && !document.hidden; + const { time, dock, phase } = sample(now, sceneOffset, span, dockDistance, playing); + const scored = frameAt(time / T_TOTAL); + // The opening loop shows the picture alone. The first inspector is + // revealed while the picture rewinds, before the scroll score begins. + const frame = + phase === "editor" + ? scored + : { ...scored, tl: 0, beat: "style" as const, panel: 1, intro: 1, palette: 0 }; + apply(frame, dock, phase); + if (visible) { + primeCam(); + primeStrip(); + } + if (!document.hidden && (following || (phase === "preview" && playing))) { + raf = requestAnimationFrame(tick); + } + }; + const schedule = () => { + if (!raf) { + if (lastTick !== undefined) lastTick = performance.now() - 16; + raf = requestAnimationFrame(tick); + } + }; const onScroll = () => { - if (raf) return; - raf = requestAnimationFrame(() => { - raf = 0; - const rect = band.getBoundingClientRect(); - const total = rect.height - window.innerHeight; - // The ride overflows the sticky on purpose: the last stretch plays while - // the section is already scrolling away, so the editor is not still - // sitting pinned and finished for a whole viewport. - const span = total + window.innerHeight * 1.04; - const off = Math.min(span, Math.max(0, -rect.top)); - if (off > 0 && off < span) { - primeCam(); - primeStrip(); - } - apply(frameAt(span > 0 ? off / span : 0)); - }); + readScroll(); + schedule(); + }; + const onPause = () => { + paused = !paused; + pause.dataset.paused = String(paused); + pause.setAttribute("aria-pressed", String(paused)); + pause.setAttribute("aria-label", (paused ? pause.dataset.play : pause.dataset.pause)!); + schedule(); + }; + const onVisibility = () => { + if (raf) cancelAnimationFrame(raf); + raf = 0; + // Browsers suspend rAF in background tabs. Record the pause now so + // returning to this tab resumes instead of jumping ahead by minutes. + tick(performance.now()); }; + const observer = new IntersectionObserver( + (entries) => { + visible = entries[0].isIntersecting; + schedule(); + }, + { threshold: 0 }, + ); + observer.observe(root); // Coalesced into a frame, like the scroll. `measure()` opens all five beats, // reads a rect for every `data-t` node and forces layout to do it; a window @@ -677,6 +761,7 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { let detached = false; + band.dataset.driven = ""; measure(); // Targets inside a closed pane cannot be measured until it opens, and the // panes open on scroll — so re-measure once the fonts have settled, which is @@ -688,19 +773,27 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { // for a driver that has since been thrown away. document.fonts?.ready .then(() => { - if (!detached) measure(); + if (!detached) { + measure(); + onScroll(); + } }) .catch(() => { // A font that never resolves leaves the first measurement standing. }); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onResize); + document.addEventListener("visibilitychange", onVisibility); + pause.addEventListener("click", onPause); onScroll(); return () => { detached = true; window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onResize); + document.removeEventListener("visibilitychange", onVisibility); + pause.removeEventListener("click", onPause); + observer.disconnect(); // The video outlives the driver — it is the same element on re-attach — // so listeners left on it accumulate one pair per breakpoint crossing, // each holding a dead driver's closure alive. @@ -708,6 +801,14 @@ export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { cam.removeEventListener("seeked", onCamSeeked); if (raf) cancelAnimationFrame(raf); if (resizeRaf) cancelAnimationFrame(resizeRaf); + pause.dataset.paused = "false"; + pause.setAttribute("aria-pressed", "false"); + pause.setAttribute("aria-label", pause.dataset.pause!); + if (hero) { + hero.style.removeProperty("opacity"); + hero.inert = false; + } + release(refs, cls); }; } diff --git a/website/src/components/Recreation/index.tsx b/website/src/components/Recreation/index.tsx index 40094a65b..961fb9e81 100644 --- a/website/src/components/Recreation/index.tsx +++ b/website/src/components/Recreation/index.tsx @@ -21,14 +21,15 @@ * * ── ONE CLOCK, TWO TIMEBASES ───────────────────────────────────────────────── * - * The scroll is the only input. `scene.ts` turns scroll position into a `Frame` + * The preview first plays on a clock, then rewinds into the scroll-driven + * editor. `scene.ts` turns its progress into a `Frame` * carrying both the scene clock and the footage clock — which differ, because a * speed ramp is in the middle of the take. `driver.ts` writes that frame to * custom properties on one element and seeks one video. React renders once. * * ── ACCESSIBILITY ──────────────────────────────────────────────────────────── * - * No focusable node. The app's controls are real buttons and sliders; recreated + * No focusable mock controls. The app's controls are real buttons and sliders; recreated * as controls they become tab stops announcing actions this page will never * perform. Every swatch, slider and pill here is a span. * @@ -39,11 +40,22 @@ */ import { translate } from "@docusaurus/Translate"; -import { Clock, Crosshair, MessageSquare, Scissors, Wand2, ZoomIn } from "lucide-react"; +import { + ArrowDown, + Clock, + Crosshair, + MessageSquare, + Pause, + Play, + Scissors, + Wand2, + ZoomIn, +} from "lucide-react"; import { useEffect, useRef } from "react"; import { attachDriver, SCENE_QUERIES } from "./driver"; import { CONTROLS, CURSORS, INSPECTOR, PANELS, WAVEFORM } from "./generated"; +import { DOCK_VIEWPORTS } from "./playback"; import { BEATS, type BeatId, @@ -209,6 +221,7 @@ export default function Recreation() { const cam = useRef(null); const padValue = useRef(null); const flow = useRef(null); + const pause = useRef(null); useEffect(() => { const refs = { @@ -217,6 +230,7 @@ export default function Recreation() { cam: cam.current, padValue: padValue.current, flow: flow.current, + pause: pause.current, }; if (Object.values(refs).some((el) => el === null)) return; const classes = { struck: styles.struck }; @@ -240,9 +254,17 @@ export default function Recreation() { const placed = trims(0); const copy = beatCopy(); + const pauseLabel = translate({ id: "recreation.pause", message: "Pause animation" }); + const playLabel = translate({ id: "recreation.play", message: "Play animation" }); return ( -
+
+ {/* Three resting values that cannot live in the stylesheet, all read off the closing frame rather than typed: @@ -269,6 +291,25 @@ export default function Recreation() { } as React.CSSProperties } > + {/* ═══ THE LEFT COLUMN ═══ The caption and the inspector, in one flow so the pair can be balanced against the picture as a unit — nothing can align two boxes that are positioned absolutely and @@ -491,7 +532,7 @@ export default function Recreation() {

so the fiction adds no heading to this page's outline. */}