Spaces:
Running
Running
Merge branch 'main' of hf.co:spaces/HuggingFaceBio/carbon-demo
Browse filesCo-authored-by: Cursor <cursoragent@cursor.com>
# Conflicts:
# demo.html
- app.py +35 -13
- assets/js/sections/vep.js +10 -10
- assets/styles/layout.css +23 -0
- assets/styles/section-intro.css +145 -0
- assets/styles/section-vep.css +11 -0
- demo.html +232 -67
- img/arc.webp +0 -0
- img/generator.webp +0 -0
- img/pareto.png +3 -0
- 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.
|
| 80 |
-
#
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
def render(template: str, site_url: str) -> str:
|
|
@@ -177,12 +191,12 @@ async def no_cache_dev_assets(request: Request, call_next):
|
|
| 177 |
|
| 178 |
@app.get("/")
|
| 179 |
def root(request: Request):
|
| 180 |
-
return HTMLResponse(render(
|
| 181 |
|
| 182 |
|
| 183 |
@app.get("/demo")
|
| 184 |
def demo(request: Request):
|
| 185 |
-
return HTMLResponse(render(
|
| 186 |
|
| 187 |
|
| 188 |
@app.get("/sandbox-only")
|
|
@@ -197,7 +211,7 @@ def social_banner(request: Request):
|
|
| 197 |
# sized to fit common social-media canvases (Twitter / OG / LinkedIn /
|
| 198 |
# HF). Used to grab cover-art screenshots without firing up the full
|
| 199 |
# demo page.
|
| 200 |
-
return HTMLResponse(render(
|
| 201 |
|
| 202 |
|
| 203 |
# ---------------------------------------------------------------------
|
|
@@ -209,13 +223,13 @@ def social_banner(request: Request):
|
|
| 209 |
|
| 210 |
@app.get("/robots.txt", response_class=PlainTextResponse)
|
| 211 |
def robots_txt(request: Request):
|
| 212 |
-
return PlainTextResponse(render(
|
| 213 |
|
| 214 |
|
| 215 |
@app.get("/sitemap.xml")
|
| 216 |
def sitemap_xml(request: Request):
|
| 217 |
return Response(
|
| 218 |
-
content=render(
|
| 219 |
media_type="application/xml",
|
| 220 |
)
|
| 221 |
|
|
@@ -226,7 +240,7 @@ def llms_txt():
|
|
| 226 |
# agents that need a compact map of the site without scraping the
|
| 227 |
# whole editorial page. No {{SITE_URL}} substitution: links are
|
| 228 |
# either site-relative or absolute to canonical external URLs.
|
| 229 |
-
return PlainTextResponse(
|
| 230 |
|
| 231 |
|
| 232 |
@app.get("/favicon.ico")
|
|
@@ -236,6 +250,14 @@ def favicon():
|
|
| 236 |
return RedirectResponse(url="/img/logo.svg", status_code=301)
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
@app.get("/config")
|
| 240 |
def config():
|
| 241 |
return {"model": MODEL_NAME}
|
|
|
|
| 76 |
# Templates loaded once at startup. demo.html and social-banner.html are
|
| 77 |
# large; reading them on every request would add ~100 us of syscall +
|
| 78 |
# parse overhead each time, which adds up under load. The substitution
|
| 79 |
+
# itself (a single str.replace) is cheap.
|
| 80 |
+
#
|
| 81 |
+
# DEV=1 disables the cache and re-reads from disk on every request so
|
| 82 |
+
# edits to demo.html / social-banner.html / robots / sitemap / llms show
|
| 83 |
+
# up on the next reload without restarting the server.
|
| 84 |
+
DEV = bool(os.environ.get("DEV"))
|
| 85 |
+
|
| 86 |
+
_TEMPLATE_PATHS = {
|
| 87 |
+
"demo": os.path.join(HERE, "demo.html"),
|
| 88 |
+
"social_banner": os.path.join(HERE, "social-banner.html"),
|
| 89 |
+
"robots": os.path.join(HERE, "robots.txt"),
|
| 90 |
+
"sitemap": os.path.join(HERE, "sitemap.xml"),
|
| 91 |
+
"llms": os.path.join(HERE, "llms.txt"),
|
| 92 |
+
}
|
| 93 |
+
_TEMPLATE_CACHE = {name: _load_text(path) for name, path in _TEMPLATE_PATHS.items()}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def template(name: str) -> str:
|
| 97 |
+
if DEV:
|
| 98 |
+
return _load_text(_TEMPLATE_PATHS[name])
|
| 99 |
+
return _TEMPLATE_CACHE[name]
|
| 100 |
|
| 101 |
|
| 102 |
def render(template: str, site_url: str) -> str:
|
|
|
|
| 191 |
|
| 192 |
@app.get("/")
|
| 193 |
def root(request: Request):
|
| 194 |
+
return HTMLResponse(render(template("demo"), site_url_for(request)))
|
| 195 |
|
| 196 |
|
| 197 |
@app.get("/demo")
|
| 198 |
def demo(request: Request):
|
| 199 |
+
return HTMLResponse(render(template("demo"), site_url_for(request)))
|
| 200 |
|
| 201 |
|
| 202 |
@app.get("/sandbox-only")
|
|
|
|
| 211 |
# sized to fit common social-media canvases (Twitter / OG / LinkedIn /
|
| 212 |
# HF). Used to grab cover-art screenshots without firing up the full
|
| 213 |
# demo page.
|
| 214 |
+
return HTMLResponse(render(template("social_banner"), site_url_for(request)))
|
| 215 |
|
| 216 |
|
| 217 |
# ---------------------------------------------------------------------
|
|
|
|
| 223 |
|
| 224 |
@app.get("/robots.txt", response_class=PlainTextResponse)
|
| 225 |
def robots_txt(request: Request):
|
| 226 |
+
return PlainTextResponse(render(template("robots"), site_url_for(request)))
|
| 227 |
|
| 228 |
|
| 229 |
@app.get("/sitemap.xml")
|
| 230 |
def sitemap_xml(request: Request):
|
| 231 |
return Response(
|
| 232 |
+
content=render(template("sitemap"), site_url_for(request)),
|
| 233 |
media_type="application/xml",
|
| 234 |
)
|
| 235 |
|
|
|
|
| 240 |
# agents that need a compact map of the site without scraping the
|
| 241 |
# whole editorial page. No {{SITE_URL}} substitution: links are
|
| 242 |
# either site-relative or absolute to canonical external URLs.
|
| 243 |
+
return PlainTextResponse(template("llms"), media_type="text/markdown; charset=utf-8")
|
| 244 |
|
| 245 |
|
| 246 |
@app.get("/favicon.ico")
|
|
|
|
| 250 |
return RedirectResponse(url="/img/logo.svg", status_code=301)
|
| 251 |
|
| 252 |
|
| 253 |
+
@app.get("/reel")
|
| 254 |
+
def reel():
|
| 255 |
+
# Scripted demo tour: loads /demo in an iframe and walks through the
|
| 256 |
+
# header → sandbox → DNA Lab §1-§7 with title cards and ken-burns
|
| 257 |
+
# transitions. Screen-record this page for socials.
|
| 258 |
+
return FileResponse(os.path.join(HERE, "social_reel.html"))
|
| 259 |
+
|
| 260 |
+
|
| 261 |
@app.get("/config")
|
| 262 |
def config():
|
| 263 |
return {"model": MODEL_NAME}
|
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
|
| 242 |
-
// (W=1000, rowH=32) viewBox was ~3.6:1 wide which scaled down
|
| 243 |
-
//
|
| 244 |
-
//
|
| 245 |
-
//
|
| 246 |
-
//
|
|
|
|
| 247 |
//
|
| 248 |
// padT carries two stacked header lines (axis title above, then
|
| 249 |
-
// VARIANT / ← LESS LIKELY / MORE LIKELY → row)
|
| 250 |
-
//
|
| 251 |
-
|
| 252 |
-
const W = 1000, rowH = 56, padL = 320, padR = 80, padT = 72, padB = 90;
|
| 253 |
// Sort variants by Δ ascending (most surprising-to-the-model first), but
|
| 254 |
// keep unscored ones at the bottom in their original order.
|
| 255 |
const indexed = VARIANTS.map((v, i) => ({ v, idx: i, d: cache[v.rs] ? cache[v.rs].altSum - cache[v.rs].refSum : null }));
|
|
|
|
| 238 |
function renderForestBars() {
|
| 239 |
if (!VARIANTS) return;
|
| 240 |
// Layout sized so the SVG renders comfortably tall in the right-hand
|
| 241 |
+
// column of the §3 two-col layout (~828 px of width). The original
|
| 242 |
+
// (W=1000, rowH=32) viewBox was ~3.6:1 wide which scaled down too
|
| 243 |
+
// short at column width, making the per-row text squint-small;
|
| 244 |
+
// (rowH=56) was the other extreme — readable but tall enough to
|
| 245 |
+
// overflow a single frame in the social reel. rowH=46 with tighter
|
| 246 |
+
// top/bottom padding lands at ~2.0:1 → ~415 px rendered, which
|
| 247 |
+
// still keeps variant names and ±Δ values legible without zoom.
|
| 248 |
//
|
| 249 |
// padT carries two stacked header lines (axis title above, then
|
| 250 |
+
// VARIANT / ← LESS LIKELY / MORE LIKELY → row). padB carries the
|
| 251 |
+
// tick row + a two-line bottom caption.
|
| 252 |
+
const W = 1000, rowH = 46, padL = 320, padR = 80, padT = 58, padB = 70;
|
|
|
|
| 253 |
// Sort variants by Δ ascending (most surprising-to-the-model first), but
|
| 254 |
// keep unscored ones at the bottom in their original order.
|
| 255 |
const indexed = VARIANTS.map((v, i) => ({ v, idx: i, d: cache[v.rs] ? cache[v.rs].altSum - cache[v.rs].refSum : null }));
|
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/
|
| 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">
|
| 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.
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
</p>
|
| 552 |
<p class="tab-lede__note">
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 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.
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
constraint
|
|
|
|
| 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">
|
| 744 |
<p class="lede">
|
| 745 |
-
Carbon assigns every 6-base chunk a log-probability under the surrounding
|
| 746 |
-
"expected" that stretch of DNA is.
|
| 747 |
-
and rises. We overlay the exon/intron annotation
|
| 748 |
-
protein-coding regions and falls in repetitive or
|
| 749 |
-
even though the model never saw a single label. The
|
| 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">
|
| 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
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
just learned what natural DNA looks like. Variants that disrupt protein-coding or
|
| 873 |
-
regulatory function show up as
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 977 |
<p class="lede">
|
| 978 |
-
The same gene (insulin, p53) exists in mouse and chicken, but the surrounding
|
| 979 |
-
has accumulated different mutations along each lineage for hundreds of millions
|
| 980 |
-
years. For each species we
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 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
|
| 1108 |
<p class="lede">
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 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">
|
| 1207 |
<p class="lede">
|
| 1208 |
-
|
| 1209 |
-
invertebrates, plants, fungi, bacteria, viruses) with Carbon, project to 2D
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 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">
|
| 1276 |
<p class="lede">
|
| 1277 |
-
|
| 1278 |
-
single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering
|
| 1279 |
-
|
|
|
|
| 1280 |
together, separates bacteria from fungi, and pairs sister clades (primates with
|
| 1281 |
-
primates, rodents with rodents, monocots with monocots)
|
| 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
|
| 1369 |
tokenizer</strong> that lets the model see ~6× more genomic context per
|
| 1370 |
-
forward pass,
|
| 1371 |
that gives the model partial credit for near-miss tokens once cross-entropy
|
| 1372 |
-
training starts to wobble
|
| 1373 |
-
|
|
|
|
|
|
|
| 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 trillion tokens (6 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 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× 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
|
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>
|