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

Hero refresh + sticky tab nav + section polish

Browse files

Banner / hero
- Desaturate the DNA helix palette (banner.js TINT) so the canvas blends
into the paper rather than punching through it.
- Cursor caret trimmed from the top so it stops poking above the N's
cap height while keeping its baseline aligned with the wordmark.

Sticky tab nav
- New strip (#tab-nav-sticky) slides in from the top once the in-banner
#tab-nav scrolls out of view (IntersectionObserver in tabs.js toggling
.is-tabs-stuck on <body>).
- Styled as a slice of the banner: same paper backing, dotted grain +
green vertical stripes, same bottom hairline. Tabs reuse the in-banner
colour scheme with bumped #b8b5a6 borders, sharp corners, top liseré
on the active tab, and a margin-bottom: -1px trick so the active card
bleeds into the page paper underneath.
- Promoted --paper / --ink / --green / --hairline / --muted to :root so
the sticky nav (a sibling of .carbon-banner, not a descendant) can
resolve them.

Section layout
- §1 tokenizer: 1-mer and 6-mer columns stacked vertically, full-width
DNA input pinned next to the 30 bp counter, stats grouped under both
sequences as a single row with 1-mer/6-mer-prefixed labels.
- §3 data: padding-top on the SNR track-axis-label to breathe under the
bar.
- §4 species: row grid collapsed from 3 columns to 2, identity stats
stacked under the species name instead of in their own column.

Status pills
- completion / species / track / vep: hide the status pill entirely
while in "idle" so the toolbar stays clean until something actually
happens. New .status.is-hidden { display:none } in controls.css.

Misc
- app.py: restore /v1/ suffix on the default ENDPOINT_URL so the OpenAI
SDK hits /v1/completions instead of /completions when running locally.

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

app.py CHANGED
@@ -11,7 +11,14 @@ from openai import OpenAI
11
 
12
  ENDPOINT_URL = os.environ.get(
13
  "ENDPOINT_URL",
14
- "https://cr2l9w72ys5pp8le.us-east-1.aws.endpoints.huggingface.cloud",
 
 
 
 
 
 
 
15
  )
16
  MODEL_NAME = os.environ.get(
17
  "MODEL_NAME",
 
11
 
12
  ENDPOINT_URL = os.environ.get(
13
  "ENDPOINT_URL",
14
+ # NOTE: must end in /v1/ — the OpenAI SDK v1+ appends "completions"
15
+ # directly to base_url with no auto /v1/ prefix. The HF dedicated
16
+ # endpoint serves the OpenAI-compatible API at /v1/completions, so
17
+ # without the suffix the SDK hits /completions and the endpoint
18
+ # returns 404. Upstream commit 2831701 dropped the /v1/ but HF Spaces
19
+ # masks this via an ENDPOINT_URL secret that includes it; running
20
+ # locally with the default URL needs the suffix put back.
21
+ "https://cr2l9w72ys5pp8le.us-east-1.aws.endpoints.huggingface.cloud/v1/",
22
  )
23
  MODEL_NAME = os.environ.get(
24
  "MODEL_NAME",
assets/js/banner.js CHANGED
@@ -1,11 +1,15 @@
1
  // =========================================================================
2
- // Carbon banner — animated DNA helix (Canvas 2D)
3
  //
4
- // Original implementation rendered ~960 SVG <path> mutations per frame, which
5
- // pinned the main thread at ~25% CPU non-stop. Canvas 2D draws the whole
6
- // scene to a bitmap each frame with no DOM mutations — typically 10-50× cheaper
7
- // for this kind of frame-by-frame animation. Math/colors are unchanged so the
8
- // visual is pixel-equivalent.
 
 
 
 
9
  // =========================================================================
10
  (function initCarbonBanner() {
11
  const banner = document.querySelector(".carbon-banner");
@@ -18,41 +22,77 @@
18
  const sequence = ["A","T","A","A","C","G","A","C","T","T","C","C","C","T","A","T","T","G"];
19
  const complement = { A: "T", T: "A", C: "G", G: "C" };
20
 
21
- // All physical parameters live in the original SVG viewBox coordinate
22
- // system. The applyVbTransform() call below maps that system onto the
23
- // canvas backing store (DPR-aware), so we can keep the math identical.
 
24
  const helix = {
25
- startX: 868, endX: 1988, centerY: 318, amplitude: 88,
26
- cycles: 3.62, speed: 0.00015,
27
- // Doubled vs the original SVG (96 → 192). On <canvas> the per-segment cost
28
- // is negligible, and tighter sampling kills the staircasing visible on
29
- // the ribbon edges at high DPR.
30
- rungCount: 27, segmentCount: 192,
31
- bodyRadius: 6.4, shadowRadius: 8.4,
32
- // glyphGap is half the punch-through height around each letter. Bumped vs
33
- // the SVG-era 13.5 because rounded line caps eat ~0.7px of the visible
34
- // gap on each side at this stroke width, making the rung feel cramped.
35
- rungInset: 9.2, glyphGap: 16.5,
 
 
 
 
36
  };
37
- // Helix bbox in viewBox coords. The CSS-positioned <canvas> mirrors this
38
- // box exactly (see .cb-helix-canvas in <style>).
39
- const VB = { x: 858, y: 220, w: 1140, h: 196 };
40
 
41
  const COLORS = {
42
- shadow: "#aeb5ad",
43
  body: "#e4e5dc",
44
- bodyStroke: "rgba(49, 127, 63, 0.14)",
45
  edge: "#2d332e",
46
  green: "#317f3f",
47
  };
48
 
49
- // Stroke-only ATCG glyphs same path strings the SVG version used.
50
- // Path2D parses an SVG path string directly; ctx.stroke(path2d) is fast.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  const glyphPaths = {
52
- A: new Path2D("M -5 8 L 0 -8 L 5 8 M -3.1 2 L 3.1 2"),
53
- C: new Path2D("M 5 -6 C 1 -9 -6 -7 -6 0 C -6 7 1 9 5 6"),
54
- G: new Path2D("M 5 -6 C 1 -9 -6 -7 -6 0 C -6 7 1 9 5 6 M 5 1 L 1 1 M 5 1 L 5 6"),
55
- T: new Path2D("M -6 -7 L 6 -7 M 0 -7 L 0 8"),
56
  };
57
 
58
  // --- Canvas sizing (DPR + viewBox→pixels mapping) ---------------------
@@ -62,14 +102,16 @@
62
  if (rect.width === 0 || rect.height === 0) return;
63
  cssW = rect.width;
64
  cssH = rect.height;
65
- // Use the device's full DPR (typically 2 or 3 on Retina displays).
66
- // The banner is small (~600×100 CSS px) so even DPR 3 stays cheap.
67
  dpr = window.devicePixelRatio || 1;
68
  canvas.width = Math.round(cssW * dpr);
69
  canvas.height = Math.round(cssH * dpr);
70
  const sx = canvas.width / VB.w;
71
  const sy = canvas.height / VB.h;
72
- uniformScale = Math.min(sx, sy);
 
 
 
 
73
  offsetX = (canvas.width - VB.w * uniformScale) / 2;
74
  offsetY = (canvas.height - VB.h * uniformScale) / 2;
75
  }
@@ -80,98 +122,213 @@
80
  -VB.y * uniformScale + offsetY,
81
  );
82
  }
83
- // Convert a CSS-pixel size to viewBox units (so strokes/fonts stay crisp
84
- // regardless of the canvas size — equivalent to vector-effect:non-scaling-stroke).
85
  function px(cssPx) { return (cssPx * dpr) / uniformScale; }
86
 
87
- // --- Math (verbatim from the SVG version) -----------------------------
88
- function pointAt(x, offset, phase) {
89
- const t = (x - helix.startX) / (helix.endX - helix.startX);
 
 
 
90
  const theta = t * helix.cycles * Math.PI * 2 + phase + offset;
91
- const slope = Math.cos(theta) * helix.amplitude * helix.cycles * Math.PI * 2 / (helix.endX - helix.startX);
92
  const normalLength = Math.hypot(slope, 1);
93
  return {
94
- x,
95
- y: helix.centerY + Math.sin(theta) * helix.amplitude,
96
  z: Math.cos(theta),
97
- nx: -slope / normalLength,
98
- ny: 1 / normalLength,
99
  };
100
  }
101
 
102
- // Pre-allocate sample buffers — never reallocated per frame.
103
  const pointsA = new Array(helix.segmentCount + 1);
104
  const pointsB = new Array(helix.segmentCount + 1);
105
  function fillSamples(buf, offset, phase) {
106
- const span = helix.endX - helix.startX;
107
  for (let i = 0; i <= helix.segmentCount; i++) {
108
- buf[i] = pointAt(helix.startX + (span * i) / helix.segmentCount, offset, phase);
109
  }
110
  }
111
 
112
- // Pre-allocated segment list (avoids GC churn).
113
- const segs = new Array(helix.segmentCount * 2);
114
- for (let i = 0; i < segs.length; i++) segs[i] = { a: null, b: null, z: 0 };
115
- function fillSegments() {
116
- const N = helix.segmentCount;
117
- for (let i = 0; i < N; i++) {
118
- const a0 = pointsA[i], b0 = pointsA[i + 1];
119
- const a1 = pointsB[i], b1 = pointsB[i + 1];
120
- const s0 = segs[2 * i]; s0.a = a0; s0.b = b0; s0.z = (a0.z + b0.z) * 0.5;
121
- const s1 = segs[2 * i + 1]; s1.a = a1; s1.b = b1; s1.z = (a1.z + b1.z) * 0.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
 
 
123
  }
124
 
125
- // --- Drawing primitives (operate in viewBox space) --------------------
126
- function ribbonPath(a, b, radius) {
 
 
127
  ctx.beginPath();
128
- ctx.moveTo(a.x + a.nx * radius, a.y + a.ny * radius);
129
- ctx.lineTo(b.x + b.nx * radius, b.y + b.ny * radius);
130
- ctx.lineTo(b.x - b.nx * radius, b.y - b.ny * radius);
131
- ctx.lineTo(a.x - a.nx * radius, a.y - a.ny * radius);
 
 
 
 
 
 
132
  ctx.closePath();
133
  }
134
- function lineEdge(a, b, radius) {
 
 
 
 
135
  ctx.beginPath();
136
- ctx.moveTo(a.x + a.nx * radius, a.y + a.ny * radius);
137
- ctx.lineTo(b.x + b.nx * radius, b.y + b.ny * radius);
 
 
 
 
138
  }
139
- function drawSegment(seg) {
140
- const z = seg.z;
141
- const front = Math.max(0, Math.min(1, (z + 1) / 2));
142
- const a = seg.a, b = seg.b;
143
- // Soft outer shadow (depth cue)
144
- ctx.globalAlpha = 0.05 + front * 0.12;
145
- ctx.fillStyle = COLORS.shadow;
146
- ribbonPath(a, b, helix.shadowRadius);
147
- ctx.fill();
148
- // Body ribbon (fill + thin green outline, same alpha — matches SVG opacity behavior)
149
- ctx.globalAlpha = 0.5 + front * 0.42;
150
- ribbonPath(a, b, helix.bodyRadius);
151
- ctx.fillStyle = COLORS.body;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  ctx.fill();
153
- ctx.strokeStyle = COLORS.bodyStroke;
154
- ctx.lineWidth = px(0.8);
155
- ctx.stroke();
156
- // Edges (top + bottom ink lines)
157
- ctx.globalAlpha = 0.2 + front * 0.68;
158
- ctx.strokeStyle = COLORS.edge;
159
- ctx.lineWidth = px(1.15);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  ctx.lineCap = "round";
161
  ctx.lineJoin = "round";
162
- lineEdge(a, b, helix.bodyRadius + 0.35);
163
  ctx.stroke();
164
- lineEdge(a, b, -(helix.bodyRadius + 0.35));
 
 
 
 
 
 
 
 
 
 
165
  ctx.stroke();
166
  }
167
- function drawRung(x, yStart, letterYs, yEnd, gap) {
168
- const start = Math.min(yStart, yEnd);
169
- const end = Math.max(yStart, yEnd);
170
- // Compute gaps where letters punch through the rung.
171
  const ranges = [];
172
- for (const y of letterYs) {
173
- const f = Math.max(start, y - gap);
174
- const t = Math.min(end, y + gap);
175
  if (t > f) ranges.push([f, t]);
176
  }
177
  ranges.sort((u, v) => u[0] - v[0]);
@@ -184,10 +341,10 @@
184
  ctx.beginPath();
185
  let cursor = start;
186
  for (const [f, t] of merged) {
187
- if (f - cursor > 0.7) { ctx.moveTo(x, cursor); ctx.lineTo(x, f); }
188
  cursor = t;
189
  }
190
- if (end - cursor > 0.7) { ctx.moveTo(x, cursor); ctx.lineTo(x, end); }
191
  ctx.stroke();
192
  }
193
  function drawGlyph(letter, x, y) {
@@ -197,77 +354,170 @@
197
  ctx.restore();
198
  }
199
 
 
 
 
 
 
 
 
 
 
 
 
200
  // --- Frame ------------------------------------------------------------
201
  function drawFrame(phase) {
202
  if (cssW === 0 || cssH === 0) return;
203
- // Identity transform to clear the raw pixel grid, then re-apply viewBox map.
204
  ctx.setTransform(1, 0, 0, 1, 0, 0);
205
  ctx.clearRect(0, 0, canvas.width, canvas.height);
206
  applyVbTransform();
207
 
208
  fillSamples(pointsA, 0, phase);
209
  fillSamples(pointsB, Math.PI, phase);
210
- fillSegments();
211
- segs.sort((u, v) => u.z - v.z);
 
 
 
 
 
 
 
 
212
 
213
- // Pass 1: back segments (z < 0) — drawn under rungs/glyphs.
214
- let i = 0;
215
- for (; i < segs.length && segs[i].z < 0; i++) drawSegment(segs[i]);
 
 
 
216
 
217
- // Rungs + ATCG glyphs (only those whose rung span makes them visible).
 
 
 
 
 
218
  for (let k = 0; k < helix.rungCount; k++) {
219
  const t = k / (helix.rungCount - 1);
220
- const x = helix.startX + (helix.endX - helix.startX) * t;
221
- const a = pointAt(x, 0, phase);
222
- const b = pointAt(x, Math.PI, phase);
223
- const yTop = Math.min(a.y, b.y);
224
- const yBottom = Math.max(a.y, b.y);
225
- const span = yBottom - yTop;
226
  const inset = Math.min(helix.rungInset, Math.max(0, span * 0.5 - 3));
227
  const visible = Math.max(0, Math.min(1, (span - 34) / 70));
228
- const aLetterY = a.y + (b.y - a.y) * 0.34;
229
- const bLetterY = b.y + (a.y - b.y) * 0.34;
230
  const letterGap = Math.min(helix.glyphGap, Math.max(8.5, span * 0.16));
231
 
232
- ctx.globalAlpha = 0.18 + visible * 0.72;
233
- ctx.strokeStyle = COLORS.green;
234
- ctx.lineWidth = px(1.35);
 
 
 
 
 
 
 
 
 
 
235
  ctx.lineCap = "round";
236
- drawRung(x, yTop + inset, [aLetterY, bLetterY], yBottom - inset, letterGap);
237
 
238
- ctx.globalAlpha = 0.16 + visible * 0.84;
239
- ctx.lineWidth = px(1.8);
 
 
 
 
 
 
240
  ctx.lineCap = "square";
241
  ctx.lineJoin = "miter";
242
  const letter = sequence[k % sequence.length];
243
- drawGlyph(letter, x, aLetterY);
244
- drawGlyph(complement[letter], x, bLetterY);
 
 
245
  }
246
 
247
- // Pass 2: front segments (z >= 0) — drawn on top of rungs/glyphs.
248
- for (; i < segs.length; i++) drawSegment(segs[i]);
249
-
250
  ctx.globalAlpha = 1;
 
 
 
 
 
 
 
 
251
  }
252
 
253
  resize();
254
 
255
- // Static frame for users who prefer reduced motion.
256
  if (prefersReduced) {
257
  drawFrame(0.6);
258
  return;
259
  }
260
 
261
  // --- Animation loop, paused off-screen and on hidden tab --------------
262
- // 30fps is visually indistinguishable from 60fps at this rotation speed
263
- // and halves the paint workload.
264
  const FRAME_INTERVAL_MS = 1000 / 30;
265
  let rafId = 0, running = false, inViewport = true, lastFrameTs = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  function tick(ts) {
267
  if (!running) return;
268
  if (ts - lastFrameTs >= FRAME_INTERVAL_MS) {
 
 
269
  lastFrameTs = ts;
270
- drawFrame(ts * helix.speed);
 
 
 
 
 
 
 
 
 
 
 
271
  }
272
  rafId = requestAnimationFrame(tick);
273
  }
@@ -275,6 +525,7 @@
275
  if (running || !inViewport || document.hidden) return;
276
  running = true;
277
  lastFrameTs = 0;
 
278
  rafId = requestAnimationFrame(tick);
279
  }
280
  function stop() {
@@ -295,14 +546,12 @@
295
  else start();
296
  });
297
 
298
- // Re-size the backing store when the banner is resized (responsive layout).
299
  const ro = new ResizeObserver(() => {
300
  resize();
301
- drawFrame(lastFrameTs * helix.speed);
302
  });
303
  ro.observe(canvas);
304
 
305
  drawFrame(0);
306
  start();
307
  })();
308
-
 
1
  // =========================================================================
2
+ // Carbon banner — animated VERTICAL DNA helix (Canvas 2D)
3
  //
4
+ // The helix is drawn upright (long axis = y). The CSS rotates the canvas
5
+ // by a few degrees for the "technical drawing on a bench" feel; the math
6
+ // here stays axis-aligned to keep the z-sort and rung logic readable.
7
+ //
8
+ // Compared to the original horizontal version, the only change is which
9
+ // coordinate carries the cycles vs the amplitude:
10
+ // horizontal: y = sin(theta(x)) * amplitude
11
+ // vertical: x = sin(theta(y)) * amplitude
12
+ // Everything else (z-sort, rung gaps, glyph shapes) carries over verbatim.
13
  // =========================================================================
14
  (function initCarbonBanner() {
15
  const banner = document.querySelector(".carbon-banner");
 
22
  const sequence = ["A","T","A","A","C","G","A","C","T","T","C","C","C","T","A","T","T","G"];
23
  const complement = { A: "T", T: "A", C: "G", G: "C" };
24
 
25
+ // All physical parameters in viewBox units. The helix is upright: the long
26
+ // axis runs from startY to endY; sinusoid wiggle is in x around centerX.
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
34
+ // polyline with this many samples (≈128 per cycle at 512), so the
35
+ // curves stay smooth even at the apex of each loop where the tangent
36
+ // direction changes fastest. Cost is negligible: we still only do
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 = {
 
51
  body: "#e4e5dc",
 
52
  edge: "#2d332e",
53
  green: "#317f3f",
54
  };
55
 
56
+ // Depth is rendered ENTIRELY through COLOR modulation, not alpha.
57
+ // Every paint operation runs at alpha = 1, which kills the compositing
58
+ // artifacts (visible "step" at section boundaries / overlap zones) that
59
+ // an alpha-based depth model produces.
60
+ //
61
+ // Each visual layer (body, green hairline, dark edge) is interpolated
62
+ // between a "back" tint (z = -1, washed-out toward the paper) and a
63
+ // "front" tint (z = +1, full ink). At z = 0 the colour is the midpoint —
64
+ // so a strand section that crosses the back/front boundary has a perfectly
65
+ // continuous colour curve, no perceptible plateau.
66
+ //
67
+ // The whole palette is tuned to sit LOW-contrast against the page paper
68
+ // (#f7f5ee). The helix should read as a soft watermark / blueprint, not
69
+ // as a hi-contrast logo: front tints are pulled toward the paper, the
70
+ // outer edge is desaturated forest rather than saturated forest, and the
71
+ // green hairline is more sage than brand-green.
72
+ //
73
+ // [r, g, b] triplets, NOT strings — we do per-frame linear interpolation
74
+ // in JS and emit a single rgb() per gradient stop.
75
+ const TINT = {
76
+ bodyBack: [243, 240, 230], // sits almost on top of the paper
77
+ bodyFront: [232, 230, 218], // gentle cream, reads as a soft ribbon
78
+ // Inner hairline: muted sage accent, not full brand green.
79
+ greenBack: [205, 220, 208],
80
+ greenFront: [125, 165, 132],
81
+ // Outer edge: low-contrast desaturated forest. Still green-leaning so
82
+ // the silhouette reads as a strand rather than a grey shape, but lifted
83
+ // far enough off black that the ribbon merges with the paper instead of
84
+ // punching a hole through it.
85
+ edgeBack: [195, 215, 198],
86
+ edgeFront: [108, 150, 118],
87
+ };
88
+
89
+ // ATCG glyphs — scaled up so they read at the larger banner size. Stroke
90
+ // widths bump in drawFrame() to keep the visual weight consistent.
91
  const glyphPaths = {
92
+ A: new Path2D("M -11 17 L 0 -17 L 11 17 M -6.6 4 L 6.6 4"),
93
+ C: new Path2D("M 11 -13 C 2 -19 -13 -15 -13 0 C -13 15 2 19 11 13"),
94
+ G: new Path2D("M 11 -13 C 2 -19 -13 -15 -13 0 C -13 15 2 19 11 13 M 11 2 L 2 2 M 11 2 L 11 13"),
95
+ T: new Path2D("M -13 -15 L 13 -15 M 0 -15 L 0 17"),
96
  };
97
 
98
  // --- Canvas sizing (DPR + viewBox→pixels mapping) ---------------------
 
102
  if (rect.width === 0 || rect.height === 0) return;
103
  cssW = rect.width;
104
  cssH = rect.height;
 
 
105
  dpr = window.devicePixelRatio || 1;
106
  canvas.width = Math.round(cssW * dpr);
107
  canvas.height = Math.round(cssH * dpr);
108
  const sx = canvas.width / VB.w;
109
  const sy = canvas.height / VB.h;
110
+ // Cover-fit: the helix fills the canvas in both dimensions, with overflow
111
+ // clipped by the parent's overflow:hidden. This makes the helix BLEED
112
+ // beyond the banner top/bottom for a "spilling out of the frame" feel —
113
+ // exactly what "much bigger" calls for.
114
+ uniformScale = Math.max(sx, sy);
115
  offsetX = (canvas.width - VB.w * uniformScale) / 2;
116
  offsetY = (canvas.height - VB.h * uniformScale) / 2;
117
  }
 
122
  -VB.y * uniformScale + offsetY,
123
  );
124
  }
125
+ // viewBox-units helper for stroke widths (mimics vector-effect:non-scaling).
 
126
  function px(cssPx) { return (cssPx * dpr) / uniformScale; }
127
 
128
+ // --- Math: vertical helix ---------------------------------------------
129
+ // tangent at (x, y) along the curve has dy=1 (we parameterize by y);
130
+ // dx/dy is the slope. The unit normal in 2D is (-dy, dx)/|tangent|, i.e.
131
+ // (-1, slope) normalised — pointing "outward" perpendicular to the curve.
132
+ function pointAt(y, offset, phase) {
133
+ const t = (y - helix.startY) / (helix.endY - helix.startY);
134
  const theta = t * helix.cycles * Math.PI * 2 + phase + offset;
135
+ const slope = Math.cos(theta) * helix.amplitude * helix.cycles * Math.PI * 2 / (helix.endY - helix.startY);
136
  const normalLength = Math.hypot(slope, 1);
137
  return {
138
+ x: helix.centerX + Math.sin(theta) * helix.amplitude,
139
+ y,
140
  z: Math.cos(theta),
141
+ nx: 1 / normalLength,
142
+ ny: -slope / normalLength,
143
  };
144
  }
145
 
 
146
  const pointsA = new Array(helix.segmentCount + 1);
147
  const pointsB = new Array(helix.segmentCount + 1);
148
  function fillSamples(buf, offset, phase) {
149
+ const span = helix.endY - helix.startY;
150
  for (let i = 0; i <= helix.segmentCount; i++) {
151
+ buf[i] = pointAt(helix.startY + (span * i) / helix.segmentCount, offset, phase);
152
  }
153
  }
154
 
155
+ // --- Drawing primitives -----------------------------------------------
156
+ //
157
+ // Each strand is rendered as a series of CONTINUOUS sections (one per
158
+ // contiguous z-half) so the curve never breaks into small straight
159
+ // facets lineJoin: "round" smooths every junction inside a section.
160
+ //
161
+ // Depth is conveyed entirely through COLOUR (see TINT / strandColourGradient
162
+ // below). Sections are still split at the z = 0 crossing so we can draw
163
+ // back-of-rungs and front-of-rungs in separate passes that's what
164
+ // produces the visual occlusion when one strand crosses over a rung.
165
+ // Because every section of a given strand shares the same colour
166
+ // gradient, the back↔front handoff is perfectly continuous: at any
167
+ // pixel y the back section and front section paint the same colour.
168
+
169
+ // Find contiguous ranges of indices in `points` where (z >= 0) === wantFront.
170
+ // Each range is extended by 1 sample on each side (when possible) so adjacent
171
+ // back/front sections overlap visually — no hairline gap at the z=0 crossing.
172
+ function findRanges(points, wantFront) {
173
+ const ranges = [];
174
+ let runStart = -1;
175
+ for (let i = 0; i < points.length; i++) {
176
+ const isFront = points[i].z >= 0;
177
+ if (isFront === wantFront) {
178
+ if (runStart === -1) runStart = i > 0 ? i - 1 : i;
179
+ } else if (runStart !== -1) {
180
+ ranges.push([runStart, i]);
181
+ runStart = -1;
182
+ }
183
  }
184
+ if (runStart !== -1) ranges.push([runStart, points.length - 1]);
185
+ return ranges;
186
  }
187
 
188
+ // Trace the OUTLINE of a ribbon section in viewBox space: walk the top
189
+ // edge from `from` to `to`, then the bottom edge back. Used both to fill
190
+ // and to stroke the green hairline outline around the body.
191
+ function ribbonOutlinePath(points, from, to, radius) {
192
  ctx.beginPath();
193
+ for (let i = from; i <= to; i++) {
194
+ const p = points[i];
195
+ const x = p.x + p.nx * radius;
196
+ const y = p.y + p.ny * radius;
197
+ if (i === from) ctx.moveTo(x, y); else ctx.lineTo(x, y);
198
+ }
199
+ for (let i = to; i >= from; i--) {
200
+ const p = points[i];
201
+ ctx.lineTo(p.x - p.nx * radius, p.y - p.ny * radius);
202
+ }
203
  ctx.closePath();
204
  }
205
+
206
+ // Open polyline along ONE edge (top or bottom) of a ribbon section. Used
207
+ // for the dark ink edge strokes, which look better drawn as a single
208
+ // continuous path (lineJoin: round smooths every join automatically).
209
+ function edgePath(points, from, to, signedRadius) {
210
  ctx.beginPath();
211
+ for (let i = from; i <= to; i++) {
212
+ const p = points[i];
213
+ const x = p.x + p.nx * signedRadius;
214
+ const y = p.y + p.ny * signedRadius;
215
+ if (i === from) ctx.moveTo(x, y); else ctx.lineTo(x, y);
216
+ }
217
  }
218
+
219
+ // Smoothstep eases the back↔front transition so the colour change happens
220
+ // gradually around z = 0 instead of swinging through that point linearly.
221
+ // Visually: less time spent in the "ambiguous middle tone", more time in
222
+ // pure-back / pure-front colour — the eye reads it as two clear depth
223
+ // zones with a soft crossfade, not as a constant fade.
224
+ function smoothstep(x) {
225
+ const t = x < 0 ? 0 : x > 1 ? 1 : x;
226
+ return t * t * (3 - 2 * t);
227
+ }
228
+
229
+ // Build a VERTICAL colour gradient that spans the full strand (startY →
230
+ // endY) and lerps each visual layer between its back-tint and front-tint
231
+ // based on the local z value at every sample. Returns a CanvasGradient
232
+ // ready to use as fillStyle / strokeStyle.
233
+ //
234
+ // CRITICAL: this gradient is built ONCE per frame per strand and reused
235
+ // by every section (back AND front) of that strand. The shared spatial
236
+ // mapping is what makes back↔front transitions perfectly seamless — at
237
+ // any pixel y, both sections compute the exact same colour.
238
+ function strandColourGradient(points, tintBack, tintFront) {
239
+ const y0 = points[0].y;
240
+ const y1 = points[points.length - 1].y;
241
+ const g = ctx.createLinearGradient(0, y0, 0, y1);
242
+ const N = points.length - 1;
243
+ const dR = tintFront[0] - tintBack[0];
244
+ const dG = tintFront[1] - tintBack[1];
245
+ const dB = tintFront[2] - tintBack[2];
246
+ for (let i = 0; i <= N; i++) {
247
+ const t = i / N;
248
+ const front = smoothstep((points[i].z + 1) * 0.5);
249
+ const r = (tintBack[0] + front * dR) | 0;
250
+ const gg = (tintBack[1] + front * dG) | 0;
251
+ const b = (tintBack[2] + front * dB) | 0;
252
+ g.addColorStop(t, `rgb(${r},${gg},${b})`);
253
+ }
254
+ return g;
255
+ }
256
+
257
+ // Pre-built per-frame gradients for the two strands. Each holds the three
258
+ // colour gradients used by drawRibbonSection() — body fill, green
259
+ // hairline, dark ink edge.
260
+ let gradsA = null, gradsB = null;
261
+ function rebuildStrandGradients() {
262
+ gradsA = {
263
+ body: strandColourGradient(pointsA, TINT.bodyBack, TINT.bodyFront),
264
+ green: strandColourGradient(pointsA, TINT.greenBack, TINT.greenFront),
265
+ edge: strandColourGradient(pointsA, TINT.edgeBack, TINT.edgeFront),
266
+ };
267
+ gradsB = {
268
+ body: strandColourGradient(pointsB, TINT.bodyBack, TINT.bodyFront),
269
+ green: strandColourGradient(pointsB, TINT.greenBack, TINT.greenFront),
270
+ edge: strandColourGradient(pointsB, TINT.edgeBack, TINT.edgeFront),
271
+ };
272
+ }
273
+
274
+ // FILL pass for one section. The closed polygon is FILLED ONLY — never
275
+ // stroked — so the perpendicular caps at the section's two ends stay
276
+ // invisible. Adjacent sections overlap by 1-2 samples, which guarantees
277
+ // that the geometric edge of one polygon falls inside the body of the
278
+ // next, so the anti-aliased polygon outlines never read as a hairline
279
+ // step either.
280
+ function drawRibbonFill(points, from, to, grads) {
281
+ if (to <= from) return;
282
+ ctx.globalAlpha = 1;
283
+ ribbonOutlinePath(points, from, to, helix.bodyRadius);
284
+ ctx.fillStyle = grads.body;
285
  ctx.fill();
286
+ }
287
+
288
+ // EDGES pass for one section. Each of the 4 lines (green hairline top,
289
+ // green hairline bottom, dark ink top, dark ink bottom) is an OPEN
290
+ // polyline drawn separately. No closed-path stroke anywhere — that's
291
+ // what eliminates the perpendicular "step" marks across the strand that
292
+ // a closed-polygon stroke would draw at the cap positions.
293
+ //
294
+ // Round caps at the polyline endpoints DO leak a tiny half-disc past the
295
+ // section boundary, but because adjacent sections overlap and share the
296
+ // same colour gradient, the leak from one section is overdrawn by the
297
+ // body fill / edge polyline of the next section in the same XY position
298
+ // with the same colour. Net visual: a single continuous edge with no
299
+ // visible joint.
300
+ function drawRibbonEdges(points, from, to, grads) {
301
+ if (to <= from) return;
302
+ ctx.globalAlpha = 1;
303
+
304
+ // Thin green hairline at the inner rim of the body.
305
+ ctx.strokeStyle = grads.green;
306
+ ctx.lineWidth = px(0.9);
307
  ctx.lineCap = "round";
308
  ctx.lineJoin = "round";
309
+ edgePath(points, from, to, helix.bodyRadius - 0.2);
310
  ctx.stroke();
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));
322
  ctx.stroke();
323
  }
324
+ // Rungs are now HORIZONTAL lines (perpendicular to the vertical helix axis).
325
+ function drawRung(y, xStart, letterXs, xEnd, gap) {
326
+ const start = Math.min(xStart, xEnd);
327
+ const end = Math.max(xStart, xEnd);
328
  const ranges = [];
329
+ for (const x of letterXs) {
330
+ const f = Math.max(start, x - gap);
331
+ const t = Math.min(end, x + gap);
332
  if (t > f) ranges.push([f, t]);
333
  }
334
  ranges.sort((u, v) => u[0] - v[0]);
 
341
  ctx.beginPath();
342
  let cursor = start;
343
  for (const [f, t] of merged) {
344
+ if (f - cursor > 0.7) { ctx.moveTo(cursor, y); ctx.lineTo(f, y); }
345
  cursor = t;
346
  }
347
+ if (end - cursor > 0.7) { ctx.moveTo(cursor, y); ctx.lineTo(end, y); }
348
  ctx.stroke();
349
  }
350
  function drawGlyph(letter, x, y) {
 
354
  ctx.restore();
355
  }
356
 
357
+ // Lerp helper for rungs / glyphs — local point in time, doesn't deserve a
358
+ // gradient. Pass two RGB triplets and a `front` value in [0, 1]; returns
359
+ // a CSS rgb() string.
360
+ function tintAt(tintBack, tintFront, front) {
361
+ const f = smoothstep(front);
362
+ const r = (tintBack[0] + f * (tintFront[0] - tintBack[0])) | 0;
363
+ const g = (tintBack[1] + f * (tintFront[1] - tintBack[1])) | 0;
364
+ const b = (tintBack[2] + f * (tintFront[2] - tintBack[2])) | 0;
365
+ return `rgb(${r},${g},${b})`;
366
+ }
367
+
368
  // --- Frame ------------------------------------------------------------
369
  function drawFrame(phase) {
370
  if (cssW === 0 || cssH === 0) return;
 
371
  ctx.setTransform(1, 0, 0, 1, 0, 0);
372
  ctx.clearRect(0, 0, canvas.width, canvas.height);
373
  applyVbTransform();
374
 
375
  fillSamples(pointsA, 0, phase);
376
  fillSamples(pointsB, Math.PI, phase);
377
+ rebuildStrandGradients();
378
+
379
+ // Section the strands so we can draw back-of-rungs and front-of-rungs
380
+ // in separate passes (only way to get the visual crossing). All
381
+ // sections of the same strand share that strand's colour gradient, so
382
+ // the back↔front handoff is perfectly continuous.
383
+ const backA = findRanges(pointsA, false);
384
+ const backB = findRanges(pointsB, false);
385
+ const frontA = findRanges(pointsA, true);
386
+ const frontB = findRanges(pointsB, true);
387
 
388
+ // Pass 1a: back BODY FILLS of both strands.
389
+ for (const [f, t] of backA) drawRibbonFill(pointsA, f, t, gradsA);
390
+ for (const [f, t] of backB) drawRibbonFill(pointsB, f, t, gradsB);
391
+ // Pass 1b: back EDGES (separate open polylines per side — no caps).
392
+ for (const [f, t] of backA) drawRibbonEdges(pointsA, f, t, gradsA);
393
+ for (const [f, t] of backB) drawRibbonEdges(pointsB, f, t, gradsB);
394
 
395
+ // Rungs + ATCG glyphs. The rung connects strand A's point and strand
396
+ // B's point, which sit at OPPOSITE z values at any given y (the strands
397
+ // are π out of phase). We exploit that: the rung's colour fades along
398
+ // its length via a horizontal gradient — bright green near whichever
399
+ // strand is in front, soft sage near whichever strand is in back.
400
+ // Glyphs follow the same logic but as a single-tone tint per letter.
401
  for (let k = 0; k < helix.rungCount; k++) {
402
  const t = k / (helix.rungCount - 1);
403
+ const y = helix.startY + (helix.endY - helix.startY) * t;
404
+ const a = pointAt(y, 0, phase);
405
+ const b = pointAt(y, Math.PI, phase);
406
+ const xLeft = Math.min(a.x, b.x);
407
+ const xRight = Math.max(a.x, b.x);
408
+ const span = xRight - xLeft;
409
  const inset = Math.min(helix.rungInset, Math.max(0, span * 0.5 - 3));
410
  const visible = Math.max(0, Math.min(1, (span - 34) / 70));
411
+ const aLetterX = a.x + (b.x - a.x) * 0.34;
412
+ const bLetterX = b.x + (a.x - b.x) * 0.34;
413
  const letterGap = Math.min(helix.glyphGap, Math.max(8.5, span * 0.16));
414
 
415
+ // Horizontal gradient along the rung. tA/tB are the colour tints at
416
+ // strand A's and strand B's ends, derived from their respective z.
417
+ const frontA01 = (a.z + 1) * 0.5;
418
+ const frontB01 = (b.z + 1) * 0.5;
419
+ const colA = tintAt(TINT.greenBack, TINT.greenFront, frontA01);
420
+ const colB = tintAt(TINT.greenBack, TINT.greenFront, frontB01);
421
+ const rungGrad = ctx.createLinearGradient(a.x, y, b.x, y);
422
+ rungGrad.addColorStop(0, colA);
423
+ rungGrad.addColorStop(1, colB);
424
+
425
+ ctx.globalAlpha = 0.35 + visible * 0.55; // taper rungs at the cycle apex
426
+ ctx.strokeStyle = rungGrad;
427
+ ctx.lineWidth = px(1.9);
428
  ctx.lineCap = "round";
429
+ drawRung(y, xLeft + inset, [aLetterX, bLetterX], xRight - inset, letterGap);
430
 
431
+ // Glyphs: each letter takes the strand-local tint (sage for back,
432
+ // green for front). The letter sitting on strand A's side uses A's
433
+ // z, the complement on B's side uses B's z.
434
+ // Stroke bumped to px(3.6) so the ATCG glyphs read as bold/poster
435
+ // weight rather than a technical line drawing — they have to hold
436
+ // their own next to a JetBrains Mono 800 wordmark.
437
+ ctx.globalAlpha = 0.35 + visible * 0.6;
438
+ ctx.lineWidth = px(4.6);
439
  ctx.lineCap = "square";
440
  ctx.lineJoin = "miter";
441
  const letter = sequence[k % sequence.length];
442
+ ctx.strokeStyle = colA;
443
+ drawGlyph(letter, aLetterX, y);
444
+ ctx.strokeStyle = colB;
445
+ drawGlyph(complement[letter], bLetterX, y);
446
  }
447
 
 
 
 
448
  ctx.globalAlpha = 1;
449
+
450
+ // Pass 3a: front BODY FILLS — drawn on top of rungs so the front
451
+ // strand visually OCCLUDES rungs it passes in front of.
452
+ for (const [f, t] of frontA) drawRibbonFill(pointsA, f, t, gradsA);
453
+ for (const [f, t] of frontB) drawRibbonFill(pointsB, f, t, gradsB);
454
+ // Pass 3b: front EDGES on top.
455
+ for (const [f, t] of frontA) drawRibbonEdges(pointsA, f, t, gradsA);
456
+ for (const [f, t] of frontB) drawRibbonEdges(pointsB, f, t, gradsB);
457
  }
458
 
459
  resize();
460
 
 
461
  if (prefersReduced) {
462
  drawFrame(0.6);
463
  return;
464
  }
465
 
466
  // --- Animation loop, paused off-screen and on hidden tab --------------
 
 
467
  const FRAME_INTERVAL_MS = 1000 / 30;
468
  let rafId = 0, running = false, inViewport = true, lastFrameTs = 0;
469
+
470
+ // Phase is now ACCUMULATED frame-by-frame instead of being computed from
471
+ // raw timestamps. That lets us multiply the per-frame phase delta by a
472
+ // dynamic speed factor — specifically a scroll-driven "boost" — without
473
+ // losing the smooth continuity of the animation.
474
+ let phase = 0;
475
+ let lastTickTs = 0;
476
+
477
+ // Scroll-driven energy:
478
+ // boostTarget — set by the scroll listener from instantaneous velocity,
479
+ // decays toward 0 every frame (half-life ≈ 700ms).
480
+ // boost — lerps toward boostTarget every frame (factor 0.18).
481
+ //
482
+ // Two-stage smoothing gives a clean ramp-UP when the user starts
483
+ // scrolling AND a clean ramp-DOWN once they stop, without ever cutting
484
+ // speed abruptly.
485
+ let boost = 0;
486
+ let boostTarget = 0;
487
+ let scrollLastY = window.scrollY;
488
+ let scrollLastTs = performance.now();
489
+
490
+ window.addEventListener("scroll", () => {
491
+ const now = performance.now();
492
+ const dy = Math.abs(window.scrollY - scrollLastY);
493
+ const dt = Math.max(1, now - scrollLastTs);
494
+ const velocity = dy / dt; // px per ms
495
+ // Map velocity to an additional speed multiplier on top of the base
496
+ // rotation speed. Capped so very fast scrolls don't turn the hero
497
+ // into a blender.
498
+ boostTarget = Math.max(boostTarget, Math.min(9, velocity * 1.5));
499
+ scrollLastY = window.scrollY;
500
+ scrollLastTs = now;
501
+ }, { passive: true });
502
+
503
  function tick(ts) {
504
  if (!running) return;
505
  if (ts - lastFrameTs >= FRAME_INTERVAL_MS) {
506
+ const dt = lastTickTs ? Math.min(64, ts - lastTickTs) : FRAME_INTERVAL_MS;
507
+ lastTickTs = ts;
508
  lastFrameTs = ts;
509
+
510
+ // Smooth ramp toward the current target. Lerp factor tuned so the
511
+ // helix RESPONDS visibly within a handful of frames (~2–3 frames at
512
+ // 30 fps to reach 75% of the target) rather than easing in slowly,
513
+ // while still avoiding any hard cut.
514
+ boost += (boostTarget - boost) * 0.4;
515
+ // Slightly faster decay of the target so the return-to-rest is also
516
+ // less drawn out (half-life ≈ 450 ms).
517
+ boostTarget *= Math.pow(0.25, dt / 1000);
518
+
519
+ phase += dt * helix.speed * (1 + boost);
520
+ drawFrame(phase);
521
  }
522
  rafId = requestAnimationFrame(tick);
523
  }
 
525
  if (running || !inViewport || document.hidden) return;
526
  running = true;
527
  lastFrameTs = 0;
528
+ lastTickTs = 0; // reset so the first dt after a pause is sane
529
  rafId = requestAnimationFrame(tick);
530
  }
531
  function stop() {
 
546
  else start();
547
  });
548
 
 
549
  const ro = new ResizeObserver(() => {
550
  resize();
551
+ drawFrame(phase);
552
  });
553
  ro.observe(canvas);
554
 
555
  drawFrame(0);
556
  start();
557
  })();
 
assets/js/sections/completion.js CHANGED
@@ -36,7 +36,12 @@
36
 
37
  function setStatus(text, mode = "") {
38
  els.statusText.textContent = text;
39
- els.status.className = "status" + (mode ? " " + mode : "");
 
 
 
 
 
40
  }
41
 
42
  function renderTrack() {
 
36
 
37
  function setStatus(text, mode = "") {
38
  els.statusText.textContent = text;
39
+ // No "idle" UI: an empty or "idle" text means the demo hasn't done
40
+ // anything meaningful yet → hide the pill entirely so the toolbar
41
+ // stays clean. setStatus("done · 432 bp", ...) or any non-idle text
42
+ // brings it back via the className reset.
43
+ const hide = !text || text === "idle";
44
+ els.status.className = "status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
45
  }
46
 
47
  function renderTrack() {
assets/js/sections/species.js CHANGED
@@ -22,7 +22,9 @@
22
 
23
  function setStatus(text, mode = "") {
24
  els.statusText.textContent = text;
25
- els.status.className = "status" + (mode ? " " + mode : "");
 
 
26
  }
27
 
28
  function basesPerLine(el) {
@@ -59,16 +61,16 @@
59
  <div class="species-name" style="border-left-color:${s.color}">${s.common}</div>
60
  <div class="species-sub">${s.ortholog_symbol}</div>
61
  <div class="species-sub">chr${s.chrom} · strand ${s.strand}</div>
 
 
 
 
 
62
  </div>
63
  <div>
64
  <div class="species-seq" data-role="output">— click "run all" to generate —</div>
65
  <div class="species-seq" data-role="ref" style="margin-top:4px"></div>
66
  </div>
67
- <div class="species-stats">
68
- <div class="stat-id">${idPct}</div>
69
- <div class="stat-sub">${total > 0 ? `${match}/${total} bases` : "not run"}</div>
70
- ${meanLp == null ? "" : `<div class="stat-sub">logP ${meanLp.toFixed(2)}</div>`}
71
- </div>
72
  `;
73
 
74
  const outEl = wrap.querySelector('[data-role="output"]');
 
22
 
23
  function setStatus(text, mode = "") {
24
  els.statusText.textContent = text;
25
+ // See §1 for the "no idle pill" rationale.
26
+ const hide = !text || text === "idle";
27
+ els.status.className = "status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
28
  }
29
 
30
  function basesPerLine(el) {
 
61
  <div class="species-name" style="border-left-color:${s.color}">${s.common}</div>
62
  <div class="species-sub">${s.ortholog_symbol}</div>
63
  <div class="species-sub">chr${s.chrom} · strand ${s.strand}</div>
64
+ <div class="species-stats">
65
+ <div class="stat-id">${idPct}</div>
66
+ <div class="stat-sub">${total > 0 ? `${match}/${total} bases` : "not run"}</div>
67
+ ${meanLp == null ? "" : `<div class="stat-sub">logP ${meanLp.toFixed(2)}</div>`}
68
+ </div>
69
  </div>
70
  <div>
71
  <div class="species-seq" data-role="output">— click "run all" to generate —</div>
72
  <div class="species-seq" data-role="ref" style="margin-top:4px"></div>
73
  </div>
 
 
 
 
 
74
  `;
75
 
76
  const outEl = wrap.querySelector('[data-role="output"]');
assets/js/sections/track.js CHANGED
@@ -25,7 +25,9 @@
25
 
26
  function setStatus(text, mode = "") {
27
  els.statusText.textContent = text;
28
- els.status.className = "status" + (mode ? " " + mode : "");
 
 
29
  }
30
 
31
  function renderTrack(scoredLen) {
 
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) {
assets/js/sections/vep.js CHANGED
@@ -20,7 +20,9 @@
20
 
21
  function setStatus(text, mode = "") {
22
  els.statusText.textContent = text;
23
- els.status.className = "status" + (mode ? " " + mode : "");
 
 
24
  }
25
 
26
  function altWindow(v) {
 
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) {
assets/js/tabs.js CHANGED
@@ -3,7 +3,10 @@
3
  // =========================================================================
4
  (function initTabs() {
5
  const TABS = ["demo", "model", "sandbox"];
6
- const tabButtons = document.querySelectorAll("#tab-nav .tab");
 
 
 
7
  const panels = document.querySelectorAll(".tab-panel");
8
 
9
  function setTab(name, opts = {}) {
@@ -47,6 +50,19 @@
47
  });
48
  window.addEventListener("hashchange", applyHash);
49
  applyHash();
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  })();
51
 
52
  loadConfig();
 
3
  // =========================================================================
4
  (function initTabs() {
5
  const TABS = ["demo", "model", "sandbox"];
6
+ // Wire BOTH the in-banner nav (#tab-nav) and the sticky-on-scroll copy
7
+ // (#tab-nav-sticky). Same data-tab values → setTab() syncs active state
8
+ // across both NodeLists in one shot, and click on either invokes setTab.
9
+ const tabButtons = document.querySelectorAll("#tab-nav .tab, #tab-nav-sticky .tab");
10
  const panels = document.querySelectorAll(".tab-panel");
11
 
12
  function setTab(name, opts = {}) {
 
50
  });
51
  window.addEventListener("hashchange", applyHash);
52
  applyHash();
53
+
54
+ // Sticky tab strip: when the in-banner #tab-nav scrolls out of view,
55
+ // toggle .is-tabs-stuck on <body> to slide the duplicate strip down from
56
+ // the top of the viewport. Uses the in-banner nav itself as the sentinel
57
+ // — no extra DOM element needed — and IntersectionObserver so the toggle
58
+ // costs nothing on scroll (no scroll listener / no layout reads).
59
+ const inBannerNav = document.getElementById("tab-nav");
60
+ if (inBannerNav && "IntersectionObserver" in window) {
61
+ const obs = new IntersectionObserver(([entry]) => {
62
+ document.body.classList.toggle("is-tabs-stuck", !entry.isIntersecting);
63
+ }, { threshold: 0 });
64
+ obs.observe(inBannerNav);
65
+ }
66
  })();
67
 
68
  loadConfig();
assets/styles/banner.css CHANGED
@@ -1,95 +1,456 @@
1
- /* banner.css — editorial Carbon hero banner.
2
- Scoped to .carbon-banner. The SVG self-sizes from its viewBox aspect
3
- ratio (drives the banner height); the animated DNA helix is rendered
4
- on a Canvas overlay positioned to mirror the original helix bbox. */
 
5
 
6
- .banner-wrap {
7
- /* Full-bleed banner: matches the page-wide layout adopted everywhere
8
- else. The CARBON-0 SVG inside has a fixed aspect ratio so it
9
- simply scales up; nothing breaks at 4K. */
10
- max-width: none;
11
- margin: 0;
12
- padding: 24px 48px 0;
13
- }
14
- .carbon-banner {
15
  --paper: #f7f5ee;
16
  --ink: #1f1f1d;
17
  --muted: #8c918b;
18
- --hairline: #b9bcb7;
19
  --green: #317f3f;
20
- --red: #bc2e25;
21
 
22
- display: block;
23
- width: 100%;
24
  position: relative;
25
- overflow: hidden;
26
  background:
27
- radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
28
- radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
29
  linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
30
  var(--paper);
31
  background-size: 7px 7px, 11px 11px, auto, auto;
 
 
 
 
 
 
 
 
32
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
33
  }
34
- /* SVG self-sizes from its viewBox aspect ratio — drives the banner height. */
35
- .carbon-banner .carbon-art { display: block; width: 100%; height: auto; }
36
- /* Canvas overlay for the animated DNA helix.
37
- Positioned to mirror the original helix bbox in viewBox space:
38
- x [858, 1998] of [40, 2010] → left 41.52%, width 57.87%
39
- y ∈ [220, 416] of [50, 590] → top 31.48%, height 36.30% */
40
- .carbon-banner .cb-helix-canvas {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  position: absolute;
42
- left: 41.52%;
43
- top: 31.48%;
44
- width: 57.87%;
45
- height: 36.30%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  pointer-events: none;
47
- display: block;
48
  }
49
- .carbon-banner .cb-mono {
50
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
51
- letter-spacing: 0.24em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  text-transform: uppercase;
 
 
 
 
 
 
 
 
 
 
53
  }
54
- .carbon-banner .cb-rule { stroke: var(--hairline); stroke-width: 1; vector-effect: non-scaling-stroke; }
55
- .carbon-banner .cb-label { fill: var(--ink); font-size: 17px; letter-spacing: 0.24em; }
56
- .carbon-banner .cb-label-green { fill: var(--green); }
57
- .carbon-banner .cb-label-red { fill: var(--red); }
58
- .carbon-banner .cb-carbon-word {
59
- fill: var(--ink);
60
- font-family: "Arial Narrow", "Helvetica Neue", Arial, sans-serif;
61
- font-size: 255px;
62
- font-stretch: condensed;
63
- font-weight: 900;
64
- letter-spacing: -0.02em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
- .carbon-banner .cb-paper-grain { mix-blend-mode: multiply; opacity: 0.62; pointer-events: none; }
67
- .carbon-banner .cb-helix-shadow { fill: #aeb5ad; opacity: 0.15; }
68
- .carbon-banner .cb-helix-body {
69
- fill: #e4e5dc;
70
- stroke: rgba(49, 127, 63, 0.14);
71
- stroke-width: 0.8;
72
- }
73
- .carbon-banner .cb-helix-edge {
74
- fill: none; stroke: #2d332e; stroke-width: 1.15;
75
- stroke-linecap: round; stroke-linejoin: round;
76
- vector-effect: non-scaling-stroke;
77
- }
78
- .carbon-banner .cb-helix-texture { fill: url(#cb-helixGrain); opacity: 0.46; }
79
- .carbon-banner .cb-base-pair {
80
- fill: none; stroke: var(--green); stroke-width: 1.35;
81
- stroke-linecap: round; vector-effect: non-scaling-stroke;
82
- }
83
- .carbon-banner .cb-base-letter-node {
84
- will-change: transform, opacity;
85
- transform-box: fill-box; transform-origin: 0 0;
86
- }
87
- .carbon-banner .cb-base-glyph {
88
- fill: none; stroke: var(--green); stroke-width: 1.8;
89
- stroke-linecap: square; stroke-linejoin: miter;
90
- vector-effect: non-scaling-stroke;
91
- }
92
- .carbon-banner .cb-tiny-bars rect { fill: var(--green); }
 
 
 
 
 
 
 
 
 
 
93
  @media (max-width: 720px) {
94
- .banner-wrap { padding: 12px 16px 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  }
 
1
+ /* banner.css — Carbon editorial hero.
2
+ Layout: 2-column grid (banner-left / banner-helix). Left column stacks
3
+ identity row giant wordmark tabs along the baseline. Right column hosts
4
+ a tall, slightly-tilted DNA helix rendered onto a <canvas>.
5
+ All ink colors live in vars so the helix JS can mirror them. */
6
 
7
+ /* Brand tokens promoted to :root so anything outside .carbon-banner
8
+ (notably .sticky-nav, which is a sibling of the banner, not a child)
9
+ can reference them. Without this, var(--paper) etc. fall back to
10
+ nothing and the sticky strip ends up transparent. */
11
+ :root {
 
 
 
 
12
  --paper: #f7f5ee;
13
  --ink: #1f1f1d;
14
  --muted: #8c918b;
15
+ --hairline: #d6d3c4;
16
  --green: #317f3f;
17
+ }
18
 
19
+ .carbon-banner {
 
20
  position: relative;
21
+ display: block;
22
  background:
23
+ radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.065), transparent 1px),
24
+ radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.06), transparent 1px),
25
  linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
26
  var(--paper);
27
  background-size: 7px 7px, 11px 11px, auto, auto;
28
+ /* Bottom hairline drawn via inset shadow rather than border-bottom so it
29
+ sits *inside* the banner's content area (1px from the bottom). With
30
+ overflow:hidden the tab's negative margin can't escape into a real
31
+ border, but an inset shadow lives at the same y as the tab's flush
32
+ bottom edge and can be covered by the tab's background — which is
33
+ how the active tab "opens onto" the panel below cleanly. */
34
+ box-shadow: inset 0 -1px 0 #cfcdbf;
35
+ overflow: hidden; /* clip the tilted helix on the right */
36
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
37
  }
38
+ .banner-inner {
39
+ position: relative;
40
+ max-width: 1200px; /* matches the site-wide content cap */
41
+ margin: 0 auto;
42
+ padding: 20px 32px 0;
43
+ display: grid;
44
+ /* Right column dedicated to the DNA helix. */
45
+ grid-template-columns: minmax(0, 1fr) 460px;
46
+ gap: 28px;
47
+ min-height: 440px; /* compact hero */
48
+ }
49
+ .banner-left {
50
+ display: grid;
51
+ grid-template-rows: auto 1fr auto; /* identity / headline (flexes) / tabs */
52
+ gap: 18px;
53
+ min-width: 0;
54
+ }
55
+
56
+ /* --- Identity row: square model-card thumbnail + breadcrumb path --- */
57
+ .banner-identity {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 10px;
61
+ }
62
+ .logo-card {
63
+ width: 44px;
64
+ height: 44px;
65
+ border: 1px solid var(--hairline);
66
+ border-radius: 0; /* sharp corners — feels more "stamp" than "chip" */
67
+ background: #fbfaf3;
68
+ display: flex;
69
+ flex-direction: column;
70
+ align-items: center;
71
+ justify-content: center;
72
+ text-decoration: none;
73
+ color: var(--ink);
74
+ flex-shrink: 0;
75
+ position: relative;
76
+ transition: border-color 0.18s, background 0.18s;
77
+ }
78
+ .logo-card:hover { border-color: #1f1f1d; background: #fff; }
79
+ .logo-glyph {
80
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
81
+ font-size: 22px;
82
+ font-weight: 700;
83
+ line-height: 1;
84
+ letter-spacing: -0.02em;
85
+ }
86
+ .logo-label {
87
+ font-family: "JetBrains Mono", ui-monospace, monospace;
88
+ font-size: 7px;
89
+ font-weight: 400;
90
+ color: #8a8a85;
91
+ margin-top: 2px;
92
+ letter-spacing: 0.04em;
93
+ }
94
+ .banner-breadcrumb {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 2px;
98
+ line-height: 1.2;
99
+ }
100
+ .banner-title {
101
+ font-family: "JetBrains Mono", ui-monospace, monospace;
102
+ font-size: 14px;
103
+ font-weight: 500;
104
+ letter-spacing: 0.18em;
105
+ text-transform: uppercase;
106
+ color: var(--ink);
107
+ }
108
+ .banner-path {
109
+ font-family: "JetBrains Mono", ui-monospace, monospace;
110
+ font-size: 11px;
111
+ font-weight: 400;
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
119
+ at the top and the tabs at the bottom. --- */
120
+ .banner-headline {
121
+ align-self: center;
122
+ min-width: 0;
123
+ }
124
+ .banner-wordmark {
125
+ margin: 0;
126
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
127
+ font-size: clamp(48px, 6.8vw, 96px);
128
+ font-weight: 800;
129
+ /* JetBrains Mono is monospace, so it reads wider than Arial Narrow at the
130
+ same size — tighten the tracking so the wordmark keeps its dense slab feel
131
+ and the green caret still hugs the right edge of the N. */
132
+ letter-spacing: -0.04em;
133
+ line-height: 0.92;
134
+ display: inline-flex;
135
+ align-items: stretch;
136
+ /* Lets the cursor pulse hug the right edge of the N without an extra wrap. */
137
+ gap: 0;
138
+ }
139
+ /* Left → right ink → green ramp, painted into the glyphs via background-clip.
140
+ The C anchors in ink black; each letter to the right picks up more of the
141
+ brand green so the wordmark visually "warms up" toward the blinking caret,
142
+ which is already solid --green. Mirrors the per-letter <tspan fill> ramp
143
+ the editorial mock uses (#1f1f1d → #2a5931 in 6 steps) but tuned to land on
144
+ the brighter brand green so the handoff to the cursor is seamless. */
145
+ .banner-wordmark > span:first-child {
146
+ display: inline-block;
147
+ background-image: linear-gradient(
148
+ 90deg,
149
+ var(--ink) 0%, /* #1f1f1d — the C reads as pure ink */
150
+ #233625 35%, /* slow desaturated step, barely shifts off black */
151
+ #2a5931 70%, /* muted forest, matches the editorial wordmark */
152
+ var(--green) 100% /* #317f3f — locks the N to the cursor's green */
153
+ );
154
+ -webkit-background-clip: text;
155
+ background-clip: text;
156
+ color: transparent;
157
+ }
158
+ /* Autoregressive cursor: stocky green caret pulsing just past the wordmark.
159
+ The caret is sized as a fraction of the wordmark font-size (em units) so
160
+ it scales 1:1 when the title resizes responsively. */
161
+ .banner-cursor {
162
+ display: inline-block;
163
+ width: 0.20em; /* wider — reads as a deliberate block caret */
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 {
180
+ 0%, 55% { opacity: 0.85; }
181
+ 55.01%, 100% { opacity: 0; }
182
+ }
183
+ @media (prefers-reduced-motion: reduce) {
184
+ .banner-cursor { animation: none; opacity: 0.5; }
185
+ }
186
+ .banner-subtitle {
187
+ margin: 8px 0 0;
188
+ font-family: "JetBrains Mono", ui-monospace, monospace;
189
+ font-size: 12.5px;
190
+ font-weight: 400;
191
+ letter-spacing: 0.28em;
192
+ text-transform: uppercase;
193
+ color: #5a5a55;
194
+ }
195
+ /* --- Model specs row: small mono datapoints sitting just under the subtitle.
196
+ Reads as a fiche-technique extension of the tagline — same family but
197
+ darker ink (concrete numbers vs editorial label) and tighter tracking
198
+ so the digits stay legible. The numerical value of each spec is wrapped
199
+ in a <strong> and inked in --green so the row gets a beat of accent
200
+ colour that echoes the wordmark caret and the active tab liseré, while
201
+ the unit/label stays neutral. Dots between items use --muted so they
202
+ feel like spec-sheet separators, not periods. --- */
203
+ .banner-specs {
204
+ list-style: none;
205
+ margin: 8px 0 0;
206
+ padding: 0;
207
+ font-family: "JetBrains Mono", ui-monospace, monospace;
208
+ font-size: 11px;
209
+ font-weight: 400;
210
+ letter-spacing: 0.16em;
211
+ text-transform: uppercase;
212
+ color: #5a5a55;
213
+ }
214
+ .banner-spec {
215
+ display: inline;
216
+ }
217
+ .banner-spec strong {
218
+ font-weight: 500;
219
+ color: var(--green);
220
+ }
221
+ .banner-spec + .banner-spec::before {
222
+ content: "·";
223
+ margin: 0 12px;
224
+ color: var(--muted);
225
+ }
226
+
227
+ /* --- Tabs anchored at the bottom of the banner. Their bottom edge sits flush
228
+ with the banner's inset hairline (see box-shadow on .carbon-banner) —
229
+ inactive tabs cover it with their darker paper, the active tab covers
230
+ it with --paper so the bottom dissolves into the panel below.
231
+ Active state is marked by a 2px green liseré on the top edge (an
232
+ ::before sitting over the hairline border), same green as the wordmark
233
+ caret and the takeaway rail. --- */
234
+ .banner-tabs {
235
+ display: flex;
236
+ position: relative;
237
+ z-index: 1;
238
+ font-family: "JetBrains Mono", ui-monospace, monospace;
239
+ }
240
+ .banner-tabs .tab {
241
+ position: relative;
242
+ display: flex;
243
+ align-items: center;
244
+ width: 150px;
245
+ padding: 18px 18px;
246
+ font-family: inherit;
247
+ font-size: 12px;
248
+ font-weight: 500;
249
+ letter-spacing: 0.16em;
250
+ text-transform: uppercase;
251
+ text-align: left;
252
+ /* One paper-shade darker than --paper so inactive tabs read as "filed
253
+ behind" the active one (which sits on top, in --paper). */
254
+ color: #6f6d65;
255
+ background: #ece9da;
256
+ border: 1px solid var(--hairline);
257
+ border-radius: 3px 3px 0 0;
258
+ cursor: pointer;
259
+ transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
260
+ }
261
+ .banner-tabs .tab + .tab { margin-left: -1px; }
262
+ /* Hover: no transform, no shadow — just a tonal lift so neighbouring tabs
263
+ feel reachable without anything moving. */
264
+ .banner-tabs .tab:hover {
265
+ background: #f2efe2;
266
+ border-color: #c8c5b4;
267
+ color: var(--ink);
268
+ z-index: 2;
269
+ }
270
+ .banner-tabs .tab.active {
271
+ /* Match the panel background below so the bottom edge dissolves into
272
+ the page — the active tab reads as "open onto" the content. */
273
+ color: var(--ink);
274
+ background: var(--paper);
275
+ border-bottom-color: var(--paper); /* covers the banner's inset hairline */
276
+ z-index: 3;
277
+ }
278
+ /* Green liseré along the top edge — sits over the 1px hairline border so
279
+ it reads as a deliberate marker rather than a thickened border. */
280
+ .banner-tabs .tab.active::before {
281
+ content: "";
282
  position: absolute;
283
+ top: -1px; left: -1px; right: -1px;
284
+ height: 2px;
285
+ background: var(--green);
286
+ }
287
+
288
+ /* ============================================================================
289
+ Sticky section nav — STANDALONE styles, no inheritance from .banner-tabs.
290
+
291
+ The strip slides down from the top of the viewport once the user scrolls
292
+ past the in-banner tab row. Visibility is driven by .is-tabs-stuck on
293
+ <body>, toggled by an IntersectionObserver in tabs.js watching the
294
+ in-banner #tab-nav.
295
+
296
+ Design — restate the in-banner tab row verbatim (same colours, same
297
+ hairline, same active-tab "dissolve into the panel below" trick), then
298
+ add a small headroom on top so the cards visibly poke up from the strip
299
+ like file dividers — that's what keeps the "onglet" / folder-tab read
300
+ even though the strip itself is just a thin horizontal bar.
301
+ ============================================================================ */
302
+ .sticky-nav {
303
+ position: fixed;
304
+ top: 0; left: 0; right: 0;
305
+ z-index: 100;
306
+ /* Reproduce the .carbon-banner backing verbatim — same dotted grain
307
+ + symmetric green vertical stripes over the same --paper base — so
308
+ the sticky strip reads as a slice of the banner pinned to the
309
+ viewport top, not as a foreign UI bar. The inactive cards (#ece9da)
310
+ stay one shade darker than this paper so they still pop, exactly
311
+ like in the banner. */
312
+ background:
313
+ radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.065), transparent 1px),
314
+ radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.06), transparent 1px),
315
+ linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
316
+ var(--paper);
317
+ background-size: 7px 7px, 11px 11px, auto, auto;
318
+ /* Bottom hairline (inset shadow) same colour as the banner's
319
+ own bottom rule so the two lines visually align. No outer drop
320
+ shadow — the strip sits flush against the page paper. */
321
+ box-shadow: inset 0 -1px 0 #cfcdbf;
322
+ /* Tiny headroom above the tabs — the strip is taller than the tab cards
323
+ so the cards visibly stand up from it, keeping the "onglet" feel even
324
+ once it's detached from the banner. */
325
+ padding-top: 7px;
326
+ /* Hidden state: lifted above the viewport; transform animates cheaply
327
+ (compositor-only, no reflow). */
328
+ transform: translateY(-100%);
329
+ transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
330
  pointer-events: none;
331
+ font-family: "JetBrains Mono", ui-monospace, monospace;
332
  }
333
+ .is-tabs-stuck .sticky-nav {
334
+ transform: translateY(0);
335
+ pointer-events: auto;
336
+ }
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;
349
+ align-items: center;
350
+ width: 150px;
351
+ padding: 18px 18px;
352
+ font-family: inherit;
353
+ font-size: 12px;
354
+ font-weight: 500;
355
+ letter-spacing: 0.16em;
356
  text-transform: uppercase;
357
+ text-align: left;
358
+ /* Sharp corners on the sticky variant — the strip is a thin slab so
359
+ the cards read better as squared "file dividers" than as the rounded
360
+ index cards used in the larger in-banner row. */
361
+ color: #6f6d65;
362
+ background: #ece9da;
363
+ border: 1px solid #b8b5a6;
364
+ border-radius: 0;
365
+ cursor: pointer;
366
+ transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
367
  }
368
+ /* Overlap adjacent borders so the inter-tab divider is a single 1px line
369
+ (no double-hairline / no gap). */
370
+ .sticky-nav .tab + .tab { margin-left: -1px; }
371
+ .sticky-nav .tab:hover:not(.active) {
372
+ background: #f2efe2;
373
+ border-color: #9c9989;
374
+ color: var(--ink);
375
+ z-index: 2;
376
+ }
377
+ .sticky-nav .tab.active {
378
+ color: var(--ink);
379
+ background: var(--paper);
380
+ /* Drop the bottom border outright — combined with margin-bottom: -1px,
381
+ the active card bleeds one pixel beyond the strip's bottom hairline
382
+ and dissolves directly into the page paper underneath, same trick
383
+ the in-banner active tab uses against the panel below. */
384
+ border-bottom: none;
385
+ margin-bottom: -1px;
386
+ z-index: 3;
387
+ }
388
+ /* Green liseré along the TOP edge of the active card — sits over its own
389
+ 1px top hairline, same green as the wordmark caret and the in-banner
390
+ active marker. */
391
+ .sticky-nav .tab.active::before {
392
+ content: "";
393
+ position: absolute;
394
+ top: -1px; left: -1px; right: -1px;
395
+ height: 2px;
396
+ background: var(--green);
397
  }
398
+
399
+ /* --- Helix column. The canvas is positioned absolutely so its natural ratio
400
+ isn't constrained by the grid track; we let it bleed slightly above and
401
+ below the banner for a "spilling out of the bench" effect. The CSS
402
+ rotate gives the technical tilt that the user asked for. --- */
403
+ .banner-helix {
404
+ position: relative;
405
+ min-width: 0;
406
+ align-self: stretch;
407
+ /* No clip-path here: we want the canvas to bleed up to the banner's top
408
+ edge (= top of the page) so the helix isn't trapped below the 20px
409
+ padding-top of .banner-inner. Clipping at the banner boundary is
410
+ handled by .carbon-banner's overflow:hidden which trims the rotated
411
+ corners cleanly at the banner's outer top/bottom/sides. */
412
+ }
413
+ /* The canvas overshoots the banner top/bottom so the helix appears to spill
414
+ beyond the editorial frame. .carbon-banner has overflow: hidden which clips
415
+ the overshoot cleanly. The tilt is a subtle clockwise lean — the "blueprint
416
+ on the lab bench" feel. */
417
+ .cb-helix-canvas {
418
+ position: absolute;
419
+ top: -90px;
420
+ bottom: -90px;
421
+ right: -48px;
422
+ width: calc(100% + 48px);
423
+ height: calc(100% + 180px);
424
+ display: block;
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
+
432
+ /* --- Responsive ---------------------------------------------------------
433
+ < 900px : collapse the grid so the helix moves below the headline. We
434
+ give it a fixed aspect so the canvas keeps its proportions. */
435
  @media (max-width: 720px) {
436
+ .banner-inner {
437
+ grid-template-columns: 1fr;
438
+ min-height: auto;
439
+ padding: 18px 18px 0;
440
+ }
441
+ .banner-helix {
442
+ height: 460px;
443
+ order: -1; /* helix above headline on narrow viewports */
444
+ }
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); }
452
+ /* Let the three tabs split the row evenly so they fit a narrow viewport
453
+ without horizontal scroll. */
454
+ .banner-tabs { width: 100%; }
455
+ .banner-tabs .tab { width: auto; flex: 1 1 0; padding: 14px 12px; }
456
  }
assets/styles/base.css CHANGED
@@ -8,26 +8,14 @@ body {
8
  font-family: "Inter", "Helvetica Neue", sans-serif;
9
  font-size: 13px; font-weight: 300; line-height: 1.7;
10
  color: #1f1f1d;
11
- background:
12
- radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
13
- radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
14
- linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
15
- #f7f5ee;
16
- background-size: 7px 7px, 11px 11px, auto, auto;
17
  padding: 0;
18
  }
19
- .container { max-width: 760px; margin: 0 auto; padding: 48px 32px 96px; }
20
- /* .container.wide is the wrapper for every Demo / Recipe section. We
21
- used to cap it at 1080px which gave a centred reading column flanked
22
- by big paper margins — fine when the demos sat under their narrative
23
- in a single column, claustrophobic now that section--two-col puts
24
- them side by side. Going full bleed lets the demos (UMAP scatter,
25
- tree, 3D viewers) breathe; the 248px sticky narrative rail keeps the
26
- line-length on prose under control regardless of viewport width. */
27
- .container.wide { max-width: none; padding: 48px 48px 96px; }
28
- @media (max-width: 720px) {
29
- .container.wide { padding: 32px 16px 64px; }
30
- }
31
 
32
  @keyframes pulse { 50% { opacity: 0.3; } }
33
 
 
8
  font-family: "Inter", "Helvetica Neue", sans-serif;
9
  font-size: 13px; font-weight: 300; line-height: 1.7;
10
  color: #1f1f1d;
11
+ /* Plain paper background — the dotted texture + green vertical stripes
12
+ are reserved for the .carbon-banner hero so the editorial pattern
13
+ reads as a deliberate hero accent, not page-wide noise. */
14
+ background: #f7f5ee;
 
 
15
  padding: 0;
16
  }
17
+ .container { max-width: 760px; margin: 0 auto; padding: 24px 32px 96px; }
18
+ .container.wide { max-width: 1200px; }
 
 
 
 
 
 
 
 
 
 
19
 
20
  @keyframes pulse { 50% { opacity: 0.3; } }
21
 
assets/styles/controls.css CHANGED
@@ -47,7 +47,11 @@ button.action.primary:disabled { background: #888; border-color: #888; }
47
  font-size: 9px; color: #aaa; margin-bottom: 8px;
48
  }
49
 
50
- /* --- Status pill (idle / streaming / error) shared by demo toolbars --- */
 
 
 
 
51
  .status {
52
  font-family: "JetBrains Mono", monospace;
53
  font-size: 10px; color: #666;
@@ -55,6 +59,7 @@ button.action.primary:disabled { background: #888; border-color: #888; }
55
  display: inline-flex; align-items: center; gap: 6px;
56
  margin-left: 8px;
57
  }
 
58
  .status .dot {
59
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
60
  background: #888;
 
47
  font-size: 9px; color: #aaa; margin-bottom: 8px;
48
  }
49
 
50
+ /* --- Status pill (streaming / error / done · X bp) shared by demo
51
+ toolbars. Hidden by default until a real state happens — there's
52
+ no "idle" UI; before the user has done anything, no pill at all.
53
+ setStatus() in each demo adds/removes .is-hidden based on whether
54
+ it has something meaningful to show. --- */
55
  .status {
56
  font-family: "JetBrains Mono", monospace;
57
  font-size: 10px; color: #666;
 
59
  display: inline-flex; align-items: center; gap: 6px;
60
  margin-left: 8px;
61
  }
62
+ .status.is-hidden { display: none; }
63
  .status .dot {
64
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
65
  background: #888;
assets/styles/header.css CHANGED
@@ -1,66 +1,7 @@
1
- /* header.css — sticky page header (CARBON title + tagline + tab strip)
2
- and the tab-panel show/hide toggles driven by .active. */
3
-
4
- header {
5
- border-bottom: 1px solid #ccc; /* separator line under the tab strip */
6
- /* 48px lateral padding mirrors .container.wide / .banner-wrap so the
7
- CARBON title, banner edge and every section's left edge all share
8
- one vertical alignment line. No bottom padding — tabs sit on the
9
- border line below. */
10
- padding: 24px 48px 0;
11
- margin-bottom: 0;
12
- background:
13
- radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
14
- radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
15
- linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
16
- #f7f5ee;
17
- background-size: 7px 7px, 11px 11px, auto, auto;
18
- position: sticky; top: 0; z-index: 10;
19
- /* Promote to its own compositing layer so the gradient pattern doesn't
20
- force the entire viewport to repaint on every scroll event. */
21
- will-change: transform;
22
- }
23
- .header-inner {
24
- /* Match the full-bleed page layout: header strip stretches edge to
25
- edge, with title pinned left and tab strip pushed flush right. */
26
- max-width: none;
27
- display: flex; justify-content: space-between; align-items: flex-end;
28
- flex-wrap: wrap; gap: 16px 32px;
29
- }
30
- .header-title { padding-bottom: 14px; }
31
- h1 {
32
- font-family: "JetBrains Mono", monospace;
33
- font-size: 16px; font-weight: 400; letter-spacing: 2px;
34
- }
35
- .tagline {
36
- font-family: "JetBrains Mono", monospace;
37
- color: #888; font-size: 10px; font-weight: 300;
38
- letter-spacing: 1px; margin-top: 4px; text-transform: uppercase;
39
- }
40
- nav#tab-nav {
41
- display: flex;
42
- font-family: "JetBrains Mono", monospace;
43
- font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px;
44
- margin-bottom: -1px; /* tabs overlap header's bottom border by 1px */
45
- position: relative;
46
- z-index: 1;
47
- }
48
- nav#tab-nav .tab {
49
- width: 130px;
50
- padding: 10px 14px;
51
- font-family: inherit; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase;
52
- color: #888; background: #f0f0f0;
53
- border: 1px solid #ccc;
54
- border-radius: 3px 3px 0 0;
55
- cursor: pointer; transition: background 0.15s, color 0.15s;
56
- }
57
- nav#tab-nav .tab + .tab { margin-left: -1px; } /* shared border between adjacent tabs */
58
- nav#tab-nav .tab:hover { color: #1f1f1d; background: #f6f6f6; }
59
- nav#tab-nav .tab.active {
60
- color: #1f1f1d; background: #f7f5ee;
61
- border-bottom-color: #f7f5ee; /* hides bottom border so tab merges into content */
62
- z-index: 2;
63
- }
64
 
65
  /* --- Tab panels --- */
66
  .tab-panel { display: none; }
 
1
+ /* header.css — tab-panel show/hide toggles driven by .active.
2
+ The CARBON title, tagline and tab strip used to live here; they're now
3
+ part of the editorial hero in banner.css. This file only retains the
4
+ class that gates panel visibility based on the active tab. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  /* --- Tab panels --- */
7
  .tab-panel { display: none; }
assets/styles/layout.css CHANGED
@@ -4,21 +4,49 @@
4
  lede / takeaway typography, and the .section--two-col layout that
5
  parks narrative in a sticky rail next to the demo. */
6
 
7
- /* --- Tab lede (short narrative paragraph below the banner) --- */
 
 
 
 
 
 
 
 
 
 
 
 
8
  .tab-lede {
9
- /* Full-bleed wrapper, padded to match .container.wide so the lede
10
- paragraph aligns under the banner and section bodies. The
11
- paragraph itself is still capped at 760px so prose stays
12
- readable on a 32" monitor. */
13
- max-width: none; margin: 4px 0 0;
14
- padding: 14px 48px 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
16
  .tab-lede p {
17
- color: #555; font-size: 14px; line-height: 1.7;
18
- max-width: 760px; margin: 0;
19
- }
20
- @media (max-width: 720px) {
21
- .tab-lede { padding: 12px 16px 0; }
 
 
 
22
  }
23
 
24
  /* --- Sections --- */
@@ -83,23 +111,22 @@ section:last-of-type { border-bottom: none; }
83
  The default layout stacks vertically: title → lede → demo → takeaway.
84
  For demo-heavy sections that means narrative and visualization never
85
  share visual space — by the time the visitor is mid-demo, the lede
86
- is scrolled away, and the takeaway only appears after they've
87
- finished. .section--two-col places the narrative (eyebrow + title +
88
- lede + takeaway) in a sticky rail on the left and lets the demo
89
- claim the bulk of the width on the right. Narration stays in view
90
- while the visitor scrolls through the demo, turning the takeaway
91
- into a live margin note rather than a post-mortem.
92
 
93
- Layout math: container.wide is 1080px max with 32px padding =>
94
- 320px rail + 40px gap + demo claims the rest. The rail width was
95
- bumped from 248 320px so the narrative breathes (~50-55 chars per
96
- line of body text vs ~36 before) at 248 every lede was wrapping
97
- on awkward stop-words and the takeaway felt cramped. Below 900px we
98
- collapse to single-column and unstick the rail. */
99
  .section--two-col {
100
  display: grid;
101
- grid-template-columns: 320px 1fr;
102
- column-gap: 40px;
103
  align-items: start;
104
  /* Land cleanly under the sticky header on anchor jumps (#folding). */
105
  scroll-margin-top: 104px;
@@ -117,37 +144,30 @@ section:last-of-type { border-bottom: none; }
117
  overflow-y: auto;
118
  scrollbar-width: thin;
119
  }
120
- /* The 640px cap on .lede / .takeaway exists to keep line length
121
- readable in the single-column layout. Inside a 320px rail that cap
122
- is moot — drop it so the text fills the rail. */
123
- .section--two-col .section-narrative .lede,
124
- .section--two-col .section-narrative .takeaway {
125
- max-width: none;
126
- }
127
  .section--two-col .section-narrative .lede {
 
128
  font-size: 13px;
129
- margin-bottom: 20px;
130
- }
131
- /* In 2-col mode the takeaway sits next to the demo as a margin note,
132
- so the heavy green-bar treatment becomes too loud. Soften to a
133
- calm hairline + neutral type. */
134
- .section--two-col .section-narrative .takeaway {
135
- margin-top: 0;
136
- padding: 14px 16px;
137
- border-left: 2px solid #d8d5c8;
138
- background: transparent;
139
- font-size: 12px;
140
- color: #555;
141
- }
142
- .section--two-col .section-narrative .takeaway strong {
143
- color: #1f1f1d;
144
  }
145
- /* The demo claims its full grid track. The default 24px y-margin
146
- was for the single-column rhythm and isn't needed here. */
147
  .section--two-col .demo {
148
- margin: 0;
149
  }
150
- @media (max-width: 900px) {
 
 
 
 
151
  .section--two-col {
152
  grid-template-columns: 1fr;
153
  row-gap: 16px;
@@ -157,14 +177,4 @@ section:last-of-type { border-bottom: none; }
157
  max-height: none;
158
  overflow: visible;
159
  }
160
- .section--two-col .section-narrative .takeaway {
161
- /* Restore a touch of the editorial green band on mobile, since
162
- it's no longer competing with a sticky sibling. */
163
- margin-top: 8px;
164
- border-left-color: #317f3f;
165
- background: #f4f8f4;
166
- }
167
- .section--two-col .demo {
168
- margin: 8px 0 0;
169
- }
170
  }
 
4
  lede / takeaway typography, and the .section--two-col layout that
5
  parks narrative in a sticky rail next to the demo. */
6
 
7
+ /* --- Tab lede ------------------------------------------------------------
8
+ Short narrative paragraph that opens each tab. Treated as an editorial
9
+ "lede" (the deck under a magazine headline): one design accent (a thin
10
+ green left-rail that echoes the green caret in the wordmark above) plus a
11
+ typographic step-up to make it clearly *the entry point* rather than just
12
+ the first body paragraph.
13
+
14
+ Structure:
15
+ .tab-lede — outer wrapper, centers the block at page width
16
+ .tab-lede__rail — green left-rail + padding + max-width on the text
17
+ .tab-lede__eyebrow — small mono "§ Intro" label up top
18
+ p — the lede text itself (large, light, dark ink)
19
+ --------------------------------------------------------------------------- */
20
  .tab-lede {
21
+ max-width: 1200px; margin: 56px auto 0;
22
+ padding: 0 32px;
23
+ }
24
+ .tab-lede__rail {
25
+ /* The accent. 3px is just thick enough to read as a deliberate rail and
26
+ not a stray border; the green matches --green / the wordmark caret. */
27
+ border-left: 3px solid #317f3f;
28
+ padding: 4px 0 4px 22px;
29
+ max-width: 820px;
30
+ }
31
+ .tab-lede__eyebrow {
32
+ display: block;
33
+ font-family: "JetBrains Mono", ui-monospace, monospace;
34
+ font-size: 11px;
35
+ font-weight: 500;
36
+ letter-spacing: 0.22em;
37
+ text-transform: uppercase;
38
+ color: #317f3f;
39
+ margin-bottom: 12px;
40
  }
41
  .tab-lede p {
42
+ margin: 0;
43
+ font-family: "Inter", "Helvetica Neue", sans-serif;
44
+ font-size: 19px;
45
+ font-weight: 300;
46
+ line-height: 1.5;
47
+ letter-spacing: -0.005em;
48
+ color: #2d2d2a;
49
+ max-width: 760px;
50
  }
51
 
52
  /* --- Sections --- */
 
111
  The default layout stacks vertically: title → lede → demo → takeaway.
112
  For demo-heavy sections that means narrative and visualization never
113
  share visual space — by the time the visitor is mid-demo, the lede
114
+ is scrolled away. .section--two-col places the eyebrow + title +
115
+ lede in a sticky rail on the left and stacks the demo + takeaway
116
+ in the right column. Narration stays in view while the visitor
117
+ scrolls through the demo; the takeaway then appears right under
118
+ the demo, on the right column, as a "now that you've played with
119
+ it…" debrief same flow on both the Demo and Model tabs.
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;
129
+ column-gap: 28px;
130
  align-items: start;
131
  /* Land cleanly under the sticky header on anchor jumps (#folding). */
132
  scroll-margin-top: 104px;
 
144
  overflow-y: auto;
145
  scrollbar-width: thin;
146
  }
147
+ /* The 640px cap on .lede exists to keep line length readable in the
148
+ single-column layout. Inside a 280px rail that cap is moot — drop
149
+ it so the text fills the rail. */
 
 
 
 
150
  .section--two-col .section-narrative .lede {
151
+ max-width: none;
152
  font-size: 13px;
153
+ margin-bottom: 0;
154
+ }
155
+ /* Right column: demo stacked over takeaway, flex-column with a clear
156
+ visual beat between them. */
157
+ .section--two-col .section-body {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 24px;
161
+ min-width: 0; /* let inner svg/canvas shrink instead of overflowing */
 
 
 
 
 
 
162
  }
 
 
163
  .section--two-col .demo {
164
+ margin: 0; /* default 24px y-margin is for single-column rhythm */
165
  }
166
+ .section--two-col .takeaway {
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;
 
177
  max-height: none;
178
  overflow: visible;
179
  }
 
 
 
 
 
 
 
 
 
 
180
  }
assets/styles/section-species.css CHANGED
@@ -1,10 +1,10 @@
1
  /* section-species.css — §4 Same gene across species. Each species gets
2
- a row with three columns: meta (kingdom-coloured name), aligned
3
- sequence block, and identity stats. */
4
 
5
  .species-row {
6
  display: grid;
7
- grid-template-columns: 140px 1fr 110px;
8
  gap: 16px;
9
  align-items: start;
10
  padding: 14px 0;
@@ -23,8 +23,9 @@
23
  }
24
  .species-sub { color: #888; font-size: 10px; margin-top: 4px; padding-left: 12px; }
25
  .species-stats {
26
- text-align: right; font-family: "JetBrains Mono", monospace;
27
  font-size: 11px; color: #666;
 
28
  }
29
  .species-stats .stat-id { font-size: 16px; color: #1f1f1d; font-weight: 500; font-variant-numeric: tabular-nums; }
30
  .species-stats .stat-sub { font-size: 10px; color: #999; margin-top: 2px; }
 
1
  /* section-species.css — §4 Same gene across species. Each species gets
2
+ a row with two columns: meta (kingdom-coloured name + identity stats
3
+ stacked underneath) and the aligned sequence block. */
4
 
5
  .species-row {
6
  display: grid;
7
+ grid-template-columns: 140px 1fr;
8
  gap: 16px;
9
  align-items: start;
10
  padding: 14px 0;
 
23
  }
24
  .species-sub { color: #888; font-size: 10px; margin-top: 4px; padding-left: 12px; }
25
  .species-stats {
26
+ text-align: left; font-family: "JetBrains Mono", monospace;
27
  font-size: 11px; color: #666;
28
+ padding-left: 12px; margin-top: 10px;
29
  }
30
  .species-stats .stat-id { font-size: 16px; color: #1f1f1d; font-weight: 500; font-variant-numeric: tabular-nums; }
31
  .species-stats .stat-sub { font-size: 10px; color: #999; margin-top: 2px; }
demo.html CHANGED
@@ -6,7 +6,7 @@
6
  <title>Carbon · what the model learned</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&family=Inter:wght@300;400;500;600&display=swap">
10
  <!-- 3Dmol.js: lightweight WebGL molecular viewer, used by §5 (folding) to
11
  render ESMFold-predicted protein cartoons. Pinned for reproducibility. -->
12
  <script defer src="https://cdn.jsdelivr.net/npm/3dmol@2.5.1/build/3Dmol-min.js"></script>
@@ -32,88 +32,78 @@
32
  </head>
33
  <body>
34
 
35
- <header>
36
- <div class="header-inner">
37
- <div class="header-title">
38
- <h1>CARBON</h1>
39
- <div class="tagline" id="meta">3B · hybrid-loss · 1T · loading…</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  </div>
41
- <nav id="tab-nav">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  <button class="tab active" data-tab="demo">Demo</button>
43
  <button class="tab" data-tab="model">Model</button>
44
  <button class="tab" data-tab="sandbox">Sandbox</button>
45
  </nav>
46
  </div>
 
 
 
 
 
 
 
47
  </header>
48
 
49
- <div class="banner-wrap">
50
- <section class="carbon-banner" aria-label="Carbon DNA model banner">
51
- <svg class="carbon-art" viewBox="40 50 1970 540" role="img" aria-labelledby="cb-carbonTitle cb-carbonDesc">
52
- <title id="cb-carbonTitle">Carbon genomic language model banner</title>
53
- <desc id="cb-carbonDesc">A technical editorial banner with the CARBON-0 model name and an adjacent animated DNA strand.</desc>
54
-
55
- <defs>
56
- <pattern id="cb-paperDot" width="12" height="12" patternUnits="userSpaceOnUse">
57
- <circle cx="2" cy="2" r="0.8" fill="#1f1f1d" opacity="0.045" />
58
- <circle cx="9" cy="8" r="0.55" fill="#317f3f" opacity="0.035" />
59
- </pattern>
60
- <pattern id="cb-helixGrain" width="9" height="9" patternUnits="userSpaceOnUse">
61
- <rect width="9" height="9" fill="transparent" />
62
- <circle cx="1.4" cy="1.7" r="0.7" fill="#263128" opacity="0.34" />
63
- <circle cx="6.2" cy="5.5" r="0.45" fill="#263128" opacity="0.24" />
64
- <line x1="3" y1="8" x2="7" y2="8" stroke="#263128" stroke-width="0.45" opacity="0.18" />
65
- </pattern>
66
- </defs>
67
-
68
- <rect class="cb-paper-grain" x="25" y="25" width="1998" height="570" fill="url(#cb-paperDot)" />
69
-
70
- <g class="cb-mono">
71
- <text class="cb-label" x="63" y="76">CARBON-0</text>
72
- <text class="cb-label cb-label-green" x="240" y="76">3B Autoregressive Genomic Foundation Model</text>
73
- <text class="cb-label" x="1162" y="76">49,152 BP Context</text>
74
- <circle cx="1456" cy="70" r="4" fill="#317f3f" />
75
- <text class="cb-label" x="1490" y="76">6-Mer Tokenizer</text>
76
- <circle cx="1760" cy="70" r="4" fill="#317f3f" />
77
- <text class="cb-label" x="1795" y="76">1T Train Tokens</text>
78
- </g>
79
-
80
- <line class="cb-rule" x1="40" y1="110" x2="2010" y2="110" />
81
-
82
- <g class="cb-mono">
83
- <text class="cb-carbon-word" x="60" y="405" textLength="795" lengthAdjust="spacingAndGlyphs">CARBON-0</text>
84
- </g>
85
-
86
- <!-- Helix is now drawn on a <canvas> overlay positioned via CSS — much
87
- cheaper than mutating ~960 SVG paths every frame. See .cb-helix-canvas
88
- and initCarbonBanner() below. -->
89
-
90
-
91
- <line class="cb-rule" x1="40" y1="545" x2="2010" y2="545" />
92
- <g class="cb-mono">
93
- <text class="cb-label" x="64" y="578">Carbon Labs</text>
94
- <line x1="240" y1="570" x2="266" y2="570" stroke="#317f3f" stroke-width="2" />
95
- <text class="cb-label" x="294" y="578">Building Foundational Models For Genomic Sequences</text>
96
- <text class="cb-label" x="1378" y="578">CLB-2026-05-11</text>
97
- <text class="cb-label" x="1638" y="578">DNA-LM-49K</text>
98
- <text class="cb-label" x="1784" y="578">CARBON-0 v0</text>
99
- <g class="cb-tiny-bars" transform="translate(1956 562)">
100
- <rect x="0" y="0" width="3" height="20" />
101
- <rect x="8" y="0" width="3" height="20" />
102
- <rect x="16" y="0" width="3" height="20" />
103
- <rect x="24" y="0" width="3" height="20" />
104
- <rect x="32" y="0" width="3" height="20" />
105
- <rect x="40" y="0" width="3" height="20" />
106
- </g>
107
- </g>
108
- </svg>
109
- <canvas class="cb-helix-canvas" aria-hidden="true"></canvas>
110
- </section>
111
- </div>
112
 
113
  <div class="tab-panel active" id="panel-demo" data-tab="demo">
114
 
115
  <div class="tab-lede">
116
- <p>We didn't tell Carbon what an exon is. We didn't tell it which mutations are pathogenic. We didn't tell it how genes differ between species. Four ways to see what it picked up anyway.</p>
 
 
 
117
  </div>
118
 
119
  <div class="container wide">
@@ -123,34 +113,29 @@
123
  <!-- ============================================================ -->
124
  <section id="completion" class="section--two-col">
125
  <div class="section-narrative">
126
- <div class="section-num">§1 · Autocomplete</div>
127
- <div class="section-title">Autocomplete for the genome</div>
128
- <p class="lede">
129
- Like GPT can complete the start of a sentence, Carbon completes the start of a gene.
130
- Pick a famous one — we feed the first <code>200 bp</code>, the model streams a continuation,
131
- and we overlay the <em>real</em> exon/intron annotations on top so you can see where the model
132
- decided structure should change.
133
- </p>
134
-
135
- <div class="takeaway">
136
- <strong>What to look for</strong>
137
- Try dragging the prompt window so the green generated region lands on an exon (the dark
138
- green blocks) and see how many green underlines you get — exons are under selection
139
- pressure, so getting them right takes real biological understanding, not just DNA
140
- statistics. Then try the same length over an intron and compare. Boundaries between
141
- high- and low-confidence stretches in Carbon's output also tend to fall near real
142
- exon/intron edges, even though the model has never seen a single annotation.
143
- </div>
144
  </div>
145
 
 
146
  <div class="demo" id="demo1">
147
  <div class="demo-toolbar">
148
  <span>gene</span>
149
  <span id="d1-pills" class="pills"></span>
150
  <span class="spacer"></span>
 
 
 
 
 
151
  <button id="d1-go" class="action primary">▶ generate</button>
152
  <button id="d1-stop" class="action" disabled>stop</button>
153
- <span class="status" id="d1-status"><span class="dot"></span><span>idle</span></span>
154
  </div>
155
 
156
  <div class="gene-info" id="d1-info">loading genes…</div>
@@ -210,6 +195,17 @@
210
  <div class="stat-pair"><span class="stat-pair-label">perplexity</span><span class="stat-pair-val muted" id="d1-ppl">—</span></div>
211
  </div>
212
  </div>
 
 
 
 
 
 
 
 
 
 
 
213
  </section>
214
 
215
  <!-- ============================================================ -->
@@ -217,37 +213,26 @@
217
  <!-- ============================================================ -->
218
  <section id="vep" class="section--two-col">
219
  <div class="section-narrative">
220
- <div class="section-num">§2 · Variant effect</div>
221
- <div class="section-title">It knows what's broken</div>
222
- <p class="lede">
223
- For a real ClinVar variant, the alternate allele lives at one specific base in the genome.
224
- We score the exact same 60-bp window two ways — once with the reference base, once with the alt —
225
- and compare the model's likelihood. The strongest loss-of-function variants stand out clearly;
226
- others show smaller signals. Raw likelihood is a partial proxy for pathogenicity, not a perfect one.
227
- </p>
228
-
229
- <div class="takeaway">
230
- <strong>What to look for</strong>
231
- Read each row two ways: the <em>dot color</em> is what ClinVar says
232
- (red = pathogenic, orange = risk, green = benign); the <em>bar color and direction</em> is
233
- what Carbon says (red bar pointing left = surprised by the alt; charcoal bar pointing right
234
- = alt looks fine). When the dot and bar agree on "left of zero" — like HBB c.20A>T sickle
235
- cell — Carbon has independently picked up the pathogenicity signal. When they disagree, the
236
- likely culprit is allele frequency: alt alleles common enough in human populations look
237
- perfectly normal to a model trained on natural sequence. For sharper variant effect
238
- prediction, Carbon can be fine-tuned (see the
239
- <a href="https://huggingface.co/spaces/hf-carbon/dna-vep-explainer" style="color:#317f3f">dna-vep-explainer</a>).
240
- </div>
241
  </div>
242
 
 
243
  <div class="demo" id="demo2">
244
  <div class="demo-toolbar">
245
  <span>variant</span>
246
  <span id="d2-pills" class="pills"></span>
247
  <span class="spacer"></span>
 
 
248
  <button id="d2-go" class="action primary">▶ score</button>
249
  <button id="d2-all" class="action">score all</button>
250
- <span class="status" id="d2-status"><span class="dot"></span><span>idle</span></span>
251
  </div>
252
 
253
  <div class="gene-info" id="d2-info">loading variants…</div>
@@ -265,6 +250,20 @@
265
  <span style="color:#888">dot = ClinVar label · bar = model signal</span>
266
  </div>
267
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  </section>
269
 
270
  <!-- ============================================================ -->
@@ -272,30 +271,25 @@
272
  <!-- ============================================================ -->
273
  <section id="track" class="section--two-col">
274
  <div class="section-narrative">
275
- <div class="section-num">§3 · Structure</div>
276
- <div class="section-title">It sees structure without being told</div>
277
- <p class="lede">
278
- For each token (a 6-base chunk), Carbon emits a log-probability under the surrounding context.
279
- Plot that along a real gene and the curve dips and rises. We overlay the exon/intron map on top —
280
- confidence rises in coding regions and falls in repetitive or unconstrained stretches, even
281
- though the model never saw a single exon label.
282
- </p>
283
-
284
- <div class="takeaway">
285
- <strong>What to look for</strong>
286
- Exons, especially the protein-coding portions, tend to score noticeably higher than introns —
287
- because exons are evolutionarily conserved and full of constrained patterns the model has learned
288
- to predict. The Δ tells you how strongly Carbon "noticed" the difference for this gene.
289
- </div>
290
  </div>
291
 
 
292
  <div class="demo" id="demo3">
293
  <div class="demo-toolbar">
294
  <span>gene</span>
295
  <span id="d3-pills" class="pills"></span>
296
  <span class="spacer"></span>
 
 
297
  <button id="d3-go" class="action primary">▶ score</button>
298
- <span class="status" id="d3-status"><span class="dot"></span><span>idle</span></span>
299
  </div>
300
 
301
  <div class="gene-info" id="d3-info">loading genes…</div>
@@ -315,6 +309,14 @@
315
  <div class="stat-pair"><span class="stat-pair-label">mean (overall)</span><span class="stat-pair-val muted" id="d3-mean">—</span></div>
316
  </div>
317
  </div>
 
 
 
 
 
 
 
 
318
  </section>
319
 
320
  <!-- ============================================================ -->
@@ -322,22 +324,16 @@
322
  <!-- ============================================================ -->
323
  <section id="species" class="section--two-col">
324
  <div class="section-narrative">
325
- <div class="section-num">§4 · Species</div>
326
- <div class="section-title">It knows who's who</div>
327
- <p class="lede">
328
- Feed the first few bases of a homologous gene from human, mouse, and zebrafish.
329
- Each continuation diverges along its species' lineage — and matches that species' real
330
- reference sequence more closely than the others'.
331
- </p>
332
-
333
- <div class="takeaway">
334
- <strong>What to look for</strong>
335
- Each species' generation should match its own reference better than another species' would.
336
- Identity drops dramatically on mouse/zebrafish/chicken when the prompt is from a different
337
- lineage — the model conditions on species context from just a few bases.
338
- </div>
339
  </div>
340
 
 
341
  <div class="demo" id="demo4">
342
  <div class="demo-toolbar">
343
  <span>gene</span>
@@ -355,7 +351,7 @@
355
  <button class="pill" data-gen="400">400</button>
356
  </span>
357
  <button id="d4-go" class="action primary">▶ run all</button>
358
- <span class="status" id="d4-status"><span class="dot"></span><span>idle</span></span>
359
  </div>
360
 
361
  <div class="gene-info" id="d4-info">loading species…</div>
@@ -368,6 +364,14 @@
368
  <span style="color:#b00020">mismatches in reference highlighted</span>
369
  </div>
370
  </div>
 
 
 
 
 
 
 
 
371
  </section>
372
 
373
  <!-- ============================================================ -->
@@ -375,23 +379,17 @@
375
  <!-- ============================================================ -->
376
  <section id="folding" class="section--two-col">
377
  <div class="section-narrative">
378
- <div class="section-num">§5 · Folding</div>
379
- <div class="section-title">From sequence to structure</div>
380
- <p class="lede">
381
- When Carbon completes an open reading frame, the resulting bases translate to a protein —
382
- a protein that folds. We feed the resulting ORF into ESMFold and render the
383
- 3D structure inline, alongside the same protein folded from the reference sequence so you
384
- can see whether Carbon's continuation produced something biologically plausible.
385
- </p>
386
- <div class="takeaway">
387
- <strong>What to look for</strong>
388
- A high <em>pLDDT</em> means ESMFold is confident the predicted structure
389
- is correct for that residue. When Carbon's completion diverges at the
390
- base level but still produces a sequence whose 3D fold matches the
391
- reference, the model has captured something deeper than memorization.
392
- </div>
393
  </div>
394
 
 
395
  <div class="demo" id="demoFold">
396
  <!-- Cached-only UI: live fold UI (prefix selector, ▶ fold button,
397
  status indicator) is intentionally not rendered. The pipeline
@@ -461,6 +459,15 @@
461
  <div class="stat-pair"><span class="stat-pair-label">identity (1D)</span><span class="stat-pair-val muted" id="dfold-id">—</span></div>
462
  </div>
463
  </div>
 
 
 
 
 
 
 
 
 
464
  </section>
465
 
466
  <!-- ============================================================ -->
@@ -468,28 +475,18 @@
468
  <!-- ============================================================ -->
469
  <section id="umap" class="section--two-col">
470
  <div class="section-narrative">
471
- <div class="section-num">§6 · Embedding space</div>
472
- <div class="section-title">The genome, organized</div>
473
- <p class="lede">
474
- Embed 571,810 sequences from 27 species across six kingdoms — vertebrates,
475
- invertebrates, plants, fungi, bacteria, viruses — with Carbon, project to 2D
476
- with UMAP, color by anything. Switch the coloring and a completely different
477
- organization emerges from the same points — the model's embedding space
478
- carries multiple axes of biology at once, none of which were ever labeled.
479
- </p>
480
-
481
- <div class="takeaway">
482
- <strong>What to look for</strong>
483
- Switch coloring from <em>species</em> to <em>biotype</em>: same points, completely
484
- different organization emerges. The macro-clusters trace six kingdoms — vertebrates,
485
- invertebrates, plants, fungi, bacteria, viruses — discovered from raw sequence alone.
486
- Switch again to <em>gc content</em> and a perpendicular axis appears: AT-rich (cool
487
- blue) vs GC-rich (warm amber) regions cut across the species clusters, revealing the
488
- composition gradient the model has internalised. <em>Points: 571,810 real Carbon 3B
489
- embeddings, projected to 2D via UMAP.</em>
490
- </div>
491
  </div>
492
 
 
493
  <div class="demo" id="demoUmap">
494
  <div class="demo-toolbar">
495
  <span>color by</span>
@@ -522,6 +519,18 @@
522
  <div class="stat-pair"><span class="stat-pair-label">render</span><span class="stat-pair-val muted" id="dumap-fps">—</span></div>
523
  </div>
524
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
525
  </section>
526
 
527
  <!-- ============================================================ -->
@@ -529,30 +538,19 @@
529
  <!-- ============================================================ -->
530
  <section id="speciesTree" class="section--two-col">
531
  <div class="section-narrative">
532
- <div class="section-num">§7 · Species tree</div>
533
- <div class="section-title">Did Carbon learn the tree of life on its own?</div>
534
- <p class="lede">
535
- Take the same 571,810 sequences from §6, average each species' embeddings into a
536
- single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering.
537
- Carbon was never told what an "organism" is. Yet the resulting tree groups vertebrates
538
- together, separates bacteria from fungi, and pairs sister clades — primates with
539
- primates, rodents with rodents, monocots with monocots — without ever being shown a
540
- single taxonomic label.
541
- </p>
542
-
543
- <div class="takeaway">
544
- <strong>What to look for</strong>
545
- Toggle <em>kingdom-level</em> vs <em>sister-level</em>: at the kingdom scale the
546
- embedding is nearly perfect — vertebrates cluster with vertebrates, bacteria with
547
- bacteria. At the sister scale (primate-with-primate, etc.) it's lower because
548
- distances inside a kingdom are extremely tight (~0.0001) and the strict nearest
549
- neighbour bounces around — the model nails the broad strokes but blurs the fine
550
- branches at this resolution. Switch <em>linkage</em> from Ward to UPGMA to see how
551
- much of the structure is method-independent. <em>Tree built from species centroids
552
- of mean-pooled Carbon-3B embeddings.</em>
553
- </div>
554
  </div>
555
 
 
556
  <div class="demo" id="demoSpeciesTree">
557
  <div class="tree-toolbar">
558
  <span>linkage</span>
@@ -605,6 +603,19 @@
605
  <div class="stat-pair"><span class="stat-pair-label">distance</span><span class="stat-pair-val">cosine</span></div>
606
  </div>
607
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  </section>
609
 
610
  </div>
@@ -614,7 +625,10 @@
614
  <div class="tab-panel" id="panel-model" data-tab="model">
615
 
616
  <div class="tab-lede">
617
- <p>Three places where the recipe needed adjustment for biology: the way DNA gets tokenized, how the loss handles near-miss tokens, and which sequence ends up in the corpus. Plus a deliberately vanilla architecture so any improvement can be attributed to the recipe rather than custom blocks.</p>
 
 
 
618
  </div>
619
 
620
  <div class="container wide">
@@ -622,7 +636,8 @@
622
  <!-- ============================================================ -->
623
  <!-- §7 — TOKENIZER -->
624
  <!-- ============================================================ -->
625
- <section id="tokenizer">
 
626
  <div class="section-num">§1 · Tokenizer</div>
627
  <div class="section-title">Read DNA in 6-base chunks</div>
628
  <p class="lede">
@@ -633,41 +648,45 @@
633
  BPE was a tempting middle ground, but its variable-length tokens collide badly with
634
  autoregressive next-token prediction — DNA doesn't have stable "words."
635
  </p>
 
636
 
 
637
  <div class="demo" id="demo7">
638
  <div class="demo-toolbar">
639
  <span>type DNA</span>
640
  <input id="d7-input" type="text" spellcheck="false" autocapitalize="characters"
641
  value="ATGGCCAAGCTGACCAGCGAGCTGCTGGCC"
642
- style="font-family:'JetBrains Mono',monospace;font-size:12px;padding:6px 10px;border:1px solid #ccc;border-radius:3px;flex:1 1 280px;letter-spacing:1px;text-transform:uppercase">
643
- <span class="spacer"></span>
644
  <span class="status"><span class="dot" style="background:#317f3f"></span><span id="d7-len">30 bp</span></span>
645
  </div>
646
 
647
- <div id="d7-cols" style="display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-top:8px">
648
  <div>
649
  <div class="seq-label" style="margin-top:0">1-mer · one token per base</div>
650
  <div class="seq-block" id="d7-1mer" style="min-height:60px"></div>
651
- <div class="stat-row" style="margin-top:8px;padding-top:8px">
652
- <div class="stat-pair"><span class="stat-pair-label">tokens</span><span class="stat-pair-val" id="d7-1mer-tok">—</span></div>
653
- <div class="stat-pair"><span class="stat-pair-label">attention cost</span><span class="stat-pair-val" id="d7-1mer-att">—</span></div>
654
- <div class="stat-pair"><span class="stat-pair-label">vocab</span><span class="stat-pair-val">4</span></div>
655
- </div>
656
  </div>
657
  <div>
658
  <div class="seq-label" style="margin-top:0">6-mer (carbon) · one token per 6 bases</div>
659
  <div class="seq-block" id="d7-6mer" style="min-height:60px"></div>
660
- <div class="stat-row" style="margin-top:8px;padding-top:8px">
661
- <div class="stat-pair"><span class="stat-pair-label">tokens</span><span class="stat-pair-val" id="d7-6mer-tok">—</span></div>
662
- <div class="stat-pair"><span class="stat-pair-label">attention cost</span><span class="stat-pair-val" id="d7-6mer-att">—</span></div>
663
- <div class="stat-pair"><span class="stat-pair-label">vocab</span><span class="stat-pair-val">4,096</span></div>
664
- </div>
665
  </div>
666
  </div>
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  <svg id="d7-bars" preserveAspectRatio="xMinYMin meet" style="display:block;width:100%;background:#fff;border:1px solid #eee;margin-top:14px"></svg>
669
 
670
- <div class="track-axis-label">
671
  <span>same DNA span</span>
672
  <span style="color:#317f3f">▼ shorter token sequence = cheaper attention</span>
673
  <span id="d7-speedup" style="color:#317f3f;font-weight:500">36× cheaper</span>
@@ -682,12 +701,14 @@
682
  a valid <em>prefix</em> of the target token. 6-mer is a deterministic, neutral compression
683
  that avoids this trap.
684
  </div>
 
685
  </section>
686
 
687
  <!-- ============================================================ -->
688
  <!-- §8 — TRAINING OBJECTIVE (CE → FNS) -->
689
  <!-- ============================================================ -->
690
- <section id="loss">
 
691
  <div class="section-num">§2 · Training objective</div>
692
  <div class="section-title">Partial credit for near-misses</div>
693
  <p class="lede">
@@ -698,7 +719,9 @@
698
  six parallel 4-way nucleotide marginals derived from the same logits. Near-miss tokens
699
  get partial credit proportional to how many bases they got right.
700
  </p>
 
701
 
 
702
  <div class="demo" id="demo8">
703
  <div class="demo-toolbar">
704
  <span>target 6-mer</span>
@@ -729,12 +752,14 @@
729
  (the "loss staircase," and BF16 inference starts diverging from FP32), FNS smooths the
730
  objective and restores numerical robustness without giving up the joint prior CE built.
731
  </div>
 
732
  </section>
733
 
734
  <!-- ============================================================ -->
735
  <!-- §9 — DATA -->
736
  <!-- ============================================================ -->
737
- <section id="data">
 
738
  <div class="section-num">§3 · Data</div>
739
  <div class="section-title">Genomes are mostly background</div>
740
  <p class="lede">
@@ -745,7 +770,9 @@
745
  and bacterial sequence — so the model spends more of its gradient updates on biologically
746
  meaningful sequence.
747
  </p>
 
748
 
 
749
  <div class="demo" id="demo9">
750
  <div style="display:grid;grid-template-columns:340px 1fr;gap:24px;align-items:center;margin-bottom:18px">
751
  <div>
@@ -757,7 +784,7 @@
757
 
758
  <div class="seq-label">signal-to-noise · raw genome vs annotation-aware curation</div>
759
  <svg id="d9-snr" viewBox="0 0 1000 100" preserveAspectRatio="none" style="display:block;width:100%;height:90px;background:#fff;border:1px solid #eee"></svg>
760
- <div class="track-axis-label">
761
  <span><span class="legend-swatch" style="background:#317f3f"></span>functional / annotated</span>
762
  <span><span class="legend-swatch" style="background:#ddd"></span>background</span>
763
  <span style="color:#888">curating raises the density of biological signal in the gradient</span>
@@ -773,12 +800,14 @@
773
  discarding 95% of background, the effective informative fraction jumps from 5% to ≈ 46%.
774
  Same training compute, ~9× more learning signal per gradient step.
775
  </div>
 
776
  </section>
777
 
778
  <!-- ============================================================ -->
779
  <!-- §10 — ARCHITECTURE -->
780
  <!-- ============================================================ -->
781
- <section id="architecture">
 
782
  <div class="section-num">§4 · Architecture</div>
783
  <div class="section-title">A deliberately vanilla transformer</div>
784
  <p class="lede">
@@ -787,7 +816,9 @@
787
  that any improvement Carbon shows on genomic tasks is attributable to the data, the
788
  tokenizer, and the loss — not to a custom block or a hand-crafted attention variant.
789
  </p>
 
790
 
 
791
  <div class="demo" id="demo10">
792
  <table id="d10-arch" style="width:100%;border-collapse:collapse;font-family:'JetBrains Mono',monospace;font-size:12px"></table>
793
  <div style="margin-top:14px;font-size:11px;color:#666;font-family:'JetBrains Mono',monospace">
@@ -802,6 +833,7 @@
802
  come from changes that <em>aren't</em> the architecture. That's where the room for genomic
803
  foundation models still is.
804
  </div>
 
805
  </section>
806
 
807
  </div>
@@ -814,10 +846,13 @@
814
  <div class="tab-panel" id="panel-sandbox" data-tab="sandbox">
815
 
816
  <div class="tab-lede">
817
- <p>Open-ended DNA continuation. Type any prefix in {A, C, G, T}, watch the model continue token by token. Toggle base-coloring or per-token logprob coloring to see where Carbon is confident and where it's guessing. Track GC content, perplexity, and throughput live.</p>
 
 
 
818
  </div>
819
 
820
- <div class="container" style="max-width:1280px">
821
 
822
  <div style="margin-bottom:20px;padding-bottom:12px;border-bottom:1px solid #ddd">
823
  <div id="sb-meta" style="font-family:'JetBrains Mono',monospace;color:#888;font-size:10px;font-weight:300;letter-spacing:0.5px">loading…</div>
 
6
  <title>Carbon · what the model learned</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
  <!-- 3Dmol.js: lightweight WebGL molecular viewer, used by §5 (folding) to
11
  render ESMFold-predicted protein cartoons. Pinned for reproducibility. -->
12
  <script defer src="https://cdn.jsdelivr.net/npm/3dmol@2.5.1/build/3Dmol-min.js"></script>
 
32
  </head>
33
  <body>
34
 
35
+ <!-- Carbon banner. Combines the model-card identity (logo + path + wordmark +
36
+ subtitle) with the section navigation (Demo / Model / Sandbox tabs) into a
37
+ single editorial hero. The DNA helix is rendered on a <canvas> positioned
38
+ to the right, rotated for a slight technical tilt; see banner.js. -->
39
+ <header class="carbon-banner" aria-label="Carbon DNA model banner">
40
+ <div class="banner-inner">
41
+ <div class="banner-left">
42
+
43
+ <!-- Top row: HF-style model-card identity. The square logo card mirrors
44
+ the thumbnail you'd find on a Hugging Face model page; the title +
45
+ path beside it functions as a breadcrumb / model identifier. -->
46
+ <div class="banner-identity">
47
+ <a class="logo-card" href="#" aria-label="Carbon — go to top">
48
+ <span class="logo-glyph" aria-hidden="true">C</span>
49
+ <span class="logo-label">carbon</span>
50
+ </a>
51
+ <div class="banner-breadcrumb">
52
+ <div class="banner-title">CARBON</div>
53
+ <div class="banner-path" id="meta">huggingfacebio/carbon-3b</div>
54
  </div>
55
+ </div>
56
+
57
+ <!-- Headline: oversized wordmark + tagline. The blinking caret after the
58
+ "N" is the visual echo of the §1 demo (model streaming a continuation
59
+ token by token). -->
60
+ <div class="banner-headline">
61
+ <h1 class="banner-wordmark"><span>CARBON</span><span class="banner-cursor" aria-hidden="true"></span></h1>
62
+ <p class="banner-subtitle">Autoregressive Genomic Foundation Model</p>
63
+ <ul class="banner-specs" aria-label="Model specs">
64
+ <li class="banner-spec"><strong>49,152</strong> bp context</li>
65
+ <li class="banner-spec"><strong>6-mer</strong> tokenizer</li>
66
+ <li class="banner-spec"><strong>1T</strong> train tokens</li>
67
+ </ul>
68
+ </div>
69
+
70
+ <!-- Tabs anchored to the bottom of the banner; they sit on the hairline
71
+ that separates the banner from the page content (margin-bottom: -1px). -->
72
+ <nav id="tab-nav" class="banner-tabs">
73
  <button class="tab active" data-tab="demo">Demo</button>
74
  <button class="tab" data-tab="model">Model</button>
75
  <button class="tab" data-tab="sandbox">Sandbox</button>
76
  </nav>
77
  </div>
78
+
79
+ <!-- Big vertical DNA helix on the right. The canvas paints upright; CSS
80
+ applies a small clockwise tilt for a "blueprint-on-the-bench" feel. -->
81
+ <div class="banner-helix" aria-hidden="true">
82
+ <canvas class="cb-helix-canvas"></canvas>
83
+ </div>
84
+ </div>
85
  </header>
86
 
87
+ <!-- Sticky tab strip: a duplicate of the in-banner nav that slides down from
88
+ the top once the user has scrolled past the original tabs. Kept in sync
89
+ with the in-banner set via tabs.js (both NodeLists are wired to the same
90
+ setTab() handler). The body gets .is-tabs-stuck toggled by an
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
 
100
  <div class="tab-panel active" id="panel-demo" data-tab="demo">
101
 
102
  <div class="tab-lede">
103
+ <div class="tab-lede__rail">
104
+ <span class="tab-lede__eyebrow">Intro</span>
105
+ <p>We didn't tell Carbon what an exon is. We didn't tell it which mutations are pathogenic. We didn't tell it how genes differ between species. Six ways to see what it picked up anyway.</p>
106
+ </div>
107
  </div>
108
 
109
  <div class="container wide">
 
113
  <!-- ============================================================ -->
114
  <section id="completion" class="section--two-col">
115
  <div class="section-narrative">
116
+ <div class="section-num">§1 · Autocomplete</div>
117
+ <div class="section-title">Autocomplete for the genome</div>
118
+ <p class="lede">
119
+ Like GPT can complete the start of a sentence, Carbon completes the start of a gene.
120
+ Pick a famous one — we feed the first <code>200 bp</code>, the model streams a continuation,
121
+ and we overlay the <em>real</em> exon/intron annotations on top so you can see where the model
122
+ decided structure should change.
123
+ </p>
 
 
 
 
 
 
 
 
 
 
124
  </div>
125
 
126
+ <div class="section-body">
127
  <div class="demo" id="demo1">
128
  <div class="demo-toolbar">
129
  <span>gene</span>
130
  <span id="d1-pills" class="pills"></span>
131
  <span class="spacer"></span>
132
+ <!-- Status sits BEFORE the buttons so that when its text width changes
133
+ (idle → generating… → done · 432 bp), the slack is absorbed by the
134
+ flex spacer to its left rather than shifting the buttons leftward
135
+ on every state transition. Buttons stay pinned to the right edge. -->
136
+ <span class="status is-hidden" id="d1-status"><span class="dot"></span><span></span></span>
137
  <button id="d1-go" class="action primary">▶ generate</button>
138
  <button id="d1-stop" class="action" disabled>stop</button>
 
139
  </div>
140
 
141
  <div class="gene-info" id="d1-info">loading genes…</div>
 
195
  <div class="stat-pair"><span class="stat-pair-label">perplexity</span><span class="stat-pair-val muted" id="d1-ppl">—</span></div>
196
  </div>
197
  </div>
198
+
199
+ <div class="takeaway">
200
+ <strong>What to look for</strong>
201
+ Try dragging the prompt window so the green generated region lands on an exon (the dark
202
+ green blocks) and see how many green underlines you get — exons are under selection
203
+ pressure, so getting them right takes real biological understanding, not just DNA
204
+ statistics. Then try the same length over an intron and compare. Boundaries between
205
+ high- and low-confidence stretches in Carbon's output also tend to fall near real
206
+ exon/intron edges, even though the model has never seen a single annotation.
207
+ </div>
208
+ </div>
209
  </section>
210
 
211
  <!-- ============================================================ -->
 
213
  <!-- ============================================================ -->
214
  <section id="vep" class="section--two-col">
215
  <div class="section-narrative">
216
+ <div class="section-num">§2 · Variant effect</div>
217
+ <div class="section-title">It knows what's broken</div>
218
+ <p class="lede">
219
+ For a real ClinVar variant, the alternate allele lives at one specific base in the genome.
220
+ We score the exact same 60-bp window two ways — once with the reference base, once with the alt —
221
+ and compare the model's likelihood. The strongest loss-of-function variants stand out clearly;
222
+ others show smaller signals. Raw likelihood is a partial proxy for pathogenicity, not a perfect one.
223
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  </div>
225
 
226
+ <div class="section-body">
227
  <div class="demo" id="demo2">
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>
 
250
  <span style="color:#888">dot = ClinVar label · bar = model signal</span>
251
  </div>
252
  </div>
253
+
254
+ <div class="takeaway">
255
+ <strong>What to look for</strong>
256
+ Read each row two ways: the <em>dot color</em> is what ClinVar says
257
+ (red = pathogenic, orange = risk, green = benign); the <em>bar color and direction</em> is
258
+ what Carbon says (red bar pointing left = surprised by the alt; charcoal bar pointing right
259
+ = alt looks fine). When the dot and bar agree on "left of zero" — like HBB c.20A>T sickle
260
+ cell — Carbon has independently picked up the pathogenicity signal. When they disagree, the
261
+ likely culprit is allele frequency: alt alleles common enough in human populations look
262
+ perfectly normal to a model trained on natural sequence. For sharper variant effect
263
+ prediction, Carbon can be fine-tuned (see the
264
+ <a href="https://huggingface.co/spaces/hf-carbon/dna-vep-explainer" style="color:#317f3f">dna-vep-explainer</a>).
265
+ </div>
266
+ </div>
267
  </section>
268
 
269
  <!-- ============================================================ -->
 
271
  <!-- ============================================================ -->
272
  <section id="track" class="section--two-col">
273
  <div class="section-narrative">
274
+ <div class="section-num">§3 · Structure</div>
275
+ <div class="section-title">It sees structure without being told</div>
276
+ <p class="lede">
277
+ For each token (a 6-base chunk), Carbon emits a log-probability under the surrounding context.
278
+ Plot that along a real gene and the curve dips and rises. We overlay the exon/intron map on top —
279
+ confidence rises in coding regions and falls in repetitive or unconstrained stretches, even
280
+ though the model never saw a single exon label.
281
+ </p>
 
 
 
 
 
 
 
282
  </div>
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>
 
309
  <div class="stat-pair"><span class="stat-pair-label">mean (overall)</span><span class="stat-pair-val muted" id="d3-mean">—</span></div>
310
  </div>
311
  </div>
312
+
313
+ <div class="takeaway">
314
+ <strong>What to look for</strong>
315
+ Exons, especially the protein-coding portions, tend to score noticeably higher than introns —
316
+ because exons are evolutionarily conserved and full of constrained patterns the model has learned
317
+ to predict. The Δ tells you how strongly Carbon "noticed" the difference for this gene.
318
+ </div>
319
+ </div>
320
  </section>
321
 
322
  <!-- ============================================================ -->
 
324
  <!-- ============================================================ -->
325
  <section id="species" class="section--two-col">
326
  <div class="section-narrative">
327
+ <div class="section-num">§4 · Species</div>
328
+ <div class="section-title">It knows who's who</div>
329
+ <p class="lede">
330
+ Feed the first few bases of a homologous gene from human, mouse, and zebrafish.
331
+ Each continuation diverges along its species' lineage — and matches that species' real
332
+ reference sequence more closely than the others'.
333
+ </p>
 
 
 
 
 
 
 
334
  </div>
335
 
336
+ <div class="section-body">
337
  <div class="demo" id="demo4">
338
  <div class="demo-toolbar">
339
  <span>gene</span>
 
351
  <button class="pill" data-gen="400">400</button>
352
  </span>
353
  <button id="d4-go" class="action primary">▶ run all</button>
354
+ <span class="status is-hidden" id="d4-status"><span class="dot"></span><span></span></span>
355
  </div>
356
 
357
  <div class="gene-info" id="d4-info">loading species…</div>
 
364
  <span style="color:#b00020">mismatches in reference highlighted</span>
365
  </div>
366
  </div>
367
+
368
+ <div class="takeaway">
369
+ <strong>What to look for</strong>
370
+ Each species' generation should match its own reference better than another species' would.
371
+ Identity drops dramatically on mouse/zebrafish/chicken when the prompt is from a different
372
+ lineage — the model conditions on species context from just a few bases.
373
+ </div>
374
+ </div>
375
  </section>
376
 
377
  <!-- ============================================================ -->
 
379
  <!-- ============================================================ -->
380
  <section id="folding" class="section--two-col">
381
  <div class="section-narrative">
382
+ <div class="section-num">§5 · Folding</div>
383
+ <div class="section-title">From sequence to structure</div>
384
+ <p class="lede">
385
+ When Carbon completes an open reading frame, the resulting bases translate to a protein —
386
+ a protein that folds. We feed the resulting ORF into ESMFold and render the
387
+ 3D structure inline, alongside the same protein folded from the reference sequence so you
388
+ can see whether Carbon's continuation produced something biologically plausible.
389
+ </p>
 
 
 
 
 
 
 
390
  </div>
391
 
392
+ <div class="section-body">
393
  <div class="demo" id="demoFold">
394
  <!-- Cached-only UI: live fold UI (prefix selector, ▶ fold button,
395
  status indicator) is intentionally not rendered. The pipeline
 
459
  <div class="stat-pair"><span class="stat-pair-label">identity (1D)</span><span class="stat-pair-val muted" id="dfold-id">—</span></div>
460
  </div>
461
  </div>
462
+
463
+ <div class="takeaway">
464
+ <strong>What to look for</strong>
465
+ A high <em>pLDDT</em> means ESMFold is confident the predicted structure
466
+ is correct for that residue. When Carbon's completion diverges at the
467
+ base level but still produces a sequence whose 3D fold matches the
468
+ reference, the model has captured something deeper than memorization.
469
+ </div>
470
+ </div>
471
  </section>
472
 
473
  <!-- ============================================================ -->
 
475
  <!-- ============================================================ -->
476
  <section id="umap" class="section--two-col">
477
  <div class="section-narrative">
478
+ <div class="section-num">§6 · Embedding space</div>
479
+ <div class="section-title">The genome, organized</div>
480
+ <p class="lede">
481
+ Embed 571,810 sequences from 27 species across six kingdoms — vertebrates,
482
+ invertebrates, plants, fungi, bacteria, viruses — with Carbon, project to 2D
483
+ with UMAP, color by anything. Switch the coloring and a completely different
484
+ organization emerges from the same points — the model's embedding space
485
+ carries multiple axes of biology at once, none of which were ever labeled.
486
+ </p>
 
 
 
 
 
 
 
 
 
 
 
487
  </div>
488
 
489
+ <div class="section-body">
490
  <div class="demo" id="demoUmap">
491
  <div class="demo-toolbar">
492
  <span>color by</span>
 
519
  <div class="stat-pair"><span class="stat-pair-label">render</span><span class="stat-pair-val muted" id="dumap-fps">—</span></div>
520
  </div>
521
  </div>
522
+
523
+ <div class="takeaway">
524
+ <strong>What to look for</strong>
525
+ Switch coloring from <em>species</em> to <em>biotype</em>: same points, completely
526
+ different organization emerges. The macro-clusters trace six kingdoms — vertebrates,
527
+ invertebrates, plants, fungi, bacteria, viruses — discovered from raw sequence alone.
528
+ Switch again to <em>gc content</em> and a perpendicular axis appears: AT-rich (cool
529
+ blue) vs GC-rich (warm amber) regions cut across the species clusters, revealing the
530
+ composition gradient the model has internalised. <em>Points: 571,810 real Carbon 3B
531
+ embeddings, projected to 2D via UMAP.</em>
532
+ </div>
533
+ </div>
534
  </section>
535
 
536
  <!-- ============================================================ -->
 
538
  <!-- ============================================================ -->
539
  <section id="speciesTree" class="section--two-col">
540
  <div class="section-narrative">
541
+ <div class="section-num">§7 · Species tree</div>
542
+ <div class="section-title">Did Carbon learn the tree of life on its own?</div>
543
+ <p class="lede">
544
+ Take the same 571,810 sequences from §6, average each species' embeddings into a
545
+ single 3072-dim vector, then cluster those 27 centroids with hierarchical clustering.
546
+ Carbon was never told what an "organism" is. Yet the resulting tree groups vertebrates
547
+ together, separates bacteria from fungi, and pairs sister clades — primates with
548
+ primates, rodents with rodents, monocots with monocots — without ever being shown a
549
+ single taxonomic label.
550
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
551
  </div>
552
 
553
+ <div class="section-body">
554
  <div class="demo" id="demoSpeciesTree">
555
  <div class="tree-toolbar">
556
  <span>linkage</span>
 
603
  <div class="stat-pair"><span class="stat-pair-label">distance</span><span class="stat-pair-val">cosine</span></div>
604
  </div>
605
  </div>
606
+
607
+ <div class="takeaway">
608
+ <strong>What to look for</strong>
609
+ Toggle <em>kingdom-level</em> vs <em>sister-level</em>: at the kingdom scale the
610
+ embedding is nearly perfect — vertebrates cluster with vertebrates, bacteria with
611
+ bacteria. At the sister scale (primate-with-primate, etc.) it's lower because
612
+ distances inside a kingdom are extremely tight (~0.0001) and the strict nearest
613
+ neighbour bounces around — the model nails the broad strokes but blurs the fine
614
+ branches at this resolution. Switch <em>linkage</em> from Ward to UPGMA to see how
615
+ much of the structure is method-independent. <em>Tree built from species centroids
616
+ of mean-pooled Carbon-3B embeddings.</em>
617
+ </div>
618
+ </div>
619
  </section>
620
 
621
  </div>
 
625
  <div class="tab-panel" id="panel-model" data-tab="model">
626
 
627
  <div class="tab-lede">
628
+ <div class="tab-lede__rail">
629
+ <span class="tab-lede__eyebrow">Intro</span>
630
+ <p>Three places where the recipe needed adjustment for biology: the way DNA gets tokenized, how the loss handles near-miss tokens, and which sequence ends up in the corpus. Plus a deliberately vanilla architecture so any improvement can be attributed to the recipe rather than custom blocks.</p>
631
+ </div>
632
  </div>
633
 
634
  <div class="container wide">
 
636
  <!-- ============================================================ -->
637
  <!-- §7 — TOKENIZER -->
638
  <!-- ============================================================ -->
639
+ <section id="tokenizer" class="section--two-col">
640
+ <div class="section-narrative">
641
  <div class="section-num">§1 · Tokenizer</div>
642
  <div class="section-title">Read DNA in 6-base chunks</div>
643
  <p class="lede">
 
648
  BPE was a tempting middle ground, but its variable-length tokens collide badly with
649
  autoregressive next-token prediction — DNA doesn't have stable "words."
650
  </p>
651
+ </div>
652
 
653
+ <div class="section-body">
654
  <div class="demo" id="demo7">
655
  <div class="demo-toolbar">
656
  <span>type DNA</span>
657
  <input id="d7-input" type="text" spellcheck="false" autocapitalize="characters"
658
  value="ATGGCCAAGCTGACCAGCGAGCTGCTGGCC"
659
+ style="font-family:'JetBrains Mono',monospace;font-size:12px;padding:6px 10px;border:1px solid #ccc;border-radius:3px;flex:1 1 auto;min-width:0;letter-spacing:1px;text-transform:uppercase">
 
660
  <span class="status"><span class="dot" style="background:#317f3f"></span><span id="d7-len">30 bp</span></span>
661
  </div>
662
 
663
+ <div id="d7-cols" style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px">
664
  <div>
665
  <div class="seq-label" style="margin-top:0">1-mer · one token per base</div>
666
  <div class="seq-block" id="d7-1mer" style="min-height:60px"></div>
 
 
 
 
 
667
  </div>
668
  <div>
669
  <div class="seq-label" style="margin-top:0">6-mer (carbon) · one token per 6 bases</div>
670
  <div class="seq-block" id="d7-6mer" style="min-height:60px"></div>
 
 
 
 
 
671
  </div>
672
  </div>
673
 
674
+ <!-- Stats for both tokenisers, grouped under the two sequences so the
675
+ eye can compare them in one glance. Labels are prefixed with
676
+ "1-mer" / "6-mer" since the row no longer sits directly below its
677
+ own sequence block. -->
678
+ <div class="stat-row" style="margin-top:14px;padding-top:12px">
679
+ <div class="stat-pair"><span class="stat-pair-label">1-mer tokens</span><span class="stat-pair-val" id="d7-1mer-tok">—</span></div>
680
+ <div class="stat-pair"><span class="stat-pair-label">1-mer attention</span><span class="stat-pair-val" id="d7-1mer-att">—</span></div>
681
+ <div class="stat-pair"><span class="stat-pair-label">1-mer vocab</span><span class="stat-pair-val">4</span></div>
682
+ <div class="stat-pair"><span class="stat-pair-label">6-mer tokens</span><span class="stat-pair-val" id="d7-6mer-tok">—</span></div>
683
+ <div class="stat-pair"><span class="stat-pair-label">6-mer attention</span><span class="stat-pair-val" id="d7-6mer-att">—</span></div>
684
+ <div class="stat-pair"><span class="stat-pair-label">6-mer vocab</span><span class="stat-pair-val">4,096</span></div>
685
+ </div>
686
+
687
  <svg id="d7-bars" preserveAspectRatio="xMinYMin meet" style="display:block;width:100%;background:#fff;border:1px solid #eee;margin-top:14px"></svg>
688
 
689
+ <div class="track-axis-label" style="padding-top:10px">
690
  <span>same DNA span</span>
691
  <span style="color:#317f3f">▼ shorter token sequence = cheaper attention</span>
692
  <span id="d7-speedup" style="color:#317f3f;font-weight:500">36× cheaper</span>
 
701
  a valid <em>prefix</em> of the target token. 6-mer is a deterministic, neutral compression
702
  that avoids this trap.
703
  </div>
704
+ </div>
705
  </section>
706
 
707
  <!-- ============================================================ -->
708
  <!-- §8 — TRAINING OBJECTIVE (CE → FNS) -->
709
  <!-- ============================================================ -->
710
+ <section id="loss" class="section--two-col">
711
+ <div class="section-narrative">
712
  <div class="section-num">§2 · Training objective</div>
713
  <div class="section-title">Partial credit for near-misses</div>
714
  <p class="lede">
 
719
  six parallel 4-way nucleotide marginals derived from the same logits. Near-miss tokens
720
  get partial credit proportional to how many bases they got right.
721
  </p>
722
+ </div>
723
 
724
+ <div class="section-body">
725
  <div class="demo" id="demo8">
726
  <div class="demo-toolbar">
727
  <span>target 6-mer</span>
 
752
  (the "loss staircase," and BF16 inference starts diverging from FP32), FNS smooths the
753
  objective and restores numerical robustness without giving up the joint prior CE built.
754
  </div>
755
+ </div>
756
  </section>
757
 
758
  <!-- ============================================================ -->
759
  <!-- §9 — DATA -->
760
  <!-- ============================================================ -->
761
+ <section id="data" class="section--two-col">
762
+ <div class="section-narrative">
763
  <div class="section-num">§3 · Data</div>
764
  <div class="section-title">Genomes are mostly background</div>
765
  <p class="lede">
 
770
  and bacterial sequence — so the model spends more of its gradient updates on biologically
771
  meaningful sequence.
772
  </p>
773
+ </div>
774
 
775
+ <div class="section-body">
776
  <div class="demo" id="demo9">
777
  <div style="display:grid;grid-template-columns:340px 1fr;gap:24px;align-items:center;margin-bottom:18px">
778
  <div>
 
784
 
785
  <div class="seq-label">signal-to-noise · raw genome vs annotation-aware curation</div>
786
  <svg id="d9-snr" viewBox="0 0 1000 100" preserveAspectRatio="none" style="display:block;width:100%;height:90px;background:#fff;border:1px solid #eee"></svg>
787
+ <div class="track-axis-label" style="padding-top:10px">
788
  <span><span class="legend-swatch" style="background:#317f3f"></span>functional / annotated</span>
789
  <span><span class="legend-swatch" style="background:#ddd"></span>background</span>
790
  <span style="color:#888">curating raises the density of biological signal in the gradient</span>
 
800
  discarding 95% of background, the effective informative fraction jumps from 5% to ≈ 46%.
801
  Same training compute, ~9× more learning signal per gradient step.
802
  </div>
803
+ </div>
804
  </section>
805
 
806
  <!-- ============================================================ -->
807
  <!-- §10 — ARCHITECTURE -->
808
  <!-- ============================================================ -->
809
+ <section id="architecture" class="section--two-col">
810
+ <div class="section-narrative">
811
  <div class="section-num">§4 · Architecture</div>
812
  <div class="section-title">A deliberately vanilla transformer</div>
813
  <p class="lede">
 
816
  that any improvement Carbon shows on genomic tasks is attributable to the data, the
817
  tokenizer, and the loss — not to a custom block or a hand-crafted attention variant.
818
  </p>
819
+ </div>
820
 
821
+ <div class="section-body">
822
  <div class="demo" id="demo10">
823
  <table id="d10-arch" style="width:100%;border-collapse:collapse;font-family:'JetBrains Mono',monospace;font-size:12px"></table>
824
  <div style="margin-top:14px;font-size:11px;color:#666;font-family:'JetBrains Mono',monospace">
 
833
  come from changes that <em>aren't</em> the architecture. That's where the room for genomic
834
  foundation models still is.
835
  </div>
836
+ </div>
837
  </section>
838
 
839
  </div>
 
846
  <div class="tab-panel" id="panel-sandbox" data-tab="sandbox">
847
 
848
  <div class="tab-lede">
849
+ <div class="tab-lede__rail">
850
+ <span class="tab-lede__eyebrow">Intro</span>
851
+ <p>Open-ended DNA continuation. Type any prefix in {A, C, G, T}, watch the model continue token by token. Toggle base-coloring or per-token logprob coloring to see where Carbon is confident and where it's guessing. Track GC content, perplexity, and throughput live.</p>
852
+ </div>
853
  </div>
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>