lvwerra HF Staff Claude Opus 4.7 (1M context) commited on
Commit
7ae5802
·
1 Parent(s): d001974

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>

Files changed (5) hide show
  1. app.py +35 -13
  2. assets/js/sections/vep.js +10 -10
  3. assets/styles/section-vep.css +11 -0
  4. demo.html +54 -53
  5. social_reel.html +639 -0
app.py CHANGED
@@ -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. Reloading on edit happens via
80
- # uvicorn --reload in dev, which restarts the worker.
81
- _DEMO_HTML = _load_text(os.path.join(HERE, "demo.html"))
82
- _SOCIAL_BANNER_HTML = _load_text(os.path.join(HERE, "social-banner.html"))
83
- _ROBOTS_TXT = _load_text(os.path.join(HERE, "robots.txt"))
84
- _SITEMAP_XML = _load_text(os.path.join(HERE, "sitemap.xml"))
85
- _LLMS_TXT = _load_text(os.path.join(HERE, "llms.txt"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(_DEMO_HTML, site_url_for(request)))
181
 
182
 
183
  @app.get("/demo")
184
  def demo(request: Request):
185
- return HTMLResponse(render(_DEMO_HTML, site_url_for(request)))
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(_SOCIAL_BANNER_HTML, site_url_for(request)))
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(_ROBOTS_TXT, site_url_for(request)))
213
 
214
 
215
  @app.get("/sitemap.xml")
216
  def sitemap_xml(request: Request):
217
  return Response(
218
- content=render(_SITEMAP_XML, site_url_for(request)),
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(_LLMS_TXT, media_type="text/markdown; charset=utf-8")
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}
assets/js/sections/vep.js CHANGED
@@ -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 previous
242
- // (W=1000, rowH=32) viewBox was ~3.6:1 wide which scaled down to ~230 px
243
- // tall at column width, making the per-row text and bar values squint-
244
- // small. Bumping rowH and font sizes here, and padding generously,
245
- // gets us to roughly a 2.2:1 viewBox ~380 px rendered, where the
246
- // variant names and ±Δ values are readable without zoom.
 
247
  //
248
  // padT carries two stacked header lines (axis title above, then
249
- // VARIANT / ← LESS LIKELY / MORE LIKELY → row), so it has to be
250
- // a bit taller than a single-line caption would need. padB carries
251
- // the tick row + a two-line bottom caption.
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 }));
assets/styles/section-vep.css CHANGED
@@ -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;
demo.html CHANGED
@@ -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. By default we feed Carbon the
582
- intron just before the 2nd exon plus the first 35 bp of that exon, and ask it to
583
- <em>finish the exon</em>. The model streams the remaining bases one 6-base token at a
584
- time. Exons are the protein-coding parts of a gene and are under strong evolutionary
585
- constraint, so they should be the most predictable stretches of DNA. We overlay the
 
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">It sees structure without being told</div>
746
  <p class="lede">
747
- Carbon assigns every 6-base chunk a log-probability under the surrounding context: how
748
- "expected" that stretch of DNA is. Plot that score along a real gene and the curve dips
749
- and rises. We overlay the exon/intron annotation on top: confidence reliably climbs in
750
- protein-coding regions and falls in repetitive or unconstrained intronic stretches,
751
- even though the model never saw a single label. The same score, summed up, is what
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">It knows what's broken</div>
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 <em>same</em> log-likelihood, but as a yardstick for individual mutations.
871
- For a real ClinVar variant we score a ~4&nbsp;kb window of human DNA <em>two ways</em>
872
- (once with the original base, once with the mutation), and ask: which version looks more
873
- like real, functioning human sequence? Carbon was never told what "pathogenic" means; it
874
- just learned what natural DNA looks like. Variants that disrupt protein-coding or
875
- regulatory function show up as <em>less likely</em> sequence under the model's
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" id="d2-window"></div>
 
 
 
 
 
 
 
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">It knows who's who</div>
979
  <p class="lede">
980
- The same gene (insulin, p53) exists in mouse and chicken, but the surrounding sequence
981
- has accumulated different mutations along each lineage for hundreds of millions of
982
- years. For each species we hand Carbon up to ~400 bp leading into the 2nd exon and
983
- ask it to continue inside the exon. Each continuation should match <em>that species'</em>
984
- real DNA better than another species' would. The model handles closely-related species
985
- well (mouse, chicken, even though they're ~300 My from human); the further you go back
986
- in evolutionary time, the more the surrounding sequence drifts and the harder this
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 sequence to structure</div>
1110
  <p class="lede">
1111
- Show Carbon the first <em>75%</em> of a coding sequence, ask it to predict the remaining
1112
- <em>25%</em>, then translate and fold the resulting C-terminal stretch with ESMFold. Each
1113
- panel below pairs Carbon's predicted protein against the reference fold for the same
1114
- residues, so you can read at a glance whether the bases the model emitted assemble into a
1115
- biologically plausible structure or collapse into noise.
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">The genome, organized</div>
1209
  <p class="lede">
1210
- Embed 571,810 sequences from 27 species across six kingdoms (vertebrates,
1211
- invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D
1212
- with UMAP, color by anything. Switch the coloring and a completely different
1213
- organization emerges from the same points: the model's embedding space
1214
- carries multiple axes of biology at once, none of which were ever labeled.
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">Did Carbon learn the tree of life on its own?</div>
1278
  <p class="lede">
1279
- Take the same 571,810 sequences from §6, average each species' embeddings into a
1280
- single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering.
1281
- Carbon was never told what an "organism" is. Yet the resulting tree groups vertebrates
 
1282
  together, separates bacteria from fungi, and pairs sister clades (primates with
1283
- primates, rodents with rodents, monocots with monocots) without ever being shown a
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&nbsp;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
 
social_reel.html ADDED
@@ -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>