tfrere HF Staff Cursor commited on
Commit
187590e
·
2 Parent(s): 5dfaccc0659e8b

Merge branch 'main' of hf.co:spaces/HuggingFaceBio/carbon-demo

Browse files

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
# demo.html

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/layout.css CHANGED
@@ -48,6 +48,29 @@
48
  color: #2d2d2a;
49
  max-width: 760px;
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  /* Secondary "navigator" paragraph that follows the lede. Drops a step
52
  in size + weight + saturation so the eye reads it as a follow-up /
53
  table of contents rather than another full lede paragraph; the inline
 
48
  color: #2d2d2a;
49
  max-width: 760px;
50
  }
51
+ /* Release-hero figure (pareto frontier). Sits below the lede rail,
52
+ left-aligned with the text column so the figure feels anchored to the
53
+ announcement rather than floating. Caption mirrors the secondary-note
54
+ typography for visual continuity. */
55
+ .tab-lede__figure {
56
+ margin: 28px 0 0;
57
+ max-width: 640px;
58
+ padding: 0;
59
+ }
60
+ .tab-lede__figure img {
61
+ display: block;
62
+ width: 100%;
63
+ height: auto;
64
+ border: 1px solid var(--hairline);
65
+ }
66
+ .tab-lede__figure figcaption {
67
+ margin-top: 10px;
68
+ font-family: "Inter", "Helvetica Neue", sans-serif;
69
+ font-size: 13px;
70
+ line-height: 1.55;
71
+ color: #5b5b56;
72
+ }
73
+
74
  /* Secondary "navigator" paragraph that follows the lede. Drops a step
75
  in size + weight + saturation so the eye reads it as a follow-up /
76
  table of contents rather than another full lede paragraph; the inline
assets/styles/section-intro.css CHANGED
@@ -507,3 +507,148 @@
507
  }
508
  .cd-splice-arrows { width: 100%; }
509
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  }
508
  .cd-splice-arrows { width: 100%; }
509
  }
510
+
511
+ /* ------------------------------------------------------------------ */
512
+ /* §0 release lede · native Pareto chart. */
513
+ /* Replaces /img/pareto.png with an inline SVG built from */
514
+ /* pareto/pareto_data.csv. Geometry mirrors the matplotlib reference, */
515
+ /* but the chrome is pulled back to fit the editorial blog tone: */
516
+ /* hairline frame + tick lines instead of the 3px black box, mono */
517
+ /* tabular tick labels, plain text data labels with a paper-coloured */
518
+ /* paint-order halo (no pill box around each marker), and the */
519
+ /* "better/faster" indicator styled as a small mono uppercase eyebrow */
520
+ /* the same way as the section labels elsewhere on the page. Carbon */
521
+ /* points still scale up + use a bolder label so the eye lands on */
522
+ /* them first. */
523
+ /* ------------------------------------------------------------------ */
524
+ .tab-lede__figure--pareto {
525
+ /* Wider than the default tab-lede__figure so the long x-axis
526
+ (decade ticks 200 → 200k) doesn't squash the right-edge labels. */
527
+ max-width: 760px;
528
+ }
529
+ .pareto-chart {
530
+ display: block;
531
+ width: 100%;
532
+ height: auto;
533
+ background: #ffffff;
534
+ border: 1px solid #cfcdbf;
535
+ }
536
+ .pareto-bg {
537
+ fill: #ffffff;
538
+ }
539
+
540
+ /* Hairline frame at the same weight as the rest of the demo's
541
+ section borders — the chart reads as another paper card rather
542
+ than a heavy matplotlib export. */
543
+ .pareto-frame {
544
+ fill: none;
545
+ stroke: #cfcdbf;
546
+ stroke-width: 1;
547
+ }
548
+
549
+ /* Tick marks at the same hairline weight. Tick labels in JetBrains
550
+ Mono with tabular nums so the decade ticks line up tabularly and
551
+ the chart picks up the page's technical-mono register. Dimmed so
552
+ they read as scale references, not primary content. */
553
+ .pareto-axis line {
554
+ stroke: #cfcdbf;
555
+ stroke-width: 1;
556
+ }
557
+ .pareto-axis text {
558
+ font-family: "JetBrains Mono", ui-monospace, monospace;
559
+ font-size: 13px;
560
+ fill: var(--ink-soft);
561
+ font-feature-settings: "tnum";
562
+ }
563
+ .pareto-axis--y text {
564
+ text-anchor: end;
565
+ dominant-baseline: middle;
566
+ }
567
+ .pareto-axis--x text {
568
+ text-anchor: middle;
569
+ dominant-baseline: hanging;
570
+ }
571
+
572
+ /* Axis titles in Inter to match the page body; italic subtitle under
573
+ "Throughput" carries the units in the muted ink-soft tone. */
574
+ .pareto-axis-title {
575
+ font-family: "Inter", "Helvetica Neue", sans-serif;
576
+ font-size: 18px;
577
+ font-weight: 600;
578
+ fill: var(--ink);
579
+ text-anchor: middle;
580
+ }
581
+ .pareto-axis-subtitle {
582
+ font-family: "Inter", "Helvetica Neue", sans-serif;
583
+ font-size: 13px;
584
+ font-style: italic;
585
+ fill: var(--ink-soft);
586
+ text-anchor: middle;
587
+ }
588
+
589
+ /* "Better/faster" axes-of-improvement indicator in the lower-left.
590
+ Arrows in muted ink, labels in the same mono-uppercase eyebrow
591
+ style as the section labels (banner-links, section-num, etc.)
592
+ so the chart's chrome doesn't read as a foreign matplotlib glyph. */
593
+ .pareto-indicator line {
594
+ stroke: var(--ink-faint);
595
+ stroke-width: 1.5;
596
+ stroke-linecap: round;
597
+ }
598
+ .pareto-indicator polygon {
599
+ fill: var(--ink-faint);
600
+ }
601
+ .pareto-indicator-text {
602
+ font-family: "JetBrains Mono", ui-monospace, monospace;
603
+ font-size: 10px;
604
+ font-weight: 500;
605
+ letter-spacing: 0.14em;
606
+ text-transform: uppercase;
607
+ fill: var(--ink-faint);
608
+ text-anchor: middle;
609
+ dominant-baseline: middle;
610
+ }
611
+
612
+ /* 275× speedup arrow — the editorial headline. Solid ink, slightly
613
+ thinner than before so it doesn't overpower the chart. The label
614
+ gets a paper-coloured paint-order halo so it reads cleanly where
615
+ it crosses the arrow line behind it. */
616
+ .pareto-speedup line {
617
+ stroke: var(--ink);
618
+ stroke-width: 2.5;
619
+ stroke-linecap: round;
620
+ }
621
+ .pareto-speedup polygon {
622
+ fill: var(--ink);
623
+ }
624
+ .pareto-speedup-label {
625
+ font-family: "Inter", "Helvetica Neue", sans-serif;
626
+ font-size: 26px;
627
+ font-weight: 700;
628
+ fill: var(--ink);
629
+ text-anchor: middle;
630
+ paint-order: stroke;
631
+ stroke: #ffffff;
632
+ stroke-width: 6px;
633
+ stroke-linejoin: round;
634
+ }
635
+
636
+ /* Data labels: plain text, no pill box. The paint-order stroke acts
637
+ as a paper-coloured halo so the text always reads cleanly — even
638
+ when it sits next to a logo or crosses a tick line. Carbon labels
639
+ step up in size + weight so the highlighted models still pop. */
640
+ .pareto-label {
641
+ font-family: "Inter", "Helvetica Neue", sans-serif;
642
+ font-size: 13px;
643
+ fill: var(--ink);
644
+ text-anchor: middle;
645
+ dominant-baseline: middle;
646
+ paint-order: stroke;
647
+ stroke: #ffffff;
648
+ stroke-width: 4px;
649
+ stroke-linejoin: round;
650
+ }
651
+ .pareto-point--highlight .pareto-label {
652
+ font-size: 15px;
653
+ font-weight: 600;
654
+ }
assets/styles/section-vep.css CHANGED
@@ -7,6 +7,7 @@
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;
@@ -18,6 +19,16 @@
18
  arrow row, and mutation row stay character-for-character aligned. */
19
  overflow-x: auto;
20
  }
 
 
 
 
 
 
 
 
 
 
21
  .vep-window .vep-stack {
22
  display: inline-grid;
23
  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;
 
19
  arrow row, and mutation row stay character-for-character aligned. */
20
  overflow-x: auto;
21
  }
22
+ /* "pending" pill in the top-right corner, aligned with the edit-hint row.
23
+ Lives as a direct child of .vep-window (outside the JS-rebuilt content
24
+ div) so it persists across re-renders. */
25
+ .vep-window > .status {
26
+ position: absolute;
27
+ top: 16px; right: 20px;
28
+ margin-left: 0;
29
+ background: #f7f7f7; /* occludes any wrapped edit-hint */
30
+ z-index: 1;
31
+ }
32
  .vep-window .vep-stack {
33
  display: inline-grid;
34
  grid-template-columns: auto auto auto;
demo.html CHANGED
@@ -193,10 +193,15 @@
193
  </ul>
194
  <ul class="banner-links" aria-label="Resources">
195
  <li>
196
- <a href="https://huggingface.co/HuggingFaceBio/Carbon-3B" target="_blank" rel="noopener">
197
  Models<span class="arrow" aria-hidden="true">↗</span>
198
  </a>
199
  </li>
 
 
 
 
 
200
  <li>
201
  <a href="https://paperswithcode.co/paper/83340" target="_blank" rel="noopener">
202
  Tech report<span class="arrow" aria-hidden="true">↗</span>
@@ -273,6 +278,163 @@
273
  shipping with the full training code, the data pipeline, and the model weights.
274
  Everything is open source on the Hugging Face Hub.
275
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  </div>
277
  </div>
278
 
@@ -480,7 +642,7 @@
480
  <div class="section--two-col intro-subsection">
481
  <div class="section-narrative">
482
  <div class="section-num">§6 · Applications</div>
483
- <div class="section-title">From bases to outcomes</div>
484
  <p class="lede">
485
  A model that understands and writes DNA is useful wherever DNA is the
486
  input or the output. There are three interesting use-cases for such
@@ -543,16 +705,16 @@
543
  <div class="tab-lede__rail">
544
  <span class="tab-lede__eyebrow">Intro</span>
545
  <p>
546
- <strong>Carbon-3B</strong> is a 3-billion-parameter language model for DNA. We trained it
547
- on roughly 1&nbsp;trillion tokens (6&nbsp;trillion base pairs) of genomic sequence with a
548
- single objective: given some DNA, predict what comes next (six bases at a time,
549
- autoregressively). That's it: no annotations, no labels, no biology curriculum.
550
- Just <em>read DNA, predict more DNA</em>.
551
  </p>
552
  <p class="tab-lede__note">
553
- The interesting question is what else falls out of that. We didn't tell Carbon-3B what an
554
- exon is. We didn't tell it which mutations are pathogenic. We didn't tell it how genes
555
- differ between species. The sections below are ways to read what it picked up
556
  anyway: autocomplete a gene <a class="lede-chip" href="#completion">§1</a>, see
557
  structure emerge in its confidence <a class="lede-chip" href="#track">§2</a>, score
558
  a disease variant against a healthy one <a class="lede-chip" href="#vep">§3</a>,
@@ -576,11 +738,12 @@
576
  <div class="section-num">§1 · Autocomplete</div>
577
  <div class="section-title">Autocomplete for the genome</div>
578
  <p class="lede">
579
- Same idea as GPT completing a sentence, but for DNA. By default we feed Carbon the
580
- intron just before the 2nd exon plus the first 35 bp of that exon, and ask it to
581
- <em>finish the exon</em>. The model streams the remaining bases one 6-base token at a
582
- time. Exons are the protein-coding parts of a gene and are under strong evolutionary
583
- constraint, so they should be the most predictable stretches of DNA. We overlay the
 
584
  <em>real</em> exon/intron annotations on top of the output so you can compare what
585
  Carbon produces to what's actually there.
586
  </p>
@@ -740,14 +903,14 @@ print(tok.decode(new_ids))</code></pre></div>
740
  <section id="track" class="section--two-col">
741
  <div class="section-narrative">
742
  <div class="section-num">§2 · Structure</div>
743
- <div class="section-title">It sees structure without being told</div>
744
  <p class="lede">
745
- Carbon assigns every 6-base chunk a log-probability under the surrounding context: how
746
- "expected" that stretch of DNA is. Plot that score along a real gene and the curve dips
747
- and rises. We overlay the exon/intron annotation on top: confidence reliably climbs in
748
- protein-coding regions and falls in repetitive or unconstrained intronic stretches,
749
- even though the model never saw a single label. The same score, summed up, is what
750
- powers the variant-effect call in §3 below.
751
  </p>
752
  </div>
753
 
@@ -862,16 +1025,15 @@ for t, lp in zip(tok.convert_ids_to_tokens(ids[0, 1:].tolist()),
862
  <section id="vep" class="section--two-col">
863
  <div class="section-narrative">
864
  <div class="section-num">§3 · Variant effect</div>
865
- <div class="section-title">It knows what's broken</div>
866
  <p class="lede">
867
  §2 showed that Carbon's per-base confidence rises and falls in step with gene structure.
868
- Now we use the <em>same</em> log-likelihood, but as a yardstick for individual mutations.
869
- For a real ClinVar variant we score a ~4&nbsp;kb window of human DNA <em>two ways</em>
870
- (once with the original base, once with the mutation), and ask: which version looks more
871
- like real, functioning human sequence? Carbon was never told what "pathogenic" means; it
872
- just learned what natural DNA looks like. Variants that disrupt protein-coding or
873
- regulatory function show up as <em>less likely</em> sequence under the model's
874
- distribution.
875
  </p>
876
  </div>
877
 
@@ -880,16 +1042,18 @@ for t, lp in zip(tok.convert_ids_to_tokens(ids[0, 1:].tolist()),
880
  <div class="demo-toolbar">
881
  <span>variant</span>
882
  <span id="d2-pills" class="pills"></span>
883
- <span class="spacer"></span>
884
- <!-- Status pill: hidden by default, surfaces when an edit triggers
885
- a live rescore (or on the initial auto-score for a variant that
886
- isn't yet in the precomputed cache). -->
887
- <span class="status is-hidden" id="d2-status"><span class="dot"></span><span></span></span>
888
  </div>
889
 
890
  <div class="vep-gene-box" id="d2-gene-box">loading variants…</div>
891
 
892
- <div class="vep-window" id="d2-window"></div>
 
 
 
 
 
 
 
893
 
894
  <svg id="d2-bars" style="display:block;width:100%;height:auto;background:#fff;border:1px solid #eee;margin-top:12px" preserveAspectRatio="xMinYMin meet"></svg>
895
  </div>
@@ -973,16 +1137,15 @@ print(f"delta = {delta:+.2f} (less likely if negative)")</code></pre></div>
973
  <section id="species" class="section--two-col">
974
  <div class="section-narrative">
975
  <div class="section-num">§4 · Species</div>
976
- <div class="section-title">It knows who's who</div>
977
  <p class="lede">
978
- The same gene (insulin, p53) exists in mouse and chicken, but the surrounding sequence
979
- has accumulated different mutations along each lineage for hundreds of millions of
980
- years. For each species we hand Carbon up to ~400 bp leading into the 2nd exon and
981
- ask it to continue inside the exon. Each continuation should match <em>that species'</em>
982
- real DNA better than another species' would. The model handles closely-related species
983
- well (mouse, chicken, even though they're ~300 My from human); the further you go back
984
- in evolutionary time, the more the surrounding sequence drifts and the harder this
985
- setup becomes.
986
  </p>
987
  </div>
988
 
@@ -1104,13 +1267,13 @@ for name, ids in zip(species_prefixes, new_ids):
1104
  <section id="folding" class="section--two-col">
1105
  <div class="section-narrative">
1106
  <div class="section-num">§5 · Folding</div>
1107
- <div class="section-title">From sequence to structure</div>
1108
  <p class="lede">
1109
- Show Carbon the first <em>75%</em> of a coding sequence, ask it to predict the remaining
1110
- <em>25%</em>, then translate and fold the resulting C-terminal stretch with ESMFold. Each
1111
- panel below pairs Carbon's predicted protein against the reference fold for the same
1112
- residues, so you can read at a glance whether the bases the model emitted assemble into a
1113
- biologically plausible structure or collapse into noise.
1114
  </p>
1115
  </div>
1116
 
@@ -1203,13 +1366,13 @@ for name, ids in zip(species_prefixes, new_ids):
1203
  <section id="umap" class="section--two-col">
1204
  <div class="section-narrative">
1205
  <div class="section-num">§6 · Embedding space</div>
1206
- <div class="section-title">The genome, organized</div>
1207
  <p class="lede">
1208
- Embed 571,810 sequences from 27 species across six kingdoms (vertebrates,
1209
- invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D
1210
- with UMAP, color by anything. Switch the coloring and a completely different
1211
- organization emerges from the same points: the model's embedding space
1212
- carries multiple axes of biology at once, none of which were ever labeled.
1213
  </p>
1214
  </div>
1215
 
@@ -1272,14 +1435,14 @@ for name, ids in zip(species_prefixes, new_ids):
1272
  <section id="speciesTree" class="section--two-col">
1273
  <div class="section-narrative">
1274
  <div class="section-num">§7 · Species tree</div>
1275
- <div class="section-title">Did Carbon learn the tree of life on its own?</div>
1276
  <p class="lede">
1277
- Take the same 571,810 sequences from §6, average each species' embeddings into a
1278
- single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering.
1279
- Carbon was never told what an "organism" is. Yet the resulting tree groups vertebrates
 
1280
  together, separates bacteria from fungi, and pairs sister clades (primates with
1281
- primates, rodents with rodents, monocots with monocots) without ever being shown a
1282
- single taxonomic label.
1283
  </p>
1284
  </div>
1285
 
@@ -1365,12 +1528,14 @@ for name, ids in zip(species_prefixes, new_ids):
1365
  <span class="tab-lede__eyebrow">Intro</span>
1366
  <p>
1367
  Carbon's architecture is deliberately vanilla. What's <em>not</em> vanilla, and what
1368
- gets the headline numbers in the DNA Lab tab, is two things: a <strong>6-mer
1369
  tokenizer</strong> that lets the model see ~6&times; more genomic context per
1370
- forward pass, and a <strong>Factorized Nucleotide Supervision (FNS)</strong> loss
1371
  that gives the model partial credit for near-miss tokens once cross-entropy
1372
- training starts to wobble. Everything else (architecture, data mix, optimizer) is
1373
- standard recipe.
 
 
1374
  </p>
1375
  <p class="tab-lede__note">
1376
  The sections below walk through each of those choices: how the tokenizer changes
 
193
  </ul>
194
  <ul class="banner-links" aria-label="Resources">
195
  <li>
196
+ <a href="https://huggingface.co/collections/HuggingFaceBio/carbon" target="_blank" rel="noopener">
197
  Models<span class="arrow" aria-hidden="true">↗</span>
198
  </a>
199
  </li>
200
+ <li>
201
+ <a href="https://huggingface.co/datasets/HuggingFaceBio/carbon-pretraining-corpus" target="_blank" rel="noopener">
202
+ Dataset<span class="arrow" aria-hidden="true">↗</span>
203
+ </a>
204
+ </li>
205
  <li>
206
  <a href="https://paperswithcode.co/paper/83340" target="_blank" rel="noopener">
207
  Tech report<span class="arrow" aria-hidden="true">↗</span>
 
278
  shipping with the full training code, the data pipeline, and the model weights.
279
  Everything is open source on the Hugging Face Hub.
280
  </p>
281
+ <!-- Pareto chart, drawn natively as inline SVG so the figure scales
282
+ sharply, picks up the page's typography, and can be tuned in
283
+ CSS without a matplotlib re-export. Source data lives in
284
+ pareto/pareto_data.csv; geometry mirrors the matplotlib
285
+ reference (scratch/plot_pareto_winrate_throughput_8b_32k_hf.py):
286
+ log-scale throughput on x, linear win-rate % on y, family
287
+ badges sitting on each data point with a plain text label
288
+ below. Chrome is pulled back to match the editorial blog
289
+ tone — hairline frame + tick lines, mono tabular tick
290
+ labels, mono-uppercase "better/faster" eyebrow indicator —
291
+ and the data labels use a paint-order halo (see
292
+ .pareto-label in section-intro.css) instead of pill boxes.
293
+ Carbon points scale up + use a heavier label per the source
294
+ script's HIGHLIGHT_LOGO_SCALE so the eye lands on them. -->
295
+ <figure class="tab-lede__figure tab-lede__figure--pareto">
296
+ <svg
297
+ class="pareto-chart"
298
+ viewBox="0 0 1000 600"
299
+ xmlns="http://www.w3.org/2000/svg"
300
+ role="img"
301
+ aria-labelledby="pareto-title pareto-desc"
302
+ >
303
+ <title id="pareto-title">Throughput vs win rate across open DNA foundation models</title>
304
+ <desc id="pareto-desc">Log-scale throughput in base pairs per second on the x-axis and win-rate percentage on the y-axis. Carbon 3B and 8B sit at roughly 275 times the throughput of Arc Evo2 7B at comparable or better win rates.</desc>
305
+
306
+ <!-- Plot interior. -->
307
+ <rect class="pareto-bg" x="100" y="30" width="870" height="470"/>
308
+
309
+ <!-- Y axis: linear win-rate %, ticks at 0/20/40/60/80/100. The
310
+ plot range runs −12..108 (matches matplotlib padding) so
311
+ the data points have headroom above 100 and below 0 for
312
+ labels; only the canonical 0..100 ticks are drawn. -->
313
+ <g class="pareto-axis pareto-axis--y">
314
+ <line x1="94" y1="61.3" x2="100" y2="61.3"/>
315
+ <line x1="94" y1="139.7" x2="100" y2="139.7"/>
316
+ <line x1="94" y1="218.0" x2="100" y2="218.0"/>
317
+ <line x1="94" y1="296.3" x2="100" y2="296.3"/>
318
+ <line x1="94" y1="374.7" x2="100" y2="374.7"/>
319
+ <line x1="94" y1="453.0" x2="100" y2="453.0"/>
320
+ <text x="86" y="61.3">100</text>
321
+ <text x="86" y="139.7">80</text>
322
+ <text x="86" y="218.0">60</text>
323
+ <text x="86" y="296.3">40</text>
324
+ <text x="86" y="374.7">20</text>
325
+ <text x="86" y="453.0">0</text>
326
+ </g>
327
+
328
+ <!-- X axis: log10 base pairs/s. x-range chosen to mirror the
329
+ matplotlib auto-padding (left_pad/right_pad in the source);
330
+ ticks drop at decade + half-decade boundaries that fall
331
+ inside the range. -->
332
+ <g class="pareto-axis pareto-axis--x">
333
+ <line x1="163.4" y1="500" x2="163.4" y2="506"/>
334
+ <line x1="263.9" y1="500" x2="263.9" y2="506"/>
335
+ <line x1="339.9" y1="500" x2="339.9" y2="506"/>
336
+ <line x1="415.9" y1="500" x2="415.9" y2="506"/>
337
+ <line x1="516.4" y1="500" x2="516.4" y2="506"/>
338
+ <line x1="592.4" y1="500" x2="592.4" y2="506"/>
339
+ <line x1="668.5" y1="500" x2="668.5" y2="506"/>
340
+ <line x1="768.9" y1="500" x2="768.9" y2="506"/>
341
+ <line x1="844.9" y1="500" x2="844.9" y2="506"/>
342
+ <line x1="920.9" y1="500" x2="920.9" y2="506"/>
343
+ <text x="163.4" y="520">200</text>
344
+ <text x="263.9" y="520">500</text>
345
+ <text x="339.9" y="520">1k</text>
346
+ <text x="415.9" y="520">2k</text>
347
+ <text x="516.4" y="520">5k</text>
348
+ <text x="592.4" y="520">10k</text>
349
+ <text x="668.5" y="520">20k</text>
350
+ <text x="768.9" y="520">50k</text>
351
+ <text x="844.9" y="520">100k</text>
352
+ <text x="920.9" y="520">200k</text>
353
+ </g>
354
+
355
+ <!-- Plot frame drawn after the axis grid so the thick black
356
+ border sits cleanly on top of the tick lines. -->
357
+ <rect class="pareto-frame" x="100" y="30" width="870" height="470"/>
358
+
359
+ <!-- Axes-of-improvement indicator: a small ⌐ of grey arrows in
360
+ the lower-left labelled "better"/"faster", same as the
361
+ matplotlib reference. Placed at the 0-winrate gridline,
362
+ just inside the y-axis. -->
363
+ <g class="pareto-indicator" transform="translate(170 450)">
364
+ <line x1="0" y1="0" x2="0" y2="-70"/>
365
+ <polygon points="0,-78 -7,-66 7,-66"/>
366
+ <text class="pareto-indicator-text" transform="translate(-14 -35) rotate(-90)">better</text>
367
+ <line x1="0" y1="0" x2="70" y2="0"/>
368
+ <polygon points="78,0 66,-7 66,7"/>
369
+ <text class="pareto-indicator-text" x="35" y="20">faster</text>
370
+ </g>
371
+
372
+ <!-- 275× speedup arrow: starts just right of the Evo2 7B label
373
+ pill and lands just left of the Carbon 3B logo. y placed
374
+ between the two points (Evo2 7B at 64.3%, Carbon 3B at
375
+ 59.5%) so it reads as level with both. -->
376
+ <g class="pareto-speedup">
377
+ <line x1="290" y1="215" x2="822" y2="215"/>
378
+ <polygon points="836,215 820,206 820,224"/>
379
+ <text class="pareto-speedup-label" x="556" y="200">275×</text>
380
+ </g>
381
+
382
+ <!-- Data points. Coordinates baked in from pareto_data.csv:
383
+ x = 100 + (log10(T) − 2.0499) / 3.4452 × 870
384
+ y = 500 − (win_rate + 12) × 3.9167
385
+ Logos sit centered on each point (32×32 for non-highlight,
386
+ 43×43 for Carbon). Labels are pinned below the logo. -->
387
+
388
+ <!-- Evo2 20B · 177.5 bp/s, 95.24% -->
389
+ <g class="pareto-point">
390
+ <image href="/img/arc.webp" x="134.3" y="64.0" width="32" height="32"/>
391
+ <text class="pareto-label" x="150.3" y="110">Evo2 20B</text>
392
+ </g>
393
+
394
+ <!-- Evo2 7B · 453.8 bp/s, 64.29% -->
395
+ <g class="pareto-point">
396
+ <image href="/img/arc.webp" x="237.3" y="185.2" width="32" height="32"/>
397
+ <text class="pareto-label" x="253.3" y="231">Evo2 7B</text>
398
+ </g>
399
+
400
+ <!-- Evo2 1B · 1342.5 bp/s, 2.38% -->
401
+ <g class="pareto-point">
402
+ <image href="/img/arc.webp" x="356.2" y="427.7" width="32" height="32"/>
403
+ <text class="pareto-label" x="372.2" y="473">Evo2 1B</text>
404
+ </g>
405
+
406
+ <!-- GENERator-v2 3B · 98494.4 bp/s, 35.71% -->
407
+ <g class="pareto-point">
408
+ <image href="/img/generator.webp" x="828.7" y="297.1" width="32" height="32"/>
409
+ <text class="pareto-label" x="844.7" y="343">GENERator-v2 3B</text>
410
+ </g>
411
+
412
+ <!-- GENERator-v2 1.2B · 123219.2 bp/s, 14.29% -->
413
+ <g class="pareto-point">
414
+ <image href="/img/generator.webp" x="853.3" y="381.0" width="32" height="32"/>
415
+ <text class="pareto-label" x="869.3" y="427">GENERator-v2 1.2B</text>
416
+ </g>
417
+
418
+ <!-- Carbon 8B · 76582.7 bp/s, 78.57% (highlighted) -->
419
+ <g class="pareto-point pareto-point--highlight">
420
+ <image href="/img/logo.svg" x="795.6" y="123.7" width="43" height="43"/>
421
+ <text class="pareto-label" x="817.1" y="180">Carbon 8B</text>
422
+ </g>
423
+
424
+ <!-- Carbon 3B · 125130.8 bp/s, 59.52% (highlighted) -->
425
+ <g class="pareto-point pareto-point--highlight">
426
+ <image href="/img/logo.svg" x="849.5" y="198.3" width="43" height="43"/>
427
+ <text class="pareto-label" x="871.0" y="255">Carbon 3B</text>
428
+ </g>
429
+
430
+ <!-- Axis titles. Y title rotated -90 along the left margin, X
431
+ title + italic "Base pairs per second" subtitle below. -->
432
+ <text class="pareto-axis-title" transform="translate(34 265) rotate(-90)">Win rate (%)</text>
433
+ <text class="pareto-axis-title" x="535" y="558">Throughput</text>
434
+ <text class="pareto-axis-subtitle" x="535" y="582">Base pairs per second</text>
435
+ </svg>
436
+ <figcaption>Throughput (base pairs per second, log scale) vs win rate across open DNA foundation models. Carbon 3B matches Evo2 7B's win rate at roughly 275× the throughput.</figcaption>
437
+ </figure>
438
  </div>
439
  </div>
440
 
 
642
  <div class="section--two-col intro-subsection">
643
  <div class="section-narrative">
644
  <div class="section-num">§6 · Applications</div>
645
+ <div class="section-title">What can the model do in the real world?</div>
646
  <p class="lede">
647
  A model that understands and writes DNA is useful wherever DNA is the
648
  input or the output. There are three interesting use-cases for such
 
705
  <div class="tab-lede__rail">
706
  <span class="tab-lede__eyebrow">Intro</span>
707
  <p>
708
+ <strong>Carbon-3B</strong> is a 3-billion-parameter language model for DNA. It is trained on
709
+ roughly 1&nbsp;trillion tokens (6&nbsp;trillion base pairs) of genomic sequence with a simple
710
+ objective: given some DNA, predict what comes next (six bases at a time, autoregressively).
711
+ Even though the objective is simple the resulting model is versatile. In the DNA lab you can
712
+ explore all the cool things we can do with a DNA model.
713
  </p>
714
  <p class="tab-lede__note">
715
+ Carbon-3B was trained unsupervised besides some simple tags for species and gene biotypes.
716
+ It wasn't trained to tell which mutations are pathogenic or how genes differ between species.
717
+ The sections below highlight what it picked up
718
  anyway: autocomplete a gene <a class="lede-chip" href="#completion">§1</a>, see
719
  structure emerge in its confidence <a class="lede-chip" href="#track">§2</a>, score
720
  a disease variant against a healthy one <a class="lede-chip" href="#vep">§3</a>,
 
738
  <div class="section-num">§1 · Autocomplete</div>
739
  <div class="section-title">Autocomplete for the genome</div>
740
  <p class="lede">
741
+ Same idea as GPT completing a sentence, but for DNA. We feed the model a DNA sequence
742
+ as input and the model produces an output sequence. The model streams the bases one
743
+ 6-base token at a time. The model is better at predicting sequences of a gene's exons
744
+ because they are the protein-coding parts of a gene and are under strong evolutionary
745
+ constraint. As such they should be the most predictable stretches of DNA. The introns
746
+ serve regulatory purposes on the other hand and are harder to predict. We overlay the
747
  <em>real</em> exon/intron annotations on top of the output so you can compare what
748
  Carbon produces to what's actually there.
749
  </p>
 
903
  <section id="track" class="section--two-col">
904
  <div class="section-narrative">
905
  <div class="section-num">§2 · Structure</div>
906
+ <div class="section-title">Recognizing gene structure</div>
907
  <p class="lede">
908
+ The Carbon model assigns every 6-base chunk a log-probability under the surrounding
909
+ context: how "expected" or "likely" that stretch of DNA is. The plot with the scores
910
+ along a real gene shows the curve dips and rises. We overlay the exon/intron annotation
911
+ on top: confidence reliably climbs in protein-coding regions and falls in repetitive or
912
+ unconstrained intronic stretches, even though the model never saw a single label. The
913
+ same score, summed up, is what powers the variant-effect call in §3 below.
914
  </p>
915
  </div>
916
 
 
1025
  <section id="vep" class="section--two-col">
1026
  <div class="section-narrative">
1027
  <div class="section-num">§3 · Variant effect</div>
1028
+ <div class="section-title">Predicting mutation effects</div>
1029
  <p class="lede">
1030
  §2 showed that Carbon's per-base confidence rises and falls in step with gene structure.
1031
+ Now we use the same log-likelihood, but as a measure for individual mutations. For a
1032
+ real ClinVar variant we score a ~4&nbsp;kb window of human DNA two ways: once with the
1033
+ original base, once with the mutation. Then we check which version looks more like
1034
+ real, functioning human sequence. Carbon was never trained on what "pathogenic" means;
1035
+ it just learned what natural DNA looks like. Variants that disrupt protein-coding or
1036
+ regulatory function show up as less likely sequence under the model's distribution.
 
1037
  </p>
1038
  </div>
1039
 
 
1042
  <div class="demo-toolbar">
1043
  <span>variant</span>
1044
  <span id="d2-pills" class="pills"></span>
 
 
 
 
 
1045
  </div>
1046
 
1047
  <div class="vep-gene-box" id="d2-gene-box">loading variants…</div>
1048
 
1049
+ <div class="vep-window">
1050
+ <!-- Status pill: hidden by default, surfaces when an edit triggers
1051
+ a live rescore (or on the initial auto-score for a variant that
1052
+ isn't yet in the precomputed cache). Lives outside the content
1053
+ div below so it survives the innerHTML rebuilds in vep.js. -->
1054
+ <span class="status is-hidden" id="d2-status"><span class="dot"></span><span></span></span>
1055
+ <div id="d2-window"></div>
1056
+ </div>
1057
 
1058
  <svg id="d2-bars" style="display:block;width:100%;height:auto;background:#fff;border:1px solid #eee;margin-top:12px" preserveAspectRatio="xMinYMin meet"></svg>
1059
  </div>
 
1137
  <section id="species" class="section--two-col">
1138
  <div class="section-narrative">
1139
  <div class="section-num">§4 · Species</div>
1140
+ <div class="section-title">Species specific generation</div>
1141
  <p class="lede">
1142
+ The same gene (insulin, p53) exists in humans, mouse and chicken, but the surrounding
1143
+ sequence has accumulated different mutations along each lineage for hundreds of millions
1144
+ of years. For each species we feed Carbon up to ~400 bp and ask it to continue. Each
1145
+ continuation should match that species' real DNA better than another species' would.
1146
+ The model handles closely-related species well (mouse, chicken, even though they're
1147
+ ~300 My from human); the further you go back in evolutionary time, the more the
1148
+ surrounding sequence drifts and the harder this setup becomes.
 
1149
  </p>
1150
  </div>
1151
 
 
1267
  <section id="folding" class="section--two-col">
1268
  <div class="section-narrative">
1269
  <div class="section-num">§5 · Folding</div>
1270
+ <div class="section-title">From DNA to proteins</div>
1271
  <p class="lede">
1272
+ When Carbon completes a protein coding region in a gene, the resulting bases translate
1273
+ to a protein: a protein that folds. We feed the resulting sequence into ESMFold
1274
+ (similar to AlphaFold) and render the 3D structure inline, alongside the same protein
1275
+ folded from the reference sequence so you can see whether Carbon's continuation
1276
+ produced something similar.
1277
  </p>
1278
  </div>
1279
 
 
1366
  <section id="umap" class="section--two-col">
1367
  <div class="section-narrative">
1368
  <div class="section-num">§6 · Embedding space</div>
1369
+ <div class="section-title">Mapping out genomes</div>
1370
  <p class="lede">
1371
+ We embed 571,810 genes from 27 species across six kingdoms (vertebrates,
1372
+ invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D with UMAP,
1373
+ color by attributes. Depending on the attribute, different kinds of organizations
1374
+ emerge from the same points: the model's embedding space encodes multiple axes of
1375
+ biology at once, most of which were never labeled.
1376
  </p>
1377
  </div>
1378
 
 
1435
  <section id="speciesTree" class="section--two-col">
1436
  <div class="section-narrative">
1437
  <div class="section-num">§7 · Species tree</div>
1438
+ <div class="section-title">Reconstructing the tree of life</div>
1439
  <p class="lede">
1440
+ If we take the same 571,810 sequences from §6 and average each species' embeddings into
1441
+ a single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering,
1442
+ we can find species the model regards as closely related. Carbon was never trained on
1443
+ what the relation between organisms is. Yet the resulting tree groups vertebrates
1444
  together, separates bacteria from fungi, and pairs sister clades (primates with
1445
+ primates, rodents with rodents, monocots with monocots).
 
1446
  </p>
1447
  </div>
1448
 
 
1528
  <span class="tab-lede__eyebrow">Intro</span>
1529
  <p>
1530
  Carbon's architecture is deliberately vanilla. What's <em>not</em> vanilla, and what
1531
+ gets the headline numbers in the DNA Lab tab, is three things: a <strong>6-mer
1532
  tokenizer</strong> that lets the model see ~6&times; more genomic context per
1533
+ forward pass, a <strong>Factorized Nucleotide Supervision (FNS)</strong> loss
1534
  that gives the model partial credit for near-miss tokens once cross-entropy
1535
+ training starts to wobble, and a <strong>multi-stage curated data mixture</strong>,
1536
+ biased toward functional genomic regions. Everything else (architecture, optimizer)
1537
+ is standard recipe. The technical report details each choice and the ablations
1538
+ behind it.
1539
  </p>
1540
  <p class="tab-lede__note">
1541
  The sections below walk through each of those choices: how the tokenizer changes
img/arc.webp ADDED
img/generator.webp ADDED
img/pareto.png ADDED

Git LFS Details

  • SHA256: 2cee784b63d5f933f8a64ead2c5e7eeecb576c8593fd27e977f89b17ca5ecd0a
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
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>