feat(site): add a hero to the browse page (#62487)
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -26,6 +26,8 @@ type Extension = {
|
||||
product?: boolean
|
||||
/** For the page's structured data; every OS when left out. */
|
||||
operatingSystem?: string
|
||||
/** A hero still per theme (videos.ts heroStills); the hero stays black when left out. */
|
||||
hero?: boolean
|
||||
}
|
||||
|
||||
export const extensionPages: readonly Extension[] = [
|
||||
@@ -54,6 +56,7 @@ export const extensionPages: readonly Extension[] = [
|
||||
},
|
||||
store: "https://chromewebstore.google.com/detail/mfnicocicmmlkpjnaffgihfjhdgjkdjg",
|
||||
product: true,
|
||||
hero: true,
|
||||
source: "https://github.com/anomalyco/browse",
|
||||
},
|
||||
// {
|
||||
|
||||
@@ -13,10 +13,12 @@ import { extensionsSource } from "~/i18n/content/areas/extensions"
|
||||
import { ExtensionDownload } from "./download-sections"
|
||||
import { extensionPages, type ExtensionSlug } from "./extension-data"
|
||||
import { NewTab } from "./icons"
|
||||
import { ProductHero } from "./product-hero"
|
||||
import { MoreFromAnomaly } from "./product-list"
|
||||
import { siteSettings } from "./settings"
|
||||
import { Site } from "./site"
|
||||
import { useMarkdown } from "./use-markdown"
|
||||
import { heroStills } from "./videos"
|
||||
import { inlineKey } from "~/lib/direction"
|
||||
|
||||
/**
|
||||
@@ -108,8 +110,8 @@ export function ExtensionPage(props: { slug: ExtensionSlug; path?: string }) {
|
||||
</Show>
|
||||
|
||||
<div data-slot="first-screen">
|
||||
{/* Plain black until there is real footage of the product. */}
|
||||
<div data-component="product-hero" data-product={props.slug} data-empty />
|
||||
{/* Plain black until the extension has a hero. */}
|
||||
<ProductHero slug={props.slug} media={data().hero ? heroStills(props.slug) : undefined} />
|
||||
|
||||
<section data-component="product-intro" data-section="intro">
|
||||
<div data-slot="copy">
|
||||
|
||||
@@ -918,6 +918,26 @@
|
||||
[1200, "ce830aa0"]
|
||||
]
|
||||
},
|
||||
"/product-videos/heroes/browse-landscape-dark.webp": {
|
||||
"avif": [
|
||||
[1280, "431eacac"],
|
||||
[1920, "d34997ae"]
|
||||
],
|
||||
"webp": [
|
||||
[1280, "573fc3b7"],
|
||||
[1920, "d861eb55"]
|
||||
]
|
||||
},
|
||||
"/product-videos/heroes/browse-landscape-light.webp": {
|
||||
"avif": [
|
||||
[1280, "6ad5a4af"],
|
||||
[1920, "2cb0c207"]
|
||||
],
|
||||
"webp": [
|
||||
[1280, "2e5a8cca"],
|
||||
[1920, "83c6b047"]
|
||||
]
|
||||
},
|
||||
"/product-videos/heroes/desktop-landscape-dark.webp": {
|
||||
"avif": [
|
||||
[1280, "70657b3a"],
|
||||
|
||||
@@ -138,6 +138,8 @@
|
||||
"/product-videos/features/terminal-3.mp4": "7a37e3671939856f",
|
||||
"/product-videos/features/terminal-4.jpg": "22e717f41c6ea257",
|
||||
"/product-videos/features/terminal-4.mp4": "4b84130f721d7caa",
|
||||
"/product-videos/heroes/browse-landscape-dark.webp": "ac1199d4a4e706e3",
|
||||
"/product-videos/heroes/browse-landscape-light.webp": "a38020f75d521615",
|
||||
"/product-videos/heroes/desktop-landscape-dark.mp4": "77ec07701c0a2ede",
|
||||
"/product-videos/heroes/desktop-landscape-dark.webp": "5605951336841e16",
|
||||
"/product-videos/heroes/desktop-landscape-light.mp4": "2777797089a72f45",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Link } from "@solidjs/meta"
|
||||
import { createEffect, createSignal, Index, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { imageSources, posterFile, stillSizes, videoSources } from "./media"
|
||||
import { prefersReducedMotion, rescueMedia } from "./products"
|
||||
import { pageScheme } from "./videos"
|
||||
|
||||
type HeroFile = { src?: string; poster: string }
|
||||
|
||||
/** A hero's files: a video and its poster, one of each per theme, or only the posters for a still. */
|
||||
export type HeroMedia = HeroFile & { themes?: { light: HeroFile; dark: HeroFile } }
|
||||
|
||||
/**
|
||||
* The hero at the top of a product page (product-page.tsx) and of Browse's page (extension-page.tsx):
|
||||
* the poster, then the video over it once it can play. Black when there is no media.
|
||||
*/
|
||||
export function ProductHero(props: { slug: string; media: HeroMedia | undefined; design?: string }) {
|
||||
// The page's colour scheme for the hero's themed files: read while hydrating, so the files match
|
||||
// what the hero's inline script picked, then kept in step with the theme and the system setting.
|
||||
const [scheme, setScheme] = createSignal(pageScheme())
|
||||
onMount(() => {
|
||||
const sync = () => setScheme(pageScheme())
|
||||
const system = matchMedia("(prefers-color-scheme: dark)")
|
||||
const observer = new MutationObserver(sync)
|
||||
const root = document.querySelector("[data-site]")
|
||||
if (root) observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] })
|
||||
system.addEventListener("change", sync)
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
system.removeEventListener("change", sync)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="product-hero"
|
||||
// Dark product footage, for the fixed marks that scroll over it (surface.ts).
|
||||
data-surface="dark"
|
||||
data-design={props.design}
|
||||
data-product={props.slug}
|
||||
data-empty={props.media ? undefined : ""}
|
||||
>
|
||||
{/* Only the chosen video set; until it loads the hero shows its backdrop colour. */}
|
||||
<Show when={props.media}>
|
||||
{(media) => {
|
||||
// A hero with a file per theme (videos.ts) leaves its video file out of the server's
|
||||
// markup: the script after it picks the theme's before first paint, so only that file
|
||||
// is fetched. Hydration keeps the server's attributes; theme changes update them
|
||||
// (scheme() reads the same theme).
|
||||
const file = () => {
|
||||
const themes = media().themes
|
||||
if (!themes) return { src: media().src, poster: media().poster }
|
||||
const current = scheme()
|
||||
return current ? themes[current] : undefined
|
||||
}
|
||||
// The poster's AVIF and WebP copies (media.tsx): the theme's, and each theme's for the script.
|
||||
const poster = () => {
|
||||
const current = file()
|
||||
return current ? imageSources(current.poster) : undefined
|
||||
}
|
||||
const themed = (theme: "light" | "dark") => {
|
||||
const themes = media().themes
|
||||
return themes ? imageSources(themes[theme].poster) : undefined
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* The hero's poster is the page's largest paint. A themed one is preloaded for the
|
||||
system's scheme, so the browser finds it in the head instead of waiting for the
|
||||
script below to name it (a visitor whose chosen theme differs from the system's
|
||||
also fetches the other, about 10 KB). */}
|
||||
<Show
|
||||
when={media().themes}
|
||||
fallback={<Link rel="preload" as="image" {...posterPreload(media().poster)} fetchpriority="high" />}
|
||||
>
|
||||
{(themes) => (
|
||||
<>
|
||||
<Link
|
||||
rel="preload"
|
||||
as="image"
|
||||
{...posterPreload(themes().light.poster)}
|
||||
media="(prefers-color-scheme: light)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<Link
|
||||
rel="preload"
|
||||
as="image"
|
||||
{...posterPreload(themes().dark.poster)}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
{/* Shown as soon as it loads (Site's pre-paint script marks it, before the page's
|
||||
code runs); the video fades in over it once it can play. AVIF then WebP
|
||||
(media.tsx), right-sized for the hero. A themed poster is in the server's markup
|
||||
for the system's scheme (the dark sources match a dark system, the light ones
|
||||
the rest), so the browser finds and loads the right file with no script; the
|
||||
theme script swaps them when the site's chosen theme differs from the
|
||||
system's. Once hydrated, every source follows the page's theme. */}
|
||||
<picture data-still>
|
||||
<Show when={media().themes && themed("dark")}>
|
||||
{(dark) => (
|
||||
<>
|
||||
<Show when={dark().avif}>
|
||||
<source
|
||||
type="image/avif"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset={poster()?.avif ?? dark().avif}
|
||||
data-light-srcset={themed("light")?.avif}
|
||||
data-dark-srcset={dark().avif}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</Show>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset={poster()?.webp ?? poster()?.src ?? dark().webp ?? dark().src}
|
||||
data-light-srcset={themed("light")?.webp ?? themed("light")?.src}
|
||||
data-dark-srcset={dark().webp ?? dark().src}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={imageSources(media().themes?.light.poster ?? media().poster).avif}>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset={poster()?.avif ?? themed("light")?.avif}
|
||||
data-light-srcset={themed("light")?.avif}
|
||||
data-dark-srcset={themed("dark")?.avif}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</Show>
|
||||
<img
|
||||
ref={(img) => img.complete && img.naturalWidth > 0 && img.setAttribute("data-loaded", "")}
|
||||
data-poster
|
||||
src={poster()?.src ?? themed("light")?.src}
|
||||
srcset={poster()?.webp ?? themed("light")?.webp}
|
||||
sizes={stillSizes.hero}
|
||||
data-light={themed("light")?.src}
|
||||
data-dark={themed("dark")?.src}
|
||||
data-light-srcset={themed("light")?.webp}
|
||||
data-dark-srcset={themed("dark")?.webp}
|
||||
width="1920"
|
||||
height={media().themes ? "1200" : "1080"}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchpriority="high"
|
||||
onLoad={(event) => event.currentTarget.setAttribute("data-loaded", "")}
|
||||
/>
|
||||
</picture>
|
||||
<Show when={media().src}>
|
||||
{/* A themed video's poster stays out of the server's markup: a poster attribute
|
||||
can't follow the system's scheme, so it would load the light file on a dark
|
||||
system too. The theme script names it, and it is hidden until the video
|
||||
plays anyway. */}
|
||||
<HeroVideo src={file()?.src} poster={file()?.poster} themes={media().themes} />
|
||||
</Show>
|
||||
<Show when={media().themes}>
|
||||
<script innerHTML={heroThemeScript} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A hero poster's preload: its AVIF copies for the hero's size (media.tsx), or the file itself. */
|
||||
function posterPreload(poster: string) {
|
||||
const sources = imageSources(poster)
|
||||
return sources.avif
|
||||
? { imagesrcset: sources.avif, imagesizes: stillSizes.hero, type: "image/avif" }
|
||||
: { href: poster }
|
||||
}
|
||||
|
||||
// Runs as soon as the themed hero is parsed, before first paint. Points the video's sources and
|
||||
// poster at the page's theme (the site root's data-theme, else the system's), the same choice
|
||||
// pageScheme() makes on the client. The poster's picture already shows the system's theme, so only
|
||||
// when the chosen theme differs does it point the picture's sources and img at the chosen one.
|
||||
const heroThemeScript = `(()=>{const box=document.currentScript.parentElement;const t=box.closest("[data-site]")?.dataset.theme;const s=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";const k=t==="light"||t==="dark"?t:s;if(k!==s)box.querySelectorAll("picture [data-light-srcset]").forEach((e)=>{const v=e.dataset[k+"Srcset"];if(v)e.setAttribute("srcset",v);if(e.dataset[k])e.setAttribute("src",e.dataset[k])});box.querySelectorAll("video[data-light-poster]").forEach((v)=>{v.setAttribute("poster",v.dataset[k+"Poster"]);v.querySelectorAll("source[data-light]").forEach((e)=>e.setAttribute("src",e.dataset[k]));v.load()})})()`
|
||||
|
||||
/**
|
||||
* The product hero's video: muted, looping, playing while the hero is on screen and the tab is
|
||||
* visible, from a direct load or after a card grows into it. With reduced motion it rests on its
|
||||
* first frame and a button plays it.
|
||||
*/
|
||||
function HeroVideo(props: { src?: string; poster?: string; themes?: { light: HeroFile; dark: HeroFile } }) {
|
||||
const i18n = useI18n()
|
||||
const [reduced, setReduced] = createSignal(false)
|
||||
const [running, setRunning] = createSignal(false)
|
||||
// With reduced motion the video plays only after the button is pressed, and stops when pressed again.
|
||||
const [wanted, setWanted] = createSignal(false)
|
||||
let video!: HTMLVideoElement
|
||||
let visible = false
|
||||
|
||||
const sync = () => {
|
||||
if (visible && !document.hidden && (!reduced() || wanted())) return void video.play().catch(() => {})
|
||||
video.pause()
|
||||
}
|
||||
|
||||
// The sources (media.tsx), each with both themes' files for the theme script; a themed hero's are
|
||||
// unnamed in the server's markup (props.src is unset until the client knows the theme).
|
||||
const sources = () => {
|
||||
const light = props.themes?.light.src ? videoSources(props.themes.light.src) : undefined
|
||||
const dark = props.themes?.dark.src ? videoSources(props.themes.dark.src) : undefined
|
||||
const current = props.src ? videoSources(props.src) : undefined
|
||||
return (current ?? light ?? []).map((source, index) => ({
|
||||
...source,
|
||||
src: current?.[index]?.src,
|
||||
light: light?.[index]?.src,
|
||||
dark: dark?.[index]?.src,
|
||||
}))
|
||||
}
|
||||
// A theme change names other files: load again to pick among them.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.src,
|
||||
() => video.load(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
setReduced(prefersReducedMotion())
|
||||
rescueMedia(video)
|
||||
// The server-rendered video may have loaded before hydration attached onLoadedData.
|
||||
if (video.readyState >= 2) video.setAttribute("data-loaded", "")
|
||||
const view = new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting
|
||||
sync()
|
||||
})
|
||||
view.observe(video)
|
||||
document.addEventListener("visibilitychange", sync)
|
||||
onCleanup(() => {
|
||||
view.disconnect()
|
||||
document.removeEventListener("visibilitychange", sync)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={video}
|
||||
poster={props.poster && posterFile(props.poster)}
|
||||
data-light-poster={props.themes && posterFile(props.themes.light.poster)}
|
||||
data-dark-poster={props.themes && posterFile(props.themes.dark.poster)}
|
||||
data-set
|
||||
aria-hidden="true"
|
||||
// Also after a theme change swaps the file: it plays again once the new one can.
|
||||
onLoadedData={(event) => {
|
||||
event.currentTarget.setAttribute("data-loaded", "")
|
||||
sync()
|
||||
}}
|
||||
onPlay={() => setRunning(true)}
|
||||
onPause={() => setRunning(false)}
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
>
|
||||
<Index each={sources()}>
|
||||
{(source) => (
|
||||
<source
|
||||
src={source().src}
|
||||
type={source().type}
|
||||
media={source().media}
|
||||
data-light={source().light}
|
||||
data-dark={source().dark}
|
||||
/>
|
||||
)}
|
||||
</Index>
|
||||
</video>
|
||||
<Show when={reduced()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="play"
|
||||
aria-label={running() ? i18n.t("changelog.media.pause") : i18n.t("changelog.media.play")}
|
||||
onClick={() => {
|
||||
setWanted(!running())
|
||||
sync()
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<Show when={running()} fallback={<path d="M3 1.5v9l7.5-4.5z" />}>
|
||||
<path d="M2.5 1.5h2.5v9H2.5zM7 1.5h2.5v9H7z" />
|
||||
</Show>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./product-page.css"
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { createAsync, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createSignal, For, Index, lazy, on, onCleanup, onMount, Show, Suspense } from "solid-js"
|
||||
import { For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import { ShareMeta } from "~/component/share-meta"
|
||||
import { ogImage } from "~/lib/og"
|
||||
@@ -18,12 +18,13 @@ import { Companions } from "./companions"
|
||||
import { moreProducts, productAreas, productDetails, type PageSlug } from "./product-data"
|
||||
import { orderedProducts } from "./products-order"
|
||||
import { designCopy, designEditing } from "./design-copy"
|
||||
import { CardVideo, InstallTabs, prefersReducedMotion, ProductAction, rescueMedia, useCardShrink } from "./products"
|
||||
import { imageSources, posterFile, Still, stillSizes, videoSources } from "./media"
|
||||
import { CardVideo, InstallTabs, ProductAction, useCardShrink } from "./products"
|
||||
import { Still, stillSizes } from "./media"
|
||||
import { LogoIcon } from "./marks"
|
||||
import { ProductDownload } from "./download-sections"
|
||||
import { ProductHero } from "./product-hero"
|
||||
import { siteSettings } from "./settings"
|
||||
import { featureClip, pageMedia, pageScheme } from "./videos"
|
||||
import { featureClip, pageMedia } from "./videos"
|
||||
import { Site } from "./site"
|
||||
import { useMarkdown } from "./use-markdown"
|
||||
import { inlineKey } from "~/lib/direction"
|
||||
@@ -52,22 +53,6 @@ export function ProductPage(props: { slug: PageSlug }) {
|
||||
return { slug, name: productCopy()[slug].name }
|
||||
}
|
||||
|
||||
// The page's colour scheme for the hero's themed files: read while hydrating, so the files match
|
||||
// what the hero's inline script picked, then kept in step with the theme and the system setting.
|
||||
const [scheme, setScheme] = createSignal(pageScheme())
|
||||
onMount(() => {
|
||||
const sync = () => setScheme(pageScheme())
|
||||
const system = matchMedia("(prefers-color-scheme: dark)")
|
||||
const observer = new MutationObserver(sync)
|
||||
const root = document.querySelector("[data-site]")
|
||||
if (root) observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] })
|
||||
system.addEventListener("change", sync)
|
||||
onCleanup(() => {
|
||||
observer.disconnect()
|
||||
system.removeEventListener("change", sync)
|
||||
})
|
||||
})
|
||||
|
||||
// The left and right arrow keys step through the products, unless focus is somewhere that
|
||||
// uses them itself (fields, tabs, the carousel, open menus).
|
||||
onMount(() => {
|
||||
@@ -173,138 +158,11 @@ export function ProductPage(props: { slug: PageSlug }) {
|
||||
</nav>
|
||||
|
||||
<div data-slot="first-screen">
|
||||
<div
|
||||
data-component="product-hero"
|
||||
// Dark product footage, for the fixed marks that scroll over it (surface.ts).
|
||||
data-surface="dark"
|
||||
data-design="productVideos"
|
||||
data-product={props.slug}
|
||||
data-empty={pageMedia(siteSettings.productVideos, props.slug, "landscape") ? undefined : ""}
|
||||
>
|
||||
{/* Only the chosen video set; until it loads the hero shows its backdrop colour. */}
|
||||
<Show when={pageMedia(siteSettings.productVideos, props.slug, "landscape")}>
|
||||
{(media) => {
|
||||
// A hero with a file per theme (videos.ts) leaves its video file out of the server's
|
||||
// markup: the script after it picks the theme's before first paint, so only that file
|
||||
// is fetched. Hydration keeps the server's attributes; theme changes update them
|
||||
// (scheme() reads the same theme).
|
||||
const file = () => {
|
||||
const themes = media().themes
|
||||
if (!themes) return { src: media().src, poster: media().poster }
|
||||
const current = scheme()
|
||||
return current ? themes[current] : undefined
|
||||
}
|
||||
// The poster's AVIF and WebP copies (media.tsx): the theme's, and each theme's for the script.
|
||||
const poster = () => {
|
||||
const current = file()
|
||||
return current ? imageSources(current.poster) : undefined
|
||||
}
|
||||
const themed = (theme: "light" | "dark") => {
|
||||
const themes = media().themes
|
||||
return themes ? imageSources(themes[theme].poster) : undefined
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* The hero's poster is the page's largest paint. A themed one is preloaded for the
|
||||
system's scheme, so the browser finds it in the head instead of waiting for the
|
||||
script below to name it (a visitor whose chosen theme differs from the system's
|
||||
also fetches the other, about 10 KB). */}
|
||||
<Show
|
||||
when={media().themes}
|
||||
fallback={<Link rel="preload" as="image" {...posterPreload(media().poster)} fetchpriority="high" />}
|
||||
>
|
||||
{(themes) => (
|
||||
<>
|
||||
<Link
|
||||
rel="preload"
|
||||
as="image"
|
||||
{...posterPreload(themes().light.poster)}
|
||||
media="(prefers-color-scheme: light)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<Link
|
||||
rel="preload"
|
||||
as="image"
|
||||
{...posterPreload(themes().dark.poster)}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
{/* Shown as soon as it loads (Site's pre-paint script marks it, before the page's
|
||||
code runs); the video fades in over it once it can play. AVIF then WebP
|
||||
(media.tsx), right-sized for the hero. A themed poster is in the server's markup
|
||||
for the system's scheme (the dark sources match a dark system, the light ones
|
||||
the rest), so the browser finds and loads the right file with no script; the
|
||||
theme script swaps them when the site's chosen theme differs from the
|
||||
system's. Once hydrated, every source follows the page's theme. */}
|
||||
<picture data-still>
|
||||
<Show when={media().themes && themed("dark")}>
|
||||
{(dark) => (
|
||||
<>
|
||||
<Show when={dark().avif}>
|
||||
<source
|
||||
type="image/avif"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset={poster()?.avif ?? dark().avif}
|
||||
data-light-srcset={themed("light")?.avif}
|
||||
data-dark-srcset={dark().avif}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</Show>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset={poster()?.webp ?? poster()?.src ?? dark().webp ?? dark().src}
|
||||
data-light-srcset={themed("light")?.webp ?? themed("light")?.src}
|
||||
data-dark-srcset={dark().webp ?? dark().src}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={imageSources(media().themes?.light.poster ?? media().poster).avif}>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset={poster()?.avif ?? themed("light")?.avif}
|
||||
data-light-srcset={themed("light")?.avif}
|
||||
data-dark-srcset={themed("dark")?.avif}
|
||||
sizes={stillSizes.hero}
|
||||
/>
|
||||
</Show>
|
||||
<img
|
||||
ref={(img) => img.complete && img.naturalWidth > 0 && img.setAttribute("data-loaded", "")}
|
||||
data-poster
|
||||
src={poster()?.src ?? themed("light")?.src}
|
||||
srcset={poster()?.webp ?? themed("light")?.webp}
|
||||
sizes={stillSizes.hero}
|
||||
data-light={themed("light")?.src}
|
||||
data-dark={themed("dark")?.src}
|
||||
data-light-srcset={themed("light")?.webp}
|
||||
data-dark-srcset={themed("dark")?.webp}
|
||||
width="1920"
|
||||
height={media().themes ? "1200" : "1080"}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchpriority="high"
|
||||
onLoad={(event) => event.currentTarget.setAttribute("data-loaded", "")}
|
||||
/>
|
||||
</picture>
|
||||
<Show when={media().src}>
|
||||
{/* A themed video's poster stays out of the server's markup: a poster attribute
|
||||
can't follow the system's scheme, so it would load the light file on a dark
|
||||
system too. The theme script names it, and it is hidden until the video
|
||||
plays anyway. */}
|
||||
<HeroVideo src={file()?.src} poster={file()?.poster} themes={media().themes} />
|
||||
</Show>
|
||||
<Show when={media().themes}>
|
||||
<script innerHTML={heroThemeScript} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
<ProductHero
|
||||
slug={props.slug}
|
||||
media={pageMedia(siteSettings.productVideos, props.slug, "landscape")}
|
||||
design="productVideos"
|
||||
/>
|
||||
|
||||
<section
|
||||
data-component="product-intro"
|
||||
@@ -449,133 +307,3 @@ function SeeAll(props: { filter: string[] }) {
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** A hero poster's preload: its AVIF copies for the hero's size (media.tsx), or the file itself. */
|
||||
function posterPreload(poster: string) {
|
||||
const sources = imageSources(poster)
|
||||
return sources.avif
|
||||
? { imagesrcset: sources.avif, imagesizes: stillSizes.hero, type: "image/avif" }
|
||||
: { href: poster }
|
||||
}
|
||||
|
||||
/**
|
||||
* The product hero's video: muted, looping, playing while the hero is on screen and the tab is
|
||||
* visible, from a direct load or after a card grows into it. With reduced motion it rests on its
|
||||
* first frame and a button plays it.
|
||||
*/
|
||||
// Runs as soon as the themed hero is parsed, before first paint. Points the video's sources and
|
||||
// poster at the page's theme (the site root's data-theme, else the system's), the same choice
|
||||
// pageScheme() makes on the client. The poster's picture already shows the system's theme, so only
|
||||
// when the chosen theme differs does it point the picture's sources and img at the chosen one.
|
||||
const heroThemeScript = `(()=>{const box=document.currentScript.parentElement;const t=box.closest("[data-site]")?.dataset.theme;const s=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";const k=t==="light"||t==="dark"?t:s;if(k!==s)box.querySelectorAll("picture [data-light-srcset]").forEach((e)=>{const v=e.dataset[k+"Srcset"];if(v)e.setAttribute("srcset",v);if(e.dataset[k])e.setAttribute("src",e.dataset[k])});box.querySelectorAll("video[data-light-poster]").forEach((v)=>{v.setAttribute("poster",v.dataset[k+"Poster"]);v.querySelectorAll("source[data-light]").forEach((e)=>e.setAttribute("src",e.dataset[k]));v.load()})})()`
|
||||
|
||||
function HeroVideo(props: {
|
||||
src?: string
|
||||
poster?: string
|
||||
themes?: { light: { src: string; poster: string }; dark: { src: string; poster: string } }
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const [reduced, setReduced] = createSignal(false)
|
||||
const [running, setRunning] = createSignal(false)
|
||||
// With reduced motion the video plays only after the button is pressed, and stops when pressed again.
|
||||
const [wanted, setWanted] = createSignal(false)
|
||||
let video!: HTMLVideoElement
|
||||
let visible = false
|
||||
|
||||
const sync = () => {
|
||||
if (visible && !document.hidden && (!reduced() || wanted())) return void video.play().catch(() => {})
|
||||
video.pause()
|
||||
}
|
||||
|
||||
// The sources (media.tsx), each with both themes' files for the theme script; a themed hero's are
|
||||
// unnamed in the server's markup (props.src is unset until the client knows the theme).
|
||||
const sources = () => {
|
||||
const light = props.themes && videoSources(props.themes.light.src)
|
||||
const dark = props.themes && videoSources(props.themes.dark.src)
|
||||
const current = props.src ? videoSources(props.src) : undefined
|
||||
return (current ?? light ?? []).map((source, index) => ({
|
||||
...source,
|
||||
src: current?.[index]?.src,
|
||||
light: light?.[index]?.src,
|
||||
dark: dark?.[index]?.src,
|
||||
}))
|
||||
}
|
||||
// A theme change names other files: load again to pick among them.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.src,
|
||||
() => video.load(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
setReduced(prefersReducedMotion())
|
||||
rescueMedia(video)
|
||||
// The server-rendered video may have loaded before hydration attached onLoadedData.
|
||||
if (video.readyState >= 2) video.setAttribute("data-loaded", "")
|
||||
const view = new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting
|
||||
sync()
|
||||
})
|
||||
view.observe(video)
|
||||
document.addEventListener("visibilitychange", sync)
|
||||
onCleanup(() => {
|
||||
view.disconnect()
|
||||
document.removeEventListener("visibilitychange", sync)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={video}
|
||||
poster={props.poster && posterFile(props.poster)}
|
||||
data-light-poster={props.themes && posterFile(props.themes.light.poster)}
|
||||
data-dark-poster={props.themes && posterFile(props.themes.dark.poster)}
|
||||
data-set
|
||||
aria-hidden="true"
|
||||
// Also after a theme change swaps the file: it plays again once the new one can.
|
||||
onLoadedData={(event) => {
|
||||
event.currentTarget.setAttribute("data-loaded", "")
|
||||
sync()
|
||||
}}
|
||||
onPlay={() => setRunning(true)}
|
||||
onPause={() => setRunning(false)}
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
>
|
||||
<Index each={sources()}>
|
||||
{(source) => (
|
||||
<source
|
||||
src={source().src}
|
||||
type={source().type}
|
||||
media={source().media}
|
||||
data-light={source().light}
|
||||
data-dark={source().dark}
|
||||
/>
|
||||
)}
|
||||
</Index>
|
||||
</video>
|
||||
<Show when={reduced()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="play"
|
||||
aria-label={running() ? i18n.t("changelog.media.pause") : i18n.t("changelog.media.play")}
|
||||
onClick={() => {
|
||||
setWanted(!running())
|
||||
sync()
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<Show when={running()} fallback={<path d="M3 1.5v9l7.5-4.5z" />}>
|
||||
<path d="M2.5 1.5h2.5v9H2.5zM7 1.5h2.5v9H7z" />
|
||||
</Show>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,16 @@ function heroThemes(slug: PageSlug) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hero with a still per theme and no video yet, framed like the themed heroes above: Browse's
|
||||
* (product-videos/heroes/browse-landscape-*.webp) is an HTML scene of OpenCode driving an agent tab,
|
||||
* rendered with Playwright.
|
||||
*/
|
||||
export function heroStills(slug: string) {
|
||||
const file = (theme: string) => `/product-videos/heroes/${slug}-landscape-${theme}.webp`
|
||||
return { poster: file("light"), themes: { light: { poster: file("light") }, dark: { poster: file("dark") } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's colour scheme on the client: the site's theme (the site root's data-theme, which the
|
||||
* pre-paint script sets from the saved choice), else the system's. Server: undefined.
|
||||
|
||||