carbon-demo / social_reel.html
lvwerra's picture
lvwerra HF Staff
Demo polish + scripted reel tour
7ae5802
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carbon · social reel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap">
<style>
:root {
--ink: #1a1a1a;
--ink-soft: #5b5b56;
--paper: #fafafa;
--green: #1a8a3a;
--rule: #e3e1d6;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #000;
color: #fff;
font-family: "Inter", system-ui, sans-serif;
overflow: hidden;
}
/* Frameless: the stage fills the entire viewport. Resize the
browser window to whatever aspect ratio you want to record. */
.stage {
position: absolute;
inset: 0;
background: #f7f5ee;
}
.stage-frame {
position: absolute;
inset: 0;
background: #f7f5ee; /* matches the demo body so a clipped iframe
blends seamlessly with the stage frame. */
overflow: hidden;
}
iframe.demo {
position: absolute; inset: 0;
width: 100%; height: 100%;
border: 0;
}
/* Scene-to-scene slide animation lives on .stage-frame (which carries
both iframes). The previous frame slides up off the top, content
swaps off-screen, the new frame slides in from below. */
.stage-frame { will-change: transform; }
.stage-frame.slide-out {
transition: transform 420ms cubic-bezier(.4, 0, .2, 1);
transform: translateY(-100%);
}
.stage-frame.slide-prep {
transition: none;
transform: translateY(100%);
}
.stage-frame.slide-in {
transition: transform 420ms cubic-bezier(.4, 0, .2, 1);
transform: translateY(0);
}
/* Two iframes share the stage; we toggle which one is visible per
scene. The banner one points at /social-banner — a dedicated hero
page that already centers the banner inside its own viewport, so
we don't have to crop or scale anything ourselves. */
iframe.demo { z-index: 1; }
/* Banner/demo swap is hidden behind the .stage-frame slide (the
change happens off-screen between slide-out and slide-in), so the
opacity flip can be instant — a cross-fade here would overlap the
slide motion and read as a double animation. */
iframe.banner-iframe { z-index: 2; opacity: 0; pointer-events: none; }
iframe.banner-iframe.active { opacity: 1; pointer-events: auto; }
iframe.demo:not(.banner-iframe).hidden { opacity: 0; }
/* "Now playing" progress dots at the top. */
.timeline {
position: absolute;
top: 18px; left: 32px; right: 32px;
z-index: 5;
display: flex; gap: 6px;
pointer-events: none;
}
.timeline .dot {
flex: 1; height: 2px; background: rgba(0,0,0,0.12); border-radius: 1px;
overflow: hidden;
}
.timeline .dot::before {
content: ""; display: block; height: 100%; width: 0%;
background: var(--ink);
transition: width .2s linear;
}
.timeline .dot.done::before { width: 100% !important; }
/* Quiet "→" hint that fades in once the scene has settled, telling
the recorder they can advance whenever ready. */
.ready-hint {
position: absolute; right: 24px; bottom: 18px; z-index: 5;
font-family: "JetBrains Mono", monospace;
font-size: 20px; color: rgba(0,0,0,0.45);
opacity: 0; transition: opacity .8s ease;
pointer-events: none;
animation: nudge 1.8s ease-in-out infinite;
}
.ready-hint.show { opacity: 1; }
@keyframes nudge {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(4px); }
}
/* Controls (hidden during recording with H). */
.controls {
position: fixed; top: 12px; right: 12px; z-index: 50;
display: flex; gap: 8px; align-items: center;
font-family: "JetBrains Mono", monospace;
font-size: 11px; color: #ccc;
background: rgba(0,0,0,0.55);
padding: 8px 10px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(6px);
}
.controls.hidden { opacity: 0; pointer-events: none; }
.controls button, .controls select {
font-family: inherit; font-size: 11px;
background: #1a1a1a; color: #eee;
border: 1px solid #444; border-radius: 3px;
padding: 4px 8px; cursor: pointer;
}
.controls button:hover { border-color: #888; }
.controls .hint { color: #888; font-size: 10px; }
.controls label.auto {
display: flex; align-items: center; gap: 5px;
color: #ccc; font-size: 11px;
padding: 4px 8px; border: 1px solid #444; border-radius: 3px;
cursor: pointer;
}
.controls label.auto input { margin: 0; cursor: pointer; }
/* Hint banner shown briefly on load. */
.toast {
position: fixed; left: 50%; bottom: 20px;
transform: translateX(-50%);
z-index: 60;
background: rgba(0,0,0,0.7);
color: #fff; padding: 8px 14px; border-radius: 4px;
font-family: "JetBrains Mono", monospace;
font-size: 11px; letter-spacing: 0.06em;
opacity: 0; transition: opacity .4s ease;
pointer-events: none;
}
.toast.show { opacity: 1; }
</style>
</head>
<body data-aspect="16-9">
<div class="controls" id="controls">
<button id="play">▶ play</button>
<button id="restart">↺ restart</button>
<label class="auto"><input type="checkbox" id="auto-toggle"> auto</label>
<span class="hint">H: hide · ←/→: scene · space: pause</span>
</div>
<div class="toast" id="toast">press H to hide controls · → advances scenes</div>
<div class="stage">
<div class="stage-frame">
<iframe class="demo" id="demo" src="/demo" title="Carbon demo"></iframe>
<iframe class="demo banner-iframe" id="banner" src="/social-banner?format=og" title="Carbon hero"></iframe>
<div class="timeline" id="timeline"></div>
<div class="ready-hint" id="ready-hint"></div>
</div>
</div>
<script>
// =============================================================
// Scene list — each entry drives one beat of the reel: switch to a
// tab, scroll to a target element, hold for `duration` ms, then
// slide-transition to the next scene.
// =============================================================
// useBanner: show the dedicated /social-banner iframe (a centred hero
// with no surrounding chrome) instead of the full demo. Used for the
// opening scene so the title shot is purpose-built and centred in the
// frame rather than cropped from the live demo.
//
// scrollOffset: extra whitespace above the scrolled-to section so the
// widget below sits comfortably in the lower part of the frame instead
// of being flush against the top edge.
const SCENES = [
{ kind: "scene", useBanner: true, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#completion", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#track", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#vep", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#species", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#folding", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#umap", scrollOffset: 120, duration: 6000 },
{ kind: "scene", tab: "dna-lab", scrollTo: "#speciesTree", scrollOffset: 120, duration: 6000 },
];
// Default to manual advance so the recorder can interact with each
// widget for as long as they want before moving on. Flip to true (or
// toggle "auto" in the controls) for hands-free playback.
let autoAdvance = false;
// --- helpers --------------------------------------------------
const $ = (id) => document.getElementById(id);
const iframe = $("demo");
const banner = $("banner");
const timeline = $("timeline");
// Once /social-banner has loaded: hide its format switcher, drop the
// outer cream "canvas" framing (body bg, body padding, box-shadow),
// and override the scale so the banner fills the iframe edge-to-edge
// instead of being clamped to its natural pixel size. Same-origin so
// direct DOM access is allowed.
banner.addEventListener("load", () => {
try {
const doc = banner.contentDocument;
const win = banner.contentWindow;
if (!doc || !win) return;
const sw = doc.querySelector(".sb-switcher");
if (sw) sw.style.display = "none";
const style = doc.createElement("style");
style.textContent = `
html, body {
background: #f7f5ee !important;
padding: 0 !important;
gap: 0 !important;
overflow: hidden !important;
}
/* Banner is the only body child in the reel; center it vertically
so any leftover slack (when the iframe aspect ratio doesn't match
the banner's) reads as symmetric letterbox margins rather than a
lone blank rectangle stacked below the banner. */
body { justify-content: center !important; }
.social-banner-stage { box-shadow: none !important; }
/* The reel only frames the banner stage; the brand contact-sheet
and OG thumbnail strip below it are out of scope for the tour. */
.sb-section-label,
.sb-logos,
.sb-thumbs { display: none !important; }
`;
doc.head.appendChild(style);
// Reel mode: instead of scaling a fixed-ratio stage into the
// iframe (which either letterboxes or crops, depending on Math.min
// vs Math.max), we override the stage dimensions to match the
// iframe exactly and keep scale at 1×. The banner's internal grid
// (wordmark left, helix right) reflows to the iframe's actual
// aspect ratio, so the helix and dot-paper background extend to
// every edge without cropping the wordmark.
const stage = doc.getElementById("sb-stage");
if (stage) {
const fit = () => {
stage.style.setProperty("--sb-w", win.innerWidth + "px");
stage.style.setProperty("--sb-h", win.innerHeight + "px");
stage.style.setProperty("--sb-scale", "1");
};
fit();
win.addEventListener("resize", fit);
// The parent reel page can also change the iframe size (e.g. when
// the browser window is resized); listen there too.
window.addEventListener("resize", fit);
}
} catch {}
});
// Inject reel-only styles into the demo iframe. Two jobs:
// 1. Hide the "Try it / What to look for" callouts (.takeaway) and
// "Run this from code" expandables (details.code-snippet) site-
// wide so each scene shows just the widget.
// 2. When body.reel-mode is active, hide *everything* except the
// one section flagged with .reel-current, and centre that
// section vertically in the viewport. This stops the next
// section peeking in below and keeps the widget on-screen.
// Only this iframe instance is affected — the live demo at /demo is
// untouched if you navigate there directly.
iframe.addEventListener("load", () => {
try {
const d = iframe.contentDocument;
if (!d) return;
const style = d.createElement("style");
style.textContent = `
.takeaway,
details.code-snippet { display: none !important; }
/* --- reel-mode: single-widget viewport ----------------------- */
body.reel-mode {
padding: 0 !important;
margin: 0 !important;
}
body.reel-mode > header.carbon-banner,
body.reel-mode > #tab-nav-sticky,
body.reel-mode > footer { display: none !important; }
body.reel-mode > .tab-panel { display: none !important; }
body.reel-mode > .tab-panel.active { display: block !important; }
body.reel-mode .tab-panel.active > .tab-lede { display: none !important; }
body.reel-mode .tab-panel.active .container.wide {
min-height: 100vh;
max-width: none;
/* Generous side padding so the section narrative + widget have
breathing room from the viewport edges. */
padding: 32px 96px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
body.reel-mode section.section--two-col { display: none !important; }
body.reel-mode section.section--two-col.reel-current {
display: grid !important;
width: 100%;
margin: 0 !important;
scroll-margin-top: 0 !important;
/* Optical-centre nudge: the section's narrative + widget reads
better seated slightly above true centre, with a touch more
paper visible below. */
transform: translateY(-50px);
}
/* The narrative rail is normally sticky under the page header;
in reel-mode there is no scroll, so park it back to static so
it sits next to the demo body instead of jumping to top:104px. */
body.reel-mode .section--two-col.reel-current .section-narrative {
position: static !important;
top: auto !important;
max-height: none !important;
overflow: visible !important;
}
`;
d.head.appendChild(style);
} catch {}
});
let idx = -1;
let timer = null;
let progressTimer = null;
let paused = false;
let progressStart = 0;
let progressDur = 0;
let started = false;
function buildTimeline() {
timeline.innerHTML = SCENES.map(() => `<div class="dot"></div>`).join("");
}
const stageFrame = document.querySelector(".stage-frame");
const SLIDE_MS = 420;
// Slide transition. Drives .stage-frame (which holds both iframes) up
// off the top, applies the new scene's content while off-screen,
// snaps to translateY(100%), then slides back to translateY(0).
// applyScene runs after the slide-out, so the user only sees the new
// content emerge from the bottom.
function slideToNext(applyScene, then) {
stageFrame.classList.remove("slide-prep", "slide-in");
stageFrame.classList.add("slide-out");
setTimeout(() => {
applyScene();
// Brief settle window so the iframe's tab swap + scrollTo finish
// off-screen before the slide-in starts — otherwise the new scene
// re-lays-out mid-slide and reads as a layout flash.
setTimeout(() => {
stageFrame.classList.remove("slide-out");
stageFrame.classList.add("slide-prep");
void stageFrame.offsetWidth;
requestAnimationFrame(() => {
stageFrame.classList.remove("slide-prep");
stageFrame.classList.add("slide-in");
if (then) setTimeout(then, SLIDE_MS);
});
}, 180);
}, SLIDE_MS);
}
// Drive the iframe: switch tabs, scroll to an element, trigger actions.
function driveIframe(scene) {
const win = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!doc || !win) return;
// Tab switch. The demo exposes window.setTab; fall back to clicking
// the tab button if it isn't there for any reason.
if (scene.tab) {
if (typeof win.setTab === "function") {
// scroll:false so the demo doesn't snap to top; we drive scroll
// ourselves a tick later. updateHash:false so we don't churn URL
// history with every scene.
win.setTab(scene.tab, { scroll: false, updateHash: false });
} else {
const btn = doc.querySelector(`#tab-nav .tab[data-tab="${scene.tab}"]`);
if (btn) btn.click();
}
}
// Give the tab a tick to render before scrolling.
setTimeout(() => {
if (scene.scrollTo === "top") {
// Used by intro scenes (which are now served by the banner
// iframe, so this branch is just a safety net).
doc.body.classList.remove("reel-mode");
win.scrollTo({ top: 0, behavior: "auto" });
} else if (typeof scene.scrollTo === "string" && scene.scrollTo.startsWith("#")) {
// Single-widget viewport: hide every other section and centre
// this one. No actual scrolling — the CSS centres it in 100vh.
doc.body.classList.add("reel-mode");
doc.querySelectorAll("section.section--two-col.reel-current")
.forEach(s => s.classList.remove("reel-current"));
const el = doc.querySelector(scene.scrollTo);
if (el) {
el.classList.add("reel-current");
win.scrollTo({ top: 0, behavior: "auto" });
// Nudge widgets whose layout depends on a ResizeObserver /
// window resize — e.g. the §6 UMAP WebGL canvas — to re-fit
// against their newly-visible bounding rect.
win.dispatchEvent(new Event("resize"));
}
}
}, 60);
}
function startProgress(durMs) {
const dot = timeline.children[idx];
if (!dot) return;
progressStart = performance.now();
progressDur = durMs;
const inner = dot.firstElementChild || dot; // dot uses ::before; animate inline
// We can't drive ::before width via JS, so apply width to the dot itself
// through a CSS variable + a child element.
dot.style.setProperty("--p", "0%");
// Use a real child for fill so it animates.
if (!dot.querySelector(".fill")) {
dot.innerHTML = `<div class="fill" style="height:100%;width:0%;background:#1a1a1a"></div>`;
}
const fill = dot.querySelector(".fill");
clearInterval(progressTimer);
progressTimer = setInterval(() => {
if (paused) return;
const t = Math.min(1, (performance.now() - progressStart) / progressDur);
fill.style.width = (t * 100).toFixed(1) + "%";
if (t >= 1) {
clearInterval(progressTimer);
dot.classList.add("done");
}
}, 33);
}
function pauseProgress() {
paused = true;
clearTimeout(timer);
}
function resumeProgress() {
if (!paused) return;
paused = false;
// Resume the remaining slice of the current scene.
const elapsed = performance.now() - progressStart;
const remaining = Math.max(0, progressDur - elapsed);
timer = setTimeout(nextScene, remaining);
}
// Move to the next scene.
function nextScene() {
clearTimeout(timer);
clearInterval(progressTimer);
idx++;
if (idx >= SCENES.length) { finish(); return; }
const s = SCENES[idx];
// Hide the "ready, press → " hint from the previous scene.
$("ready-hint").classList.remove("show");
// Swap iframe / drive scene state. Called from inside the slide
// transition so the content change is hidden off-screen.
const applyScene = () => {
if (s.useBanner) {
banner.classList.add("active");
} else {
banner.classList.remove("active");
driveIframe(s);
}
};
// First scene: no previous frame to slide out — just apply and
// start counting. Subsequent scenes use the up-then-from-below slide.
const onLanded = () => {
startProgress(s.duration);
if (autoAdvance) {
timer = setTimeout(nextScene, s.duration);
} else {
// Subtle "press → for next" hint, surfaces once the scene has
// had a moment to settle so the recorder knows it can advance.
setTimeout(() => {
if (!autoAdvance) $("ready-hint").classList.add("show");
}, s.duration);
}
};
if (idx === 0) {
applyScene();
// Give the iframe content one tick to lay out before timing starts.
setTimeout(onLanded, 200);
} else {
slideToNext(applyScene, onLanded);
}
}
function finish() {
// End of reel: leave the last scene on screen. User can hit restart.
}
function restart() {
clearTimeout(timer);
clearInterval(progressTimer);
// Reset all dots
Array.from(timeline.children).forEach(d => {
d.classList.remove("done");
d.innerHTML = "";
});
stageFrame.classList.remove("slide-out", "slide-prep", "slide-in");
idx = -1;
paused = false;
nextScene();
}
function skipForward() {
clearTimeout(timer);
clearInterval(progressTimer);
if (idx >= 0 && idx < timeline.children.length) {
timeline.children[idx].classList.add("done");
}
nextScene();
}
function skipBackward() {
clearTimeout(timer);
clearInterval(progressTimer);
idx = Math.max(-1, idx - 2);
nextScene();
}
// --- wiring ---------------------------------------------------
buildTimeline();
$("play").addEventListener("click", () => {
if (!started) {
started = true;
restart();
$("play").textContent = "⏸ pause";
} else if (paused) {
resumeProgress();
$("play").textContent = "⏸ pause";
} else {
pauseProgress();
$("play").textContent = "▶ resume";
}
});
$("restart").addEventListener("click", () => {
started = true;
$("play").textContent = "⏸ pause";
restart();
});
$("auto-toggle").addEventListener("change", (e) => {
autoAdvance = e.target.checked;
// If enabling mid-scene, kick off an auto-advance for the remainder
// of this scene's duration. (Cheap implementation: just nudge to the
// next scene immediately — fancier would resume from the elapsed
// ken-burns position.)
if (autoAdvance && idx >= 0) {
$("ready-hint").classList.remove("show");
clearTimeout(timer);
timer = setTimeout(skipForward, 600);
}
});
// Keyboard: H toggles chrome, arrows skip, space pauses, R restarts.
function handleKey(e) {
if (e.key === "h" || e.key === "H") {
$("controls").classList.toggle("hidden");
} else if (e.key === "ArrowRight") {
e.preventDefault(); skipForward();
} else if (e.key === "ArrowLeft") {
e.preventDefault(); skipBackward();
} else if (e.key === " ") {
// Ignore space when the user is typing in an input/textarea —
// otherwise pressing space in a sandbox prompt would pause the
// reel instead of inserting a space.
const t = e.target;
const tag = t && t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (t && t.isContentEditable)) return;
e.preventDefault();
$("play").click();
} else if (e.key === "r" || e.key === "R") {
restart(); started = true;
}
}
document.addEventListener("keydown", handleKey);
// Mirror the same handler inside each iframe so shortcuts keep working
// after the user has clicked into the iframe (which moves keyboard
// focus down into its document). Re-attached on every load() in case
// the iframe navigates.
function attachKeysToIframe(frame) {
frame.addEventListener("load", () => {
try {
frame.contentDocument?.addEventListener("keydown", handleKey);
} catch {}
});
}
attachKeysToIframe(iframe);
attachKeysToIframe(banner);
// Show the recording hint briefly.
window.addEventListener("load", () => {
const t = $("toast");
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 3500);
});
// Auto-start once the iframe content has had a moment to settle.
iframe.addEventListener("load", () => {
// Small delay so fonts, helix canvas, and tab JS are ready.
setTimeout(() => {
if (!started) {
started = true;
$("play").textContent = "⏸ pause";
restart();
}
}, 800);
});
</script>
</body>
</html>