Spaces:
Running
Demo polish + scripted reel tour
Browse files§1-§7 section titles rewritten (Autocomplete for the DNA, Recognizing
gene structure, Predicting mutation effects, Species specific generation,
From DNA to proteins, Mapping out genomes, Reconstructing the tree of
life) with matching lede rewrites; §3 status pill moves to the top-right
of the DNA box and forest-bar viewBox tightens to rowH=46 so each row
fits a single reel frame; social banner reflects the 393,216 bp context.
app.py adds a DEV=1 mode that re-reads templates per request (so edits
to demo.html / social-banner.html show up without restarting uvicorn)
and exposes /reel for the scripted tour. social_reel.html drives the
tour: banner stage now adopts the iframe size (no letterbox, no crop),
ken-burns zoom is gone, scene-to-scene transitions are a vertical slide
on .stage-frame, and the logo / thumb contact sheets are hidden in the
embedded banner so only the hero shows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app.py +35 -13
- assets/js/sections/vep.js +10 -10
- assets/styles/section-vep.css +11 -0
- demo.html +54 -53
- social_reel.html +639 -0
|
@@ -76,13 +76,27 @@ def _load_text(path: str) -> str:
|
|
| 76 |
# Templates loaded once at startup. demo.html and social-banner.html are
|
| 77 |
# large; reading them on every request would add ~100 us of syscall +
|
| 78 |
# parse overhead each time, which adds up under load. The substitution
|
| 79 |
-
# itself (a single str.replace) is cheap.
|
| 80 |
-
#
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
def render(template: str, site_url: str) -> str:
|
|
@@ -177,12 +191,12 @@ async def no_cache_dev_assets(request: Request, call_next):
|
|
| 177 |
|
| 178 |
@app.get("/")
|
| 179 |
def root(request: Request):
|
| 180 |
-
return HTMLResponse(render(
|
| 181 |
|
| 182 |
|
| 183 |
@app.get("/demo")
|
| 184 |
def demo(request: Request):
|
| 185 |
-
return HTMLResponse(render(
|
| 186 |
|
| 187 |
|
| 188 |
@app.get("/sandbox-only")
|
|
@@ -197,7 +211,7 @@ def social_banner(request: Request):
|
|
| 197 |
# sized to fit common social-media canvases (Twitter / OG / LinkedIn /
|
| 198 |
# HF). Used to grab cover-art screenshots without firing up the full
|
| 199 |
# demo page.
|
| 200 |
-
return HTMLResponse(render(
|
| 201 |
|
| 202 |
|
| 203 |
# ---------------------------------------------------------------------
|
|
@@ -209,13 +223,13 @@ def social_banner(request: Request):
|
|
| 209 |
|
| 210 |
@app.get("/robots.txt", response_class=PlainTextResponse)
|
| 211 |
def robots_txt(request: Request):
|
| 212 |
-
return PlainTextResponse(render(
|
| 213 |
|
| 214 |
|
| 215 |
@app.get("/sitemap.xml")
|
| 216 |
def sitemap_xml(request: Request):
|
| 217 |
return Response(
|
| 218 |
-
content=render(
|
| 219 |
media_type="application/xml",
|
| 220 |
)
|
| 221 |
|
|
@@ -226,7 +240,7 @@ def llms_txt():
|
|
| 226 |
# agents that need a compact map of the site without scraping the
|
| 227 |
# whole editorial page. No {{SITE_URL}} substitution: links are
|
| 228 |
# either site-relative or absolute to canonical external URLs.
|
| 229 |
-
return PlainTextResponse(
|
| 230 |
|
| 231 |
|
| 232 |
@app.get("/favicon.ico")
|
|
@@ -236,6 +250,14 @@ def favicon():
|
|
| 236 |
return RedirectResponse(url="/img/logo.svg", status_code=301)
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
@app.get("/config")
|
| 240 |
def config():
|
| 241 |
return {"model": MODEL_NAME}
|
|
|
|
| 76 |
# Templates loaded once at startup. demo.html and social-banner.html are
|
| 77 |
# large; reading them on every request would add ~100 us of syscall +
|
| 78 |
# parse overhead each time, which adds up under load. The substitution
|
| 79 |
+
# itself (a single str.replace) is cheap.
|
| 80 |
+
#
|
| 81 |
+
# DEV=1 disables the cache and re-reads from disk on every request so
|
| 82 |
+
# edits to demo.html / social-banner.html / robots / sitemap / llms show
|
| 83 |
+
# up on the next reload without restarting the server.
|
| 84 |
+
DEV = bool(os.environ.get("DEV"))
|
| 85 |
+
|
| 86 |
+
_TEMPLATE_PATHS = {
|
| 87 |
+
"demo": os.path.join(HERE, "demo.html"),
|
| 88 |
+
"social_banner": os.path.join(HERE, "social-banner.html"),
|
| 89 |
+
"robots": os.path.join(HERE, "robots.txt"),
|
| 90 |
+
"sitemap": os.path.join(HERE, "sitemap.xml"),
|
| 91 |
+
"llms": os.path.join(HERE, "llms.txt"),
|
| 92 |
+
}
|
| 93 |
+
_TEMPLATE_CACHE = {name: _load_text(path) for name, path in _TEMPLATE_PATHS.items()}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def template(name: str) -> str:
|
| 97 |
+
if DEV:
|
| 98 |
+
return _load_text(_TEMPLATE_PATHS[name])
|
| 99 |
+
return _TEMPLATE_CACHE[name]
|
| 100 |
|
| 101 |
|
| 102 |
def render(template: str, site_url: str) -> str:
|
|
|
|
| 191 |
|
| 192 |
@app.get("/")
|
| 193 |
def root(request: Request):
|
| 194 |
+
return HTMLResponse(render(template("demo"), site_url_for(request)))
|
| 195 |
|
| 196 |
|
| 197 |
@app.get("/demo")
|
| 198 |
def demo(request: Request):
|
| 199 |
+
return HTMLResponse(render(template("demo"), site_url_for(request)))
|
| 200 |
|
| 201 |
|
| 202 |
@app.get("/sandbox-only")
|
|
|
|
| 211 |
# sized to fit common social-media canvases (Twitter / OG / LinkedIn /
|
| 212 |
# HF). Used to grab cover-art screenshots without firing up the full
|
| 213 |
# demo page.
|
| 214 |
+
return HTMLResponse(render(template("social_banner"), site_url_for(request)))
|
| 215 |
|
| 216 |
|
| 217 |
# ---------------------------------------------------------------------
|
|
|
|
| 223 |
|
| 224 |
@app.get("/robots.txt", response_class=PlainTextResponse)
|
| 225 |
def robots_txt(request: Request):
|
| 226 |
+
return PlainTextResponse(render(template("robots"), site_url_for(request)))
|
| 227 |
|
| 228 |
|
| 229 |
@app.get("/sitemap.xml")
|
| 230 |
def sitemap_xml(request: Request):
|
| 231 |
return Response(
|
| 232 |
+
content=render(template("sitemap"), site_url_for(request)),
|
| 233 |
media_type="application/xml",
|
| 234 |
)
|
| 235 |
|
|
|
|
| 240 |
# agents that need a compact map of the site without scraping the
|
| 241 |
# whole editorial page. No {{SITE_URL}} substitution: links are
|
| 242 |
# either site-relative or absolute to canonical external URLs.
|
| 243 |
+
return PlainTextResponse(template("llms"), media_type="text/markdown; charset=utf-8")
|
| 244 |
|
| 245 |
|
| 246 |
@app.get("/favicon.ico")
|
|
|
|
| 250 |
return RedirectResponse(url="/img/logo.svg", status_code=301)
|
| 251 |
|
| 252 |
|
| 253 |
+
@app.get("/reel")
|
| 254 |
+
def reel():
|
| 255 |
+
# Scripted demo tour: loads /demo in an iframe and walks through the
|
| 256 |
+
# header → sandbox → DNA Lab §1-§7 with title cards and ken-burns
|
| 257 |
+
# transitions. Screen-record this page for socials.
|
| 258 |
+
return FileResponse(os.path.join(HERE, "social_reel.html"))
|
| 259 |
+
|
| 260 |
+
|
| 261 |
@app.get("/config")
|
| 262 |
def config():
|
| 263 |
return {"model": MODEL_NAME}
|
|
@@ -238,18 +238,18 @@
|
|
| 238 |
function renderForestBars() {
|
| 239 |
if (!VARIANTS) return;
|
| 240 |
// Layout sized so the SVG renders comfortably tall in the right-hand
|
| 241 |
-
// column of the §3 two-col layout (~828 px of width). The
|
| 242 |
-
// (W=1000, rowH=32) viewBox was ~3.6:1 wide which scaled down
|
| 243 |
-
//
|
| 244 |
-
//
|
| 245 |
-
//
|
| 246 |
-
//
|
|
|
|
| 247 |
//
|
| 248 |
// padT carries two stacked header lines (axis title above, then
|
| 249 |
-
// VARIANT / ← LESS LIKELY / MORE LIKELY → row)
|
| 250 |
-
//
|
| 251 |
-
|
| 252 |
-
const W = 1000, rowH = 56, padL = 320, padR = 80, padT = 72, padB = 90;
|
| 253 |
// Sort variants by Δ ascending (most surprising-to-the-model first), but
|
| 254 |
// keep unscored ones at the bottom in their original order.
|
| 255 |
const indexed = VARIANTS.map((v, i) => ({ v, idx: i, d: cache[v.rs] ? cache[v.rs].altSum - cache[v.rs].refSum : null }));
|
|
|
|
| 238 |
function renderForestBars() {
|
| 239 |
if (!VARIANTS) return;
|
| 240 |
// Layout sized so the SVG renders comfortably tall in the right-hand
|
| 241 |
+
// column of the §3 two-col layout (~828 px of width). The original
|
| 242 |
+
// (W=1000, rowH=32) viewBox was ~3.6:1 wide which scaled down too
|
| 243 |
+
// short at column width, making the per-row text squint-small;
|
| 244 |
+
// (rowH=56) was the other extreme — readable but tall enough to
|
| 245 |
+
// overflow a single frame in the social reel. rowH=46 with tighter
|
| 246 |
+
// top/bottom padding lands at ~2.0:1 → ~415 px rendered, which
|
| 247 |
+
// still keeps variant names and ±Δ values legible without zoom.
|
| 248 |
//
|
| 249 |
// padT carries two stacked header lines (axis title above, then
|
| 250 |
+
// VARIANT / ← LESS LIKELY / MORE LIKELY → row). padB carries the
|
| 251 |
+
// tick row + a two-line bottom caption.
|
| 252 |
+
const W = 1000, rowH = 46, padL = 320, padR = 80, padT = 58, padB = 70;
|
|
|
|
| 253 |
// Sort variants by Δ ascending (most surprising-to-the-model first), but
|
| 254 |
// keep unscored ones at the bottom in their original order.
|
| 255 |
const indexed = VARIANTS.map((v, i) => ({ v, idx: i, d: cache[v.rs] ? cache[v.rs].altSum - cache[v.rs].refSum : null }));
|
|
@@ -7,10 +7,21 @@
|
|
| 7 |
|
| 8 |
/* --- VEP demo (§3): two-row sequence display + inline scores --- */
|
| 9 |
.vep-window {
|
|
|
|
| 10 |
background: #f7f7f7; border: 1px solid #e0e0e0;
|
| 11 |
padding: 16px 20px; margin: 12px 0;
|
| 12 |
text-align: left;
|
| 13 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
.vep-window .vep-stack {
|
| 15 |
display: inline-grid;
|
| 16 |
grid-template-columns: auto auto auto;
|
|
|
|
| 7 |
|
| 8 |
/* --- VEP demo (§3): two-row sequence display + inline scores --- */
|
| 9 |
.vep-window {
|
| 10 |
+
position: relative; /* anchors the .status pill below */
|
| 11 |
background: #f7f7f7; border: 1px solid #e0e0e0;
|
| 12 |
padding: 16px 20px; margin: 12px 0;
|
| 13 |
text-align: left;
|
| 14 |
}
|
| 15 |
+
/* "pending" pill in the top-right corner, aligned with the edit-hint row.
|
| 16 |
+
Lives as a direct child of .vep-window (outside the JS-rebuilt content
|
| 17 |
+
div) so it persists across re-renders. */
|
| 18 |
+
.vep-window > .status {
|
| 19 |
+
position: absolute;
|
| 20 |
+
top: 16px; right: 20px;
|
| 21 |
+
margin-left: 0;
|
| 22 |
+
background: #f7f7f7; /* occludes any wrapped edit-hint */
|
| 23 |
+
z-index: 1;
|
| 24 |
+
}
|
| 25 |
.vep-window .vep-stack {
|
| 26 |
display: inline-grid;
|
| 27 |
grid-template-columns: auto auto auto;
|
|
@@ -578,11 +578,12 @@
|
|
| 578 |
<div class="section-num">§1 · Autocomplete</div>
|
| 579 |
<div class="section-title">Autocomplete for the genome</div>
|
| 580 |
<p class="lede">
|
| 581 |
-
Same idea as GPT completing a sentence, but for DNA.
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
constraint
|
|
|
|
| 586 |
<em>real</em> exon/intron annotations on top of the output so you can compare what
|
| 587 |
Carbon produces to what's actually there.
|
| 588 |
</p>
|
|
@@ -742,14 +743,14 @@ print(tok.decode(new_ids))</code></pre></div>
|
|
| 742 |
<section id="track" class="section--two-col">
|
| 743 |
<div class="section-narrative">
|
| 744 |
<div class="section-num">§2 · Structure</div>
|
| 745 |
-
<div class="section-title">
|
| 746 |
<p class="lede">
|
| 747 |
-
Carbon assigns every 6-base chunk a log-probability under the surrounding
|
| 748 |
-
"expected" that stretch of DNA is.
|
| 749 |
-
and rises. We overlay the exon/intron annotation
|
| 750 |
-
protein-coding regions and falls in repetitive or
|
| 751 |
-
even though the model never saw a single label. The
|
| 752 |
-
powers the variant-effect call in §3 below.
|
| 753 |
</p>
|
| 754 |
</div>
|
| 755 |
|
|
@@ -864,16 +865,15 @@ for t, lp in zip(tok.convert_ids_to_tokens(ids[0, 1:].tolist()),
|
|
| 864 |
<section id="vep" class="section--two-col">
|
| 865 |
<div class="section-narrative">
|
| 866 |
<div class="section-num">§3 · Variant effect</div>
|
| 867 |
-
<div class="section-title">
|
| 868 |
<p class="lede">
|
| 869 |
§2 showed that Carbon's per-base confidence rises and falls in step with gene structure.
|
| 870 |
-
Now we use the
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
just learned what natural DNA looks like. Variants that disrupt protein-coding or
|
| 875 |
-
regulatory function show up as
|
| 876 |
-
distribution.
|
| 877 |
</p>
|
| 878 |
</div>
|
| 879 |
|
|
@@ -882,16 +882,18 @@ for t, lp in zip(tok.convert_ids_to_tokens(ids[0, 1:].tolist()),
|
|
| 882 |
<div class="demo-toolbar">
|
| 883 |
<span>variant</span>
|
| 884 |
<span id="d2-pills" class="pills"></span>
|
| 885 |
-
<span class="spacer"></span>
|
| 886 |
-
<!-- Status pill: hidden by default, surfaces when an edit triggers
|
| 887 |
-
a live rescore (or on the initial auto-score for a variant that
|
| 888 |
-
isn't yet in the precomputed cache). -->
|
| 889 |
-
<span class="status is-hidden" id="d2-status"><span class="dot"></span><span></span></span>
|
| 890 |
</div>
|
| 891 |
|
| 892 |
<div class="vep-gene-box" id="d2-gene-box">loading variants…</div>
|
| 893 |
|
| 894 |
-
<div class="vep-window"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
|
| 896 |
<svg id="d2-bars" style="display:block;width:100%;height:auto;background:#fff;border:1px solid #eee;margin-top:12px" preserveAspectRatio="xMinYMin meet"></svg>
|
| 897 |
</div>
|
|
@@ -975,16 +977,15 @@ print(f"delta = {delta:+.2f} (less likely if negative)")</code></pre></div>
|
|
| 975 |
<section id="species" class="section--two-col">
|
| 976 |
<div class="section-narrative">
|
| 977 |
<div class="section-num">§4 · Species</div>
|
| 978 |
-
<div class="section-title">
|
| 979 |
<p class="lede">
|
| 980 |
-
The same gene (insulin, p53) exists in mouse and chicken, but the surrounding
|
| 981 |
-
has accumulated different mutations along each lineage for hundreds of millions
|
| 982 |
-
years. For each species we
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
setup becomes.
|
| 988 |
</p>
|
| 989 |
</div>
|
| 990 |
|
|
@@ -1106,13 +1107,13 @@ for name, ids in zip(species_prefixes, new_ids):
|
|
| 1106 |
<section id="folding" class="section--two-col">
|
| 1107 |
<div class="section-narrative">
|
| 1108 |
<div class="section-num">§5 · Folding</div>
|
| 1109 |
-
<div class="section-title">From
|
| 1110 |
<p class="lede">
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
</p>
|
| 1117 |
</div>
|
| 1118 |
|
|
@@ -1205,13 +1206,13 @@ for name, ids in zip(species_prefixes, new_ids):
|
|
| 1205 |
<section id="umap" class="section--two-col">
|
| 1206 |
<div class="section-narrative">
|
| 1207 |
<div class="section-num">§6 · Embedding space</div>
|
| 1208 |
-
<div class="section-title">
|
| 1209 |
<p class="lede">
|
| 1210 |
-
|
| 1211 |
-
invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
</p>
|
| 1216 |
</div>
|
| 1217 |
|
|
@@ -1274,14 +1275,14 @@ for name, ids in zip(species_prefixes, new_ids):
|
|
| 1274 |
<section id="speciesTree" class="section--two-col">
|
| 1275 |
<div class="section-narrative">
|
| 1276 |
<div class="section-num">§7 · Species tree</div>
|
| 1277 |
-
<div class="section-title">
|
| 1278 |
<p class="lede">
|
| 1279 |
-
|
| 1280 |
-
single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering
|
| 1281 |
-
|
|
|
|
| 1282 |
together, separates bacteria from fungi, and pairs sister clades (primates with
|
| 1283 |
-
primates, rodents with rodents, monocots with monocots)
|
| 1284 |
-
single taxonomic label.
|
| 1285 |
</p>
|
| 1286 |
</div>
|
| 1287 |
|
|
|
|
| 578 |
<div class="section-num">§1 · Autocomplete</div>
|
| 579 |
<div class="section-title">Autocomplete for the genome</div>
|
| 580 |
<p class="lede">
|
| 581 |
+
Same idea as GPT completing a sentence, but for DNA. We feed the model a DNA sequence
|
| 582 |
+
as input and the model produces an output sequence. The model streams the bases one
|
| 583 |
+
6-base token at a time. The model is better at predicting sequences of a gene's exons
|
| 584 |
+
because they are the protein-coding parts of a gene and are under strong evolutionary
|
| 585 |
+
constraint. As such they should be the most predictable stretches of DNA. The introns
|
| 586 |
+
serve regulatory purposes on the other hand and are harder to predict. We overlay the
|
| 587 |
<em>real</em> exon/intron annotations on top of the output so you can compare what
|
| 588 |
Carbon produces to what's actually there.
|
| 589 |
</p>
|
|
|
|
| 743 |
<section id="track" class="section--two-col">
|
| 744 |
<div class="section-narrative">
|
| 745 |
<div class="section-num">§2 · Structure</div>
|
| 746 |
+
<div class="section-title">Recognizing gene structure</div>
|
| 747 |
<p class="lede">
|
| 748 |
+
The Carbon model assigns every 6-base chunk a log-probability under the surrounding
|
| 749 |
+
context: how "expected" or "likely" that stretch of DNA is. The plot with the scores
|
| 750 |
+
along a real gene shows the curve dips and rises. We overlay the exon/intron annotation
|
| 751 |
+
on top: confidence reliably climbs in protein-coding regions and falls in repetitive or
|
| 752 |
+
unconstrained intronic stretches, even though the model never saw a single label. The
|
| 753 |
+
same score, summed up, is what powers the variant-effect call in §3 below.
|
| 754 |
</p>
|
| 755 |
</div>
|
| 756 |
|
|
|
|
| 865 |
<section id="vep" class="section--two-col">
|
| 866 |
<div class="section-narrative">
|
| 867 |
<div class="section-num">§3 · Variant effect</div>
|
| 868 |
+
<div class="section-title">Predicting mutation effects</div>
|
| 869 |
<p class="lede">
|
| 870 |
§2 showed that Carbon's per-base confidence rises and falls in step with gene structure.
|
| 871 |
+
Now we use the same log-likelihood, but as a measure for individual mutations. For a
|
| 872 |
+
real ClinVar variant we score a ~4 kb window of human DNA two ways: once with the
|
| 873 |
+
original base, once with the mutation. Then we check which version looks more like
|
| 874 |
+
real, functioning human sequence. Carbon was never trained on what "pathogenic" means;
|
| 875 |
+
it just learned what natural DNA looks like. Variants that disrupt protein-coding or
|
| 876 |
+
regulatory function show up as less likely sequence under the model's distribution.
|
|
|
|
| 877 |
</p>
|
| 878 |
</div>
|
| 879 |
|
|
|
|
| 882 |
<div class="demo-toolbar">
|
| 883 |
<span>variant</span>
|
| 884 |
<span id="d2-pills" class="pills"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
</div>
|
| 886 |
|
| 887 |
<div class="vep-gene-box" id="d2-gene-box">loading variants…</div>
|
| 888 |
|
| 889 |
+
<div class="vep-window">
|
| 890 |
+
<!-- Status pill: hidden by default, surfaces when an edit triggers
|
| 891 |
+
a live rescore (or on the initial auto-score for a variant that
|
| 892 |
+
isn't yet in the precomputed cache). Lives outside the content
|
| 893 |
+
div below so it survives the innerHTML rebuilds in vep.js. -->
|
| 894 |
+
<span class="status is-hidden" id="d2-status"><span class="dot"></span><span></span></span>
|
| 895 |
+
<div id="d2-window"></div>
|
| 896 |
+
</div>
|
| 897 |
|
| 898 |
<svg id="d2-bars" style="display:block;width:100%;height:auto;background:#fff;border:1px solid #eee;margin-top:12px" preserveAspectRatio="xMinYMin meet"></svg>
|
| 899 |
</div>
|
|
|
|
| 977 |
<section id="species" class="section--two-col">
|
| 978 |
<div class="section-narrative">
|
| 979 |
<div class="section-num">§4 · Species</div>
|
| 980 |
+
<div class="section-title">Species specific generation</div>
|
| 981 |
<p class="lede">
|
| 982 |
+
The same gene (insulin, p53) exists in humans, mouse and chicken, but the surrounding
|
| 983 |
+
sequence has accumulated different mutations along each lineage for hundreds of millions
|
| 984 |
+
of years. For each species we feed Carbon up to ~400 bp and ask it to continue. Each
|
| 985 |
+
continuation should match that species' real DNA better than another species' would.
|
| 986 |
+
The model handles closely-related species well (mouse, chicken, even though they're
|
| 987 |
+
~300 My from human); the further you go back in evolutionary time, the more the
|
| 988 |
+
surrounding sequence drifts and the harder this setup becomes.
|
|
|
|
| 989 |
</p>
|
| 990 |
</div>
|
| 991 |
|
|
|
|
| 1107 |
<section id="folding" class="section--two-col">
|
| 1108 |
<div class="section-narrative">
|
| 1109 |
<div class="section-num">§5 · Folding</div>
|
| 1110 |
+
<div class="section-title">From DNA to proteins</div>
|
| 1111 |
<p class="lede">
|
| 1112 |
+
When Carbon completes a protein coding region in a gene, the resulting bases translate
|
| 1113 |
+
to a protein: a protein that folds. We feed the resulting sequence into ESMFold
|
| 1114 |
+
(similar to AlphaFold) and render the 3D structure inline, alongside the same protein
|
| 1115 |
+
folded from the reference sequence so you can see whether Carbon's continuation
|
| 1116 |
+
produced something similar.
|
| 1117 |
</p>
|
| 1118 |
</div>
|
| 1119 |
|
|
|
|
| 1206 |
<section id="umap" class="section--two-col">
|
| 1207 |
<div class="section-narrative">
|
| 1208 |
<div class="section-num">§6 · Embedding space</div>
|
| 1209 |
+
<div class="section-title">Mapping out genomes</div>
|
| 1210 |
<p class="lede">
|
| 1211 |
+
We embed 571,810 genes from 27 species across six kingdoms (vertebrates,
|
| 1212 |
+
invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D with UMAP,
|
| 1213 |
+
color by attributes. Depending on the attribute, different kinds of organizations
|
| 1214 |
+
emerge from the same points: the model's embedding space encodes multiple axes of
|
| 1215 |
+
biology at once, most of which were never labeled.
|
| 1216 |
</p>
|
| 1217 |
</div>
|
| 1218 |
|
|
|
|
| 1275 |
<section id="speciesTree" class="section--two-col">
|
| 1276 |
<div class="section-narrative">
|
| 1277 |
<div class="section-num">§7 · Species tree</div>
|
| 1278 |
+
<div class="section-title">Reconstructing the tree of life</div>
|
| 1279 |
<p class="lede">
|
| 1280 |
+
If we take the same 571,810 sequences from §6 and average each species' embeddings into
|
| 1281 |
+
a single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering,
|
| 1282 |
+
we can find species the model regards as closely related. Carbon was never trained on
|
| 1283 |
+
what the relation between organisms is. Yet the resulting tree groups vertebrates
|
| 1284 |
together, separates bacteria from fungi, and pairs sister clades (primates with
|
| 1285 |
+
primates, rodents with rodents, monocots with monocots).
|
|
|
|
| 1286 |
</p>
|
| 1287 |
</div>
|
| 1288 |
|
|
@@ -0,0 +1,639 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Carbon · social reel</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<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">
|
| 10 |
+
<style>
|
| 11 |
+
:root {
|
| 12 |
+
--ink: #1a1a1a;
|
| 13 |
+
--ink-soft: #5b5b56;
|
| 14 |
+
--paper: #fafafa;
|
| 15 |
+
--green: #1a8a3a;
|
| 16 |
+
--rule: #e3e1d6;
|
| 17 |
+
}
|
| 18 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 19 |
+
html, body {
|
| 20 |
+
height: 100%;
|
| 21 |
+
background: #000;
|
| 22 |
+
color: #fff;
|
| 23 |
+
font-family: "Inter", system-ui, sans-serif;
|
| 24 |
+
overflow: hidden;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Frameless: the stage fills the entire viewport. Resize the
|
| 28 |
+
browser window to whatever aspect ratio you want to record. */
|
| 29 |
+
.stage {
|
| 30 |
+
position: absolute;
|
| 31 |
+
inset: 0;
|
| 32 |
+
background: #f7f5ee;
|
| 33 |
+
}
|
| 34 |
+
.stage-frame {
|
| 35 |
+
position: absolute;
|
| 36 |
+
inset: 0;
|
| 37 |
+
background: #f7f5ee; /* matches the demo body so a clipped iframe
|
| 38 |
+
blends seamlessly with the stage frame. */
|
| 39 |
+
overflow: hidden;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
iframe.demo {
|
| 43 |
+
position: absolute; inset: 0;
|
| 44 |
+
width: 100%; height: 100%;
|
| 45 |
+
border: 0;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* Scene-to-scene slide animation lives on .stage-frame (which carries
|
| 49 |
+
both iframes). The previous frame slides up off the top, content
|
| 50 |
+
swaps off-screen, the new frame slides in from below. */
|
| 51 |
+
.stage-frame { will-change: transform; }
|
| 52 |
+
.stage-frame.slide-out {
|
| 53 |
+
transition: transform 420ms cubic-bezier(.4, 0, .2, 1);
|
| 54 |
+
transform: translateY(-100%);
|
| 55 |
+
}
|
| 56 |
+
.stage-frame.slide-prep {
|
| 57 |
+
transition: none;
|
| 58 |
+
transform: translateY(100%);
|
| 59 |
+
}
|
| 60 |
+
.stage-frame.slide-in {
|
| 61 |
+
transition: transform 420ms cubic-bezier(.4, 0, .2, 1);
|
| 62 |
+
transform: translateY(0);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* Two iframes share the stage; we toggle which one is visible per
|
| 66 |
+
scene. The banner one points at /social-banner — a dedicated hero
|
| 67 |
+
page that already centers the banner inside its own viewport, so
|
| 68 |
+
we don't have to crop or scale anything ourselves. */
|
| 69 |
+
iframe.demo { z-index: 1; }
|
| 70 |
+
/* Banner/demo swap is hidden behind the .stage-frame slide (the
|
| 71 |
+
change happens off-screen between slide-out and slide-in), so the
|
| 72 |
+
opacity flip can be instant — a cross-fade here would overlap the
|
| 73 |
+
slide motion and read as a double animation. */
|
| 74 |
+
iframe.banner-iframe { z-index: 2; opacity: 0; pointer-events: none; }
|
| 75 |
+
iframe.banner-iframe.active { opacity: 1; pointer-events: auto; }
|
| 76 |
+
iframe.demo:not(.banner-iframe).hidden { opacity: 0; }
|
| 77 |
+
|
| 78 |
+
/* "Now playing" progress dots at the top. */
|
| 79 |
+
.timeline {
|
| 80 |
+
position: absolute;
|
| 81 |
+
top: 18px; left: 32px; right: 32px;
|
| 82 |
+
z-index: 5;
|
| 83 |
+
display: flex; gap: 6px;
|
| 84 |
+
pointer-events: none;
|
| 85 |
+
}
|
| 86 |
+
.timeline .dot {
|
| 87 |
+
flex: 1; height: 2px; background: rgba(0,0,0,0.12); border-radius: 1px;
|
| 88 |
+
overflow: hidden;
|
| 89 |
+
}
|
| 90 |
+
.timeline .dot::before {
|
| 91 |
+
content: ""; display: block; height: 100%; width: 0%;
|
| 92 |
+
background: var(--ink);
|
| 93 |
+
transition: width .2s linear;
|
| 94 |
+
}
|
| 95 |
+
.timeline .dot.done::before { width: 100% !important; }
|
| 96 |
+
|
| 97 |
+
/* Quiet "→" hint that fades in once the scene has settled, telling
|
| 98 |
+
the recorder they can advance whenever ready. */
|
| 99 |
+
.ready-hint {
|
| 100 |
+
position: absolute; right: 24px; bottom: 18px; z-index: 5;
|
| 101 |
+
font-family: "JetBrains Mono", monospace;
|
| 102 |
+
font-size: 20px; color: rgba(0,0,0,0.45);
|
| 103 |
+
opacity: 0; transition: opacity .8s ease;
|
| 104 |
+
pointer-events: none;
|
| 105 |
+
animation: nudge 1.8s ease-in-out infinite;
|
| 106 |
+
}
|
| 107 |
+
.ready-hint.show { opacity: 1; }
|
| 108 |
+
@keyframes nudge {
|
| 109 |
+
0%, 100% { transform: translateX(0); }
|
| 110 |
+
50% { transform: translateX(4px); }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/* Controls (hidden during recording with H). */
|
| 114 |
+
.controls {
|
| 115 |
+
position: fixed; top: 12px; right: 12px; z-index: 50;
|
| 116 |
+
display: flex; gap: 8px; align-items: center;
|
| 117 |
+
font-family: "JetBrains Mono", monospace;
|
| 118 |
+
font-size: 11px; color: #ccc;
|
| 119 |
+
background: rgba(0,0,0,0.55);
|
| 120 |
+
padding: 8px 10px; border-radius: 6px;
|
| 121 |
+
border: 1px solid rgba(255,255,255,0.1);
|
| 122 |
+
backdrop-filter: blur(6px);
|
| 123 |
+
}
|
| 124 |
+
.controls.hidden { opacity: 0; pointer-events: none; }
|
| 125 |
+
.controls button, .controls select {
|
| 126 |
+
font-family: inherit; font-size: 11px;
|
| 127 |
+
background: #1a1a1a; color: #eee;
|
| 128 |
+
border: 1px solid #444; border-radius: 3px;
|
| 129 |
+
padding: 4px 8px; cursor: pointer;
|
| 130 |
+
}
|
| 131 |
+
.controls button:hover { border-color: #888; }
|
| 132 |
+
.controls .hint { color: #888; font-size: 10px; }
|
| 133 |
+
.controls label.auto {
|
| 134 |
+
display: flex; align-items: center; gap: 5px;
|
| 135 |
+
color: #ccc; font-size: 11px;
|
| 136 |
+
padding: 4px 8px; border: 1px solid #444; border-radius: 3px;
|
| 137 |
+
cursor: pointer;
|
| 138 |
+
}
|
| 139 |
+
.controls label.auto input { margin: 0; cursor: pointer; }
|
| 140 |
+
|
| 141 |
+
/* Hint banner shown briefly on load. */
|
| 142 |
+
.toast {
|
| 143 |
+
position: fixed; left: 50%; bottom: 20px;
|
| 144 |
+
transform: translateX(-50%);
|
| 145 |
+
z-index: 60;
|
| 146 |
+
background: rgba(0,0,0,0.7);
|
| 147 |
+
color: #fff; padding: 8px 14px; border-radius: 4px;
|
| 148 |
+
font-family: "JetBrains Mono", monospace;
|
| 149 |
+
font-size: 11px; letter-spacing: 0.06em;
|
| 150 |
+
opacity: 0; transition: opacity .4s ease;
|
| 151 |
+
pointer-events: none;
|
| 152 |
+
}
|
| 153 |
+
.toast.show { opacity: 1; }
|
| 154 |
+
</style>
|
| 155 |
+
</head>
|
| 156 |
+
<body data-aspect="16-9">
|
| 157 |
+
|
| 158 |
+
<div class="controls" id="controls">
|
| 159 |
+
<button id="play">▶ play</button>
|
| 160 |
+
<button id="restart">↺ restart</button>
|
| 161 |
+
<label class="auto"><input type="checkbox" id="auto-toggle"> auto</label>
|
| 162 |
+
<span class="hint">H: hide · ←/→: scene · space: pause</span>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="toast" id="toast">press H to hide controls · → advances scenes</div>
|
| 166 |
+
|
| 167 |
+
<div class="stage">
|
| 168 |
+
<div class="stage-frame">
|
| 169 |
+
<iframe class="demo" id="demo" src="/demo" title="Carbon demo"></iframe>
|
| 170 |
+
<iframe class="demo banner-iframe" id="banner" src="/social-banner?format=og" title="Carbon hero"></iframe>
|
| 171 |
+
|
| 172 |
+
<div class="timeline" id="timeline"></div>
|
| 173 |
+
<div class="ready-hint" id="ready-hint">→</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<script>
|
| 178 |
+
// =============================================================
|
| 179 |
+
// Scene list — each entry drives one beat of the reel: switch to a
|
| 180 |
+
// tab, scroll to a target element, hold for `duration` ms, then
|
| 181 |
+
// slide-transition to the next scene.
|
| 182 |
+
// =============================================================
|
| 183 |
+
// useBanner: show the dedicated /social-banner iframe (a centred hero
|
| 184 |
+
// with no surrounding chrome) instead of the full demo. Used for the
|
| 185 |
+
// opening scene so the title shot is purpose-built and centred in the
|
| 186 |
+
// frame rather than cropped from the live demo.
|
| 187 |
+
//
|
| 188 |
+
// scrollOffset: extra whitespace above the scrolled-to section so the
|
| 189 |
+
// widget below sits comfortably in the lower part of the frame instead
|
| 190 |
+
// of being flush against the top edge.
|
| 191 |
+
const SCENES = [
|
| 192 |
+
{ kind: "scene", useBanner: true, duration: 6000 },
|
| 193 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#completion", scrollOffset: 120, duration: 6000 },
|
| 194 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#track", scrollOffset: 120, duration: 6000 },
|
| 195 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#vep", scrollOffset: 120, duration: 6000 },
|
| 196 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#species", scrollOffset: 120, duration: 6000 },
|
| 197 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#folding", scrollOffset: 120, duration: 6000 },
|
| 198 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#umap", scrollOffset: 120, duration: 6000 },
|
| 199 |
+
{ kind: "scene", tab: "dna-lab", scrollTo: "#speciesTree", scrollOffset: 120, duration: 6000 },
|
| 200 |
+
];
|
| 201 |
+
|
| 202 |
+
// Default to manual advance so the recorder can interact with each
|
| 203 |
+
// widget for as long as they want before moving on. Flip to true (or
|
| 204 |
+
// toggle "auto" in the controls) for hands-free playback.
|
| 205 |
+
let autoAdvance = false;
|
| 206 |
+
|
| 207 |
+
// --- helpers --------------------------------------------------
|
| 208 |
+
const $ = (id) => document.getElementById(id);
|
| 209 |
+
const iframe = $("demo");
|
| 210 |
+
const banner = $("banner");
|
| 211 |
+
const timeline = $("timeline");
|
| 212 |
+
|
| 213 |
+
// Once /social-banner has loaded: hide its format switcher, drop the
|
| 214 |
+
// outer cream "canvas" framing (body bg, body padding, box-shadow),
|
| 215 |
+
// and override the scale so the banner fills the iframe edge-to-edge
|
| 216 |
+
// instead of being clamped to its natural pixel size. Same-origin so
|
| 217 |
+
// direct DOM access is allowed.
|
| 218 |
+
banner.addEventListener("load", () => {
|
| 219 |
+
try {
|
| 220 |
+
const doc = banner.contentDocument;
|
| 221 |
+
const win = banner.contentWindow;
|
| 222 |
+
if (!doc || !win) return;
|
| 223 |
+
|
| 224 |
+
const sw = doc.querySelector(".sb-switcher");
|
| 225 |
+
if (sw) sw.style.display = "none";
|
| 226 |
+
|
| 227 |
+
const style = doc.createElement("style");
|
| 228 |
+
style.textContent = `
|
| 229 |
+
html, body {
|
| 230 |
+
background: #f7f5ee !important;
|
| 231 |
+
padding: 0 !important;
|
| 232 |
+
gap: 0 !important;
|
| 233 |
+
overflow: hidden !important;
|
| 234 |
+
}
|
| 235 |
+
/* Banner is the only body child in the reel; center it vertically
|
| 236 |
+
so any leftover slack (when the iframe aspect ratio doesn't match
|
| 237 |
+
the banner's) reads as symmetric letterbox margins rather than a
|
| 238 |
+
lone blank rectangle stacked below the banner. */
|
| 239 |
+
body { justify-content: center !important; }
|
| 240 |
+
.social-banner-stage { box-shadow: none !important; }
|
| 241 |
+
/* The reel only frames the banner stage; the brand contact-sheet
|
| 242 |
+
and OG thumbnail strip below it are out of scope for the tour. */
|
| 243 |
+
.sb-section-label,
|
| 244 |
+
.sb-logos,
|
| 245 |
+
.sb-thumbs { display: none !important; }
|
| 246 |
+
`;
|
| 247 |
+
doc.head.appendChild(style);
|
| 248 |
+
|
| 249 |
+
// Reel mode: instead of scaling a fixed-ratio stage into the
|
| 250 |
+
// iframe (which either letterboxes or crops, depending on Math.min
|
| 251 |
+
// vs Math.max), we override the stage dimensions to match the
|
| 252 |
+
// iframe exactly and keep scale at 1×. The banner's internal grid
|
| 253 |
+
// (wordmark left, helix right) reflows to the iframe's actual
|
| 254 |
+
// aspect ratio, so the helix and dot-paper background extend to
|
| 255 |
+
// every edge without cropping the wordmark.
|
| 256 |
+
const stage = doc.getElementById("sb-stage");
|
| 257 |
+
if (stage) {
|
| 258 |
+
const fit = () => {
|
| 259 |
+
stage.style.setProperty("--sb-w", win.innerWidth + "px");
|
| 260 |
+
stage.style.setProperty("--sb-h", win.innerHeight + "px");
|
| 261 |
+
stage.style.setProperty("--sb-scale", "1");
|
| 262 |
+
};
|
| 263 |
+
fit();
|
| 264 |
+
win.addEventListener("resize", fit);
|
| 265 |
+
// The parent reel page can also change the iframe size (e.g. when
|
| 266 |
+
// the browser window is resized); listen there too.
|
| 267 |
+
window.addEventListener("resize", fit);
|
| 268 |
+
}
|
| 269 |
+
} catch {}
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
// Inject reel-only styles into the demo iframe. Two jobs:
|
| 273 |
+
// 1. Hide the "Try it / What to look for" callouts (.takeaway) and
|
| 274 |
+
// "Run this from code" expandables (details.code-snippet) site-
|
| 275 |
+
// wide so each scene shows just the widget.
|
| 276 |
+
// 2. When body.reel-mode is active, hide *everything* except the
|
| 277 |
+
// one section flagged with .reel-current, and centre that
|
| 278 |
+
// section vertically in the viewport. This stops the next
|
| 279 |
+
// section peeking in below and keeps the widget on-screen.
|
| 280 |
+
// Only this iframe instance is affected — the live demo at /demo is
|
| 281 |
+
// untouched if you navigate there directly.
|
| 282 |
+
iframe.addEventListener("load", () => {
|
| 283 |
+
try {
|
| 284 |
+
const d = iframe.contentDocument;
|
| 285 |
+
if (!d) return;
|
| 286 |
+
const style = d.createElement("style");
|
| 287 |
+
style.textContent = `
|
| 288 |
+
.takeaway,
|
| 289 |
+
details.code-snippet { display: none !important; }
|
| 290 |
+
|
| 291 |
+
/* --- reel-mode: single-widget viewport ----------------------- */
|
| 292 |
+
body.reel-mode {
|
| 293 |
+
padding: 0 !important;
|
| 294 |
+
margin: 0 !important;
|
| 295 |
+
}
|
| 296 |
+
body.reel-mode > header.carbon-banner,
|
| 297 |
+
body.reel-mode > #tab-nav-sticky,
|
| 298 |
+
body.reel-mode > footer { display: none !important; }
|
| 299 |
+
body.reel-mode > .tab-panel { display: none !important; }
|
| 300 |
+
body.reel-mode > .tab-panel.active { display: block !important; }
|
| 301 |
+
|
| 302 |
+
body.reel-mode .tab-panel.active > .tab-lede { display: none !important; }
|
| 303 |
+
body.reel-mode .tab-panel.active .container.wide {
|
| 304 |
+
min-height: 100vh;
|
| 305 |
+
max-width: none;
|
| 306 |
+
/* Generous side padding so the section narrative + widget have
|
| 307 |
+
breathing room from the viewport edges. */
|
| 308 |
+
padding: 32px 96px;
|
| 309 |
+
display: flex;
|
| 310 |
+
align-items: center;
|
| 311 |
+
justify-content: center;
|
| 312 |
+
box-sizing: border-box;
|
| 313 |
+
}
|
| 314 |
+
body.reel-mode section.section--two-col { display: none !important; }
|
| 315 |
+
body.reel-mode section.section--two-col.reel-current {
|
| 316 |
+
display: grid !important;
|
| 317 |
+
width: 100%;
|
| 318 |
+
margin: 0 !important;
|
| 319 |
+
scroll-margin-top: 0 !important;
|
| 320 |
+
/* Optical-centre nudge: the section's narrative + widget reads
|
| 321 |
+
better seated slightly above true centre, with a touch more
|
| 322 |
+
paper visible below. */
|
| 323 |
+
transform: translateY(-50px);
|
| 324 |
+
}
|
| 325 |
+
/* The narrative rail is normally sticky under the page header;
|
| 326 |
+
in reel-mode there is no scroll, so park it back to static so
|
| 327 |
+
it sits next to the demo body instead of jumping to top:104px. */
|
| 328 |
+
body.reel-mode .section--two-col.reel-current .section-narrative {
|
| 329 |
+
position: static !important;
|
| 330 |
+
top: auto !important;
|
| 331 |
+
max-height: none !important;
|
| 332 |
+
overflow: visible !important;
|
| 333 |
+
}
|
| 334 |
+
`;
|
| 335 |
+
d.head.appendChild(style);
|
| 336 |
+
} catch {}
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
let idx = -1;
|
| 340 |
+
let timer = null;
|
| 341 |
+
let progressTimer = null;
|
| 342 |
+
let paused = false;
|
| 343 |
+
let progressStart = 0;
|
| 344 |
+
let progressDur = 0;
|
| 345 |
+
let started = false;
|
| 346 |
+
|
| 347 |
+
function buildTimeline() {
|
| 348 |
+
timeline.innerHTML = SCENES.map(() => `<div class="dot"></div>`).join("");
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const stageFrame = document.querySelector(".stage-frame");
|
| 352 |
+
const SLIDE_MS = 420;
|
| 353 |
+
|
| 354 |
+
// Slide transition. Drives .stage-frame (which holds both iframes) up
|
| 355 |
+
// off the top, applies the new scene's content while off-screen,
|
| 356 |
+
// snaps to translateY(100%), then slides back to translateY(0).
|
| 357 |
+
// applyScene runs after the slide-out, so the user only sees the new
|
| 358 |
+
// content emerge from the bottom.
|
| 359 |
+
function slideToNext(applyScene, then) {
|
| 360 |
+
stageFrame.classList.remove("slide-prep", "slide-in");
|
| 361 |
+
stageFrame.classList.add("slide-out");
|
| 362 |
+
setTimeout(() => {
|
| 363 |
+
applyScene();
|
| 364 |
+
// Brief settle window so the iframe's tab swap + scrollTo finish
|
| 365 |
+
// off-screen before the slide-in starts — otherwise the new scene
|
| 366 |
+
// re-lays-out mid-slide and reads as a layout flash.
|
| 367 |
+
setTimeout(() => {
|
| 368 |
+
stageFrame.classList.remove("slide-out");
|
| 369 |
+
stageFrame.classList.add("slide-prep");
|
| 370 |
+
void stageFrame.offsetWidth;
|
| 371 |
+
requestAnimationFrame(() => {
|
| 372 |
+
stageFrame.classList.remove("slide-prep");
|
| 373 |
+
stageFrame.classList.add("slide-in");
|
| 374 |
+
if (then) setTimeout(then, SLIDE_MS);
|
| 375 |
+
});
|
| 376 |
+
}, 180);
|
| 377 |
+
}, SLIDE_MS);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// Drive the iframe: switch tabs, scroll to an element, trigger actions.
|
| 381 |
+
function driveIframe(scene) {
|
| 382 |
+
const win = iframe.contentWindow;
|
| 383 |
+
const doc = iframe.contentDocument;
|
| 384 |
+
if (!doc || !win) return;
|
| 385 |
+
|
| 386 |
+
// Tab switch. The demo exposes window.setTab; fall back to clicking
|
| 387 |
+
// the tab button if it isn't there for any reason.
|
| 388 |
+
if (scene.tab) {
|
| 389 |
+
if (typeof win.setTab === "function") {
|
| 390 |
+
// scroll:false so the demo doesn't snap to top; we drive scroll
|
| 391 |
+
// ourselves a tick later. updateHash:false so we don't churn URL
|
| 392 |
+
// history with every scene.
|
| 393 |
+
win.setTab(scene.tab, { scroll: false, updateHash: false });
|
| 394 |
+
} else {
|
| 395 |
+
const btn = doc.querySelector(`#tab-nav .tab[data-tab="${scene.tab}"]`);
|
| 396 |
+
if (btn) btn.click();
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// Give the tab a tick to render before scrolling.
|
| 401 |
+
setTimeout(() => {
|
| 402 |
+
if (scene.scrollTo === "top") {
|
| 403 |
+
// Used by intro scenes (which are now served by the banner
|
| 404 |
+
// iframe, so this branch is just a safety net).
|
| 405 |
+
doc.body.classList.remove("reel-mode");
|
| 406 |
+
win.scrollTo({ top: 0, behavior: "auto" });
|
| 407 |
+
} else if (typeof scene.scrollTo === "string" && scene.scrollTo.startsWith("#")) {
|
| 408 |
+
// Single-widget viewport: hide every other section and centre
|
| 409 |
+
// this one. No actual scrolling — the CSS centres it in 100vh.
|
| 410 |
+
doc.body.classList.add("reel-mode");
|
| 411 |
+
doc.querySelectorAll("section.section--two-col.reel-current")
|
| 412 |
+
.forEach(s => s.classList.remove("reel-current"));
|
| 413 |
+
const el = doc.querySelector(scene.scrollTo);
|
| 414 |
+
if (el) {
|
| 415 |
+
el.classList.add("reel-current");
|
| 416 |
+
win.scrollTo({ top: 0, behavior: "auto" });
|
| 417 |
+
// Nudge widgets whose layout depends on a ResizeObserver /
|
| 418 |
+
// window resize — e.g. the §6 UMAP WebGL canvas — to re-fit
|
| 419 |
+
// against their newly-visible bounding rect.
|
| 420 |
+
win.dispatchEvent(new Event("resize"));
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
}, 60);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
function startProgress(durMs) {
|
| 428 |
+
const dot = timeline.children[idx];
|
| 429 |
+
if (!dot) return;
|
| 430 |
+
progressStart = performance.now();
|
| 431 |
+
progressDur = durMs;
|
| 432 |
+
const inner = dot.firstElementChild || dot; // dot uses ::before; animate inline
|
| 433 |
+
// We can't drive ::before width via JS, so apply width to the dot itself
|
| 434 |
+
// through a CSS variable + a child element.
|
| 435 |
+
dot.style.setProperty("--p", "0%");
|
| 436 |
+
// Use a real child for fill so it animates.
|
| 437 |
+
if (!dot.querySelector(".fill")) {
|
| 438 |
+
dot.innerHTML = `<div class="fill" style="height:100%;width:0%;background:#1a1a1a"></div>`;
|
| 439 |
+
}
|
| 440 |
+
const fill = dot.querySelector(".fill");
|
| 441 |
+
clearInterval(progressTimer);
|
| 442 |
+
progressTimer = setInterval(() => {
|
| 443 |
+
if (paused) return;
|
| 444 |
+
const t = Math.min(1, (performance.now() - progressStart) / progressDur);
|
| 445 |
+
fill.style.width = (t * 100).toFixed(1) + "%";
|
| 446 |
+
if (t >= 1) {
|
| 447 |
+
clearInterval(progressTimer);
|
| 448 |
+
dot.classList.add("done");
|
| 449 |
+
}
|
| 450 |
+
}, 33);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
function pauseProgress() {
|
| 454 |
+
paused = true;
|
| 455 |
+
clearTimeout(timer);
|
| 456 |
+
}
|
| 457 |
+
function resumeProgress() {
|
| 458 |
+
if (!paused) return;
|
| 459 |
+
paused = false;
|
| 460 |
+
// Resume the remaining slice of the current scene.
|
| 461 |
+
const elapsed = performance.now() - progressStart;
|
| 462 |
+
const remaining = Math.max(0, progressDur - elapsed);
|
| 463 |
+
timer = setTimeout(nextScene, remaining);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
// Move to the next scene.
|
| 467 |
+
function nextScene() {
|
| 468 |
+
clearTimeout(timer);
|
| 469 |
+
clearInterval(progressTimer);
|
| 470 |
+
idx++;
|
| 471 |
+
if (idx >= SCENES.length) { finish(); return; }
|
| 472 |
+
const s = SCENES[idx];
|
| 473 |
+
|
| 474 |
+
// Hide the "ready, press → " hint from the previous scene.
|
| 475 |
+
$("ready-hint").classList.remove("show");
|
| 476 |
+
|
| 477 |
+
// Swap iframe / drive scene state. Called from inside the slide
|
| 478 |
+
// transition so the content change is hidden off-screen.
|
| 479 |
+
const applyScene = () => {
|
| 480 |
+
if (s.useBanner) {
|
| 481 |
+
banner.classList.add("active");
|
| 482 |
+
} else {
|
| 483 |
+
banner.classList.remove("active");
|
| 484 |
+
driveIframe(s);
|
| 485 |
+
}
|
| 486 |
+
};
|
| 487 |
+
|
| 488 |
+
// First scene: no previous frame to slide out — just apply and
|
| 489 |
+
// start counting. Subsequent scenes use the up-then-from-below slide.
|
| 490 |
+
const onLanded = () => {
|
| 491 |
+
startProgress(s.duration);
|
| 492 |
+
if (autoAdvance) {
|
| 493 |
+
timer = setTimeout(nextScene, s.duration);
|
| 494 |
+
} else {
|
| 495 |
+
// Subtle "press → for next" hint, surfaces once the scene has
|
| 496 |
+
// had a moment to settle so the recorder knows it can advance.
|
| 497 |
+
setTimeout(() => {
|
| 498 |
+
if (!autoAdvance) $("ready-hint").classList.add("show");
|
| 499 |
+
}, s.duration);
|
| 500 |
+
}
|
| 501 |
+
};
|
| 502 |
+
|
| 503 |
+
if (idx === 0) {
|
| 504 |
+
applyScene();
|
| 505 |
+
// Give the iframe content one tick to lay out before timing starts.
|
| 506 |
+
setTimeout(onLanded, 200);
|
| 507 |
+
} else {
|
| 508 |
+
slideToNext(applyScene, onLanded);
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
function finish() {
|
| 513 |
+
// End of reel: leave the last scene on screen. User can hit restart.
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
function restart() {
|
| 517 |
+
clearTimeout(timer);
|
| 518 |
+
clearInterval(progressTimer);
|
| 519 |
+
// Reset all dots
|
| 520 |
+
Array.from(timeline.children).forEach(d => {
|
| 521 |
+
d.classList.remove("done");
|
| 522 |
+
d.innerHTML = "";
|
| 523 |
+
});
|
| 524 |
+
stageFrame.classList.remove("slide-out", "slide-prep", "slide-in");
|
| 525 |
+
idx = -1;
|
| 526 |
+
paused = false;
|
| 527 |
+
nextScene();
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
function skipForward() {
|
| 531 |
+
clearTimeout(timer);
|
| 532 |
+
clearInterval(progressTimer);
|
| 533 |
+
if (idx >= 0 && idx < timeline.children.length) {
|
| 534 |
+
timeline.children[idx].classList.add("done");
|
| 535 |
+
}
|
| 536 |
+
nextScene();
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function skipBackward() {
|
| 540 |
+
clearTimeout(timer);
|
| 541 |
+
clearInterval(progressTimer);
|
| 542 |
+
idx = Math.max(-1, idx - 2);
|
| 543 |
+
nextScene();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
// --- wiring ---------------------------------------------------
|
| 547 |
+
buildTimeline();
|
| 548 |
+
|
| 549 |
+
$("play").addEventListener("click", () => {
|
| 550 |
+
if (!started) {
|
| 551 |
+
started = true;
|
| 552 |
+
restart();
|
| 553 |
+
$("play").textContent = "⏸ pause";
|
| 554 |
+
} else if (paused) {
|
| 555 |
+
resumeProgress();
|
| 556 |
+
$("play").textContent = "⏸ pause";
|
| 557 |
+
} else {
|
| 558 |
+
pauseProgress();
|
| 559 |
+
$("play").textContent = "▶ resume";
|
| 560 |
+
}
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
$("restart").addEventListener("click", () => {
|
| 564 |
+
started = true;
|
| 565 |
+
$("play").textContent = "⏸ pause";
|
| 566 |
+
restart();
|
| 567 |
+
});
|
| 568 |
+
|
| 569 |
+
$("auto-toggle").addEventListener("change", (e) => {
|
| 570 |
+
autoAdvance = e.target.checked;
|
| 571 |
+
// If enabling mid-scene, kick off an auto-advance for the remainder
|
| 572 |
+
// of this scene's duration. (Cheap implementation: just nudge to the
|
| 573 |
+
// next scene immediately — fancier would resume from the elapsed
|
| 574 |
+
// ken-burns position.)
|
| 575 |
+
if (autoAdvance && idx >= 0) {
|
| 576 |
+
$("ready-hint").classList.remove("show");
|
| 577 |
+
clearTimeout(timer);
|
| 578 |
+
timer = setTimeout(skipForward, 600);
|
| 579 |
+
}
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
// Keyboard: H toggles chrome, arrows skip, space pauses, R restarts.
|
| 583 |
+
function handleKey(e) {
|
| 584 |
+
if (e.key === "h" || e.key === "H") {
|
| 585 |
+
$("controls").classList.toggle("hidden");
|
| 586 |
+
} else if (e.key === "ArrowRight") {
|
| 587 |
+
e.preventDefault(); skipForward();
|
| 588 |
+
} else if (e.key === "ArrowLeft") {
|
| 589 |
+
e.preventDefault(); skipBackward();
|
| 590 |
+
} else if (e.key === " ") {
|
| 591 |
+
// Ignore space when the user is typing in an input/textarea —
|
| 592 |
+
// otherwise pressing space in a sandbox prompt would pause the
|
| 593 |
+
// reel instead of inserting a space.
|
| 594 |
+
const t = e.target;
|
| 595 |
+
const tag = t && t.tagName;
|
| 596 |
+
if (tag === "INPUT" || tag === "TEXTAREA" || (t && t.isContentEditable)) return;
|
| 597 |
+
e.preventDefault();
|
| 598 |
+
$("play").click();
|
| 599 |
+
} else if (e.key === "r" || e.key === "R") {
|
| 600 |
+
restart(); started = true;
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
document.addEventListener("keydown", handleKey);
|
| 604 |
+
|
| 605 |
+
// Mirror the same handler inside each iframe so shortcuts keep working
|
| 606 |
+
// after the user has clicked into the iframe (which moves keyboard
|
| 607 |
+
// focus down into its document). Re-attached on every load() in case
|
| 608 |
+
// the iframe navigates.
|
| 609 |
+
function attachKeysToIframe(frame) {
|
| 610 |
+
frame.addEventListener("load", () => {
|
| 611 |
+
try {
|
| 612 |
+
frame.contentDocument?.addEventListener("keydown", handleKey);
|
| 613 |
+
} catch {}
|
| 614 |
+
});
|
| 615 |
+
}
|
| 616 |
+
attachKeysToIframe(iframe);
|
| 617 |
+
attachKeysToIframe(banner);
|
| 618 |
+
|
| 619 |
+
// Show the recording hint briefly.
|
| 620 |
+
window.addEventListener("load", () => {
|
| 621 |
+
const t = $("toast");
|
| 622 |
+
t.classList.add("show");
|
| 623 |
+
setTimeout(() => t.classList.remove("show"), 3500);
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
// Auto-start once the iframe content has had a moment to settle.
|
| 627 |
+
iframe.addEventListener("load", () => {
|
| 628 |
+
// Small delay so fonts, helix canvas, and tab JS are ready.
|
| 629 |
+
setTimeout(() => {
|
| 630 |
+
if (!started) {
|
| 631 |
+
started = true;
|
| 632 |
+
$("play").textContent = "⏸ pause";
|
| 633 |
+
restart();
|
| 634 |
+
}
|
| 635 |
+
}, 800);
|
| 636 |
+
});
|
| 637 |
+
</script>
|
| 638 |
+
</body>
|
| 639 |
+
</html>
|