Spaces:
Running
Hero refresh + sticky tab nav + section polish
Browse filesBanner / 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 +8 -1
- assets/js/banner.js +379 -130
- assets/js/sections/completion.js +6 -1
- assets/js/sections/species.js +8 -6
- assets/js/sections/track.js +3 -1
- assets/js/sections/vep.js +3 -1
- assets/js/tabs.js +17 -1
- assets/styles/banner.css +435 -74
- assets/styles/base.css +6 -18
- assets/styles/controls.css +6 -1
- assets/styles/header.css +4 -63
- assets/styles/layout.css +72 -62
- assets/styles/section-species.css +5 -4
- demo.html +257 -222
|
@@ -11,7 +11,14 @@ from openai import OpenAI
|
|
| 11 |
|
| 12 |
ENDPOINT_URL = os.environ.get(
|
| 13 |
"ENDPOINT_URL",
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
@@ -1,11 +1,15 @@
|
|
| 1 |
// =========================================================================
|
| 2 |
-
// Carbon banner — animated DNA helix (Canvas 2D)
|
| 3 |
//
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
-
//
|
| 7 |
-
//
|
| 8 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 22 |
-
//
|
| 23 |
-
//
|
|
|
|
| 24 |
const helix = {
|
| 25 |
-
|
| 26 |
-
cycles:
|
| 27 |
-
|
| 28 |
-
//
|
| 29 |
-
//
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
//
|
| 33 |
-
//
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
};
|
| 37 |
-
// Helix bbox in viewBox coords.
|
| 38 |
-
//
|
| 39 |
-
const VB = { x:
|
| 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 |
-
//
|
| 50 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const glyphPaths = {
|
| 52 |
-
A: new Path2D("M -
|
| 53 |
-
C: new Path2D("M
|
| 54 |
-
G: new Path2D("M
|
| 55 |
-
T: new Path2D("M -
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 84 |
-
// regardless of the canvas size — equivalent to vector-effect:non-scaling-stroke).
|
| 85 |
function px(cssPx) { return (cssPx * dpr) / uniformScale; }
|
| 86 |
|
| 87 |
-
// --- Math
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 92 |
const normalLength = Math.hypot(slope, 1);
|
| 93 |
return {
|
| 94 |
-
x,
|
| 95 |
-
y
|
| 96 |
z: Math.cos(theta),
|
| 97 |
-
nx:
|
| 98 |
-
ny:
|
| 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.
|
| 107 |
for (let i = 0; i <= helix.segmentCount; i++) {
|
| 108 |
-
buf[i] = pointAt(helix.
|
| 109 |
}
|
| 110 |
}
|
| 111 |
|
| 112 |
-
//
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
-
//
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
ctx.beginPath();
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
ctx.closePath();
|
| 133 |
}
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
ctx.beginPath();
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
ctx.fill();
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
ctx.lineCap = "round";
|
| 161 |
ctx.lineJoin = "round";
|
| 162 |
-
|
| 163 |
ctx.stroke();
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
ctx.stroke();
|
| 166 |
}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
const
|
| 170 |
-
|
| 171 |
const ranges = [];
|
| 172 |
-
for (const
|
| 173 |
-
const f = Math.max(start,
|
| 174 |
-
const t = Math.min(end,
|
| 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(
|
| 188 |
cursor = t;
|
| 189 |
}
|
| 190 |
-
if (end - cursor > 0.7) { ctx.moveTo(
|
| 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 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
// Pass
|
| 214 |
-
|
| 215 |
-
for (
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
// Rungs + ATCG glyphs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
for (let k = 0; k < helix.rungCount; k++) {
|
| 219 |
const t = k / (helix.rungCount - 1);
|
| 220 |
-
const
|
| 221 |
-
const a = pointAt(
|
| 222 |
-
const b = pointAt(
|
| 223 |
-
const
|
| 224 |
-
const
|
| 225 |
-
const span =
|
| 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
|
| 229 |
-
const
|
| 230 |
const letterGap = Math.min(helix.glyphGap, Math.max(8.5, span * 0.16));
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
ctx.lineCap = "round";
|
| 236 |
-
drawRung(
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
ctx.lineCap = "square";
|
| 241 |
ctx.lineJoin = "miter";
|
| 242 |
const letter = sequence[k % sequence.length];
|
| 243 |
-
|
| 244 |
-
drawGlyph(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
})();
|
|
|
|
@@ -36,7 +36,12 @@
|
|
| 36 |
|
| 37 |
function setStatus(text, mode = "") {
|
| 38 |
els.statusText.textContent = text;
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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() {
|
|
@@ -22,7 +22,9 @@
|
|
| 22 |
|
| 23 |
function setStatus(text, mode = "") {
|
| 24 |
els.statusText.textContent = text;
|
| 25 |
-
|
|
|
|
|
|
|
| 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"]');
|
|
@@ -25,7 +25,9 @@
|
|
| 25 |
|
| 26 |
function setStatus(text, mode = "") {
|
| 27 |
els.statusText.textContent = text;
|
| 28 |
-
|
|
|
|
|
|
|
| 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) {
|
|
@@ -20,7 +20,9 @@
|
|
| 20 |
|
| 21 |
function setStatus(text, mode = "") {
|
| 22 |
els.statusText.textContent = text;
|
| 23 |
-
|
|
|
|
|
|
|
| 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) {
|
|
@@ -3,7 +3,10 @@
|
|
| 3 |
// =========================================================================
|
| 4 |
(function initTabs() {
|
| 5 |
const TABS = ["demo", "model", "sandbox"];
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 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();
|
|
@@ -1,95 +1,456 @@
|
|
| 1 |
-
/* banner.css —
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
margin: 0;
|
| 12 |
-
padding: 24px 48px 0;
|
| 13 |
-
}
|
| 14 |
-
.carbon-banner {
|
| 15 |
--paper: #f7f5ee;
|
| 16 |
--ink: #1f1f1d;
|
| 17 |
--muted: #8c918b;
|
| 18 |
-
--hairline: #
|
| 19 |
--green: #317f3f;
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
width: 100%;
|
| 24 |
position: relative;
|
| 25 |
-
|
| 26 |
background:
|
| 27 |
-
radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.
|
| 28 |
-
radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
/*
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
position: absolute;
|
| 42 |
-
left:
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
pointer-events: none;
|
| 47 |
-
|
| 48 |
}
|
| 49 |
-
.
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
text-transform: uppercase;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
.
|
| 57 |
-
.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
.carbon-banner
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
@media (max-width: 720px) {
|
| 94 |
-
.banner-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
#f7f5ee;
|
| 16 |
-
background-size: 7px 7px, 11px 11px, auto, auto;
|
| 17 |
padding: 0;
|
| 18 |
}
|
| 19 |
-
.container { max-width: 760px; margin: 0 auto; padding:
|
| 20 |
-
|
| 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 |
|
|
@@ -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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
@@ -1,66 +1,7 @@
|
|
| 1 |
-
/* header.css —
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 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; }
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
.tab-lede {
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
.tab-lede p {
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
-
Layout math: container.wide is
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
collapse to single-column and unstick the rail. */
|
| 99 |
.section--two-col {
|
| 100 |
display: grid;
|
| 101 |
-
grid-template-columns:
|
| 102 |
-
column-gap:
|
| 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
|
| 121 |
-
|
| 122 |
-
|
| 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:
|
| 130 |
-
}
|
| 131 |
-
/*
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -1,10 +1,10 @@
|
|
| 1 |
/* section-species.css — §4 Same gene across species. Each species gets
|
| 2 |
-
a row with
|
| 3 |
-
|
| 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,8 +23,9 @@
|
|
| 23 |
}
|
| 24 |
.species-sub { color: #888; font-size: 10px; margin-top: 4px; padding-left: 12px; }
|
| 25 |
.species-stats {
|
| 26 |
-
text-align:
|
| 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; }
|
|
@@ -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 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 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 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 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 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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>
|
| 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 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 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 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 818 |
</div>
|
| 819 |
|
| 820 |
-
<div class="container" style="max-width:
|
| 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>
|