tfrere HF Staff Cursor commited on
Commit
b64beb5
·
1 Parent(s): 1cc98cf

Sandbox card layout, sticky-nav breadcrumb, drop §2/§3 score buttons

Browse files

- Sandbox: refactor into stacked cards (sb-card with eyebrow/title/hint
headers, Input → Output) plus a Connected-to strip up top, replacing
the flat wall of controls.
- Sticky nav: add a CARBON / huggingfacebio-carbon-3b breadcrumb on the
left of the sticky strip so the "you're on the Carbon page" cue stays
visible after the hero scrolls out.
- §2 (VEP) and §3 (Track): drop the manual ▶ score / score all buttons.
§3 tracks are precomputed (logprobs ship in data/genes.json), so the
toolbar is just a gene selector now; §2 will auto-score on select.
- Misc: app.py, precompute.py, controls.css, layout.css, section-tree.css,
banner.js and data/genes.json picked up along the way.
- Add social-banner.html.

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

app.py CHANGED
@@ -146,6 +146,15 @@ def sandbox_only():
146
  return FileResponse(os.path.join(HERE, "index.html"))
147
 
148
 
 
 
 
 
 
 
 
 
 
149
  @app.get("/config")
150
  def config():
151
  return {"model": MODEL_NAME, "endpoint": ENDPOINT_URL}
 
146
  return FileResponse(os.path.join(HERE, "index.html"))
147
 
148
 
149
+ @app.get("/social-banner")
150
+ def social_banner():
151
+ # Standalone hero — wordmark + subtitle + specs + animated DNA helix,
152
+ # sized to fit common social-media canvases (Twitter / OG / LinkedIn /
153
+ # HF). Used to grab cover-art screenshots without firing up the full
154
+ # demo page.
155
+ return FileResponse(os.path.join(HERE, "social-banner.html"))
156
+
157
+
158
  @app.get("/config")
159
  def config():
160
  return {"model": MODEL_NAME, "endpoint": ENDPOINT_URL}
assets/js/banner.js CHANGED
@@ -27,7 +27,7 @@
27
  // Numbers tuned for a hero that *dominates* the right half of the banner:
28
  // big amplitude, thick ribbons, oversized ATCG glyphs.
29
  const helix = {
30
- startY: 0, endY: 1100, centerX: 220, amplitude: 165,
31
  cycles: 4.0, speed: 0.00015,
32
  rungCount: 26,
33
  // Dense sampling: each strand section is rendered as a continuous
@@ -37,14 +37,14 @@
37
  // 4 fills + 8 stroke calls per frame total (one fill per back/front
38
  // section per strand, plus the 4 edge polylines).
39
  segmentCount: 512,
40
- // Strand half-thickness in viewBox units. Trimmed 14 → 11 so the ribbon
41
- // reads slimmer/more technical and the saturated green edge gets to do
42
- // the heavy lifting rather than the cream body fill.
43
- bodyRadius: 11,
44
  rungInset: 16, glyphGap: 30,
45
  };
46
- // Helix bbox in viewBox coords. Width gives room for amplitude (±165 around
47
- // centerX=220 → wave reaches x=55..385, fits comfortably in 440-wide VB).
48
  const VB = { x: 0, y: -30, w: 440, h: 1160 };
49
 
50
  const COLORS = {
@@ -311,11 +311,11 @@
311
  edgePath(points, from, to, -(helix.bodyRadius - 0.2));
312
  ctx.stroke();
313
 
314
- // Dark forest edge at the outer rim — the primary depth cue. Slightly
315
- // wider than before (1.6 → 2.0) so the green reads as a deliberate
316
- // outline rather than a hairline.
317
  ctx.strokeStyle = grads.edge;
318
- ctx.lineWidth = px(2.0);
319
  edgePath(points, from, to, helix.bodyRadius + 0.4);
320
  ctx.stroke();
321
  edgePath(points, from, to, -(helix.bodyRadius + 0.4));
 
27
  // Numbers tuned for a hero that *dominates* the right half of the banner:
28
  // big amplitude, thick ribbons, oversized ATCG glyphs.
29
  const helix = {
30
+ startY: 0, endY: 1100, centerX: 220, amplitude: 150,
31
  cycles: 4.0, speed: 0.00015,
32
  rungCount: 26,
33
  // Dense sampling: each strand section is rendered as a continuous
 
37
  // 4 fills + 8 stroke calls per frame total (one fill per back/front
38
  // section per strand, plus the 4 edge polylines).
39
  segmentCount: 512,
40
+ // Strand half-thickness in viewBox units. Trimmed 14 → 11 → 8 so the
41
+ // ribbon reads slimmer/more technical and the saturated green edge gets
42
+ // to do the heavy lifting rather than the cream body fill.
43
+ bodyRadius: 8,
44
  rungInset: 16, glyphGap: 30,
45
  };
46
+ // Helix bbox in viewBox coords. Width gives room for amplitude (±138 around
47
+ // centerX=220 → wave reaches x=82..358, fits comfortably in 440-wide VB).
48
  const VB = { x: 0, y: -30, w: 440, h: 1160 };
49
 
50
  const COLORS = {
 
311
  edgePath(points, from, to, -(helix.bodyRadius - 0.2));
312
  ctx.stroke();
313
 
314
+ // Dark forest edge at the outer rim — the primary depth cue. Kept on
315
+ // the bolder side (1.6 → 2.0 → 1.6) so even with the slimmer ribbon
316
+ // the green outline still reads as a deliberate stroke.
317
  ctx.strokeStyle = grads.edge;
318
+ ctx.lineWidth = px(1.6);
319
  edgePath(points, from, to, helix.bodyRadius + 0.4);
320
  ctx.stroke();
321
  edgePath(points, from, to, -(helix.bodyRadius + 0.4));
assets/js/sections/sandbox.js CHANGED
@@ -312,7 +312,12 @@
312
  }
313
  function setStatus(text, mode = "") {
314
  els.statusText.textContent = text;
315
- els.status.className = "sb-status" + (mode ? " " + mode : "");
 
 
 
 
 
316
  }
317
 
318
  els.modeBtns.querySelectorAll(".sb-mode-btn").forEach(b => {
 
312
  }
313
  function setStatus(text, mode = "") {
314
  els.statusText.textContent = text;
315
+ // Hide the pill outright in the idle/empty state same "no idle UI"
316
+ // pattern used by the other demos in §1–§5. The pill comes back as
317
+ // soon as setStatus is called with a meaningful state ("connecting…",
318
+ // "streaming", "done", an error message, etc.).
319
+ const hide = !text || text === "idle";
320
+ els.status.className = "sb-status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
321
  }
322
 
323
  els.modeBtns.querySelectorAll(".sb-mode-btn").forEach(b => {
assets/js/sections/track.js CHANGED
@@ -1,5 +1,10 @@
1
  // =========================================================================
2
  // §3 — Likelihood track over a real gene
 
 
 
 
 
3
  // =========================================================================
4
  (function initDemo3() {
5
  const els = {
@@ -8,9 +13,6 @@
8
  track: document.getElementById("d3-track"),
9
  chart: document.getElementById("d3-chart"),
10
  bpLabel: document.getElementById("d3-bp-label"),
11
- go: document.getElementById("d3-go"),
12
- status: document.getElementById("d3-status"),
13
- statusText: document.querySelector("#d3-status span:last-child"),
14
  meanExon: document.getElementById("d3-mean-exon"),
15
  meanIntron: document.getElementById("d3-mean-intron"),
16
  delta: document.getElementById("d3-delta"),
@@ -20,16 +22,9 @@
20
 
21
  let gene = null;
22
  let scoreData = null; // { tokens, token_logprobs, scoredLength }
23
- const cache = {}; // cache scored data by gene symbol so re-clicking is instant
24
  const MAX_WINDOW = 6000;
25
 
26
- function setStatus(text, mode = "") {
27
- els.statusText.textContent = text;
28
- // See §1 for the "no idle pill" rationale.
29
- const hide = !text || text === "idle";
30
- els.status.className = "status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
31
- }
32
-
33
  function renderTrack(scoredLen) {
34
  const W = 1000, H = 28;
35
  if (!gene) { els.track.innerHTML = ""; return; }
@@ -49,7 +44,7 @@
49
  function renderChart() {
50
  const W = 1000, H = 140, padT = 6, padB = 16;
51
  if (!scoreData || !gene) {
52
- els.chart.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— score the gene to see the likelihood track —</text>`;
53
  return;
54
  }
55
  const tokens = scoreData.tokens;
@@ -188,53 +183,18 @@
188
  [els.meanExon, els.meanIntron, els.delta, els.tokens, els.mean].forEach(e => e.classList.remove("muted"));
189
  }
190
 
191
- async function score() {
192
- if (!gene) return;
193
- const cached = cache[gene.symbol];
194
- if (cached) {
195
- scoreData = cached;
196
- renderTrack(scoreData.scoredLength);
197
- renderChart();
198
- updateStats();
199
- setStatus("cached");
200
- return;
201
- }
202
- setStatus("scoring (cold endpoint takes ~30s)…", "streaming");
203
- els.go.disabled = true;
204
- try {
205
- const seq = gene.seq.slice(0, MAX_WINDOW);
206
- const r = await fetch("/score", {
207
- method: "POST",
208
- headers: { "Content-Type": "application/json" },
209
- body: JSON.stringify({ sequence: seq, max_window: MAX_WINDOW }),
210
- });
211
- const data = await r.json();
212
- if (data.error) throw new Error(data.error);
213
- data.scoredLength = seq.length;
214
- cache[gene.symbol] = data;
215
- scoreData = data;
216
- renderTrack(data.scoredLength);
217
- renderChart();
218
- updateStats();
219
- setStatus("done");
220
- } catch (e) {
221
- setStatus(e.message, "error");
222
- } finally {
223
- els.go.disabled = false;
224
- }
225
- }
226
-
227
  function selectGene(symbol) {
228
  const g = GENES.find(x => x.symbol === symbol);
229
  if (!g) return;
230
  gene = g;
231
  els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
232
- els.info.innerHTML = `<strong>${gene.symbol}</strong> · ${gene.blurb} · <span style="color:#888">${Math.min(gene.length, MAX_WINDOW).toLocaleString("en-US")} bp will be scored${gene.length > MAX_WINDOW ? ` (of ${gene.length.toLocaleString("en-US")})` : ""}</span>`;
 
 
233
  scoreData = cache[symbol] || null;
234
  renderTrack(scoreData ? scoreData.scoredLength : Math.min(gene.length, MAX_WINDOW));
235
  renderChart();
236
  updateStats();
237
- setStatus(scoreData ? "cached" : "idle");
238
  }
239
 
240
  loadGenes().then(genes => {
@@ -257,7 +217,5 @@
257
  });
258
  selectGene(genes[0].symbol);
259
  });
260
-
261
- els.go.addEventListener("click", score);
262
  })();
263
 
 
1
  // =========================================================================
2
  // §3 — Likelihood track over a real gene
3
+ //
4
+ // All tracks are precomputed (each gene in data/genes.json ships with
5
+ // its token logprobs under `track`), so this section is read-only: pick
6
+ // a gene, the cached track is rendered instantly. There is no "score"
7
+ // button — rescoring would just replay numbers we already have.
8
  // =========================================================================
9
  (function initDemo3() {
10
  const els = {
 
13
  track: document.getElementById("d3-track"),
14
  chart: document.getElementById("d3-chart"),
15
  bpLabel: document.getElementById("d3-bp-label"),
 
 
 
16
  meanExon: document.getElementById("d3-mean-exon"),
17
  meanIntron: document.getElementById("d3-mean-intron"),
18
  delta: document.getElementById("d3-delta"),
 
22
 
23
  let gene = null;
24
  let scoreData = null; // { tokens, token_logprobs, scoredLength }
25
+ const cache = {}; // by gene symbol hydrated from precomputed tracks
26
  const MAX_WINDOW = 6000;
27
 
 
 
 
 
 
 
 
28
  function renderTrack(scoredLen) {
29
  const W = 1000, H = 28;
30
  if (!gene) { els.track.innerHTML = ""; return; }
 
44
  function renderChart() {
45
  const W = 1000, H = 140, padT = 6, padB = 16;
46
  if (!scoreData || !gene) {
47
+ els.chart.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— no precomputed track for this gene —</text>`;
48
  return;
49
  }
50
  const tokens = scoreData.tokens;
 
183
  [els.meanExon, els.meanIntron, els.delta, els.tokens, els.mean].forEach(e => e.classList.remove("muted"));
184
  }
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  function selectGene(symbol) {
187
  const g = GENES.find(x => x.symbol === symbol);
188
  if (!g) return;
189
  gene = g;
190
  els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
191
+ const scoredBp = Math.min(gene.length, MAX_WINDOW).toLocaleString("en-US");
192
+ const totalBp = gene.length.toLocaleString("en-US");
193
+ els.info.innerHTML = `<strong>${gene.symbol}</strong> · ${gene.blurb} · <span style="color:#888">${scoredBp} bp scored${gene.length > MAX_WINDOW ? ` (of ${totalBp})` : ""}</span>`;
194
  scoreData = cache[symbol] || null;
195
  renderTrack(scoreData ? scoreData.scoredLength : Math.min(gene.length, MAX_WINDOW));
196
  renderChart();
197
  updateStats();
 
198
  }
199
 
200
  loadGenes().then(genes => {
 
217
  });
218
  selectGene(genes[0].symbol);
219
  });
 
 
220
  })();
221
 
assets/js/sections/vep.js CHANGED
@@ -1,5 +1,11 @@
1
  // =========================================================================
2
  // §2 — VEP: ref vs alt allele likelihood
 
 
 
 
 
 
3
  // =========================================================================
4
  (function initDemo2() {
5
  const els = {
@@ -8,23 +14,12 @@
8
  window: document.getElementById("d2-window"),
9
  result: document.getElementById("d2-result"),
10
  bars: document.getElementById("d2-bars"),
11
- go: document.getElementById("d2-go"),
12
- all: document.getElementById("d2-all"),
13
- status: document.getElementById("d2-status"),
14
- statusText: document.querySelector("#d2-status span:last-child"),
15
  };
16
 
17
  let VARIANTS = null;
18
  let selected = null;
19
  const cache = {}; // by rs id → { refSum, altSum, refLps, altLps }
20
 
21
- function setStatus(text, mode = "") {
22
- els.statusText.textContent = text;
23
- // See §1 for the "no idle pill" rationale.
24
- const hide = !text || text === "idle";
25
- els.status.className = "status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
26
- }
27
-
28
  function altWindow(v) {
29
  return v.ref_window.slice(0, v.var_offset) + v.alt + v.ref_window.slice(v.var_offset + 1);
30
  }
@@ -42,7 +37,7 @@
42
  if (!v) { els.result.innerHTML = ""; return; }
43
  const c = cache[v.rs];
44
  if (!c) {
45
- els.result.innerHTML = `<div style="grid-column:1/-1;color:#aaa;font-style:italic">click "score" to compute likelihoods…</div>`;
46
  return;
47
  }
48
  // Map sums to a common scale: take min/max across both for visual ratio.
@@ -81,7 +76,7 @@
81
  els.bars.setAttribute("height", H);
82
 
83
  if (!scored.length) {
84
- els.bars.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— score variants to populate the comparison —</text>`;
85
  return;
86
  }
87
  const absMax = Math.max(2, ...scored.map(x => Math.abs(x.d)));
@@ -171,69 +166,6 @@
171
  els.bars.innerHTML = svg;
172
  }
173
 
174
- async function scoreOne(v) {
175
- if (cache[v.rs]) return cache[v.rs];
176
- const ref = v.ref_window;
177
- const alt = altWindow(v);
178
- // Score both in parallel
179
- const [refResp, altResp] = await Promise.all([
180
- fetch("/score", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ sequence: ref }) }).then(r => r.json()),
181
- fetch("/score", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ sequence: alt }) }).then(r => r.json()),
182
- ]);
183
- if (refResp.error) throw new Error("ref: " + refResp.error);
184
- if (altResp.error) throw new Error("alt: " + altResp.error);
185
- const sumLp = (lps) => {
186
- let s = 0, n = 0;
187
- for (const lp of lps) {
188
- if (lp != null && !isNaN(lp)) { s += lp; n++; }
189
- }
190
- return { sum: s, n };
191
- };
192
- const r = sumLp(refResp.token_logprobs);
193
- const a = sumLp(altResp.token_logprobs);
194
- const result = {
195
- refSum: r.sum, altSum: a.sum, n: r.n,
196
- refLps: refResp.token_logprobs, altLps: altResp.token_logprobs,
197
- };
198
- cache[v.rs] = result;
199
- return result;
200
- }
201
-
202
- async function scoreSelected() {
203
- if (!selected) return;
204
- setStatus(`scoring ${selected.name}…`, "streaming");
205
- els.go.disabled = true; els.all.disabled = true;
206
- try {
207
- await scoreOne(selected);
208
- renderResult(selected);
209
- renderForestBars();
210
- setStatus("done");
211
- } catch (e) {
212
- setStatus(e.message, "error");
213
- } finally {
214
- els.go.disabled = false; els.all.disabled = false;
215
- }
216
- }
217
-
218
- async function scoreAll() {
219
- setStatus("scoring all…", "streaming");
220
- els.go.disabled = true; els.all.disabled = true;
221
- try {
222
- // Sequential to be polite to the endpoint and to allow incremental UI updates.
223
- for (const v of VARIANTS) {
224
- if (cache[v.rs]) continue;
225
- await scoreOne(v);
226
- renderForestBars();
227
- }
228
- if (selected) renderResult(selected);
229
- setStatus("done");
230
- } catch (e) {
231
- setStatus(e.message, "error");
232
- } finally {
233
- els.go.disabled = false; els.all.disabled = false;
234
- }
235
- }
236
-
237
  function selectVariant(rs) {
238
  const v = VARIANTS.find(x => x.rs === rs);
239
  if (!v) return;
@@ -269,8 +201,5 @@
269
  }).catch(e => {
270
  els.info.textContent = "failed to load variants: " + e.message;
271
  });
272
-
273
- els.go.addEventListener("click", scoreSelected);
274
- els.all.addEventListener("click", scoreAll);
275
  })();
276
 
 
1
  // =========================================================================
2
  // §2 — VEP: ref vs alt allele likelihood
3
+ //
4
+ // This section runs entirely off the precomputed scores baked into
5
+ // data/variants.json (each variant ships with ref/alt logprobs already).
6
+ // There is no "score" button or live /score call here: rescoring would
7
+ // just replay numbers we already have, so the toolbar is just the
8
+ // variant pills and the bars/result panes render straight from cache.
9
  // =========================================================================
10
  (function initDemo2() {
11
  const els = {
 
14
  window: document.getElementById("d2-window"),
15
  result: document.getElementById("d2-result"),
16
  bars: document.getElementById("d2-bars"),
 
 
 
 
17
  };
18
 
19
  let VARIANTS = null;
20
  let selected = null;
21
  const cache = {}; // by rs id → { refSum, altSum, refLps, altLps }
22
 
 
 
 
 
 
 
 
23
  function altWindow(v) {
24
  return v.ref_window.slice(0, v.var_offset) + v.alt + v.ref_window.slice(v.var_offset + 1);
25
  }
 
37
  if (!v) { els.result.innerHTML = ""; return; }
38
  const c = cache[v.rs];
39
  if (!c) {
40
+ els.result.innerHTML = `<div style="grid-column:1/-1;color:#aaa;font-style:italic">no precomputed score for this variant</div>`;
41
  return;
42
  }
43
  // Map sums to a common scale: take min/max across both for visual ratio.
 
76
  els.bars.setAttribute("height", H);
77
 
78
  if (!scored.length) {
79
+ els.bars.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— no precomputed scores available —</text>`;
80
  return;
81
  }
82
  const absMax = Math.max(2, ...scored.map(x => Math.abs(x.d)));
 
166
  els.bars.innerHTML = svg;
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  function selectVariant(rs) {
170
  const v = VARIANTS.find(x => x.rs === rs);
171
  if (!v) return;
 
201
  }).catch(e => {
202
  els.info.textContent = "failed to load variants: " + e.message;
203
  });
 
 
 
204
  })();
205
 
assets/styles/banner.css CHANGED
@@ -112,7 +112,22 @@
112
  letter-spacing: 0.18em;
113
  text-transform: uppercase;
114
  color: #8a8a85;
 
 
 
 
 
 
 
 
 
 
 
115
  }
 
 
 
 
116
 
117
  /* --- Headline: oversized wordmark + tagline. Vertically centered in the
118
  middle row of the grid so it sits dead-center between the identity row
@@ -164,16 +179,14 @@
164
  margin-left: 0.15em;
165
  align-self: stretch;
166
  background: var(--green);
167
- /* Keep the caret's box at line height (so the wordmark's flex line
168
- doesn't grow) but lift the whole bar visually so its top hits the
169
- N's apex and its bottom sits at the baseline rather than below it.
170
- margin-top: 0.05em trim a hair off the top so the caret is just
171
- under cap height instead of poking above (the translate then lifts
172
- the whole bar up by 0.08em so the trim happens at the right place).
173
- transform: translateY(-0.08em) — visual-only shift, no layout
174
- impact. Together: caret top ≈ cap-top of N, caret bottom ≈ baseline. */
175
- margin-top: 0.15em;
176
- transform: translateY(-0.08em);
177
  animation: cb-cursor-blink 1.05s steps(1) infinite;
178
  }
179
  @keyframes cb-cursor-blink {
@@ -337,12 +350,38 @@
337
  .sticky-nav__inner {
338
  display: flex;
339
  align-items: stretch;
 
 
340
  max-width: 1200px;
341
  /* Match the container.wide horizontal padding so the strip's tabs sit
342
  at the same left edge as the content column underneath. */
343
  margin: 0 auto;
344
  padding: 0 32px;
345
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  .sticky-nav .tab {
347
  position: relative;
348
  display: flex;
@@ -425,7 +464,7 @@
425
  pointer-events: none;
426
  /* Slight leftward shift so the helix sits closer to the wordmark instead
427
  of hugging the right edge of the banner. */
428
- transform: translateX(-25px) rotate(4deg);
429
  transform-origin: 60% 50%;
430
  }
431
 
@@ -445,7 +484,7 @@
445
  .cb-helix-canvas {
446
  top: -40px; right: 0; bottom: -40px;
447
  width: 100%; height: calc(100% + 80px);
448
- transform: rotate(3deg);
449
  transform-origin: center;
450
  }
451
  .banner-wordmark { font-size: clamp(56px, 16vw, 96px); }
@@ -453,4 +492,28 @@
453
  without horizontal scroll. */
454
  .banner-tabs { width: 100%; }
455
  .banner-tabs .tab { width: auto; flex: 1 1 0; padding: 14px 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  }
 
112
  letter-spacing: 0.18em;
113
  text-transform: uppercase;
114
  color: #8a8a85;
115
+ text-decoration: none;
116
+ transition: color 120ms ease;
117
+ }
118
+ a.banner-path,
119
+ a.banner-path:link,
120
+ a.banner-path:visited,
121
+ a.banner-path:hover,
122
+ a.banner-path:active,
123
+ a.banner-path:focus,
124
+ a.banner-path:focus-visible {
125
+ text-decoration: none;
126
  }
127
+ a.banner-path { cursor: pointer; }
128
+ a.banner-path:hover,
129
+ a.banner-path:focus-visible { color: var(--green); }
130
+ a.banner-path:focus-visible { outline: none; }
131
 
132
  /* --- Headline: oversized wordmark + tagline. Vertically centered in the
133
  middle row of the grid so it sits dead-center between the identity row
 
179
  margin-left: 0.15em;
180
  align-self: stretch;
181
  background: var(--green);
182
+ /* Caret stretches to the wordmark's flex line, then drops a hair below
183
+ the baseline via a small negative bottom margin reads as a
184
+ deliberate block caret that hangs slightly under the N rather than
185
+ being trapped inside the cap-height box. The top sits flush with
186
+ the flex line (margin-top: 0) so it no longer overshoots the N's
187
+ apex. */
188
+ margin-top: 0;
189
+ margin-bottom: -0.015em;
 
 
190
  animation: cb-cursor-blink 1.05s steps(1) infinite;
191
  }
192
  @keyframes cb-cursor-blink {
 
350
  .sticky-nav__inner {
351
  display: flex;
352
  align-items: stretch;
353
+ /* Brand to the left, tab cluster pinned to the right. */
354
+ justify-content: space-between;
355
  max-width: 1200px;
356
  /* Match the container.wide horizontal padding so the strip's tabs sit
357
  at the same left edge as the content column underneath. */
358
  margin: 0 auto;
359
  padding: 0 32px;
360
  }
361
+ /* Left-side identity: stacked title + breadcrumb path, mirroring the
362
+ in-banner .banner-breadcrumb verbatim so the two reads as the same
363
+ model-card identity. Anchored as a link back to the top of the page. */
364
+ .sticky-nav__brand {
365
+ display: flex;
366
+ flex-direction: column;
367
+ justify-content: center;
368
+ gap: 2px;
369
+ line-height: 1.2;
370
+ text-decoration: none;
371
+ color: inherit;
372
+ /* Sits flush with the tab tops — push the brand down by the same 7px
373
+ as .sticky-nav padding-top so it's vertically centred against the
374
+ full tab card, not against the headroom above them. */
375
+ padding-bottom: 7px;
376
+ font-family: "JetBrains Mono", ui-monospace, monospace;
377
+ }
378
+ .sticky-nav__brand .banner-title { font-size: 13px; }
379
+ .sticky-nav__brand .banner-path { font-size: 10.5px; }
380
+ .sticky-nav__brand:hover .banner-title { color: var(--green); }
381
+ .sticky-nav__tabs {
382
+ display: flex;
383
+ align-items: stretch;
384
+ }
385
  .sticky-nav .tab {
386
  position: relative;
387
  display: flex;
 
464
  pointer-events: none;
465
  /* Slight leftward shift so the helix sits closer to the wordmark instead
466
  of hugging the right edge of the banner. */
467
+ transform: translateX(-25px) rotate(5deg);
468
  transform-origin: 60% 50%;
469
  }
470
 
 
484
  .cb-helix-canvas {
485
  top: -40px; right: 0; bottom: -40px;
486
  width: 100%; height: calc(100% + 80px);
487
+ transform: rotate(8deg);
488
  transform-origin: center;
489
  }
490
  .banner-wordmark { font-size: clamp(56px, 16vw, 96px); }
 
492
  without horizontal scroll. */
493
  .banner-tabs { width: 100%; }
494
  .banner-tabs .tab { width: auto; flex: 1 1 0; padding: 14px 12px; }
495
+
496
+ /* Sticky nav goes mobile too: drop the CARBON/huggingfacebio brand on
497
+ the left (the in-banner hero has already scrolled away by the time
498
+ this strip appears, but on narrow viewports the brand + 3×150px tabs
499
+ simply won't fit on one line). Tabs then expand to fill the row
500
+ evenly, mirroring the in-banner mobile treatment above. */
501
+ .sticky-nav__inner {
502
+ padding: 0 18px;
503
+ }
504
+ .sticky-nav__brand {
505
+ display: none;
506
+ }
507
+ .sticky-nav__tabs {
508
+ flex: 1 1 auto;
509
+ width: 100%;
510
+ }
511
+ .sticky-nav .tab {
512
+ width: auto;
513
+ flex: 1 1 0;
514
+ padding: 14px 12px;
515
+ /* Slightly tighter tracking so 'SANDBOX' still fits comfortably at
516
+ narrow widths without truncating. */
517
+ letter-spacing: 0.12em;
518
+ }
519
  }
assets/styles/controls.css CHANGED
@@ -16,10 +16,15 @@
16
  }
17
  .demo-toolbar .spacer { flex: 1; }
18
 
19
- /* --- Buttons --- */
 
 
 
 
 
20
  button.action, .pill {
21
  font-family: "JetBrains Mono", monospace;
22
- font-size: 10px; font-weight: 400;
23
  padding: 5px 11px; border: 1px solid #ccc; border-radius: 3px;
24
  background: #fff; color: #555; cursor: pointer;
25
  text-transform: uppercase; letter-spacing: 1.5px;
@@ -31,8 +36,7 @@ button.action.primary:hover { background: #000; }
31
  button.action:disabled { opacity: 0.4; cursor: not-allowed; }
32
  button.action.primary:disabled { background: #888; border-color: #888; }
33
  .pill.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
34
- .pills { display: inline-flex; gap: 4px; }
35
- .pills .pill { font-size: 9px; padding: 4px 8px; }
36
 
37
  /* --- Stub placeholder for unbuilt demos --- */
38
  .stub {
 
16
  }
17
  .demo-toolbar .spacer { flex: 1; }
18
 
19
+ /* --- Buttons ---
20
+ `.pill` size is the canonical pill size used across every demo (the
21
+ §7 Species tree toolbar set the precedent: 11px mono / 5px 11px
22
+ padding / 6px gap reads cleanly without dominating the toolbar).
23
+ Demo-specific stylesheets only override colour or text-transform,
24
+ never the size, so the pill rhythm stays identical between sections. */
25
  button.action, .pill {
26
  font-family: "JetBrains Mono", monospace;
27
+ font-size: 11px; font-weight: 400;
28
  padding: 5px 11px; border: 1px solid #ccc; border-radius: 3px;
29
  background: #fff; color: #555; cursor: pointer;
30
  text-transform: uppercase; letter-spacing: 1.5px;
 
36
  button.action:disabled { opacity: 0.4; cursor: not-allowed; }
37
  button.action.primary:disabled { background: #888; border-color: #888; }
38
  .pill.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
39
+ .pills { display: inline-flex; gap: 6px; }
 
40
 
41
  /* --- Stub placeholder for unbuilt demos --- */
42
  .stub {
assets/styles/layout.css CHANGED
@@ -120,9 +120,11 @@ section:last-of-type { border-bottom: none; }
120
 
121
  Layout math: container.wide is 1200px max with 32px padding =>
122
  1136px usable. 280px rail + 28px gap + 828px right column. Below
123
- 540px we collapse to single-column and unstick the rail at that
124
- point the right column would be too cramped to be useful, so the
125
- narrative stacks above the demo + takeaway instead. */
 
 
126
  .section--two-col {
127
  display: grid;
128
  grid-template-columns: 280px 1fr;
@@ -167,7 +169,7 @@ section:last-of-type { border-bottom: none; }
167
  margin: 0; /* gap on section-body handles vertical rhythm */
168
  max-width: none; /* fill the right column rather than capping at 640 */
169
  }
170
- @media (max-width: 540px) {
171
  .section--two-col {
172
  grid-template-columns: 1fr;
173
  row-gap: 16px;
 
120
 
121
  Layout math: container.wide is 1200px max with 32px padding =>
122
  1136px usable. 280px rail + 28px gap + 828px right column. Below
123
+ 960px the right column gets squeezed under ~620px (280 + 28 + ~620
124
+ 928px usable inside a ~960px viewport), which is the point where
125
+ the demos (gene tracks, SVG bars, 3D viewers) start clipping or
126
+ becoming unreadable. We collapse to single-column and unstick the
127
+ rail there so the narrative stacks above the demo + takeaway. */
128
  .section--two-col {
129
  display: grid;
130
  grid-template-columns: 280px 1fr;
 
169
  margin: 0; /* gap on section-body handles vertical rhythm */
170
  max-width: none; /* fill the right column rather than capping at 640 */
171
  }
172
+ @media (max-width: 960px) {
173
  .section--two-col {
174
  grid-template-columns: 1fr;
175
  row-gap: 16px;
assets/styles/sandbox.css CHANGED
@@ -2,12 +2,72 @@
2
  with #panel-sandbox to avoid leaking onto the Demo / Recipe panels).
3
  Originally ported from the legacy index.html sandbox. */
4
 
5
- #panel-sandbox .sb-section-title {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  font-family: "JetBrains Mono", monospace;
7
- font-size: 11px; font-weight: 400;
8
- text-transform: uppercase; letter-spacing: 2px; color: #444;
9
- margin-top: 24px; margin-bottom: 8px;
10
- border-bottom: 1px solid #ccc; padding-bottom: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
  #panel-sandbox .sb-examples {
13
  display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;
@@ -41,11 +101,28 @@
41
  #panel-sandbox .sb-prompt-area {
42
  width: 100%; resize: none; overflow: hidden;
43
  letter-spacing: 1px; line-height: 1.7;
44
- min-height: 36px;
 
 
45
  }
 
 
 
46
  #panel-sandbox .sb-controls {
47
  display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
48
- margin-top: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
  #panel-sandbox .sb-control {
51
  display: flex; align-items: center; gap: 6px;
@@ -74,18 +151,20 @@
74
  #panel-sandbox .sb-mode-btn:hover { color: #1f1f1d; }
75
  #panel-sandbox .sb-mode-btn.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
76
 
77
- #panel-sandbox .sb-button-row { margin-left: auto; display: flex; gap: 6px; }
78
-
79
  #panel-sandbox .sb-status {
80
  font-family: "JetBrains Mono", monospace;
81
  font-size: 10px; color: #666;
82
  text-transform: uppercase; letter-spacing: 1.5px;
83
- margin-top: 10px; min-height: 14px;
 
 
 
84
  }
 
85
  #panel-sandbox .sb-status.error { color: #b00020; text-transform: none; letter-spacing: 0.3px; }
86
  #panel-sandbox .sb-status .dot {
87
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
88
- background: #888; margin-right: 6px; vertical-align: middle;
89
  }
90
  /* `pulse` keyframe lives in base.css. */
91
  #panel-sandbox .sb-status.streaming .dot { background: #317f3f; animation: pulse 1.2s ease-in-out infinite; }
@@ -94,13 +173,16 @@
94
  display: grid;
95
  grid-template-columns: minmax(0, 1fr) 200px;
96
  gap: 16px;
97
- align-items: start;
98
  }
99
  @media (max-width: 720px) {
100
  #panel-sandbox .sb-output-row { grid-template-columns: 1fr; }
101
  }
102
 
103
- #panel-sandbox .sb-seq-wrap { position: relative; }
 
 
 
 
104
  #panel-sandbox .sb-copy-btn {
105
  position: absolute; top: 8px; right: 8px; z-index: 2;
106
  font-family: "JetBrains Mono", monospace;
@@ -117,10 +199,11 @@
117
  #panel-sandbox .sb-seq-block {
118
  font-family: "JetBrains Mono", monospace;
119
  background: #f4f4f4; border: 1px solid #ddd;
120
- padding: 16px 20px; overflow-x: auto;
121
  white-space: pre; font-size: 12px; font-weight: 400;
122
  line-height: 1.85; letter-spacing: 1.5px;
123
  min-height: 80px;
 
124
  }
125
  #panel-sandbox .sb-seq-block.empty { color: #aaa; font-weight: 300; letter-spacing: normal; }
126
  #panel-sandbox .sb-seq-line { white-space: pre; }
 
2
  with #panel-sandbox to avoid leaking onto the Demo / Recipe panels).
3
  Originally ported from the legacy index.html sandbox. */
4
 
5
+ /* Extra breathing room between the intro lede and the first card stack
6
+ so the playground header doesn't feel glued to the paragraph above. */
7
+ #panel-sandbox .tab-lede { margin-bottom: 24px; }
8
+
9
+ /* --- Connection header strip (model + endpoint at the top of the panel).
10
+ Same paper/border treatment as the .sb-card panels below so the whole
11
+ page reads as a stack of layered cards. --- */
12
+ #panel-sandbox .sb-header {
13
+ display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
14
+ padding: 12px 18px;
15
+ background: #fbfaf3;
16
+ border: 1px solid var(--hairline);
17
+ border-radius: 4px;
18
+ margin-bottom: 18px;
19
+ }
20
+ #panel-sandbox .sb-header__meta {
21
+ font-family: "JetBrains Mono", monospace;
22
+ font-size: 11px; color: #555; font-weight: 400;
23
+ letter-spacing: 0.4px; word-break: break-all;
24
+ flex: 1 1 0; min-width: 0;
25
+ }
26
+
27
+ /* --- Card panel (Prompt / Sequence). Replaces the old .sb-section-title
28
+ single-line headers — each section now sits in its own bordered card
29
+ with an eyebrow/title/hint header on top and a padded body below. */
30
+ #panel-sandbox .sb-card {
31
+ background: #fbfaf3;
32
+ border: 1px solid var(--hairline);
33
+ border-radius: 4px;
34
+ margin-bottom: 18px;
35
+ padding: 12px 12px;
36
+ }
37
+ #panel-sandbox .sb-card__header {
38
+ padding: 0 12px 12px;
39
+ border-bottom: 1px solid #ece9da;
40
+ }
41
+ #panel-sandbox .sb-card__eyebrow {
42
+ display: inline-block;
43
+ font-family: "JetBrains Mono", monospace;
44
+ font-size: 9px; font-weight: 500;
45
+ color: var(--green);
46
+ text-transform: uppercase; letter-spacing: 1.5px;
47
+ margin-right: 6px;
48
+ }
49
+ #panel-sandbox .sb-card__title {
50
+ margin: 4px 0 0;
51
  font-family: "JetBrains Mono", monospace;
52
+ font-size: 15px; font-weight: 500;
53
+ letter-spacing: 0.04em; text-transform: uppercase;
54
+ color: var(--ink);
55
+ }
56
+ #panel-sandbox .sb-card__hint {
57
+ margin: 4px 0 0;
58
+ font-family: "Inter", sans-serif;
59
+ font-size: 11.5px; color: #777; font-weight: 300;
60
+ line-height: 1.5;
61
+ }
62
+ #panel-sandbox .sb-card__hint code {
63
+ font-family: "JetBrains Mono", monospace;
64
+ font-size: 10.5px;
65
+ background: #ece9da;
66
+ padding: 1px 5px; border-radius: 2px;
67
+ color: #1f1f1d;
68
+ }
69
+ #panel-sandbox .sb-card__body {
70
+ padding: 16px 20px 0;
71
  }
72
  #panel-sandbox .sb-examples {
73
  display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;
 
101
  #panel-sandbox .sb-prompt-area {
102
  width: 100%; resize: none; overflow: hidden;
103
  letter-spacing: 1px; line-height: 1.7;
104
+ min-height: 72px;
105
+ padding: 12px 14px;
106
+ margin-top: 12px;
107
  }
108
+ /* Controls row: params on the left (max tokens, temperature, top-p,
109
+ color mode), action buttons pinned to the right with a divider in
110
+ between. Wraps cleanly on narrow viewports. */
111
  #panel-sandbox .sb-controls {
112
  display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
113
+ margin-top: 14px;
114
+ padding-top: 14px;
115
+ border-top: 1px solid #ece9da;
116
+ }
117
+ #panel-sandbox .sb-controls__params {
118
+ display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
119
+ flex: 1 1 auto;
120
+ }
121
+ #panel-sandbox .sb-controls__actions {
122
+ display: flex; gap: 6px;
123
+ margin-left: auto;
124
+ padding-left: 16px;
125
+ border-left: 1px solid #ece9da;
126
  }
127
  #panel-sandbox .sb-control {
128
  display: flex; align-items: center; gap: 6px;
 
151
  #panel-sandbox .sb-mode-btn:hover { color: #1f1f1d; }
152
  #panel-sandbox .sb-mode-btn.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
153
 
 
 
154
  #panel-sandbox .sb-status {
155
  font-family: "JetBrains Mono", monospace;
156
  font-size: 10px; color: #666;
157
  text-transform: uppercase; letter-spacing: 1.5px;
158
+ margin-top: 12px;
159
+ display: inline-flex; align-items: center; gap: 6px;
160
+ padding: 5px 10px;
161
+ background: #fff; border: 1px solid #e6e3d4; border-radius: 12px;
162
  }
163
+ #panel-sandbox .sb-status.is-hidden { display: none; }
164
  #panel-sandbox .sb-status.error { color: #b00020; text-transform: none; letter-spacing: 0.3px; }
165
  #panel-sandbox .sb-status .dot {
166
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
167
+ background: #888;
168
  }
169
  /* `pulse` keyframe lives in base.css. */
170
  #panel-sandbox .sb-status.streaming .dot { background: #317f3f; animation: pulse 1.2s ease-in-out infinite; }
 
173
  display: grid;
174
  grid-template-columns: minmax(0, 1fr) 200px;
175
  gap: 16px;
 
176
  }
177
  @media (max-width: 720px) {
178
  #panel-sandbox .sb-output-row { grid-template-columns: 1fr; }
179
  }
180
 
181
+ #panel-sandbox .sb-seq-wrap {
182
+ position: relative;
183
+ display: flex;
184
+ flex-direction: column;
185
+ }
186
  #panel-sandbox .sb-copy-btn {
187
  position: absolute; top: 8px; right: 8px; z-index: 2;
188
  font-family: "JetBrains Mono", monospace;
 
199
  #panel-sandbox .sb-seq-block {
200
  font-family: "JetBrains Mono", monospace;
201
  background: #f4f4f4; border: 1px solid #ddd;
202
+ padding: 16px 20px;
203
  white-space: pre; font-size: 12px; font-weight: 400;
204
  line-height: 1.85; letter-spacing: 1.5px;
205
  min-height: 80px;
206
+ flex: 1 0 auto;
207
  }
208
  #panel-sandbox .sb-seq-block.empty { color: #aaa; font-weight: 300; letter-spacing: normal; }
209
  #panel-sandbox .sb-seq-line { white-space: pre; }
assets/styles/section-tree.css CHANGED
@@ -13,19 +13,13 @@
13
  margin-bottom: 14px;
14
  }
15
  .tree-toolbar .spacer { flex: 1; }
16
- .tree-toolbar .pills {
17
- display: inline-flex; gap: 0;
18
- border: 1px solid #d8d5c8; border-radius: 3px; overflow: hidden;
19
- }
20
  .tree-toolbar .pills .pill {
21
- background: #fff; border: 0; padding: 5px 11px;
22
- font: inherit; color: #666; cursor: pointer;
23
- border-right: 1px solid #d8d5c8;
24
  text-transform: lowercase;
25
- }
26
- .tree-toolbar .pills .pill:last-child { border-right: 0; }
27
- .tree-toolbar .pills .pill.active {
28
- background: #1f1f1d; color: #f7f5ee;
29
  }
30
  /* Big agreement score chip up top — the headline metric for §7. */
31
  .tree-score {
 
13
  margin-bottom: 14px;
14
  }
15
  .tree-toolbar .spacer { flex: 1; }
16
+ /* Pill sizing + spacing live in controls.css now (canonical across demos);
17
+ we only override the casing here because §7's vocabulary — `ward`,
18
+ `upgma`, `kingdom-level`, `sister-level` reads better lowercase than
19
+ in the global uppercase + tracked treatment. */
20
  .tree-toolbar .pills .pill {
 
 
 
21
  text-transform: lowercase;
22
+ letter-spacing: 0;
 
 
 
23
  }
24
  /* Big agreement score chip up top — the headline metric for §7. */
25
  .tree-score {
data/genes.json CHANGED
The diff for this file is too large to render. See raw diff
 
demo.html CHANGED
@@ -91,9 +91,19 @@
91
  IntersectionObserver watching the original #tab-nav. -->
92
  <nav id="tab-nav-sticky" class="sticky-nav" aria-label="Section navigation (sticky)">
93
  <div class="sticky-nav__inner">
94
- <button class="tab active" data-tab="demo">Demo</button>
95
- <button class="tab" data-tab="model">Model</button>
96
- <button class="tab" data-tab="sandbox">Sandbox</button>
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
  </nav>
99
 
@@ -228,11 +238,6 @@
228
  <div class="demo-toolbar">
229
  <span>variant</span>
230
  <span id="d2-pills" class="pills"></span>
231
- <span class="spacer"></span>
232
- <!-- See §1 toolbar for why status sits before the buttons. -->
233
- <span class="status is-hidden" id="d2-status"><span class="dot"></span><span></span></span>
234
- <button id="d2-go" class="action primary">▶ score</button>
235
- <button id="d2-all" class="action">score all</button>
236
  </div>
237
 
238
  <div class="gene-info" id="d2-info">loading variants…</div>
@@ -283,13 +288,13 @@
283
 
284
  <div class="section-body">
285
  <div class="demo" id="demo3">
 
 
 
 
286
  <div class="demo-toolbar">
287
  <span>gene</span>
288
  <span id="d3-pills" class="pills"></span>
289
- <span class="spacer"></span>
290
- <!-- See §1 toolbar for why status sits before the buttons. -->
291
- <span class="status is-hidden" id="d3-status"><span class="dot"></span><span></span></span>
292
- <button id="d3-go" class="action primary">▶ score</button>
293
  </div>
294
 
295
  <div class="gene-info" id="d3-info">loading genes…</div>
@@ -854,79 +859,109 @@
854
 
855
  <div class="container" style="max-width:1200px">
856
 
857
- <div style="margin-bottom:20px;padding-bottom:12px;border-bottom:1px solid #ddd">
858
- <div id="sb-meta" style="font-family:'JetBrains Mono',monospace;color:#888;font-size:10px;font-weight:300;letter-spacing:0.5px">loading…</div>
859
- </div>
860
-
861
- <div class="sb-section-title">Prompt</div>
862
-
863
- <div class="sb-examples">
864
- <span class="sb-examples-label">examples</span>
865
- <button class="sb-ex-btn" data-ex="">empty<span class="sb-ex-label">unconditional</span></button>
866
- <button class="sb-ex-btn" data-ex="ATG">ATG<span class="sb-ex-label">start codon</span></button>
867
- <button class="sb-ex-btn" data-ex="TATAAA">TATAAA<span class="sb-ex-label">TATA box</span></button>
868
- <button class="sb-ex-btn" data-ex="CGCGCGCGCG">CGCG…<span class="sb-ex-label">CpG island</span></button>
869
- <button class="sb-ex-btn" data-ex="ATGGCCAAGCTGACCAGCGAGCTGCTG">ATGGCC…<span class="sb-ex-label">ORF start</span></button>
870
- <button class="sb-ex-btn" data-ex="AAAAAAAAAAAAAAAA">A·16<span class="sb-ex-label">poly-A</span></button>
871
  </div>
872
 
873
- <textarea id="sb-prompt" class="sb-prompt-area" rows="1" spellcheck="false" autocapitalize="characters">AGT</textarea>
874
-
875
- <div class="sb-controls">
876
- <label class="sb-control">max tokens
877
- <input type="number" id="sb-max-tokens" value="128" min="1" max="2048" step="1">
878
- </label>
879
- <label class="sb-control">temperature
880
- <input type="number" id="sb-temperature" value="1.0" min="0" max="2" step="0.1">
881
- </label>
882
- <label class="sb-control">top-p
883
- <input type="number" id="sb-top-p" value="1.0" min="0" max="1" step="0.05">
884
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
 
886
- <div class="sb-mode-group">color
887
- <div class="sb-mode-btns" id="sb-mode-btns">
888
- <button class="sb-mode-btn active" data-mode="none">none</button>
889
- <button class="sb-mode-btn" data-mode="bases">bases</button>
890
- <button class="sb-mode-btn" data-mode="logprob">logprob</button>
891
  </div>
892
- </div>
893
 
894
- <div class="sb-button-row">
895
- <button id="sb-clear-btn" class="action">clear</button>
896
- <button id="sb-stop-btn" class="action" disabled>stop</button>
897
- <button id="sb-generate-btn" class="action primary">generate</button>
898
  </div>
899
- </div>
900
-
901
- <div class="sb-status" id="sb-status"><span class="dot"></span><span id="sb-status-text">idle</span></div>
902
-
903
- <div class="sb-section-title">Sequence</div>
904
 
905
- <div class="sb-output-row">
906
- <div class="sb-seq-wrap">
907
- <button id="sb-copy-btn" class="sb-copy-btn" disabled>copy</button>
908
- <div class="sb-seq-block empty" id="sb-seq">prompt + generated bases will stream here</div>
909
- </div>
 
 
 
 
 
 
 
 
 
910
 
911
- <div>
912
- <div class="sb-stats" id="sb-stats">
913
- <div class="sb-stat"><span class="sb-stat-label">prompt</span><span class="sb-stat-value" id="sb-stat-prompt">0<span class="sb-unit">bp</span></span></div>
914
- <div class="sb-stat"><span class="sb-stat-label">generated</span><span class="sb-stat-value" id="sb-stat-gen">0<span class="sb-unit">bp</span></span></div>
915
- <div class="sb-stat"><span class="sb-stat-label">tokens</span><span class="sb-stat-value" id="sb-stat-tok">0</span></div>
916
- <div class="sb-stat"><span class="sb-stat-label">elapsed</span><span class="sb-stat-value" id="sb-stat-time">0.0<span class="sb-unit">s</span></span></div>
917
- <div class="sb-stat"><span class="sb-stat-label">throughput</span><span class="sb-stat-value" id="sb-stat-rate">0<span class="sb-unit">bp/s</span></span></div>
918
- <div class="sb-stat"><span class="sb-stat-label">GC content</span><span class="sb-stat-value" id="sb-stat-gc">—</span></div>
919
- <div class="sb-stat"><span class="sb-stat-label">mean logprob</span><span class="sb-stat-value" id="sb-stat-lp">—</span></div>
920
- <div class="sb-stat"><span class="sb-stat-label">perplexity</span><span class="sb-stat-value" id="sb-stat-ppl">—</span></div>
921
- </div>
922
- <div class="sb-legend" id="sb-legend">
923
- <div>token logprob</div>
924
- <div class="sb-legend-bar" id="sb-legend-bar"></div>
925
- <div class="sb-legend-row"><span id="sb-lp-min">—</span><span id="sb-lp-mid">—</span><span id="sb-lp-max">—</span></div>
926
- <svg id="sb-lp-chart" class="sb-lp-chart" preserveAspectRatio="none"></svg>
 
 
927
  </div>
928
  </div>
929
- </div>
930
 
931
  </div>
932
  </div> <!-- /panel-sandbox -->
 
91
  IntersectionObserver watching the original #tab-nav. -->
92
  <nav id="tab-nav-sticky" class="sticky-nav" aria-label="Section navigation (sticky)">
93
  <div class="sticky-nav__inner">
94
+ <!-- Mini breadcrumb on the left: same identity as the in-banner
95
+ .banner-breadcrumb (title + model path stacked) so the sticky
96
+ strip carries the "you're on the Carbon model card" cue even
97
+ after the hero has scrolled out of view. -->
98
+ <a class="sticky-nav__brand" href="#" aria-label="Carbon — go to top">
99
+ <span class="banner-title">CARBON</span>
100
+ <span class="banner-path">huggingfacebio/carbon-3b</span>
101
+ </a>
102
+ <div class="sticky-nav__tabs">
103
+ <button class="tab active" data-tab="demo">Demo</button>
104
+ <button class="tab" data-tab="model">Model</button>
105
+ <button class="tab" data-tab="sandbox">Sandbox</button>
106
+ </div>
107
  </div>
108
  </nav>
109
 
 
238
  <div class="demo-toolbar">
239
  <span>variant</span>
240
  <span id="d2-pills" class="pills"></span>
 
 
 
 
 
241
  </div>
242
 
243
  <div class="gene-info" id="d2-info">loading variants…</div>
 
288
 
289
  <div class="section-body">
290
  <div class="demo" id="demo3">
291
+ <!-- Likelihood tracks are precomputed (each gene ships with its
292
+ token logprobs in data/genes.json), so this toolbar is just
293
+ the gene selector — selecting a pill renders the track from
294
+ cache instantly, no live /score call needed. -->
295
  <div class="demo-toolbar">
296
  <span>gene</span>
297
  <span id="d3-pills" class="pills"></span>
 
 
 
 
298
  </div>
299
 
300
  <div class="gene-info" id="d3-info">loading genes…</div>
 
859
 
860
  <div class="container" style="max-width:1200px">
861
 
862
+ <!-- Connection strip: tells you which model the playground is talking to.
863
+ Same eyebrow + value pattern reused by the two card headers below so
864
+ the whole panel reads as a single layered stack rather than a flat
865
+ wall of controls. -->
866
+ <div class="sb-header">
867
+ <span class="sb-card__eyebrow">Connected to</span>
868
+ <div id="sb-meta" class="sb-header__meta">loading…</div>
 
 
 
 
 
 
 
869
  </div>
870
 
871
+ <!-- INPUT card: examples → prompt controls → status. -->
872
+ <section class="sb-card">
873
+ <header class="sb-card__header">
874
+ <span class="sb-card__eyebrow">§ Input</span>
875
+ <h2 class="sb-card__title">Prompt</h2>
876
+ <p class="sb-card__hint">DNA prefix in <code>{A, C, G, T}</code> — pick an example or type your own.</p>
877
+ </header>
878
+
879
+ <div class="sb-card__body">
880
+ <div class="sb-examples">
881
+ <span class="sb-examples-label">examples</span>
882
+ <button class="sb-ex-btn" data-ex="">empty<span class="sb-ex-label">unconditional</span></button>
883
+ <button class="sb-ex-btn" data-ex="ATG">ATG<span class="sb-ex-label">start codon</span></button>
884
+ <button class="sb-ex-btn" data-ex="TATAAA">TATAAA<span class="sb-ex-label">TATA box</span></button>
885
+ <button class="sb-ex-btn" data-ex="CGCGCGCGCG">CGCG…<span class="sb-ex-label">CpG island</span></button>
886
+ <button class="sb-ex-btn" data-ex="ATGGCCAAGCTGACCAGCGAGCTGCTG">ATGGCC…<span class="sb-ex-label">ORF start</span></button>
887
+ <button class="sb-ex-btn" data-ex="AAAAAAAAAAAAAAAA">A·16<span class="sb-ex-label">poly-A</span></button>
888
+ </div>
889
+
890
+ <textarea id="sb-prompt" class="sb-prompt-area" rows="3" spellcheck="false" autocapitalize="characters">AGT</textarea>
891
+
892
+ <!-- Controls split into two visual halves: sampling/display params on
893
+ the left, action buttons pinned to the right. The vertical rule
894
+ between them makes the parameter cluster read as one group. -->
895
+ <div class="sb-controls">
896
+ <div class="sb-controls__params">
897
+ <label class="sb-control">max tokens
898
+ <input type="number" id="sb-max-tokens" value="128" min="1" max="2048" step="1">
899
+ </label>
900
+ <label class="sb-control">temperature
901
+ <input type="number" id="sb-temperature" value="1.0" min="0" max="2" step="0.1">
902
+ </label>
903
+ <label class="sb-control">top-p
904
+ <input type="number" id="sb-top-p" value="1.0" min="0" max="1" step="0.05">
905
+ </label>
906
+
907
+ <div class="sb-mode-group">color
908
+ <div class="sb-mode-btns" id="sb-mode-btns">
909
+ <button class="sb-mode-btn active" data-mode="none">none</button>
910
+ <button class="sb-mode-btn" data-mode="bases">bases</button>
911
+ <button class="sb-mode-btn" data-mode="logprob">logprob</button>
912
+ </div>
913
+ </div>
914
+ </div>
915
 
916
+ <div class="sb-controls__actions">
917
+ <button id="sb-clear-btn" class="action">clear</button>
918
+ <button id="sb-stop-btn" class="action" disabled>stop</button>
919
+ <button id="sb-generate-btn" class="action primary">▶ generate</button>
920
+ </div>
921
  </div>
 
922
 
923
+ <!-- Hidden by setStatus("idle") so the toolbar stays clean until
924
+ something actually happens (connecting / streaming / done). -->
925
+ <div class="sb-status is-hidden" id="sb-status"><span class="dot"></span><span id="sb-status-text">idle</span></div>
 
926
  </div>
927
+ </section>
 
 
 
 
928
 
929
+ <!-- OUTPUT card: streamed sequence + sticky stats sidebar. -->
930
+ <section class="sb-card">
931
+ <header class="sb-card__header">
932
+ <span class="sb-card__eyebrow">§ Output</span>
933
+ <h2 class="sb-card__title">Sequence</h2>
934
+ <p class="sb-card__hint">Streams as the model generates · live stats on the right.</p>
935
+ </header>
936
+
937
+ <div class="sb-card__body">
938
+ <div class="sb-output-row">
939
+ <div class="sb-seq-wrap">
940
+ <button id="sb-copy-btn" class="sb-copy-btn" disabled>copy</button>
941
+ <div class="sb-seq-block empty" id="sb-seq">— prompt + generated bases will stream here —</div>
942
+ </div>
943
 
944
+ <div>
945
+ <div class="sb-stats" id="sb-stats">
946
+ <div class="sb-stat"><span class="sb-stat-label">prompt</span><span class="sb-stat-value" id="sb-stat-prompt">0<span class="sb-unit">bp</span></span></div>
947
+ <div class="sb-stat"><span class="sb-stat-label">generated</span><span class="sb-stat-value" id="sb-stat-gen">0<span class="sb-unit">bp</span></span></div>
948
+ <div class="sb-stat"><span class="sb-stat-label">tokens</span><span class="sb-stat-value" id="sb-stat-tok">0</span></div>
949
+ <div class="sb-stat"><span class="sb-stat-label">elapsed</span><span class="sb-stat-value" id="sb-stat-time">0.0<span class="sb-unit">s</span></span></div>
950
+ <div class="sb-stat"><span class="sb-stat-label">throughput</span><span class="sb-stat-value" id="sb-stat-rate">0<span class="sb-unit">bp/s</span></span></div>
951
+ <div class="sb-stat"><span class="sb-stat-label">GC content</span><span class="sb-stat-value" id="sb-stat-gc">—</span></div>
952
+ <div class="sb-stat"><span class="sb-stat-label">mean logprob</span><span class="sb-stat-value" id="sb-stat-lp">—</span></div>
953
+ <div class="sb-stat"><span class="sb-stat-label">perplexity</span><span class="sb-stat-value" id="sb-stat-ppl">—</span></div>
954
+ </div>
955
+ <div class="sb-legend" id="sb-legend">
956
+ <div>token logprob</div>
957
+ <div class="sb-legend-bar" id="sb-legend-bar"></div>
958
+ <div class="sb-legend-row"><span id="sb-lp-min">—</span><span id="sb-lp-mid">—</span><span id="sb-lp-max">—</span></div>
959
+ <svg id="sb-lp-chart" class="sb-lp-chart" preserveAspectRatio="none"></svg>
960
+ </div>
961
+ </div>
962
  </div>
963
  </div>
964
+ </section>
965
 
966
  </div>
967
  </div> <!-- /panel-sandbox -->
scripts/precompute.py CHANGED
@@ -7,9 +7,10 @@ Writes results back into data/genes.json and data/variants.json:
7
  - per-gene `fold_example` (Carbon /generate + NVIDIA NIM ESMFold)
8
 
9
  Usage:
10
- python scripts/precompute.py # everything
11
- python scripts/precompute.py --folds # only the folding fixtures
12
- python scripts/precompute.py --no-folds # skip folding fixtures
 
13
  """
14
  import json
15
  import os
@@ -297,7 +298,7 @@ def nim_fold(api_key, sequence):
297
  }
298
 
299
 
300
- def precompute_folds(client):
301
  api_key = os.environ.get("NVIDIA_API_KEY")
302
  if not api_key:
303
  raise RuntimeError("NVIDIA_API_KEY missing (set in .env or env)")
@@ -306,6 +307,9 @@ def precompute_folds(client):
306
  for g in genes:
307
  if not g.get("exons"):
308
  continue
 
 
 
309
  last_exon_end = g["exons"][-1]["end"]
310
  n_tries = FOLD_BEST_OF_LONG if last_exon_end > FOLD_LONG_THRESHOLD else FOLD_BEST_OF_SHORT
311
  print(f" folding {g['symbol']} (last exon end {last_exon_end} bp, best-of-{n_tries})…", flush=True)
@@ -376,6 +380,7 @@ def main():
376
  argv = set(sys.argv[1:])
377
  only_folds = "--folds" in argv
378
  skip_folds = "--no-folds" in argv
 
379
 
380
  client = make_client()
381
  if not only_folds:
@@ -387,7 +392,7 @@ def main():
387
  if not skip_folds:
388
  print()
389
  print("=== precomputing fold fixtures ===")
390
- precompute_folds(client)
391
 
392
 
393
  if __name__ == "__main__":
 
7
  - per-gene `fold_example` (Carbon /generate + NVIDIA NIM ESMFold)
8
 
9
  Usage:
10
+ python scripts/precompute.py # everything
11
+ python scripts/precompute.py --folds # only the folding fixtures
12
+ python scripts/precompute.py --no-folds # skip folding fixtures
13
+ python scripts/precompute.py --folds --only-missing # only genes lacking fold_example
14
  """
15
  import json
16
  import os
 
298
  }
299
 
300
 
301
+ def precompute_folds(client, only_missing=False):
302
  api_key = os.environ.get("NVIDIA_API_KEY")
303
  if not api_key:
304
  raise RuntimeError("NVIDIA_API_KEY missing (set in .env or env)")
 
307
  for g in genes:
308
  if not g.get("exons"):
309
  continue
310
+ if only_missing and g.get("fold_example"):
311
+ print(f" skipping {g['symbol']} (fold_example already cached)")
312
+ continue
313
  last_exon_end = g["exons"][-1]["end"]
314
  n_tries = FOLD_BEST_OF_LONG if last_exon_end > FOLD_LONG_THRESHOLD else FOLD_BEST_OF_SHORT
315
  print(f" folding {g['symbol']} (last exon end {last_exon_end} bp, best-of-{n_tries})…", flush=True)
 
380
  argv = set(sys.argv[1:])
381
  only_folds = "--folds" in argv
382
  skip_folds = "--no-folds" in argv
383
+ only_missing = "--only-missing" in argv
384
 
385
  client = make_client()
386
  if not only_folds:
 
392
  if not skip_folds:
393
  print()
394
  print("=== precomputing fold fixtures ===")
395
+ precompute_folds(client, only_missing=only_missing)
396
 
397
 
398
  if __name__ == "__main__":
social-banner.html ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 banner</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;800&family=Inter:wght@300;400;500;600&display=swap">
10
+
11
+ <!-- Reuses the same hero stylesheet as demo.html so the banner reads
12
+ identical to the editorial hero on the main page. -->
13
+ <link rel="stylesheet" href="/assets/styles/base.css">
14
+ <link rel="stylesheet" href="/assets/styles/banner.css">
15
+
16
+ <style>
17
+ /* ------------------------------------------------------------------ */
18
+ /* Standalone page chrome. */
19
+ /* The banner sits in the middle of the viewport at one of a handful */
20
+ /* of fixed social-media canvas sizes (Twitter / OG / LinkedIn / HF). */
21
+ /* The page paper around it is a touch darker than the banner so the */
22
+ /* edges of the social-media canvas read clearly — handy when you're */
23
+ /* about to crop a screenshot. */
24
+ /* ------------------------------------------------------------------ */
25
+ html, body { height: 100%; }
26
+ body {
27
+ background: #e8e5d6;
28
+ display: flex;
29
+ flex-direction: column;
30
+ align-items: center;
31
+ justify-content: center;
32
+ gap: 28px;
33
+ padding: 40px 24px;
34
+ overflow: hidden;
35
+ }
36
+
37
+ /* The social-banner-stage IS the social-media canvas: a fixed width
38
+ and height drawn at 1:1 device pixels so what you see is exactly
39
+ what gets exported. Sizes are driven by --sb-w / --sb-h CSS vars
40
+ set on <html> by the format switcher below. */
41
+ .social-banner-stage {
42
+ --sb-w: 1500px;
43
+ --sb-h: 500px;
44
+ width: var(--sb-w);
45
+ height: var(--sb-h);
46
+ flex-shrink: 0;
47
+ position: relative;
48
+ box-shadow:
49
+ 0 1px 0 #cfcdbf inset,
50
+ 0 30px 60px -28px rgba(31, 31, 29, 0.28),
51
+ 0 8px 18px -10px rgba(31, 31, 29, 0.18);
52
+ /* Auto-scale to fit the viewport when the window is smaller than
53
+ the social canvas. The factor is set by JS (see fitStage()) so
54
+ the banner always renders centred and intact, even on a laptop
55
+ that can't physically fit a 1584-wide canvas. */
56
+ transform: scale(var(--sb-scale, 1));
57
+ transform-origin: center center;
58
+ transition: width 0.22s ease, height 0.22s ease;
59
+ }
60
+
61
+ /* Inside the stage the banner fills it entirely (no max-width cap
62
+ like on the main page — here the banner IS the canvas). */
63
+ .social-banner-stage .carbon-banner {
64
+ width: 100%;
65
+ height: 100%;
66
+ }
67
+ .social-banner-stage .banner-inner {
68
+ max-width: none;
69
+ height: 100%;
70
+ /* Tabs are gone — drop the asymmetric bottom-zero padding from the
71
+ main hero so the headline sits visually centred top↔bottom. */
72
+ padding: 36px 56px;
73
+ /* 2-col grid: headline on the left, helix on the right. The right
74
+ track is sized as a share of the total stage width so the helix
75
+ gets more room on a wide LinkedIn cover and less on a tall OG
76
+ card. */
77
+ grid-template-columns: minmax(0, 1fr) clamp(280px, 36%, 520px);
78
+ gap: 32px;
79
+ min-height: 0;
80
+ }
81
+ .social-banner-stage .banner-left {
82
+ /* No more bottom tab row → 2-row layout: identity strip on top,
83
+ headline filling the rest. */
84
+ grid-template-rows: auto 1fr;
85
+ gap: 14px;
86
+ }
87
+
88
+ /* Headline sizing: tied to the stage height instead of the viewport
89
+ so the wordmark scales with the social canvas, not the browser
90
+ window. cqh = container-query-height, falls back to a fixed size
91
+ on browsers without container queries. */
92
+ .social-banner-stage { container-type: size; }
93
+ .social-banner-stage .banner-wordmark {
94
+ font-size: clamp(64px, 22cqh, 140px);
95
+ }
96
+ .social-banner-stage .banner-subtitle {
97
+ font-size: clamp(11px, 2.6cqh, 16px);
98
+ margin-top: 10px;
99
+ }
100
+ .social-banner-stage .banner-specs {
101
+ font-size: clamp(10px, 2.2cqh, 13px);
102
+ margin-top: 10px;
103
+ }
104
+ .social-banner-stage .banner-title { font-size: clamp(12px, 2.6cqh, 16px); }
105
+ .social-banner-stage .banner-path { font-size: clamp(10px, 2.2cqh, 13px); }
106
+
107
+ /* Slightly stronger inset hairline replaces the bottom one we lose
108
+ with the tabs gone — keeps the banner from looking unfinished. */
109
+ .social-banner-stage .carbon-banner {
110
+ box-shadow: inset 0 0 0 1px #cfcdbf;
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ */
114
+ /* Format switcher — discreet row of pills below the banner. Sets a */
115
+ /* CSS var on <html> + a data attribute the JS reads to recompute */
116
+ /* the scale-to-fit factor. Excluded from screenshots: the row sits */
117
+ /* far enough below the stage that any crop framed on the banner */
118
+ /* itself won't catch it. */
119
+ /* ------------------------------------------------------------------ */
120
+ .sb-switcher {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 6px;
124
+ font-family: "JetBrains Mono", ui-monospace, monospace;
125
+ font-size: 11px;
126
+ letter-spacing: 0.12em;
127
+ text-transform: uppercase;
128
+ color: #6f6d65;
129
+ }
130
+ .sb-switcher__label {
131
+ margin-right: 8px;
132
+ color: #8a8a85;
133
+ }
134
+ .sb-switcher__btn {
135
+ appearance: none;
136
+ border: 1px solid #cfcdbf;
137
+ background: #f7f5ee;
138
+ color: inherit;
139
+ font-family: inherit;
140
+ font-size: inherit;
141
+ letter-spacing: inherit;
142
+ text-transform: inherit;
143
+ padding: 7px 12px;
144
+ cursor: pointer;
145
+ transition: background 0.16s, border-color 0.16s, color 0.16s;
146
+ }
147
+ .sb-switcher__btn:hover {
148
+ background: #fff;
149
+ border-color: #b8b5a6;
150
+ color: #1f1f1d;
151
+ }
152
+ .sb-switcher__btn.is-active {
153
+ background: #1f1f1d;
154
+ border-color: #1f1f1d;
155
+ color: #f7f5ee;
156
+ }
157
+ .sb-switcher__dim {
158
+ margin-left: 8px;
159
+ color: #b0ada0;
160
+ font-feature-settings: "tnum";
161
+ }
162
+
163
+ /* Print: hide chrome, show the banner at its true size. */
164
+ @media print {
165
+ body { background: white; padding: 0; }
166
+ .sb-switcher { display: none; }
167
+ .social-banner-stage { transform: none !important; box-shadow: none; }
168
+ }
169
+ </style>
170
+ </head>
171
+ <body>
172
+
173
+ <!-- The social-media canvas. The wrapping .social-banner-stage carries
174
+ the exact pixel dimensions (1500×500 for Twitter by default); the
175
+ .carbon-banner inside is the same hero block as on demo.html, minus
176
+ the logo card and minus the tab strip. -->
177
+ <div class="social-banner-stage" id="sb-stage">
178
+ <header class="carbon-banner" aria-label="Carbon DNA model banner">
179
+ <div class="banner-inner">
180
+ <div class="banner-left">
181
+
182
+ <!-- Identity strip — no logo card here, just the model-path
183
+ breadcrumb so the banner reads as a Hugging Face model
184
+ card identifier without the square thumbnail. -->
185
+ <div class="banner-identity">
186
+ <div class="banner-breadcrumb">
187
+ <div class="banner-title">CARBON</div>
188
+ <div class="banner-path">huggingfacebio/carbon-3b</div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Headline: oversized wordmark with blinking caret, then
193
+ tagline + spec sheet underneath. Same structure as
194
+ demo.html so the styling carries over verbatim. -->
195
+ <div class="banner-headline">
196
+ <h1 class="banner-wordmark"><span>CARBON</span><span class="banner-cursor" aria-hidden="true"></span></h1>
197
+ <p class="banner-subtitle">Autoregressive Genomic Foundation Model</p>
198
+ <ul class="banner-specs" aria-label="Model specs">
199
+ <li class="banner-spec"><strong>49,152</strong> bp context</li>
200
+ <li class="banner-spec"><strong>6-mer</strong> tokenizer</li>
201
+ <li class="banner-spec"><strong>1T</strong> train tokens</li>
202
+ </ul>
203
+ </div>
204
+
205
+ </div>
206
+
207
+ <!-- Animated DNA helix, painted by banner.js. -->
208
+ <div class="banner-helix" aria-hidden="true">
209
+ <canvas class="cb-helix-canvas"></canvas>
210
+ </div>
211
+ </div>
212
+ </header>
213
+ </div>
214
+
215
+ <!-- Format switcher. Stays outside the stage so it doesn't show up
216
+ when you crop the banner. -->
217
+ <div class="sb-switcher" role="group" aria-label="Banner format">
218
+ <span class="sb-switcher__label">format</span>
219
+ <button class="sb-switcher__btn is-active" data-format="twitter" data-w="1500" data-h="500">twitter / x · 1500×500</button>
220
+ <button class="sb-switcher__btn" data-format="og" data-w="1200" data-h="630">og / share · 1200×630</button>
221
+ <button class="sb-switcher__btn" data-format="linkedin" data-w="1584" data-h="396">linkedin · 1584×396</button>
222
+ <button class="sb-switcher__btn" data-format="hf" data-w="1280" data-h="640">hugging face · 1280×640</button>
223
+ <span class="sb-switcher__dim" id="sb-scale-readout">100%</span>
224
+ </div>
225
+
226
+ <!-- Helix animation. Same script as the main demo — it queries
227
+ .carbon-banner + .cb-helix-canvas, both of which exist here. -->
228
+ <script src="/assets/js/banner.js"></script>
229
+
230
+ <script>
231
+ // ----------------------------------------------------------------
232
+ // Format switcher + auto-fit-to-viewport.
233
+ //
234
+ // The .social-banner-stage carries the EXACT social-media canvas
235
+ // dimensions (1500×500 for a Twitter header, etc). On a screen that
236
+ // can't physically fit that many pixels we don't want a scrollbar —
237
+ // we want the banner to scale down uniformly so it stays centred and
238
+ // fully visible. We compute the largest scale that fits both axes
239
+ // inside the viewport (minus some breathing room for the switcher
240
+ // row) and apply it via CSS transform.
241
+ // ----------------------------------------------------------------
242
+ (function initSocialBanner() {
243
+ const stage = document.getElementById("sb-stage");
244
+ const readout = document.getElementById("sb-scale-readout");
245
+ const buttons = document.querySelectorAll(".sb-switcher__btn");
246
+
247
+ // Vertical breathing room reserved for the format switcher
248
+ // (height of the row + body padding + gap). Kept conservative so
249
+ // the banner always has clear margin around it.
250
+ const CHROME_V = 160;
251
+ const CHROME_H = 80;
252
+
253
+ function fitStage() {
254
+ const w = parseFloat(getComputedStyle(stage).getPropertyValue("--sb-w"));
255
+ const h = parseFloat(getComputedStyle(stage).getPropertyValue("--sb-h"));
256
+ const availW = Math.max(200, window.innerWidth - CHROME_H);
257
+ const availH = Math.max(200, window.innerHeight - CHROME_V);
258
+ const scale = Math.min(1, availW / w, availH / h);
259
+ stage.style.setProperty("--sb-scale", scale.toFixed(4));
260
+ readout.textContent = Math.round(scale * 100) + "%";
261
+ }
262
+
263
+ function setFormat(btn) {
264
+ buttons.forEach(b => b.classList.toggle("is-active", b === btn));
265
+ const w = btn.dataset.w + "px";
266
+ const h = btn.dataset.h + "px";
267
+ stage.style.setProperty("--sb-w", w);
268
+ stage.style.setProperty("--sb-h", h);
269
+ // Reflect in the URL so a chosen format is shareable / bookmarkable.
270
+ const url = new URL(window.location);
271
+ url.searchParams.set("format", btn.dataset.format);
272
+ window.history.replaceState(null, "", url);
273
+ // Wait one frame so the new --sb-w/--sb-h are applied before we
274
+ // measure for the fit-to-viewport calculation.
275
+ requestAnimationFrame(() => {
276
+ fitStage();
277
+ // Nudge the canvas to resize against its new CSS box. The
278
+ // banner.js ResizeObserver should catch this on its own, but
279
+ // firing a synthetic resize is a cheap belt-and-braces.
280
+ window.dispatchEvent(new Event("resize"));
281
+ });
282
+ }
283
+
284
+ buttons.forEach(btn => btn.addEventListener("click", () => setFormat(btn)));
285
+
286
+ // Restore from URL query (?format=twitter / og / linkedin / hf).
287
+ const initial = new URL(window.location).searchParams.get("format");
288
+ if (initial) {
289
+ const match = Array.from(buttons).find(b => b.dataset.format === initial);
290
+ if (match) setFormat(match);
291
+ }
292
+
293
+ fitStage();
294
+ window.addEventListener("resize", fitStage);
295
+ })();
296
+ </script>
297
+
298
+ </body>
299
+ </html>