Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/landing-hero-clean-60fps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-web": patch
---

Landing hero video: re-render caption-free (pure animation, no burned-in text overlays), motion-interpolate 30 → 60 fps, and ship a true 16:9 1080p frame — the old 2.4:1 canvas baked blurred letterbox bars into the pixels, which read as empty side gutters on wide viewports. object-cover now always fills the screen with real content.
2 changes: 2 additions & 0 deletions .changeset/release-notes-20260607.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
13 changes: 13 additions & 0 deletions .github/release-notes-20260607.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Fixed

- Landing video no longer shows blurred side bars on wide screens.

## New Feature

- None in this release.

## Changed

- Landing intro video is pure animation now — text overlays removed.
- Landing video plays at a smoother 60 fps.
- Faster repeat visits: the landing video is cached, not re-downloaded.
5 changes: 4 additions & 1 deletion ornn-web/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ server {
}

# ── Static asset caching ──────────────────────────────────────────────
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# mp4 is safe to cache immutably because video assets are imported
# through Vite and served under content-hashed URLs (see HeroVideo);
# an asset swap changes the URL, never the bytes behind a cached one.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
Expand Down
Binary file removed ornn-web/public/ornn-intro-poster.jpg
Binary file not shown.
Binary file added ornn-web/src/assets/ornn-intro-poster.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
13 changes: 8 additions & 5 deletions ornn-web/src/pages/landing/HeroVideo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { render, screen, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";

import { HeroVideo } from "./HeroVideo";
// Same Vite-hashed asset URLs the component imports — assert against these
// instead of hardcoded /public paths (the assets are content-hashed for
// cache-busting, so their served URLs are not stable strings).
import introPoster from "@/assets/ornn-intro-poster.jpg";
import introVideo from "@/assets/ornn-intro.mp4";

function stubMatchMedia(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
Expand Down Expand Up @@ -67,12 +72,12 @@ describe("HeroVideo", () => {
expect(el.autoplay).toBe(true);
expect(el.loop).toBe(true);
expect(el.playsInline).toBe(true);
expect(el.getAttribute("poster")).toBe("/ornn-intro-poster.jpg");
expect(el.getAttribute("poster")).toBe(introPoster);
expect(el.getAttribute("aria-hidden")).toBe("true");

const source = el.querySelector("source");
expect(source).not.toBeNull();
expect(source?.getAttribute("src")).toBe("/ornn-intro.mp4");
expect(source?.getAttribute("src")).toBe(introVideo);
expect(source?.getAttribute("type")).toBe("video/mp4");
});

Expand All @@ -93,9 +98,7 @@ describe("HeroVideo", () => {
it("renders the static poster image and no video", () => {
const { container } = renderHero();
expect(container.querySelector("video")).toBeNull();
const poster = container.querySelector(
'img[src="/ornn-intro-poster.jpg"]',
);
const poster = container.querySelector(`img[src="${introPoster}"]`);
expect(poster).not.toBeNull();
});
});
Expand Down
41 changes: 24 additions & 17 deletions ornn-web/src/pages/landing/HeroVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { EmberLink } from "./EmberButton";

// Vite-managed (content-hashed) hero assets. Hashed URLs make every asset
// swap self-cache-busting — nginx serves images/fonts with a 1y immutable
// Cache-Control, so replacing bytes at a stable /public URL would leave
// returning visitors on the stale file.
import introPoster from "@/assets/ornn-intro-poster.jpg";
import introVideo from "@/assets/ornn-intro.mp4";

/**
* Subscribe to the OS "reduce motion" preference and re-render on change.
*
Expand All @@ -24,19 +31,19 @@ function usePrefersReducedMotion(): boolean {
}

/**
* HeroVideo — the full-bleed landing hero (#840).
* HeroVideo — the full-bleed landing hero (#840, #896).
*
* Replaces the scroll-scrub `HeroStage` narrative with a single autoplaying,
* muted, looping background intro video that fills the viewport. The video is
* decorative (`aria-hidden`)the burned-in captions carry the message — so
* the only interactive content is the CTA pair.
* pure animationno caption layer (#896) — and decorative (`aria-hidden`),
* so the only interactive (and only textual) content is the CTA pair.
*
* Reduced-motion users get the static poster frame instead of the video.
*
* Caption-safety (AC4): the source video burns its captions into the
* lower-center band, so the CTA + scrim live at the TOP-LEFT (clear of the
* Navbar) and the scrim is a top-down gradient. A bottom CTA/scrim would sit
* on top of — and an ember-tinted scrim would muddy — the orange caption text.
* The asset is true 16:9 (1920x1080@60, H.264 High@L4.2 — kept ≤ L5.1 so
* phones can hardware-decode it, see #870). `object-cover` scales/crops it to
* any viewport ratio, so real content pixels fill the screen edge-to-edge —
* there are no baked-in letterbox bars (unlike the pre-#896 2.4:1 frame).
*/
export function HeroVideo() {
const reduced = usePrefersReducedMotion();
Expand All @@ -56,7 +63,7 @@ export function HeroVideo() {
>
{reduced ? (
<img
src="/ornn-intro-poster.jpg"
src={introPoster}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
Expand All @@ -69,27 +76,27 @@ export function HeroVideo() {
loop
playsInline
preload="auto"
poster="/ornn-intro-poster.jpg"
poster={introPoster}
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
>
<source src="/ornn-intro.mp4" type="video/mp4" />
<source src={introVideo} type="video/mp4" />
</video>
)}

{/* Top scrim — local to the CTA corner, never the full frame, so the
lower-center burned-in captions stay legible. Top-down page-tone
gradient anchors the CTA against the bright forge ceiling + Navbar. */}
animation stays unobscured. Top-down page-tone gradient anchors the
CTA against the bright forge ceiling + Navbar. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-0 h-56 bg-gradient-to-b from-[var(--color-page)] to-transparent opacity-90"
/>

{/* CTA — top-left, below the Navbar, clear of the caption band. The
local plate (overlay surface + blur + strong border, the same
vocabulary as HeroStage's final-CTA overlay) keeps the labels above
WCAG AA regardless of the decorative video frame behind them — the
top scrim alone can't guarantee contrast once the CTA outgrows it. */}
{/* CTA — top-left, below the Navbar. The local plate (overlay surface +
blur + strong border, the same vocabulary as HeroStage's final-CTA
overlay) keeps the labels above WCAG AA regardless of the decorative
video frame behind them — the top scrim alone can't guarantee
contrast once the CTA outgrows it. */}
<div className="absolute left-8 top-24 z-10 flex gap-3 rounded-[4px] border border-[color:var(--color-border-strong)] [background-color:var(--surface-overlay)] p-3 backdrop-blur-[14px] sm:left-12 sm:top-28">
<EmberLink to="/registry">{t("landing.browseSkills")}</EmberLink>
<EmberLink to="/skills/new" variant="ghost">
Expand Down
Loading