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
64 changes: 45 additions & 19 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,25 +148,51 @@ table of contents, code examples, and previous/next navigation.

The supplied Scope showreel plays in a framed 16:9 player below the
hero calls to action, so the headline stays clean and the footage is
shown without an overlay. The local [video](public/scope-showreel.mp4)
is re-encoded as H.264 at half the original speed (30 seconds instead
of 15), with the audio track removed and metadata moved to the front
for web playback. It loops, is always muted, and plays inline on
mobile. A visually hidden caption describes the scenes it shows.

[showreel.ts](src/scripts/showreel.ts) starts playback when at least a
quarter of the player is visible. A keyboard-accessible button pauses
or resumes it. Scrolling it out of view or hiding the tab pauses
playback; an explicit user pause persists when returning. The button
overlays the bottom corner of the video, and moves below it on narrow
screens so it does not cover the footage.

Reduced-motion visitors see the [poster](public/scope-showreel-poster.jpg),
taken from the closing Scope title card, without downloading the video
until they choose to play it. Without JavaScript, the poster remains
visible and the playback button stays hidden. Blocked autoplay offers
manual playback; media failures display a status message and log the
error. Both media URLs use the configured deployment base.
shown without an overlay. It comes in two cuts styled with the landing
tokens: a [dark video](public/scope-showreel-dark.mp4) and a
[light video](public/scope-showreel-light.mp4). Both show the same
scenes and are re-encoded as H.264 at half the original speed (30
seconds instead of 15), with the audio track removed and metadata moved
to the front for web playback. The player loops, is always muted, and
plays inline on mobile. A visually hidden caption describes the scenes.

The cut follows the active Starlight theme: the `data-theme` attribute
on `<html>`, which Starlight sets before first paint, including when it
resolves **Auto** from the system color scheme. The
[dark poster](public/scope-showreel-poster-dark.jpg) and
[light poster](public/scope-showreel-poster-light.jpg), taken from the
closing Scope title card, are exposed to CSS as base-aware custom
properties. The frame paints the active theme's poster, and the video
stays transparent over it until it has a frame. Neither the first paint
nor a theme change shows the other cut, and the browser only fetches the
poster it paints.

[showreel.ts](src/scripts/showreel.ts) keeps both video URLs in data
attributes and assigns the active theme's source only when playback
starts, when at least a quarter of the player is visible. A
keyboard-accessible button pauses or resumes it. Scrolling it below a
quarter visible or hiding the tab pauses playback; an explicit user
pause persists when returning. Selecting Play still starts a player that
is less than a quarter visible, and it keeps playing until it leaves the
viewport. The button overlays the bottom corner of the video, and moves
below it on narrow screens so it does not cover the footage. The
controls and frame use the landing tokens, so they stay readable over
either cut.

When the theme changes, a `MutationObserver` on `data-theme` swaps a
loaded player to the matching cut at the same position, keeping it
playing or paused. A paused player loads only the frame at that
position. A player that is off screen, or less than a quarter visible
and not started with Play, switches to the new poster at once and keeps
its current source until it is visible again. A player that has not
loaded yet only changes its poster.

Reduced-motion visitors see the poster for their theme without
downloading either video until they choose to play it. Without
JavaScript, the poster remains visible and the playback button stays
hidden. Blocked autoplay offers manual playback; media failures display
a status message and log the error. All media URLs use the configured
deployment base.

### Interactive example

Expand Down
Binary file added website/public/scope-showreel-dark.mp4
Binary file not shown.
Binary file added website/public/scope-showreel-light.mp4
Binary file not shown.
Binary file added website/public/scope-showreel-poster-dark.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 added website/public/scope-showreel-poster-light.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 removed website/public/scope-showreel-poster.jpg
Binary file not shown.
Binary file removed website/public/scope-showreel.mp4
Binary file not shown.
11 changes: 8 additions & 3 deletions website/src/components/Home.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import CommunityTeaser from './community/CommunityTeaser.astro';
import FlowDemo from './FlowDemo.astro';

const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
// CSS paints the active theme's poster, so first paint never shows the other theme's cut.
const showreelPosters = {
'--showreel-poster-dark': `url("${base}scope-showreel-poster-dark.jpg")`,
'--showreel-poster-light': `url("${base}scope-showreel-poster-light.jpg")`,
};
const paths = [
{
number: '01',
Expand Down Expand Up @@ -45,11 +50,11 @@ const paths = [
</div>
<p class="scope-hero-note">Your tasks. Your criteria. Repeatable experiments.</p>
<scope-showreel>
<figure class="scope-showreel-frame">
<figure class="scope-showreel-frame" style={showreelPosters}>
<video
id="scope-showreel-video"
data-src={`${base}scope-showreel.mp4`}
poster={`${base}scope-showreel-poster.jpg`}
data-src-dark={`${base}scope-showreel-dark.mp4`}
data-src-light={`${base}scope-showreel-light.mp4`}
width="1920"
height="1080"
autoplay
Expand Down
88 changes: 80 additions & 8 deletions website/src/scripts/showreel.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,95 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

type Theme = 'dark' | 'light';

// Starlight resolves 'auto' and writes 'dark' or 'light'; the site CSS treats anything else as dark.
const activeTheme = (): Theme => (document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');

class ScopeShowreel extends HTMLElement {
private events: AbortController | undefined;
private observer: IntersectionObserver | undefined;
private themeObserver: MutationObserver | undefined;

connectedCallback() {
const video = this.querySelector('video');
const button = this.querySelector('button');
const status = this.querySelector<HTMLElement>('[role="status"]');
const source = video?.dataset.src;
if (!video || !button || !status || !source) throw new Error('Missing Scope showreel elements');
const dark = video?.dataset.srcDark;
const light = video?.dataset.srcLight;
if (!video || !button || !status || !dark || !light) throw new Error('Missing Scope showreel elements');
const sources: Record<Theme, string> = { dark, light };

this.events = new AbortController();
const { signal } = this.events;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
let wantsPlayback = !motion.matches;
let intersecting = false;
// At least a quarter is on screen.
let visible = false;
// The user pressed Play while part of the player was on screen; holds until it leaves the viewport.
let manualPlay = false;
let failed = false;
let playbackRequest = 0;
// Position to restore once a theme swap's new source has metadata.
let pendingStart: number | undefined;

video.muted = true;
button.hidden = false;
button.disabled = false;

const themeSource = () => sources[activeTheme()];
const renderPlayback = () => {
button.textContent = video.paused ? 'Play showreel' : 'Pause showreel';
};
const reportFailure = (error: unknown) => {
wantsPlayback = false;
failed = true;
video.pause();
// Fall back to the CSS poster, which keeps following the theme.
delete video.dataset.ready;
status.textContent = 'The showreel is unavailable.';
status.hidden = false;
button.disabled = true;
console.error('Scope showreel failed', error);
};
// Until the video has a frame at the right position, it stays transparent over the CSS poster.
const markReady = () => {
if (failed || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || video.seeking) return;
pendingStart = undefined;
video.dataset.ready = '';
};
// Only an assigned source can show the wrong theme; an unassigned player just shows the CSS poster.
const sourceIsStale = () => {
const assigned = video.getAttribute('src');
return !failed && assigned !== null && assigned !== themeSource();
};
const swapSource = () => {
const start = pendingStart ?? video.currentTime;
// A paused player that showed a frame keeps showing one; one that showed the poster keeps it.
const showsFrame = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA || video.preload !== 'none';
pendingStart = start;
delete video.dataset.ready;
// Playback fetches on demand, and a paused frame only needs the data at its position.
video.preload = !wantsPlayback && showsFrame ? 'metadata' : 'none';
video.src = themeSource();
video.currentTime = start;
renderPlayback();
};
const syncPlayback = () => {
const request = ++playbackRequest;
video.autoplay = wantsPlayback && visible && !document.hidden;
const onScreen = (visible || manualPlay) && !document.hidden;
// Off-screen players keep their current source and position until they are seen again.
if (onScreen && sourceIsStale()) swapSource();
video.autoplay = wantsPlayback && onScreen;
if (!video.autoplay) {
video.pause();
return;
}
if (!video.getAttribute('src')) video.src = source;
if (!video.getAttribute('src')) video.src = themeSource();
if (!video.paused) return;
void video.play().catch((error: unknown) => {
// Pausing or a newer play request can invalidate a pending promise.
// Pausing, a theme swap, or a newer play request can invalidate a pending promise.
if (request !== playbackRequest || signal.aborted) return;
if (error instanceof DOMException && error.name === 'NotAllowedError') {
wantsPlayback = false;
Expand All @@ -59,28 +104,55 @@ class ScopeShowreel extends HTMLElement {

button.addEventListener('click', () => {
wantsPlayback = video.paused;
// An explicit Play overrides the autoplay threshold; Pause clears the override.
manualPlay = wantsPlayback && intersecting;
syncPlayback();
}, { signal });
video.addEventListener('play', renderPlayback, { signal });
video.addEventListener('pause', renderPlayback, { signal });
video.addEventListener('playing', () => { status.hidden = true; }, { signal });
video.addEventListener('loadedmetadata', () => {
// Fallback for browsers that ignore a start position set before metadata loads.
if (pendingStart !== undefined && !video.seeking && Math.abs(video.currentTime - pendingStart) > 0.1) {
video.currentTime = pendingStart;
}
}, { signal });
video.addEventListener('loadeddata', markReady, { signal });
video.addEventListener('seeked', markReady, { signal });
video.addEventListener('error', () => reportFailure(video.error), { signal });
motion.addEventListener('change', () => {
if (motion.matches) wantsPlayback = false;
syncPlayback();
}, { signal });
document.addEventListener('visibilitychange', syncPlayback, { signal });
this.observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
// The 0 threshold reports entering and leaving: Chrome keeps isIntersecting false below the smallest threshold.
this.observer = new IntersectionObserver((entries) => {
const entry = entries[entries.length - 1];
intersecting = entry.isIntersecting;
if (!intersecting) manualPlay = false;
visible = intersecting && entry.intersectionRatio >= 0.25;
Comment thread
manekinekko marked this conversation as resolved.
syncPlayback();
}, { threshold: 0.25 });
}, { threshold: [0, 0.25] });
this.observer.observe(this);
// The Starlight theme toggle rewrites data-theme on <html>; follow it with the matching cut.
this.themeObserver = new MutationObserver(() => {
if (sourceIsStale()) {
// Hide the other cut at once, even when a partly visible player defers the swap.
delete video.dataset.ready;
syncPlayback();
Comment thread
manekinekko marked this conversation as resolved.
} else {
// The theme flipped back to the assigned cut before its swap ran.
markReady();
}
});
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
renderPlayback();
}

disconnectedCallback() {
this.events?.abort();
this.observer?.disconnect();
this.themeObserver?.disconnect();
const video = this.querySelector('video');
if (video) {
video.autoplay = false;
Expand Down
20 changes: 12 additions & 8 deletions website/src/styles/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,21 @@ body:has(.scope-home) header.header { background: color-mix(in srgb, var(--scope
.scope-btn--secondary { color: var(--scope-text); background: var(--scope-surface); border-color: var(--scope-border); }
.scope-btn--secondary:hover { background: var(--scope-inset); }
.scope-hero .scope-hero-note { margin-top: 1rem; color: var(--scope-muted); font-size: 0.75rem; }
/* The showreel is dark in both themes, so its overlay controls use fixed dark styling. */
/* Each theme has its own showreel cut. The frame paints the active theme's poster, and the video
stays transparent over it until it has a frame, so neither first paint nor a theme swap shows
the other cut. The overlay controls use the landing tokens to stay readable over either video. */
scope-showreel { display: block; max-width: 60rem; margin: 3rem auto 0; }
.scope-showreel-frame { position: relative; margin: 0; overflow: hidden; border: 1px solid var(--scope-border); border-radius: 1rem; background: #0b0d18; box-shadow: var(--scope-shadow), 0 0 0 6px color-mix(in srgb, var(--scope-brand) 7%, transparent); }
.scope-showreel-frame video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; }
.scope-showreel-frame { position: relative; margin: 0; overflow: hidden; border: 1px solid var(--scope-border); border-radius: 1rem; background: center top / 100% auto no-repeat var(--scope-bg); background-image: var(--showreel-poster-dark); box-shadow: var(--scope-shadow), 0 0 0 6px color-mix(in srgb, var(--scope-brand) 7%, transparent); }
:root[data-theme='light'] .scope-showreel-frame { background-image: var(--showreel-poster-light); }
.scope-showreel-frame video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; opacity: 0; }
.scope-showreel-frame video[data-ready] { opacity: 1; transition: opacity 200ms; }
.scope-showreel-controls { position: absolute; right: 0.75rem; bottom: 0.75rem; left: 0.75rem; display: flex; flex-wrap: wrap; justify-content: end; align-items: center; gap: 0.5rem; pointer-events: none; text-align: right; }
.scope-showreel-controls > * { pointer-events: auto; }
.scope-showreel-controls button { min-height: 44px; padding: 0.5rem 0.85rem; border: 1px solid #ffffff33; border-radius: 0.5rem; background: #0b0d18c7; color: #e9eaf6; font-size: 0.75rem; backdrop-filter: blur(6px); }
.scope-showreel-controls button:hover { border-color: #ffffff80; }
.scope-showreel-controls button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.scope-showreel-controls button { min-height: 44px; padding: 0.5rem 0.85rem; border: 1px solid color-mix(in srgb, var(--scope-text) 22%, transparent); border-radius: 0.5rem; background: color-mix(in srgb, var(--scope-surface) 86%, transparent); color: var(--scope-text); font-size: 0.75rem; backdrop-filter: blur(6px); }
.scope-showreel-controls button:hover { border-color: var(--scope-brand); }
.scope-showreel-controls button:focus-visible { outline: 2px solid var(--scope-brand); outline-offset: 2px; }
.scope-showreel-controls button:disabled { opacity: 0.6; }
.scope-showreel-controls [role='status'] { max-width: 36ch; padding: 0.45rem 0.65rem; border-radius: 0.4rem; background: #0b0d18d9; color: #e9eaf6; font-size: 0.75rem; }
.scope-showreel-controls [role='status'] { max-width: 36ch; padding: 0.45rem 0.65rem; border: 1px solid var(--scope-border); border-radius: 0.4rem; background: color-mix(in srgb, var(--scope-surface) 92%, transparent); color: var(--scope-text); font-size: 0.75rem; }
.scope-agent-strip { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 1.4rem; margin-top: 3.7rem; padding: 1.5rem 0; border-top: 1px solid var(--scope-border); border-bottom: 1px solid var(--scope-border); font-size: 0.8rem; }
.scope-agent-strip > span:first-child { color: var(--scope-muted); font-size: 0.75rem; }
.scope-agent-strip strong { font-size: 1rem; font-weight: 600; }
Expand Down Expand Up @@ -285,7 +289,7 @@ scope-showreel { display: block; max-width: 60rem; margin: 3rem auto 0; }
@media (max-width: 30rem) {
.scope-hero { padding-inline: 0; }.scope-hero h1 { font-size: 2.65rem; }.scope-hero > .scope-eyebrow { letter-spacing: 0.07em; font-size: 0.6rem; }
scope-showreel { margin-top: 2.25rem; }
.scope-showreel-controls { position: static; padding: 0.5rem; border-top: 1px solid #ffffff1f; }
.scope-showreel-controls { position: static; padding: 0.5rem; border-top: 1px solid var(--scope-border); }
.scope-showreel-controls:not(:has(> :not([hidden]))) { display: none; }
.scope-evidence-grid { grid-template-columns: 1fr; }.scope-evidence--wide { grid-column: auto; }
.flow-controls { flex-wrap: wrap; }.flow-step-buttons { width: 100%; justify-content: space-between; }.flow-step-buttons button { min-width: 44px; }.flow-step-name { display: inline; }
Expand Down
Loading