{
  "id": "mg_sci_dna_melting",
  "name": "DNA Melting",
  "width": 1440,
  "height": 810,
  "cornerRadius": 0,
  "fit": "Layout",
  "backgroundColor": "#06080b",
  "opacity": 1,
  "x": -720,
  "y": -405,
  "responsive": {
    "enabled": true,
    "mode": "constraint",
    "width": {
      "type": "fixed"
    },
    "height": {
      "type": "fixed"
    },
    "maintainAspectRatio": false,
    "scaleObjects": false,
    "breakpoints": [
      {
        "id": "bp_mobile",
        "name": "Mobile",
        "minWidth": 0,
        "maxWidth": 599
      },
      {
        "id": "bp_tablet",
        "name": "Tablet",
        "minWidth": 600,
        "maxWidth": 1023
      },
      {
        "id": "bp_desktop",
        "name": "Desktop",
        "minWidth": 1024
      }
    ]
  },
  "layoutProps": {
    "mode": "linear",
    "direction": "vertical",
    "gap": 0,
    "padding": {
      "top": 0,
      "right": 0,
      "bottom": 0,
      "left": 0
    },
    "alignItems": "stretch",
    "layoutSizingHorizontal": "fixed",
    "layoutSizingVertical": "fixed"
  },
  "seo": {
    "title": "DNA Melting — Exode Motion Graphics",
    "description": "A DNA duplex melting with temperature and salt: AT bubbles open first, GC regions hold, and the measured curve tracks the predicted Tm. Swap base pairs to retune it."
  },
  "stateMachine": {
    "inputs": [
      {
        "id": "in_title",
        "name": "title",
        "value": {
          "type": "Text",
          "value": "DNA Melting",
          "defaultValue": "DNA Melting"
        }
      },
      {
        "id": "in_sequence",
        "name": "sequence",
        "value": {
          "type": "Text",
          "value": "ATATTAGCGCGCCGGCTATATAATTAGGCCGCGCTA",
          "defaultValue": "ATATTAGCGCGCCGGCTATATAATTAGGCCGCGCTA"
        }
      },
      {
        "id": "in_temp",
        "name": "temp",
        "value": {
          "type": "Number",
          "value": 55,
          "defaultValue": 55
        }
      },
      {
        "id": "in_salt",
        "name": "salt",
        "value": {
          "type": "Number",
          "value": 50,
          "defaultValue": 50
        }
      },
      {
        "id": "in_hbonds",
        "name": "hbonds",
        "value": {
          "type": "Boolean",
          "value": true,
          "defaultValue": true
        }
      },
      {
        "id": "in_speed",
        "name": "speed",
        "value": {
          "type": "Number",
          "value": 1,
          "defaultValue": 1
        }
      },
      {
        "id": "in_accent",
        "name": "accent",
        "value": {
          "type": "Text",
          "value": "#5ee0ff",
          "defaultValue": "#5ee0ff"
        }
      },
      {
        "id": "in_accent2",
        "name": "accent2",
        "value": {
          "type": "Text",
          "value": "#ff9b6a",
          "defaultValue": "#ff9b6a"
        }
      },
      {
        "id": "in_bg",
        "name": "bg",
        "value": {
          "type": "Text",
          "value": "#06080b",
          "defaultValue": "#06080b"
        }
      },
      {
        "id": "in_text",
        "name": "text",
        "value": {
          "type": "Text",
          "value": "#e6edf3",
          "defaultValue": "#e6edf3"
        }
      }
    ],
    "layers": [
      {
        "name": "Base",
        "entryStateId": "s_idle",
        "states": [
          {
            "id": "s_idle",
            "name": "Idle",
            "loop": true,
            "transitions": []
          }
        ]
      }
    ],
    "logicNodes": []
  },
  "animations": [],
  "physics": {
    "enabled": false,
    "gravity": {
      "x": 0,
      "y": 9.8
    }
  },
  "animationFlow": [],
  "navigation": [],
  "stateVariables": [],
  "objects": [
    {
      "id": "stage",
      "name": "Stage",
      "type": "Group",
      "transform": {
        "x": 0,
        "y": 0,
        "rotation": 0,
        "scale_x": 1,
        "scale_y": 1,
        "opacity": 1,
        "skew_x": 0,
        "skew_y": 0
      },
      "width": 1440,
      "height": 810,
      "geometry": {
        "type": "Rectangle",
        "width": 1440,
        "height": 810,
        "cornerRadius": 0
      },
      "cornerRadius": 0,
      "children": [],
      "clipContent": true,
      "style": {
        "fill": {
          "type": "Solid",
          "color": "#06080b",
          "opacity": 1
        },
        "effects": [
          {
            "id": "fx_sci-dna-melting",
            "type": "CustomEffect",
            "name": "DNA Melting",
            "isEnabled": true,
            "params": {},
            "code": "/* Shared by every motion-ui effect (prepended to each one).\n   Rules for this file and the effects: the .bexode compiler minifies effect\n   code, so use explicit semicolons, block comments only, and never write a\n   minus followed by a negative value (it collapses to a decrement). */\n\n/* Read a live state-machine input, falling back to a default. */\nfunction inp(env, name, fallback) {\n  const v = env.inputs ? env.inputs[name] : undefined;\n  if (v === undefined || v === null || v === '') return fallback;\n  if (typeof fallback === 'number') {\n    const n = Number(v);\n    return isFinite(n) ? n : fallback;\n  }\n  if (typeof fallback === 'boolean') return v === true || v === 1 || v === 'true';\n  return v;\n}\n\n/* '#rgb' / '#rrggbb' to [r, g, b]; anything else falls back to white. */\nfunction rgbOf(hex) {\n  let h = String(hex || '').trim().replace('#', '');\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  if (!/^[0-9a-fA-F]{6}$/.test(h)) return [255, 255, 255];\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex, a) {\n  const c = rgbOf(hex);\n  return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + Math.max(0, Math.min(1, a)) + ')';\n}\n\nfunction mix(a, b, t) {\n  const x = rgbOf(a), y = rgbOf(b);\n  const r = Math.round(x[0] + (y[0] - x[0]) * t);\n  const g = Math.round(x[1] + (y[1] - x[1]) * t);\n  const bl = Math.round(x[2] + (y[2] - x[2]) * t);\n  return '#' + ((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1);\n}\n\n/* A soft round glow, rendered once per colour and blitted with drawImage\n   (much cheaper than shadowBlur on every particle). */\nconst __glows = {};\nfunction glowSprite(hex) {\n  if (__glows[hex]) return __glows[hex];\n  const s = 64;\n  const c = document.createElement('canvas');\n  c.width = s; c.height = s;\n  const g = c.getContext('2d');\n  const gr = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);\n  gr.addColorStop(0, rgba(hex, 1));\n  gr.addColorStop(0.18, rgba(hex, 0.55));\n  gr.addColorStop(0.45, rgba(hex, 0.14));\n  gr.addColorStop(1, rgba(hex, 0));\n  g.fillStyle = gr;\n  g.fillRect(0, 0, s, s);\n  __glows[hex] = c;\n  return c;\n}\n\nfunction glow(ctx, hex, x, y, r, a) {\n  if (r <= 0 || a <= 0) return;\n  ctx.globalAlpha = Math.min(1, a);\n  ctx.drawImage(glowSprite(hex), x - r, y - r, r * 2, r * 2);\n  ctx.globalAlpha = 1;\n}\n\n/* Frame clock: seconds since start and a clamped delta. */\nfunction tick(S) {\n  const now = performance.now();\n  if (!S.t0) { S.t0 = now; S.last = now; }\n  S.dt = Math.min(0.05, Math.max(0.001, (now - S.last) / 1000));\n  S.last = now;\n  S.age = (now - S.t0) / 1000;\n  return S.dt;\n}\n\n/* Deterministic pseudo-random so layouts are stable across resizes. */\nfunction rng(seed) {\n  let s = seed >>> 0 || 1;\n  return function () {\n    s = (s * 1664525 + 1013904223) >>> 0;\n    return s / 4294967296;\n  };\n}\n\nfunction ease(x) { return x < 0.5 ? 4 * x * x * x : 1 + (Math.pow(2 * x - 2, 3)) / 2; }\nfunction clamp(v, a, b) { return v < a ? a : v > b ? b : v; }\n\nconst SANS = 'Inter,\\u0020Helvetica,\\u0020Arial,\\u0020sans-serif';\nconst MONO = 'ui-monospace,\\u0020Menlo,\\u0020monospace';\n\nfunction roundRect(ctx, x, y, w, h, r) {\n  r = Math.min(r, w / 2, h / 2);\n  ctx.beginPath();\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x + w, y, x + w, y + h, r);\n  ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r);\n  ctx.arcTo(x, y, x + w, y, r);\n  ctx.closePath();\n}\n\n/* Shared backdrop: flat colour, soft vignette and fine grain. */\nfunction backdrop(ctx, W, H, bg, tint, amt) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  if (tint && amt > 0) {\n    const gr = ctx.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.max(W, H) * 0.7);\n    gr.addColorStop(0, rgba(tint, 0.10 * amt));\n    gr.addColorStop(1, rgba(tint, 0));\n    ctx.fillStyle = gr;\n    ctx.fillRect(0, 0, W, H);\n  }\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.8);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.45)');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Greedy word wrap with the current ctx.font. */\nfunction wrap(ctx, text, maxW) {\n  const out = [];\n  String(text).split('\\n').forEach(function (para) {\n    const words = para.split('\\u0020');\n    let cur = '';\n    for (let i = 0; i < words.length; i++) {\n      const next = cur ? cur + '\\u0020' + words[i] : words[i];\n      if (cur && ctx.measureText(next).width > maxW) { out.push(cur); cur = words[i]; } else cur = next;\n    }\n    out.push(cur);\n  });\n  return out;\n}\n\n/* Largest font size (up to max) at which text wraps into maxLines within maxW. */\nfunction fitFont(ctx, text, weight, family, maxW, maxLines, max, min) {\n  let fs = max;\n  while (fs > min) {\n    ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n    if (wrap(ctx, text, maxW).length <= maxLines) break;\n    fs = fs * 0.92;\n  }\n  ctx.font = weight + '\\u0020' + fs + 'px\\u0020' + family;\n  return fs;\n}\n\n/* Smoothed pointer that idles along a slow figure-eight when the cursor is away. */\nfunction follow(S, env, W, H, k) {\n  const pt = env.pointer || {};\n  const t = S.age || 0;\n  const tx = pt.isHovered ? pt.localX : W / 2 + Math.sin(t * 0.37) * W * 0.3;\n  const ty = pt.isHovered ? pt.localY : H / 2 + Math.sin(t * 0.74) * H * 0.22;\n  if (S.px === undefined) { S.px = tx; S.py = ty; }\n  S.px += (tx - S.px) * Math.min(1, S.dt * (k || 6));\n  S.py += (ty - S.py) * Math.min(1, S.dt * (k || 6));\n  S.inside = !!pt.isHovered;\n  return pt;\n}\n\n/* Optional centred headline over a background effect. */\nfunction title(ctx, W, H, text, sub, ink, accent, bg) {\n  if (!text) return;\n  if (bg) {\n    /* Soft scrim so the headline reads over busy motion. */\n    const sc = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.min(W * 0.5, 560));\n    sc.addColorStop(0, rgba(bg, 0.72));\n    sc.addColorStop(1, rgba(bg, 0));\n    ctx.fillStyle = sc;\n    ctx.fillRect(0, 0, W, H);\n  }\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'alphabetic';\n  const maxW = Math.min(W * 0.86, 900);\n  const fs = fitFont(ctx, text, 600, SANS, maxW, 2, clamp(W * 0.065, 26, 72), 16);\n  const lines = wrap(ctx, text, maxW);\n  const lh = fs * 1.08;\n  const y0 = H / 2 - (lines.length * lh) / 2 + fs * 0.8 - (sub ? fs * 0.35 : 0);\n  ctx.fillStyle = ink;\n  lines.forEach(function (l, i) { ctx.fillText(l, W / 2, y0 + i * lh); });\n  if (sub) {\n    ctx.font = '500\\u0020' + clamp(fs * 0.24, 11, 15) + 'px\\u0020' + MONO;\n    ctx.fillStyle = rgba(accent, 0.85);\n    ctx.fillText(String(sub).toUpperCase(), W / 2, y0 + (lines.length - 1) * lh + fs * 0.9);\n  }\n}\n\nconst PIECE = { n: '57', total: '57' };\n/* Shared by every motion-graphics piece (prepended after motion-ui/_kit.js).\n   Same minifier rules as the kit: explicit semicolons, block comments only,\n   never a minus followed by a negative value. */\n\nconst SERIF = 'Georgia,\\u0020serif';\n\n/* Value noise and fractal noise, deterministic. */\nconst NZP = (function () {\n  const r = rng(1337);\n  const p = [];\n  for (let i = 0; i < 512; i++) p.push(Math.floor(r() * 256));\n  return p;\n})();\nfunction hash2(x, y) { return NZP[(x & 255) + NZP[y & 255]] / 255; }\nfunction noise2(x, y) {\n  const xi = Math.floor(x), yi = Math.floor(y);\n  const xf = x - xi, yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction fbm(x, y) {\n  return noise2(x, y) * 0.55 + noise2(x * 2.03 + 17.1, y * 2.03 + 3.7) * 0.3 + noise2(x * 4.1 + 5.3, y * 4.1 + 41.9) * 0.15;\n}\n\n/* Film grain: one noise tile, re-offset every frame. */\nlet GRAIN = null;\nfunction grain(ctx, W, H, a) {\n  if (a <= 0) return;\n  if (!GRAIN) {\n    const c = document.createElement('canvas');\n    c.width = 160; c.height = 160;\n    const g = c.getContext('2d');\n    const im = g.createImageData(160, 160);\n    for (let i = 0; i < im.data.length; i += 4) {\n      const v = Math.random() * 255;\n      im.data[i] = v; im.data[i + 1] = v; im.data[i + 2] = v; im.data[i + 3] = 255;\n    }\n    g.putImageData(im, 0, 0);\n    GRAIN = ctx.createPattern(c, 'repeat');\n  }\n  const ox = Math.floor(Math.random() * 160), oy = Math.floor(Math.random() * 160);\n  ctx.save();\n  ctx.globalAlpha = a;\n  ctx.globalCompositeOperation = 'overlay';\n  ctx.translate(ox, oy);\n  ctx.fillStyle = GRAIN;\n  ctx.fillRect(0 - ox, 0 - oy, W, H);\n  ctx.restore();\n}\n\n/* Soft edge vignette. */\nfunction vignette(ctx, W, H, a) {\n  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,' + a + ')');\n  ctx.fillStyle = vg;\n  ctx.fillRect(0, 0, W, H);\n}\n\n/* Portfolio chrome: four mono captions and hairline registration marks. */\nfunction chrome(ctx, W, H, ink, name, caption) {\n  const s = Math.min(W, H);\n  const pad = clamp(s * 0.05, 14, 38);\n  const fs = clamp(s * 0.017, 9, 11);\n  ctx.save();\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textBaseline = 'middle';\n  ctx.fillStyle = rgba(ink, 0.62);\n  ctx.textAlign = 'left';\n  ctx.fillText(String(name).toUpperCase(), pad, pad);\n  ctx.textAlign = 'right';\n  ctx.fillText(PIECE.n + '\\u0020/\\u0020' + PIECE.total, W - pad, pad);\n  ctx.fillStyle = rgba(ink, 0.4);\n  ctx.textAlign = 'left';\n  if (W > 520) ctx.fillText(String(caption).toUpperCase(), pad, H - pad);\n  ctx.textAlign = 'right';\n  ctx.fillText('EXODE MOTION · 2026', W - pad, H - pad);\n  ctx.strokeStyle = rgba(ink, 0.22);\n  ctx.lineWidth = 1;\n  const m = pad * 0.45, k = 7;\n  ctx.beginPath();\n  ctx.moveTo(m, m + k); ctx.lineTo(m, m); ctx.lineTo(m + k, m);\n  ctx.moveTo(W - m - k, m); ctx.lineTo(W - m, m); ctx.lineTo(W - m, m + k);\n  ctx.moveTo(m, H - m - k); ctx.lineTo(m, H - m); ctx.lineTo(m + k, H - m);\n  ctx.moveTo(W - m - k, H - m); ctx.lineTo(W - m, H - m); ctx.lineTo(W - m, H - m - k);\n  ctx.stroke();\n  ctx.restore();\n}\n\n/* Spaced display text (letterSpacing where supported). */\nfunction spaced(ctx, text, x, y, em) {\n  ctx.letterSpacing = em + 'em';\n  ctx.fillText(text, x, y);\n  ctx.letterSpacing = '0px';\n}\n\n/* Backing-store scale of the canvas, for offscreen buffers (max 2). */\nfunction dprOf(ctx) {\n  const m = ctx.getTransform ? ctx.getTransform() : null;\n  return m ? clamp(Math.sqrt(m.a * m.a + m.b * m.b), 1, 2) : 1;\n}\n\n/* One pointer press per click. */\nfunction pressed(S, pt) {\n  const p = !!pt.isDown && !S.wasDown && !!pt.isHovered;\n  S.wasDown = !!pt.isDown;\n  return p;\n}\n\n/* ---------- Product launch helpers ---------- */\n\n/* Where the product sits: right of the copy on wide frames, above it on tall ones. */\nfunction productStage(W, H) {\n  const land = W > H * 1.05;\n  return land\n    ? { land: true, cx: W * 0.67, cy: H * 0.52, size: Math.min(W * 0.5, H * 0.78) }\n    : { land: false, cx: W / 2, cy: H * 0.32, size: Math.min(W * 0.88, H * 0.46) };\n}\n\n/* Studio sweep: spotlight behind the product and a soft floor. */\nfunction studio(ctx, W, H, bg, tint, st) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, W, H);\n  const r = st.size * 1.1;\n  const g = ctx.createRadialGradient(st.cx, st.cy, 0, st.cx, st.cy, r);\n  g.addColorStop(0, rgba(tint, 0.22));\n  g.addColorStop(0.5, rgba(tint, 0.06));\n  g.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, H);\n  const fy = st.cy + st.size * 0.42;\n  const fl = ctx.createLinearGradient(0, fy, 0, H);\n  fl.addColorStop(0, rgba(tint, 0.05));\n  fl.addColorStop(1, rgba(tint, 0));\n  ctx.fillStyle = fl;\n  ctx.fillRect(0, fy, W, H - fy);\n}\n\n/* Soft contact shadow. */\nfunction floorShadow(ctx, x, y, w, a) {\n  ctx.save();\n  ctx.translate(x, y);\n  ctx.scale(1, 0.16);\n  const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n  g.addColorStop(0, 'rgba(0,0,0,' + a + ')');\n  g.addColorStop(1, 'rgba(0,0,0,0)');\n  ctx.fillStyle = g;\n  ctx.fillRect(0 - w, 0 - w, w * 2, w * 2);\n  ctx.restore();\n}\n\n/* Launch copy block: eyebrow, name, tagline, three specs and a CTA pill.\n   Staggers in with `appear` (seconds since start) and records CTA hover on S. */\nfunction productCopy(ctx, W, H, o, S, env) {\n  const s = Math.min(W, H);\n  const land = W > H * 1.05;\n  const pad = clamp(s * 0.09, 20, 96);\n  const cw = land ? Math.min(W * 0.34, 500) : W - pad * 2;\n  /* Small frames (cards, thumbnails) drop the specs and shorten the tagline. */\n  const compact = cw < 220 || H < 300;\n  const fe = clamp(s * 0.016, 7, 12);\n  const nameFs = fitFont(ctx, o.name, 600, SANS, cw, 2, clamp(s * 0.085, 16, 78), 12);\n  const nl = wrap(ctx, o.name, cw);\n  const ft = clamp(s * 0.024, 9, 18);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  const tl = wrap(ctx, o.tagline, cw).slice(0, compact ? 2 : 3);\n  const fv = clamp(s * 0.034, 12, 26);\n  const specs = compact ? [] : (o.specs || []).slice(0, 3);\n  const ch = fe * 3.6;\n  const total = fe * 2.6 + nl.length * nameFs * 1.02 + ft * 0.9 + tl.length * ft * 1.5 + (specs.length ? fv * 3.4 : 0) + ch + fe;\n  let y = land ? H / 2 - total / 2 : H - pad - total;\n  const x = pad;\n  const reveal = function (i) {\n    const k = ease(clamp(((o.appear || 9) - 0.15 - i * 0.12) / 0.7, 0, 1));\n    ctx.globalAlpha = k;\n    return (1 - k) * 18;\n  };\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n\n  let d = reveal(0);\n  ctx.font = '600\\u0020' + fe + 'px\\u0020' + MONO;\n  ctx.fillStyle = o.accent;\n  spaced(ctx, String(o.eyebrow).toUpperCase(), x, y + fe + d, 0.22);\n  y += fe * 2.6;\n\n  d = reveal(1);\n  ctx.font = '600\\u0020' + nameFs + 'px\\u0020' + SANS;\n  ctx.fillStyle = o.ink;\n  ctx.letterSpacing = '-0.025em';\n  for (let i = 0; i < nl.length; i++) ctx.fillText(nl[i], x, y + nameFs * 0.86 + i * nameFs * 1.02 + d);\n  ctx.letterSpacing = '0px';\n  y += nl.length * nameFs * 1.02 + ft * 0.9;\n\n  d = reveal(2);\n  ctx.font = '400\\u0020' + ft + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.62);\n  for (let i = 0; i < tl.length; i++) ctx.fillText(tl[i], x, y + ft + i * ft * 1.5 + d);\n  y += tl.length * ft * 1.5;\n\n  if (specs.length) {\n    d = reveal(3);\n    y += fv * 0.9;\n    ctx.fillStyle = rgba(o.ink, 0.14);\n    ctx.fillRect(x, y + d, cw, 1);\n    const colw = cw / 3;\n    for (let i = 0; i < specs.length; i++) {\n      ctx.font = '600\\u0020' + fv + 'px\\u0020' + SANS;\n      ctx.fillStyle = o.ink;\n      ctx.fillText(String(specs[i][0]), x + i * colw, y + fv * 1.5 + d);\n      ctx.font = '500\\u0020' + fe * 0.9 + 'px\\u0020' + MONO;\n      ctx.fillStyle = rgba(o.ink, 0.45);\n      ctx.fillText(String(specs[i][1]).toUpperCase(), x + i * colw, y + fv * 1.5 + fe * 1.7 + d);\n    }\n    y += fv * 2.5;\n  }\n\n  d = reveal(4);\n  ctx.font = '600\\u0020' + fe * 1.2 + 'px\\u0020' + SANS;\n  const label = String(o.cta);\n  const bw = ctx.measureText(label).width + fe * 4;\n  const by = y + d;\n  const pt = env.pointer || {};\n  const hot = !!pt.isHovered && pt.localX >= x && pt.localX <= x + bw && pt.localY >= by && pt.localY <= by + ch;\n  S.ctaHot = hot;\n  S.ctaK = (S.ctaK || 0) + ((hot ? 1 : 0) - (S.ctaK || 0)) * 0.2;\n  ctx.fillStyle = mix(o.accent, '#ffffff', S.ctaK * 0.18);\n  roundRect(ctx, x, by, bw, ch, ch / 2);\n  ctx.fill();\n  ctx.fillStyle = o.ctaInk;\n  ctx.textBaseline = 'middle';\n  ctx.fillText(label, x + fe * 2, by + ch / 2 + 1);\n  ctx.font = '500\\u0020' + fe * 1.1 + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.7);\n  if (cw > bw + 120) ctx.fillText('Learn more\\u0020\\u0020→', x + bw + fe * 2, by + ch / 2 + 1);\n  ctx.textBaseline = 'alphabetic';\n  ctx.globalAlpha = 1;\n}\n\nconst PRESET = {\"buttons\":[{\"id\":\"reset\",\"label\":\"Reset sequence\"}],\"mode\":\"melting\",\"seq\":\"ATATTAGCGCGCCGGCTATATAATTAGGCCGCGCTA\",\"eyebrow\":\"Biophysics · Thermal denaturation\",\"sub\":\"Heat breaks the hydrogen bonds between strands. AT-rich stretches open first as bubbles,\\u0020GC clamps hold longest,\\u0020and the curve snaps at the melting temperature.\",\"hint\":\"Click a base pair to swap AT ↔ GC\",\"title\":\"DNA Melting\",\"no\":\"016\"};\nconst CONTROLS = [{\"name\":\"temp\",\"label\":\"Temperature\",\"type\":\"number\",\"value\":55,\"min\":20,\"max\":100,\"step\":1,\"unit\":\"°C\"},{\"name\":\"salt\",\"label\":\"Na⁺\",\"type\":\"number\",\"value\":50,\"min\":10,\"max\":1000,\"step\":10,\"unit\":\"mM\"},{\"name\":\"hbonds\",\"label\":\"Hydrogen bonds\",\"type\":\"boolean\",\"value\":true},{\"name\":\"speed\",\"label\":\"Sim speed\",\"type\":\"number\",\"value\":1,\"min\":0.1,\"max\":3,\"step\":0.1,\"unit\":\"×\"}];\n/* Science series kit (prepended after _frame.js, before each engine).\n   A lab-instrument layout: the simulation viewport plus a control panel\n   (right on wide frames, below on tall ones) with live sliders, switches,\n   segmented choices, buttons, readouts and a plot. Every control mirrors a\n   state-machine input of the same name, so values can also be set from the\n   editor or a host page. CONTROLS and PRESET are injected per template. */\n\nfunction sciLayout(W, H) {\n  const land = W > H * 1.05;\n  const s = Math.min(W, H);\n  if (land) {\n    const pw = Math.round(clamp(W * 0.25, 168, 340));\n    return { land: true, s: s, W: W, H: H, sim: { x: 0, y: 0, w: W - pw, h: H }, panel: { x: W - pw, y: 0, w: pw, h: H } };\n  }\n  const ph = Math.round(clamp(H * 0.42, 190, 520));\n  return { land: false, s: s, W: W, H: H, sim: { x: 0, y: 0, w: W, h: H - ph }, panel: { x: 0, y: H - ph, w: W, h: ph } };\n}\n\n/* Where the experiment sits inside the viewport, below the header. */\nfunction sciStage(L) {\n  const s = Math.min(L.sim.w, L.sim.h);\n  const pad = clamp(s * 0.06, 12, 44);\n  const top = L.sim.y + clamp(L.sim.h * 0.2, 54, 170);\n  const bot = L.sim.y + L.sim.h - clamp(s * 0.12, 30, 84);\n  return { x: L.sim.x + pad, y: top, w: L.sim.w - pad * 2, h: Math.max(40, bot - top) };\n}\n\n/* Values (synced from inputs whenever an input changes) and pointer routing:\n   a press that starts in the panel belongs to the panel until release. */\nfunction sciFrame(S, env, L) {\n  if (!S.ui) S.ui = { v: {}, seen: {}, drag: null, cap: null, was: false, fire: null, fired: null };\n  const U = S.ui;\n  for (let i = 0; i < CONTROLS.length; i++) {\n    const c = CONTROLS[i];\n    const iv = inp(env, c.name, c.type === 'boolean' ? !!c.value : c.value);\n    if (U.seen[c.name] !== iv) { U.seen[c.name] = iv; U.v[c.name] = iv; }\n  }\n  const pt = env.pointer || {};\n  const hov = !!pt.isHovered;\n  const down = hov && !!pt.isDown;\n  const x = pt.localX || 0, y = pt.localY || 0;\n  const P = L.panel;\n  const inPanel = hov && x >= P.x && x <= P.x + P.w && y >= P.y && y <= P.y + P.h;\n  U.click = down && !U.was;\n  const up = !down && U.was;\n  if (U.click) U.cap = inPanel ? 'panel' : 'sim';\n  const cap = U.cap;\n  if (!down) { U.cap = null; U.drag = null; }\n  U.was = down;\n  U.fired = U.fire;\n  U.fire = null;\n  U.x = x; U.y = y; U.hov = hov; U.down = down;\n  return {\n    v: U.v,\n    fired: U.fired,\n    p: { x: x, y: y, over: hov && !inPanel && cap !== 'panel', down: down && cap === 'sim', click: U.click && cap === 'sim', up: up && cap === 'sim' },\n  };\n}\n\nfunction sciDec(step) { return step >= 1 ? 0 : step >= 0.1 ? 1 : step >= 0.01 ? 2 : 3; }\nfunction sciFmt(v, d) {\n  if (typeof v !== 'number') return String(v);\n  if (!isFinite(v)) return '—';\n  const a = Math.abs(v);\n  if (d === undefined) d = a >= 100 ? 0 : a >= 10 ? 1 : 2;\n  return v.toFixed(d);\n}\n\n/* Cached shaded sphere sprite per colour. */\nconst __balls = {};\nfunction ballSprite(hex) {\n  if (__balls[hex]) return __balls[hex];\n  const n = 48;\n  const c = document.createElement('canvas');\n  c.width = n; c.height = n;\n  const g = c.getContext('2d');\n  const gr = g.createRadialGradient(n * 0.36, n * 0.34, n * 0.02, n / 2, n / 2, n / 2);\n  gr.addColorStop(0, mix(hex, '#ffffff', 0.7));\n  gr.addColorStop(0.35, hex);\n  gr.addColorStop(1, mix(hex, '#000000', 0.55));\n  g.fillStyle = gr;\n  g.beginPath(); g.arc(n / 2, n / 2, n / 2 - 0.5, 0, Math.PI * 2); g.fill();\n  __balls[hex] = c;\n  return c;\n}\nfunction ball(ctx, hex, x, y, r) {\n  if (r < 1.1) { ctx.fillStyle = hex; ctx.fillRect(x - r, y - r, r * 2, r * 2); return; }\n  ctx.drawImage(ballSprite(hex), x - r, y - r, r * 2, r * 2);\n}\n\n/* Perceptual cold-to-hot ramp, quantised so sprites stay cached. */\nconst HEAT = ['#2f5bff', '#3fa2ff', '#4fe0ff', '#9ef5c8', '#ffe27a', '#ffa64a', '#ff5a3c'];\nfunction heat(t) {\n  const u = clamp(t, 0, 0.999) * (HEAT.length - 1);\n  const i = Math.floor(u);\n  return mix(HEAT[i], HEAT[i + 1], Math.round((u - i) * 4) / 4);\n}\n\n/* Instrument backdrop: flat field, faint measuring grid, vignette. */\nfunction sciBack(ctx, L, bg, ink) {\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, L.W, L.H);\n  const g = clamp(L.s * 0.05, 16, 40);\n  ctx.fillStyle = rgba(ink, 0.045);\n  for (let x = L.sim.x + g; x < L.sim.x + L.sim.w; x += g) for (let y = L.sim.y + g; y < L.sim.y + L.sim.h; y += g) ctx.fillRect(x, y, 1, 1);\n  const vg = ctx.createRadialGradient(L.sim.x + L.sim.w / 2, L.sim.y + L.sim.h / 2, Math.min(L.sim.w, L.sim.h) * 0.3, L.sim.x + L.sim.w / 2, L.sim.y + L.sim.h / 2, Math.max(L.sim.w, L.sim.h) * 0.75);\n  vg.addColorStop(0, 'rgba(0,0,0,0)');\n  vg.addColorStop(1, 'rgba(0,0,0,0.35)');\n  ctx.fillStyle = vg;\n  ctx.fillRect(L.sim.x, L.sim.y, L.sim.w, L.sim.h);\n}\n\n/* Header over the viewport: field eyebrow, title, one-line description, and\n   an interaction hint at the bottom. */\nfunction sciHeader(ctx, L, o) {\n  const s = Math.min(L.sim.w, L.sim.h);\n  const pad = clamp(s * 0.06, 12, 44);\n  const fe = clamp(s * 0.018, 8, 11);\n  const x = L.sim.x + pad;\n  let y = L.sim.y + pad;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.font = '600\\u0020' + fe + 'px\\u0020' + MONO;\n  ctx.fillStyle = o.accent;\n  spaced(ctx, String(o.eyebrow).toUpperCase(), x, y + fe, 0.18);\n  ctx.textAlign = 'right';\n  ctx.fillStyle = rgba(o.ink, 0.4);\n  if (L.sim.w > 360) spaced(ctx, 'SCI ·\\u0020' + PRESET.no + '\\u0020/\\u0020100', L.sim.x + L.sim.w - pad, y + fe, 0.14);\n  ctx.textAlign = 'left';\n  y += fe * 1.6;\n  const maxW = Math.min(L.sim.w - pad * 2, Math.max(260, L.sim.w * 0.62));\n  const fs = fitFont(ctx, o.title, 600, SANS, maxW, 1, clamp(s * 0.06, 16, 44), 12);\n  ctx.fillStyle = o.ink;\n  ctx.letterSpacing = '-0.02em';\n  ctx.fillText(o.title, x, y + fs * 0.95);\n  ctx.letterSpacing = '0px';\n  y += fs * 1.15;\n  const fsub = clamp(s * 0.022, 10, 15);\n  ctx.font = '400\\u0020' + fsub + 'px\\u0020' + SANS;\n  ctx.fillStyle = rgba(o.ink, 0.55);\n  if (o.sub && L.sim.h > 220) {\n    const lines = wrap(ctx, o.sub, Math.min(maxW, 660)).slice(0, L.sim.w > 520 ? 2 : 1);\n    for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x, y + fsub * 1.1 + i * fsub * 1.4);\n  }\n  if (o.hint && L.sim.h > 200) {\n    ctx.font = '500\\u0020' + fe + 'px\\u0020' + MONO;\n    ctx.fillStyle = rgba(o.ink, 0.38);\n    spaced(ctx, String(o.hint).toUpperCase(), x, L.sim.y + L.sim.h - pad * 0.7, 0.14);\n  }\n}\n\n/* A small label chip used on the viewport (annotations, callouts). */\nfunction sciTag(ctx, text, x, y, ink, bg, fs, align) {\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  const w = ctx.measureText(text).width + fs * 1.2;\n  const h = fs * 1.9;\n  const x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;\n  ctx.fillStyle = rgba(bg, 0.82);\n  roundRect(ctx, x0, y - h / 2, w, h, h / 2);\n  ctx.fill();\n  ctx.strokeStyle = rgba(ink, 0.18);\n  ctx.lineWidth = 1;\n  ctx.stroke();\n  ctx.fillStyle = ink;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'middle';\n  ctx.fillText(text, x0 + fs * 0.6, y + 0.5);\n  ctx.textBaseline = 'alphabetic';\n}\n\n/* Plot: line series (uniform `d` or `pts` pairs), bar series, and an optional\n   reference curve. G: { title, kind, series:[{d|pts|bars, c, name}], y0, y1, x0, x1, xl, yl } */\nfunction sciPlot(ctx, x, y, w, h, G, ink, fl) {\n  ctx.save();\n  ctx.font = '500\\u0020' + fl * 0.85 + 'px\\u0020' + MONO;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'alphabetic';\n  ctx.fillStyle = rgba(ink, 0.5);\n  spaced(ctx, String(G.title || '').toUpperCase(), x, y + fl * 0.85, 0.12);\n  /* Legend, right-aligned; drops to its own line when it would hit the title. */\n  const tw = ctx.measureText(String(G.title || '').toUpperCase()).width * 1.25 + fl;\n  let lw = 0;\n  (G.series || []).forEach(function (sr) { if (sr.name) lw += ctx.measureText(sr.name).width + fl * 1.9; });\n  const below = tw + lw > w;\n  const ly = below ? y + fl * 2.1 : y + fl * 0.85;\n  ctx.textAlign = 'right';\n  let lx = x + w;\n  for (let i = (G.series || []).length - 1; i >= 0; i--) {\n    const sr = G.series[i];\n    if (!sr.name) continue;\n    ctx.fillStyle = rgba(ink, 0.55);\n    ctx.fillText(sr.name, lx, ly);\n    lx -= ctx.measureText(sr.name).width + fl * 0.5;\n    ctx.fillStyle = sr.c;\n    ctx.fillRect(lx - fl * 0.6, ly - fl * 0.55, fl * 0.6, fl * 0.6);\n    lx -= fl * 1.4;\n  }\n  if (below) { y += fl * 1.25; h -= fl * 1.25; }\n  const py = y + fl * 1.6, ph = h - fl * 2.9, px = x, pw = w;\n  if (ph < 12) { ctx.restore(); return; }\n  ctx.fillStyle = rgba(ink, 0.03);\n  ctx.fillRect(px, py, pw, ph);\n  ctx.strokeStyle = rgba(ink, 0.08);\n  ctx.lineWidth = 1;\n  ctx.beginPath();\n  for (let k = 1; k < 4; k++) { ctx.moveTo(px, py + ph * k / 4); ctx.lineTo(px + pw, py + ph * k / 4); }\n  ctx.stroke();\n  ctx.strokeStyle = rgba(ink, 0.22);\n  ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke();\n  /* Ranges. */\n  let y0 = G.y0, y1 = G.y1;\n  if (y0 === undefined || y1 === undefined) {\n    let lo = 1e9, hi = -1e9;\n    (G.series || []).forEach(function (sr) {\n      const arr = sr.d || sr.bars || (sr.pts ? sr.pts.map(function (q) { return q[1]; }) : []);\n      for (let i = 0; i < arr.length; i++) { if (arr[i] < lo) lo = arr[i]; if (arr[i] > hi) hi = arr[i]; }\n    });\n    if (lo > hi) { lo = 0; hi = 1; }\n    if (hi - lo < 1e-9) { hi += 0.5; lo -= 0.5; }\n    if (y0 === undefined) y0 = lo - (hi - lo) * 0.08;\n    if (y1 === undefined) y1 = hi + (hi - lo) * 0.08;\n  }\n  const x0 = G.x0 === undefined ? 0 : G.x0, x1 = G.x1 === undefined ? 1 : G.x1;\n  const Y = function (v) { return py + ph - clamp((v - y0) / (y1 - y0), 0, 1) * ph; };\n  const X = function (v) { return px + clamp((v - x0) / (x1 - x0), 0, 1) * pw; };\n  ctx.beginPath(); ctx.rect(px, py, pw, ph); ctx.clip();\n  (G.series || []).forEach(function (sr) {\n    if (sr.bars) {\n      const n = sr.bars.length, bw = pw / n;\n      ctx.fillStyle = rgba(sr.c, sr.a === undefined ? 0.55 : sr.a);\n      for (let i = 0; i < n; i++) { const t = Y(sr.bars[i]); ctx.fillRect(px + i * bw + 0.5, t, Math.max(1, bw - 1), py + ph - t); }\n      return;\n    }\n    ctx.strokeStyle = sr.c;\n    ctx.lineWidth = sr.w || 1.6;\n    ctx.setLineDash(sr.dash ? [4, 4] : []);\n    ctx.beginPath();\n    if (sr.pts) {\n      for (let i = 0; i < sr.pts.length; i++) { const q = sr.pts[i]; if (i) ctx.lineTo(X(q[0]), Y(q[1])); else ctx.moveTo(X(q[0]), Y(q[1])); }\n    } else if (sr.d && sr.d.length) {\n      const n = sr.d.length, span = Math.max(n - 1, sr.n ? sr.n - 1 : 1);\n      for (let i = 0; i < n; i++) { const xx = px + (i / span) * pw; if (i) ctx.lineTo(xx, Y(sr.d[i])); else ctx.moveTo(xx, Y(sr.d[i])); }\n    }\n    ctx.stroke();\n    ctx.setLineDash([]);\n    if (sr.dot && sr.d && sr.d.length) {\n      const n = sr.d.length, span = Math.max(n - 1, sr.n ? sr.n - 1 : 1);\n      glow(ctx, sr.c, px + ((n - 1) / span) * pw, Y(sr.d[n - 1]), fl * 0.9, 0.9);\n    }\n  });\n  if (G.marker !== undefined) {\n    ctx.strokeStyle = rgba(ink, 0.4);\n    ctx.setLineDash([3, 3]);\n    ctx.beginPath(); ctx.moveTo(X(G.marker), py); ctx.lineTo(X(G.marker), py + ph); ctx.stroke();\n    ctx.setLineDash([]);\n  }\n  ctx.restore();\n  ctx.font = '500\\u0020' + fl * 0.75 + 'px\\u0020' + MONO;\n  ctx.fillStyle = rgba(ink, 0.38);\n  ctx.textAlign = 'left';\n  if (G.xl) ctx.fillText(G.xl, px, py + ph + fl * 1.1);\n  ctx.textAlign = 'right';\n  if (G.yl) ctx.fillText(G.yl, px + pw, py + ph + fl * 1.1);\n  ctx.textAlign = 'left';\n}\n\n/* Control panel. o: { ink, accent, bg, readouts: [[label, value, unit]], graph } */\nfunction sciPanel(ctx, S, L, o) {\n  const U = S.ui;\n  const P = L.panel;\n  const ink = o.ink, A = o.accent;\n  ctx.save();\n  ctx.fillStyle = mix(o.bg, ink, 0.035);\n  ctx.fillRect(P.x, P.y, P.w, P.h);\n  ctx.fillStyle = rgba(ink, 0.1);\n  if (L.land) ctx.fillRect(P.x, P.y, 1, P.h); else ctx.fillRect(P.x, P.y, P.w, 1);\n\n  const twoCol = !L.land && P.w >= 560;\n  const p = clamp(Math.min(P.w, P.h) * 0.07, 10, 24);\n  const colW = twoCol ? (P.w - p * 3) / 2 : P.w - p * 2;\n  const ctrls = CONTROLS.filter(function (c) { return !c.hidden; });\n  const btns = PRESET.buttons || [];\n  const R = o.readouts || [];\n  /* Size everything from a base font, shrinking until the controls fit. */\n  let fl = clamp(Math.min(colW * 0.042, L.s * 0.016), 8, 12);\n  const rowH = function (c) { return c.type === 'boolean' ? fl * 2.5 : c.options ? fl * 4.2 : fl * 3.5; };\n  const need = function () {\n    let h = fl * 2.4;\n    for (let i = 0; i < ctrls.length; i++) h += rowH(ctrls[i]);\n    if (btns.length) h += Math.ceil(btns.length / 2) * fl * 3;\n    return h;\n  };\n  const room = P.h - p * 2;\n  for (let k = 0; k < 8 && need() > (twoCol ? room : room * 0.72) && fl > 7; k++) fl *= 0.9;\n  if (fl < 7) fl = 7;\n\n  let cx = P.x + p, cy = P.y + p;\n  const section = function (label, x, y, w) {\n    ctx.font = '600\\u0020' + fl * 0.8 + 'px\\u0020' + MONO;\n    ctx.textAlign = 'left';\n    ctx.fillStyle = rgba(ink, 0.42);\n    spaced(ctx, label, x, y + fl * 0.8, 0.16);\n    ctx.fillStyle = rgba(ink, 0.08);\n    ctx.fillRect(x, y + fl * 1.4, w, 1);\n    return y + fl * 2.4;\n  };\n  const hit = function (x, y, w, h) { return U.hov && U.x >= x && U.x <= x + w && U.y >= y && U.y <= y + h; };\n\n  cy = section('PARAMETERS', cx, cy, colW);\n  for (let i = 0; i < ctrls.length; i++) {\n    const c = ctrls[i];\n    const v = U.v[c.name];\n    ctx.textBaseline = 'alphabetic';\n    if (c.type === 'boolean') {\n      const sw = fl * 2.6, sh = fl * 1.4, sx = cx + colW - sw, sy = cy + fl * 0.3;\n      if (U.click && U.cap === 'panel' && hit(cx, cy, colW, rowH(c))) U.v[c.name] = !v;\n      const on = !!U.v[c.name];\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl * 1.3);\n      ctx.fillStyle = on ? A : rgba(ink, 0.14);\n      roundRect(ctx, sx, sy, sw, sh, sh / 2);\n      ctx.fill();\n      ctx.fillStyle = on ? o.bg : rgba(ink, 0.7);\n      ctx.beginPath(); ctx.arc(on ? sx + sw - sh / 2 : sx + sh / 2, sy + sh / 2, sh * 0.36, 0, Math.PI * 2); ctx.fill();\n    } else if (c.options) {\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl);\n      const n = c.options.length, by = cy + fl * 1.6, bh = fl * 2, bw = colW / n;\n      ctx.fillStyle = rgba(ink, 0.06);\n      roundRect(ctx, cx, by, colW, bh, fl * 0.5);\n      ctx.fill();\n      for (let k = 0; k < n; k++) {\n        const bx = cx + k * bw;\n        if (U.click && U.cap === 'panel' && hit(bx, by, bw, bh)) U.v[c.name] = k;\n        const on = Math.round(U.v[c.name]) === k;\n        if (on) { ctx.fillStyle = A; roundRect(ctx, bx + 2, by + 2, bw - 4, bh - 4, fl * 0.4); ctx.fill(); }\n        ctx.font = (on ? '600\\u0020' : '500\\u0020') + fl * 0.85 + 'px\\u0020' + SANS;\n        ctx.fillStyle = on ? o.bg : rgba(ink, 0.7);\n        ctx.textAlign = 'center';\n        ctx.textBaseline = 'middle';\n        let lab = c.options[k];\n        while (lab.length > 2 && ctx.measureText(lab).width > bw - 6) lab = lab.slice(0, lab.length - 2) + '.';\n        ctx.fillText(lab, bx + bw / 2, by + bh / 2 + 0.5);\n        ctx.textBaseline = 'alphabetic';\n      }\n    } else {\n      const tx = cx, tw = colW, ty = cy + fl * 2.3;\n      if (U.click && U.cap === 'panel' && hit(cx, cy, colW, rowH(c))) U.drag = c.name;\n      if (U.drag === c.name && U.down) {\n        let nv = c.min + clamp((U.x - tx) / tw, 0, 1) * (c.max - c.min);\n        nv = Math.round(nv / c.step) * c.step;\n        U.v[c.name] = Number(clamp(nv, c.min, c.max).toFixed(sciDec(c.step) + 1));\n      }\n      const val = Number(U.v[c.name]);\n      const k = clamp((val - c.min) / (c.max - c.min), 0, 1);\n      const act = U.drag === c.name;\n      ctx.font = '500\\u0020' + fl + 'px\\u0020' + SANS;\n      ctx.fillStyle = rgba(ink, 0.82);\n      ctx.textAlign = 'left';\n      ctx.fillText(c.label, cx, cy + fl * 1.1);\n      ctx.font = '500\\u0020' + fl * 0.95 + 'px\\u0020' + MONO;\n      ctx.fillStyle = act ? A : rgba(ink, 0.6);\n      ctx.textAlign = 'right';\n      ctx.fillText(sciFmt(val, sciDec(c.step)) + (c.unit ? '\\u0020' + c.unit : ''), cx + colW, cy + fl * 1.1);\n      ctx.fillStyle = rgba(ink, 0.12);\n      ctx.fillRect(tx, ty, tw, 2);\n      ctx.fillStyle = A;\n      ctx.fillRect(tx, ty, tw * k, 2);\n      ctx.beginPath(); ctx.arc(tx + tw * k, ty + 1, fl * (act ? 0.62 : 0.5), 0, Math.PI * 2);\n      ctx.fillStyle = act ? A : mix(o.bg, ink, 0.9);\n      ctx.fill();\n      if (act) glow(ctx, A, tx + tw * k, ty + 1, fl * 2, 0.5);\n    }\n    cy += rowH(c);\n  }\n  if (btns.length) {\n    const bw = (colW - fl * 0.8) / Math.min(2, btns.length), bh = fl * 2.3;\n    for (let i = 0; i < btns.length; i++) {\n      const bx = cx + (i % 2) * (bw + fl * 0.8), by = cy + Math.floor(i / 2) * fl * 3 + fl * 0.3;\n      const h = hit(bx, by, bw, bh);\n      if (U.click && U.cap === 'panel' && h) U.fire = btns[i].id;\n      const primary = i === 0;\n      ctx.fillStyle = primary ? (h ? mix(A, '#ffffff', 0.15) : A) : (h ? rgba(ink, 0.12) : rgba(ink, 0.06));\n      roundRect(ctx, bx, by, bw, bh, bh / 2);\n      ctx.fill();\n      ctx.font = '600\\u0020' + fl * 0.9 + 'px\\u0020' + SANS;\n      ctx.fillStyle = primary ? o.bg : rgba(ink, 0.85);\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText(btns[i].label, bx + bw / 2, by + bh / 2 + 0.5);\n      ctx.textBaseline = 'alphabetic';\n    }\n    cy += Math.ceil(btns.length / 2) * fl * 3;\n  }\n\n  /* Measurements and plot: second column when two-column, else below. */\n  if (twoCol) { cx = P.x + p * 2 + colW; cy = P.y + p; } else cy += fl * 0.8;\n  const bottom = P.y + P.h - p;\n  if (R.length && bottom - cy > fl * 5) {\n    cy = section('MEASUREMENTS', cx, cy, colW);\n    const cols = colW > 300 ? 3 : 2;\n    const cwid = colW / cols;\n    const rh = fl * 3.4;\n    const rows = Math.min(Math.ceil(R.length / cols), Math.max(1, Math.floor((bottom - cy) / rh)));\n    for (let i = 0; i < Math.min(R.length, rows * cols); i++) {\n      const rx = cx + (i % cols) * cwid, ry = cy + Math.floor(i / cols) * rh;\n      ctx.textAlign = 'left';\n      ctx.font = '500\\u0020' + fl * 0.75 + 'px\\u0020' + MONO;\n      ctx.fillStyle = rgba(ink, 0.45);\n      ctx.fillText(String(R[i][0]).toUpperCase(), rx, ry + fl * 0.8);\n      ctx.font = '600\\u0020' + fl * 1.45 + 'px\\u0020' + SANS;\n      ctx.fillStyle = R[i][3] || ink;\n      const val = String(R[i][1]);\n      ctx.fillText(val, rx, ry + fl * 2.55);\n      if (R[i][2]) {\n        const vw = ctx.measureText(val).width;\n        ctx.font = '500\\u0020' + fl * 0.8 + 'px\\u0020' + MONO;\n        ctx.fillStyle = rgba(ink, 0.45);\n        ctx.fillText(R[i][2], rx + vw + fl * 0.35, ry + fl * 2.55);\n      }\n    }\n    cy += rows * rh + fl * 0.6;\n  }\n  if (o.graph && bottom - cy > fl * 8) {\n    sciPlot(ctx, cx, cy, colW, Math.min(bottom - cy, colW * 0.85), o.graph, ink, fl);\n  }\n  ctx.restore();\n}\n\n/* Science engine · DNA & biomolecules. Real sequences and the standard\n   genetic code drive every view: a 3D double helix (B, A and Z forms),\n   replication at a fork, transcription, translation at the ribosome, CRISPR\n   targeting, point mutations, HP-model protein folding and DNA melting.\n   PRESET.mode: helix | replication | transcription | translation | crispr | mutation | folding | melting. */\nlet S = null;\nconst MODE = PRESET.mode;\nconst BC = { A: '#46d39a', T: '#ff6f7d', G: '#ffc35c', C: '#5d8bff', U: '#c08bff' };\nconst COMP = { A: 'T', T: 'A', G: 'C', C: 'G', U: 'A' };\nconst CODE = 'FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG';\nconst AA3 = { A: 'Ala', R: 'Arg', N: 'Asn', D: 'Asp', C: 'Cys', Q: 'Gln', E: 'Glu', G: 'Gly', H: 'His', I: 'Ile', L: 'Leu', K: 'Lys', M: 'Met', F: 'Phe', P: 'Pro', S: 'Ser', T: 'Thr', W: 'Trp', Y: 'Tyr', V: 'Val', '*': 'Stop' };\nconst AAN = { A: 'Alanine', R: 'Arginine', N: 'Asparagine', D: 'Aspartate', C: 'Cysteine', Q: 'Glutamine', E: 'Glutamate', G: 'Glycine', H: 'Histidine', I: 'Isoleucine', L: 'Leucine', K: 'Lysine', M: 'Methionine', F: 'Phenylalanine', P: 'Proline', S: 'Serine', T: 'Threonine', W: 'Tryptophan', Y: 'Tyrosine', V: 'Valine', '*': 'Stop codon' };\nconst KD = { A: 1.8, R: -4.5, N: -3.5, D: -3.5, C: 2.5, Q: -3.5, E: -3.5, G: -0.4, H: -3.2, I: 4.5, L: 3.8, K: -3.9, M: 1.9, F: 2.8, P: -1.6, S: -0.8, T: -0.7, W: -0.9, Y: -1.3, V: 4.2 };\n\nfunction cleanSeq(text, fb, rna) {\n  let out = '';\n  const s = String(text || '').toUpperCase();\n  for (let i = 0; i < s.length; i++) {\n    let c = s[i];\n    if (c === 'U') c = 'T';\n    if (c === 'A' || c === 'C' || c === 'G' || c === 'T') out += c;\n  }\n  if (out.length < 6) out = fb;\n  return rna ? out.split('T').join('U') : out;\n}\nfunction translate1(c) {\n  const k = 'TCAG';\n  c = c.split('U').join('T');\n  const i = k.indexOf(c[0]) * 16 + k.indexOf(c[1]) * 4 + k.indexOf(c[2]);\n  return i >= 0 && c.length === 3 ? CODE[i] : '?';\n}\nfunction protein(dna, stopAtStop) {\n  let p = '';\n  for (let i = 0; i + 2 < dna.length; i += 3) { const a = translate1(dna.slice(i, i + 3)); p += a; if (a === '*' && stopAtStop) break; }\n  return p;\n}\nfunction aaColor(a) {\n  if (a === '*') return '#8a8f98';\n  if ('KRH'.indexOf(a) >= 0) return '#5d8bff';\n  if ('DE'.indexOf(a) >= 0) return '#ff6f7d';\n  if ('AVILMFWPC'.indexOf(a) >= 0) return '#ffc35c';\n  return '#46d39a';\n}\nfunction gcOf(s) { let g = 0; for (let i = 0; i < s.length; i++) if (s[i] === 'G' || s[i] === 'C') g++; return s.length ? g / s.length : 0; }\n/* Melting temperature: salt-adjusted rule for long duplexes, Wallace rule below 14 bp. */\nfunction tmOf(s, naM) {\n  let gc = 0;\n  for (let i = 0; i < s.length; i++) if (s[i] === 'G' || s[i] === 'C') gc++;\n  const n = s.length;\n  if (n < 14) return 2 * (n - gc) + 4 * gc;\n  return 81.5 + 16.6 * Math.log10(naM || 0.05) + 41 * (gc / n) - 675 / n;\n}\nfunction randSeq(r, n) { let s = ''; for (let i = 0; i < n; i++) s += 'ACGT'[Math.floor(r() * 4)]; return s; }\nfunction hist(key, val, max) {\n  const h = S.hist[key] || (S.hist[key] = []);\n  if (S.frame % 3 === 0) { h.push(val); if (h.length > (max || 160)) h.shift(); }\n  return h;\n}\nfunction letter(ctx, ch, x, y, fs, col) {\n  ctx.font = '700\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.fillStyle = col;\n  ctx.textAlign = 'center';\n  ctx.textBaseline = 'middle';\n  ctx.fillText(ch, x, y + 0.5);\n  ctx.textBaseline = 'alphabetic';\n}\nfunction blob(ctx, x, y, w, h, col, a) {\n  ctx.save();\n  const g = ctx.createRadialGradient(x - w * 0.15, y - h * 0.25, 0, x, y, Math.max(w, h) * 0.6);\n  g.addColorStop(0, rgba(mix(col, '#ffffff', 0.35), a * 1.2));\n  g.addColorStop(1, rgba(col, a * 0.45));\n  ctx.fillStyle = g;\n  ctx.beginPath(); ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.fill();\n  ctx.strokeStyle = rgba(mix(col, '#ffffff', 0.4), 0.5);\n  ctx.lineWidth = 1;\n  ctx.stroke();\n  ctx.restore();\n}\n\n/* Flat duplex: backbones, base rods that meet in the middle (with 2 or 3\n   hydrogen bonds) when paired and stick out when the strands are apart. */\nfunction duplex(ctx, o) {\n  const n = o.n, sp = o.sp;\n  const bw = Math.max(1.2, sp * 0.16);\n  for (let s = 0; s < 2; s++) {\n    ctx.strokeStyle = s ? o.c2 : o.c1;\n    ctx.lineWidth = bw;\n    ctx.lineJoin = 'round';\n    ctx.beginPath();\n    let on = false;\n    for (let i = 0; i < n; i++) {\n      if (o.skip && o.skip(s, i)) { on = false; continue; }\n      const x = o.x(i), y = s ? o.yb(i) : o.yt(i);\n      if (on) ctx.lineTo(x, y); else ctx.moveTo(x, y);\n      on = true;\n    }\n    ctx.stroke();\n  }\n  for (let i = 0; i < n; i++) {\n    const x = o.x(i), yt = o.yt(i), yb = o.yb(i);\n    const gap = yb - yt;\n    const paired = gap < sp * 2.6;\n    const rl = paired ? gap / 2 - Math.max(1, sp * 0.08) : sp * 0.95;\n    const bt = o.top(i), bb = o.bot(i);\n    ctx.lineWidth = Math.max(1.5, sp * 0.42);\n    ctx.lineCap = 'round';\n    if (bt && !(o.skip && o.skip(0, i))) { ctx.strokeStyle = o.dim && o.dim(0, i) ? rgba(BC[bt], 0.35) : BC[bt]; ctx.beginPath(); ctx.moveTo(x, yt); ctx.lineTo(x, yt + rl); ctx.stroke(); }\n    if (bb && !(o.skip && o.skip(1, i))) { ctx.strokeStyle = o.dim && o.dim(1, i) ? rgba(BC[bb], 0.35) : BC[bb]; ctx.beginPath(); ctx.moveTo(x, yb); ctx.lineTo(x, yb - rl); ctx.stroke(); }\n    ctx.lineCap = 'butt';\n    if (paired && o.hb && bt && bb) {\n      const nb = bt === 'G' || bt === 'C' ? 3 : 2;\n      ctx.fillStyle = rgba(o.ink, 0.7);\n      for (let k = 0; k < nb; k++) ctx.fillRect(x - sp * 0.18 + (k - (nb - 1) / 2) * sp * 0.18, (yt + yb) / 2 - 0.5, sp * 0.1, 1.2);\n    }\n    if (o.letters && sp >= 11) {\n      const fs = Math.min(sp * 0.6, 13);\n      if (bt && !(o.skip && o.skip(0, i))) letter(ctx, bt, x, yt - fs * 0.95, fs, rgba(o.ink, 0.6));\n      if (bb && !(o.skip && o.skip(1, i))) letter(ctx, bb, x, yb + fs * 0.95, fs, rgba(o.ink, 0.6));\n    }\n  }\n  /* Phosphates. */\n  for (let s = 0; s < 2; s++) for (let i = 0; i < n; i++) {\n    if (o.skip && o.skip(s, i)) continue;\n    ball(ctx, s ? o.c2 : o.c1, o.x(i), s ? o.yb(i) : o.yt(i), bw * 1.1);\n  }\n}\n\n/* ---------- 1. Double helix ---------- */\nconst FORMS = [\n  { n: 'B-DNA', rise: 0.34, bpt: 10.5, r: 1.0, hand: 1, groove: 2.4 },\n  { n: 'A-DNA', rise: 0.26, bpt: 11, r: 1.15, hand: 1, groove: 2.0 },\n  { n: 'Z-DNA', rise: 0.37, bpt: 12, r: 0.9, hand: -1, groove: 2.9 },\n];\nfunction modeHelix(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  const seq = cleanSeq(inp(env, 'sequence', PRESET.seq), PRESET.seq).slice(0, 64);\n  const N = seq.length;\n  const fm = FORMS[clamp(Math.round(v.form || 0), 0, 2)];\n  if (!S.fm) S.fm = { rise: fm.rise, bpt: fm.bpt, r: fm.r, hand: fm.hand, groove: fm.groove, spin: 0, dp: 0 };\n  const G = S.fm;\n  ['rise', 'bpt', 'r', 'hand', 'groove'].forEach(function (k) { G[k] += (fm[k] - G[k]) * Math.min(1, dt * 2.5); });\n  if (p.click) S.dragH = { x: p.x, y: p.y, spin: G.spin, dp: G.dp };\n  if (p.down && S.dragH) { G.spin = S.dragH.spin + (p.x - S.dragH.x) * 0.012; G.dp = clamp(S.dragH.dp + (p.y - S.dragH.y) * 0.006, -1.2, 1.2); }\n  else { S.dragH = null; G.spin += dt * (v.spin / 60) * Math.PI * 2; }\n  const pitch = (v.tilt * Math.PI) / 180 + G.dp;\n  const len = N * G.rise;\n  const sc = Math.min((st.w * 0.92) / len, (st.h * 0.36) / G.r);\n  const R = G.r * sc;\n  const x0 = st.x + st.w / 2 - (len * sc) / 2, cy = st.y + st.h / 2;\n  const cp = Math.cos(pitch), spn = Math.sin(pitch);\n  const proj = function (x, a) { const y = R * Math.cos(a), z = R * Math.sin(a); return { x: x, y: cy + y * cp - z * spn, z: y * spn + z * cp }; };\n  const items = [];\n  const at = function (i, s) {\n    const th = G.hand * ((i * Math.PI * 2) / G.bpt) + G.spin + (s ? G.groove : 0);\n    return proj(x0 + (i + 0.5) * G.rise * sc, th);\n  };\n  let hov = -1;\n  if (p.over && Math.abs(p.y - cy) < R * 1.4) { const i = Math.floor((p.x - x0) / (G.rise * sc)); if (i >= 0 && i < N) hov = i; }\n  for (let i = 0; i < N; i++) {\n    const a = at(i, 0), b = at(i, 1);\n    const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 };\n    const bse = seq[i];\n    items.push({ k: 0, z: (a.z + m.z) / 2, a: a, b: m, c: BC[bse], i: i });\n    items.push({ k: 0, z: (b.z + m.z) / 2, a: b, b: m, c: BC[COMP[bse]], i: i });\n    for (let s = 0; s < 2; s++) {\n      for (let h = 0; h < 2; h++) {\n        if (i === N - 1 && h === 1) continue;\n        const q0 = at(i + h * 0.5, s), q1 = at(i + h * 0.5 + 0.5, s);\n        items.push({ k: 1, z: (q0.z + q1.z) / 2, a: q0, b: q1, c: s ? C.B : C.A });\n      }\n      const q = s ? b : a;\n      items.push({ k: 2, z: q.z + 0.01, a: q, c: s ? C.B : C.A, i: i });\n    }\n  }\n  items.sort(function (u, w) { return u.z - w.z; });\n  ctx.lineCap = 'round';\n  for (let j = 0; j < items.length; j++) {\n    const it = items[j];\n    const d = (it.z / R + 1) / 2;\n    const col = mix(C.bg, it.c, 0.3 + 0.7 * d);\n    if (it.k === 0) {\n      ctx.strokeStyle = hov === it.i ? mix(it.c, '#ffffff', 0.35) : col;\n      ctx.lineWidth = Math.max(1.5, R * 0.13 * (0.75 + 0.4 * d));\n      ctx.beginPath(); ctx.moveTo(it.a.x, it.a.y); ctx.lineTo(it.b.x, it.b.y); ctx.stroke();\n    } else if (it.k === 1) {\n      ctx.strokeStyle = col;\n      ctx.lineWidth = Math.max(2, R * 0.2 * (0.7 + 0.45 * d));\n      ctx.beginPath(); ctx.moveTo(it.a.x, it.a.y); ctx.lineTo(it.b.x, it.b.y); ctx.stroke();\n    } else ball(ctx, col, it.a.x, it.a.y, Math.max(2, R * 0.13 * (0.75 + 0.4 * d)));\n  }\n  ctx.lineCap = 'butt';\n  if (v.labels && G.rise * sc > 9) {\n    for (let i = 0; i < N; i++) { const a = at(i, 0); if (a.z > 0) letter(ctx, seq[i], a.x, a.y - R * 0.3, Math.min(13, G.rise * sc * 0.7), rgba(C.ink, 0.85)); }\n  }\n  const fs = clamp(L.s * 0.014, 8, 11);\n  if (hov >= 0) {\n    const b = seq[hov], nb = b === 'G' || b === 'C' ? 3 : 2;\n    const a = at(hov, 0);\n    glow(ctx, BC[b], a.x, cy, R * 0.8, 0.25);\n    sciTag(ctx, 'bp\\u0020' + (hov + 1) + '\\u0020·\\u0020' + b + (nb === 3 ? '≡' : '=') + COMP[b] + '\\u0020·\\u0020' + nb + '\\u0020H-bonds', a.x, cy - R * 1.45, C.ink, C.bg, fs, 'center');\n  }\n  /* Axis ruler with one helical turn marked. */\n  const turn = G.bpt * G.rise * sc;\n  ctx.strokeStyle = rgba(C.ink, 0.3);\n  ctx.lineWidth = 1;\n  const ry = cy + R * 1.5;\n  ctx.beginPath(); ctx.moveTo(x0, ry); ctx.lineTo(x0 + turn, ry); ctx.moveTo(x0, ry - 4); ctx.lineTo(x0, ry + 4); ctx.moveTo(x0 + turn, ry - 4); ctx.lineTo(x0 + turn, ry + 4); ctx.stroke();\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.fillStyle = rgba(C.ink, 0.5);\n  ctx.textAlign = 'left';\n  ctx.fillText('1 turn ·\\u0020' + sciFmt(G.bpt * G.rise, 2) + '\\u0020nm', x0 + turn + 8, ry + fs * 0.35);\n  const prof = [];\n  for (let i = 0; i < N; i++) prof.push(gcOf(seq.slice(Math.max(0, i - 3), i + 4)) * 100);\n  return {\n    R: [['Form', fm.n, '', C.A], ['Length', sciFmt(N * fm.rise, 1), 'nm'], ['Turns', sciFmt(N / fm.bpt, 1), ''],\n      ['Base pairs', N, ''], ['GC content', sciFmt(gcOf(seq) * 100, 0), '%'], ['Melting T', sciFmt(tmOf(seq, 0.05), 0), '°C', C.B]],\n    G: { title: 'GC content along the strand', series: [{ d: prof, c: C.A, name: 'GC\\u0020%' }], y0: 0, y1: 100, xl: '5′', yl: '3′' },\n  };\n}\n\n/* ---------- 2. Replication fork ---------- */\nfunction modeReplication(ctx, env, L, st, F, C, dt) {\n  const v = F.v;\n  const sp = clamp(st.w / 84, 5, 15);\n  const N = Math.max(20, Math.floor(st.w / sp) - 3);\n  if (!S.R || S.R.N !== N || F.fired === 'reset') {\n    const r = rng(((S.seed = (S.seed || 7) + 1)) * 97);\n    S.R = { N: N, seq: randSeq(r, N), f: 2, pl: 0, lead: [], lag: [], frags: [], last: 0, added: 0, errs: 0, fixed: 0, wait: 0, fix: [] };\n    S.hist = {};\n  }\n  const R = S.R;\n  const add = function (arr, i, tmpl, primer) {\n    let b = COMP[tmpl];\n    let bad = false;\n    if (Math.random() < v.error / 100) { bad = true; const o = 'ACGT'.replace(b, ''); b = o[Math.floor(Math.random() * 3)]; R.errs++; if (v.proofread && Math.random() < 0.99) R.fix.push({ arr: arr, i: i, t: 0.35 }); }\n    arr[i] = { b: b, bad: bad, primer: !!primer, t: 0 };\n    R.added++;\n  };\n  const bot = function (i) { return COMP[R.seq[i]]; };\n  /* Fork, leading strand, Okazaki fragments, proofreading. */\n  R.f = Math.min(N + 6, R.f + v.fork * dt);\n  while (R.pl < Math.min(R.f - 2, N)) { add(R.lead, Math.floor(R.pl), R.seq[Math.floor(R.pl)]); R.pl += 1; }\n  if (R.f - R.last > v.okazaki && R.f < N) {\n    const s0 = Math.min(N - 1, Math.floor(R.f) - 2);\n    const prev = R.frags.length ? R.frags[R.frags.length - 1] : null;\n    const fr = { s: s0, cur: s0 - 3, end: prev ? prev.s + 1 : 0, sealT: 0, prev: prev };\n    for (let k = 0; k < 4; k++) if (s0 - k >= 0) add(R.lag, s0 - k, bot(s0 - k), true);\n    R.frags.push(fr);\n    R.last = R.f;\n  }\n  for (let k = 0; k < R.frags.length; k++) {\n    const fr = R.frags[k];\n    fr.acc = (fr.acc || 0) + v.fork * 1.3 * dt;\n    while (fr.acc >= 1 && fr.cur - 1 >= fr.end) { fr.cur -= 1; fr.acc -= 1; add(R.lag, fr.cur, bot(fr.cur)); }\n    if (fr.cur - 1 < fr.end) {\n      fr.sealT += dt;\n      /* Pol I swaps the neighbour's RNA primer for DNA, then ligase seals the nick. */\n      if (fr.prev && fr.sealT > 0.4) for (let q = 0; q < 4; q++) { const c = R.lag[fr.prev.s - q]; if (c && c.primer) c.primer = false; }\n    }\n  }\n  for (let k = R.fix.length - 1; k >= 0; k--) {\n    const fx = R.fix[k];\n    fx.t -= dt;\n    if (fx.t <= 0) {\n      const c = fx.arr[fx.i];\n      if (c && c.bad) { c.b = COMP[fx.arr === R.lead ? R.seq[fx.i] : bot(fx.i)]; c.bad = false; c.flash = 0.6; R.fixed++; }\n      R.fix.splice(k, 1);\n    }\n  }\n  const allDone = R.f >= N + 5 && R.frags.every(function (fr) { return fr.cur - 1 < fr.end && fr.sealT > 0.8; });\n  if (allDone) { R.wait += dt; if (R.wait > 2.2) S.R = null; }\n\n  /* Geometry. */\n  const cy = st.y + st.h / 2, g = sp * 2.2, D = Math.min(st.h * 0.27, sp * 9);\n  const X = function (i) { return st.x + (i + 1.5) * sp; };\n  const u = function (i) { const q = clamp((R.f - i) / 7, 0, 1); return q * q * (3 - 2 * q); };\n  const yTop = function (i) { return cy - g / 2 - D * u(i); };\n  const yBot = function (i) { return cy + g / 2 + D * u(i); };\n  const lead = R.lead, lag = R.lag;\n  const grey1 = mix(C.bg, C.ink, 0.55);\n  /* Parental strands: paired ahead of the fork, unzipped behind it. */\n  duplex(ctx, { n: N, sp: sp, x: X, yt: yTop, yb: yBot, top: function (i) { return R.seq[i]; }, bot: bot,\n    c1: grey1, c2: grey1, ink: C.ink, hb: true, letters: v.letters });\n  /* New leading strand under the top template, lagging fragments over the bottom one. */\n  duplex(ctx, { n: N, sp: sp, x: X, yt: yTop, yb: function (i) { return yTop(i) + g; },\n    top: function (i) { return R.seq[i]; }, bot: function (i) { return lead[i] ? lead[i].b : null; },\n    skip: function (s, i) { return s === 0 || !lead[i] || u(i) < 0.6; }, c1: grey1, c2: C.A, ink: C.ink, hb: true, letters: v.letters });\n  duplex(ctx, { n: N, sp: sp, x: X, yt: function (i) { return yBot(i) - g; }, yb: yBot,\n    top: function (i) { return lag[i] ? lag[i].b : null; }, bot: bot,\n    skip: function (s, i) { return s === 1 || !lag[i] || u(i) < 0.6; }, c1: C.B, c2: grey1, ink: C.ink, hb: true, letters: v.letters });\n  /* Primers, mismatches, fixes. */\n  for (let i = 0; i < N; i++) {\n    const c = lag[i];\n    if (c && c.primer) { ctx.fillStyle = rgba('#c08bff', 0.85); ctx.fillRect(X(i) - sp / 2, yBot(i) - g - sp * 0.3, sp, sp * 0.6); }\n    [[lead[i], yTop(i) + g / 2], [lag[i], yBot(i) - g / 2]].forEach(function (q) {\n      const cc = q[0];\n      if (!cc) return;\n      if (cc.bad) glow(ctx, '#ff3b3b', X(i), q[1], sp * 1.6, 0.8);\n      if (cc.flash > 0) { cc.flash -= dt; glow(ctx, '#7dffb0', X(i), q[1], sp * 1.8, cc.flash); }\n    });\n  }\n  /* Enzymes. */\n  const fs = clamp(L.s * 0.014, 8, 11);\n  const fx = X(Math.min(R.f, N + 1));\n  if (R.f < N + 1) {\n    ctx.save(); ctx.translate(fx, cy); ctx.rotate(S.age * 3);\n    for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; ball(ctx, '#ffb454', Math.cos(a) * sp * 1.1, Math.sin(a) * sp * 1.1, sp * 0.65); }\n    ctx.restore();\n    if (L.sim.w > 420) sciTag(ctx, 'Helicase', fx + sp * 2.5, cy, C.ink, C.bg, fs, 'left');\n  }\n  if (R.pl < N) {\n    const px = X(R.pl), py = yTop(R.pl) + g / 2;\n    blob(ctx, px, py, sp * 4.2, sp * 3, C.A, 0.5);\n    if (L.sim.w > 420) sciTag(ctx, 'DNA Pol III · leading', px, py - sp * 3, C.ink, C.bg, fs, 'center');\n  }\n  const act = R.frags.filter(function (fr) { return fr.cur - 1 >= fr.end; });\n  if (act.length) {\n    const fr = act[act.length - 1];\n    const px = X(fr.cur), py = yBot(fr.cur) - g / 2;\n    blob(ctx, px, py, sp * 4.2, sp * 3, C.B, 0.5);\n    if (L.sim.w > 420) sciTag(ctx, 'DNA Pol III · lagging', px, py + sp * 3, C.ink, C.bg, fs, 'center');\n  }\n  const open = R.fix.length + lead.concat(lag).filter(function (c) { return c && c.bad; }).length - R.fix.length;\n  const he = hist('e', R.errs - R.fixed), hf = hist('f', R.fixed);\n  return {\n    R: [['Replicated', Math.min(N, Math.floor(R.pl)), 'bp'], ['Okazaki frags', R.frags.length, '', C.B], ['Mismatches', open, '', open ? '#ff6f7d' : C.ink],\n      ['Proofread', R.fixed, 'fixed', '#46d39a'], ['Fidelity', R.errs - R.fixed > 0 ? '1/' + Math.round(R.added / (R.errs - R.fixed)) : 'perfect', ''], ['Fork', sciFmt(v.fork, 0), 'nt/s']],\n    G: { title: 'Errors kept vs fixed', series: [{ d: he, c: '#ff6f7d', name: 'kept', n: 160 }, { d: hf, c: '#46d39a', name: 'fixed', n: 160 }], y0: 0 },\n  };\n}\n\n/* ---------- 3. Transcription ---------- */\nfunction modeTranscription(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  const sp = clamp(st.w / 70, 5, 16);\n  const N = Math.max(30, Math.floor(st.w / sp) - 2);\n  const P0 = 12;\n  if (!S.T || S.T.N !== N || F.fired === 'reset') {\n    const r = rng(41);\n    let s = randSeq(r, N);\n    s = s.slice(0, 3) + 'TATAAT' + s.slice(9, P0) + 'ATG' + s.slice(P0 + 3);\n    S.T = { N: N, seq: s, pols: [], free: [], next: 0.3, done: 0, id: 0 };\n    S.hist = {};\n  }\n  const T = S.T;\n  const end = N - 5;\n  const X = function (i) { return st.x + (i + 1) * sp; };\n  const cy = st.y + st.h * 0.66, g = sp * 2.2;\n  T.next -= dt;\n  const firePt = p.click && Math.abs(p.x - X(6)) < sp * 6 && Math.abs(p.y - cy) < sp * 5;\n  if ((T.next <= 0 || firePt) && !T.pols.some(function (q) { return q.x < P0 + 16; })) {\n    T.pols.push({ x: P0, rna: '', id: T.id++ });\n    T.next = (60 / v.firing) * (0.6 + Math.random() * 0.8);\n  }\n  for (let k = T.pols.length - 1; k >= 0; k--) {\n    const q = T.pols[k];\n    q.x += v.elong * dt;\n    while (q.rna.length < Math.floor(q.x - P0) && P0 + q.rna.length < end) { const b = T.seq[P0 + q.rna.length]; q.rna += b === 'T' ? 'U' : b; }\n    if (q.x >= end) { T.free.push({ rna: q.rna, x: X(end), y: cy, age: 0, id: q.id }); T.pols.splice(k, 1); T.done++; }\n  }\n  const openAt = function (i) { let o = 0; for (let k = 0; k < T.pols.length; k++) { const d = Math.abs(i - T.pols[k].x); o = Math.max(o, clamp(1 - d / 8, 0, 1)); } return o * o * (3 - 2 * o); };\n  const amp = sp * 1.6;\n  /* Gene annotation. */\n  const fs = clamp(L.s * 0.014, 8, 11);\n  ctx.fillStyle = rgba(C.B, 0.12);\n  ctx.fillRect(X(3) - sp / 2, cy - g * 2.4, sp * 6, g * 4.8);\n  ctx.fillStyle = rgba(C.A, 0.06);\n  ctx.fillRect(X(P0) - sp / 2, cy - g * 2.4, (end - P0) * sp, g * 4.8);\n  ctx.fillStyle = rgba('#ff6f7d', 0.1);\n  ctx.fillRect(X(end) - sp / 2, cy - g * 2.4, sp * 4, g * 4.8);\n  ctx.font = '500\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textAlign = 'left';\n  ctx.fillStyle = rgba(C.ink, 0.5);\n  ctx.fillText('PROMOTER · TATA', X(3) - sp / 2, cy + g * 2.4 + fs * 1.6);\n  ctx.fillText('GENE\\u0020\\u00205′ → 3′', X(P0) + sp, cy + g * 2.4 + fs * 1.6);\n  ctx.textAlign = 'right';\n  ctx.fillText('TERMINATOR', X(end + 3) + sp / 2, cy + g * 2.4 + fs * 1.6);\n  duplex(ctx, { n: N, sp: sp, x: X, yt: function (i) { return cy - g / 2 - openAt(i) * amp; }, yb: function (i) { return cy + g / 2 + openAt(i) * amp; },\n    top: function (i) { return T.seq[i]; }, bot: function (i) { return COMP[T.seq[i]]; }, c1: mix(C.bg, C.ink, 0.6), c2: mix(C.bg, C.ink, 0.45), ink: C.ink, hb: true, letters: v.letters });\n  /* mRNA chains, then polymerases on top. */\n  const chain = function (rna, x0, y0, id, alpha) {\n    let x = x0, y = y0;\n    const st2 = sp * 0.62;\n    const pts = [[x, y]];\n    for (let j = 1; j < rna.length; j++) {\n      const a = 0 - Math.PI / 2 - 0.35 + (noise2(j * 0.09 + S.age * 0.15, id * 3.7) - 0.5) * 2.6;\n      x += Math.cos(a) * st2; y += Math.sin(a) * st2;\n      pts.push([x, y]);\n    }\n    ctx.globalAlpha = alpha;\n    ctx.strokeStyle = C.B;\n    ctx.lineWidth = Math.max(1.2, sp * 0.18);\n    ctx.beginPath();\n    for (let j = 0; j < pts.length; j++) if (j) ctx.lineTo(pts[j][0], pts[j][1]); else ctx.moveTo(pts[j][0], pts[j][1]);\n    ctx.stroke();\n    for (let j = 0; j < pts.length; j++) ball(ctx, BC[rna[rna.length - 1 - j]] || C.B, pts[j][0], pts[j][1], Math.max(1.2, sp * 0.26));\n    ctx.globalAlpha = 1;\n  };\n  for (let k = T.free.length - 1; k >= 0; k--) {\n    const m = T.free[k];\n    m.age += dt;\n    chain(m.rna, m.x + m.age * sp * 3, m.y - g - m.age * sp * 4, m.id, clamp(1 - m.age / 2.5, 0, 1));\n    if (m.age > 2.5) T.free.splice(k, 1);\n  }\n  for (let k = 0; k < T.pols.length; k++) {\n    const q = T.pols[k];\n    if (q.rna.length > 1) chain(q.rna, X(q.x) - sp, cy - g * 1.6, q.id, 1);\n    blob(ctx, X(q.x), cy, sp * 9, g * 3.4, C.A, 0.42);\n  }\n  const lead = T.pols.length ? T.pols[0] : null;\n  if (lead && lead.rna.length > 3 && L.sim.w > 420) sciTag(ctx, '5′-' + lead.rna.slice(Math.max(0, lead.rna.length - 12)) + '-3′', X(lead.x), cy + g * 2.2 + amp, C.ink, C.bg, fs, 'center');\n  const h = hist('d', T.done);\n  return {\n    R: [['RNA polymerases', T.pols.length, 'active', C.A], ['Transcripts', T.done, '', C.B], ['Leading mRNA', lead ? lead.rna.length : 0, 'nt'],\n      ['Elongation', sciFmt(v.elong, 0), 'nt/s'], ['Firing', sciFmt(v.firing, 0), '/min'], ['Gene', end - P0, 'nt']],\n    G: { title: 'Transcripts made', series: [{ d: h, c: C.B, name: 'mRNA', n: 160, dot: true }], y0: 0 },\n  };\n}\n\n/* ---------- 4. Translation ---------- */\nfunction modeTranslation(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  const src = cleanSeq(inp(env, 'sequence', PRESET.seq), PRESET.seq, true);\n  if (!S.Tl || S.Tl.src !== src || F.fired === 'reset') {\n    let a = src.indexOf('AUG');\n    if (a < 0) a = 0;\n    S.Tl = { src: src, m: src.split(''), start: a, pos: a, ph: 0, chain: [translate1(src.slice(a, a + 3))], made: 0, free: [], pause: 0 };\n    S.hist = {};\n  }\n  const T = S.Tl;\n  const m = T.m;\n  const cod = function (i) { return m.slice(i, i + 3).join(''); };\n  const aCod = cod(T.pos + 3);\n  const aAA = aCod.length === 3 ? translate1(aCod) : '*';\n  if (T.pause > 0) {\n    T.pause -= dt;\n    if (T.pause <= 0) { T.pos = T.start; T.ph = 0; T.chain = [translate1(cod(T.start))]; }\n  } else {\n    const before = T.ph;\n    T.ph += dt * v.cps;\n    if (before < 0.35 && T.ph >= 0.35 && aAA !== '*') T.chain.push(aAA);\n    if (aAA === '*' && T.ph >= 0.5) { T.free.push({ chain: T.chain.slice(), age: 0 }); T.made++; T.pause = 1.4; T.ph = 0; }\n    if (T.ph >= 1) { T.ph = 0; T.pos += 3; }\n  }\n  const ph = T.ph;\n  const shift = T.pause > 0 ? 0 : clamp((ph - 0.5) / 0.35, 0, 1);\n  const sh = ease(shift);\n  const sp = clamp(Math.min(st.w / 34, st.h / 15), 8, 26);\n  const rx = st.x + st.w * 0.46, my = st.y + st.h * 0.72;\n  const X = function (n) { return rx + (n - T.pos - 1) * sp - sh * 3 * sp; };\n  const fs = Math.min(sp * 0.62, 15);\n  /* mRNA. */\n  ctx.strokeStyle = rgba(C.B, 0.7);\n  ctx.lineWidth = Math.max(1.5, sp * 0.14);\n  ctx.beginPath(); ctx.moveTo(st.x, my); ctx.lineTo(st.x + st.w, my); ctx.stroke();\n  let hovN = -1;\n  for (let n = 0; n < m.length; n++) {\n    const x = X(n);\n    if (x < st.x - sp || x > st.x + st.w + sp) continue;\n    const ci = Math.floor((n - T.start) / 3);\n    if (n >= T.start && ci % 2 === 0) { ctx.fillStyle = rgba(C.ink, 0.04); ctx.fillRect(x - sp / 2, my - sp * 0.2, sp, sp * 1.9); }\n    ctx.strokeStyle = BC[m[n]];\n    ctx.lineWidth = Math.max(1.5, sp * 0.36);\n    ctx.beginPath(); ctx.moveTo(x, my); ctx.lineTo(x, my - sp * 0.9); ctx.stroke();\n    letter(ctx, m[n], x, my + sp * 0.75, fs, rgba(C.ink, 0.8));\n    if (p.over && Math.abs(p.x - x) < sp / 2 && Math.abs(p.y - my) < sp * 1.6) hovN = n;\n  }\n  if (hovN >= 0 && p.click && hovN >= T.pos + 3) {\n    const ord = 'ACGU';\n    m[hovN] = ord[(ord.indexOf(m[hovN]) + 1) % 4];\n  }\n  /* Ribosome. */\n  const lw = sp * 11, lh = sp * 6.5;\n  blob(ctx, rx + sp * 0.5, my - lh * 0.62, lw, lh, '#7a8cff', 0.28);\n  blob(ctx, rx + sp * 0.5, my + sp * 1.5, lw * 0.85, sp * 3, '#7a8cff', 0.22);\n  ctx.font = '600\\u0020' + fs * 0.8 + 'px\\u0020' + MONO;\n  ctx.fillStyle = rgba(C.ink, 0.45);\n  ctx.textAlign = 'center';\n  ['E', 'P', 'A'].forEach(function (s, k) { ctx.fillText(s, rx + (k - 1) * 3 * sp, my - lh * 1.05); });\n  /* tRNAs: P-site holds the chain; A-site fills; E-site leaves. */\n  const tRNA = function (x, cd, aa, a, holding) {\n    if (a <= 0) return;\n    ctx.globalAlpha = a;\n    const w = sp * 2.6, h = sp * 4.4, top = my - sp * 1.15 - h;\n    ctx.fillStyle = rgba(C.A, 0.22);\n    ctx.strokeStyle = rgba(C.A, 0.8);\n    ctx.lineWidth = 1.2;\n    roundRect(ctx, x - w / 2, top, w, h, sp * 0.8);\n    ctx.fill(); ctx.stroke();\n    ctx.beginPath(); ctx.arc(x - w / 2, top + h * 0.45, sp * 0.55, 0, Math.PI * 2); ctx.arc(x + w / 2, top + h * 0.45, sp * 0.55, 0, Math.PI * 2);\n    ctx.fillStyle = rgba(C.A, 0.3); ctx.fill();\n    const ac = cd.split('').map(function (c) { return COMP[c] === 'T' ? 'U' : COMP[c]; }).join('');\n    for (let k = 0; k < 3; k++) letter(ctx, ac[k] || '', x + (k - 1) * sp, my - sp * 1.55, fs * 0.85, BC[ac[k]] || C.ink);\n    if (aa && !holding) { ball(ctx, aaColor(aa), x, top - sp * 0.45, sp * 0.55); letter(ctx, aa, x, top - sp * 0.45, fs * 0.7, '#0b0d10'); }\n    ctx.globalAlpha = 1;\n    return top;\n  };\n  const pX = rx - sh * 3 * sp, aX = rx + 3 * sp - sh * 3 * sp;\n  const pCod = cod(T.pos), eCod = cod(T.pos - 3);\n  const xfer = clamp((ph - 0.35) / 0.15, 0, 1);\n  if (T.pos - 3 >= T.start) tRNA(rx - 3 * sp - sh * 3 * sp - (ph > 0.85 ? (ph - 0.85) * 20 * sp : 0), eCod, null, ph < 0.5 ? 1 - ph * 1.5 : clamp(1 - (ph - 0.5) * 3, 0, 1), true);\n  tRNA(pX, pCod, null, 1, true);\n  let anchorX = pX, anchorY = my - sp * 1.15 - sp * 4.4 - sp * 0.4;\n  if (aAA !== '*' && T.pause <= 0) {\n    const inK = ease(clamp(ph / 0.35, 0, 1));\n    const ax = aX + (1 - inK) * sp * 7, ay = (1 - inK) * (0 - sp * 8);\n    ctx.save(); ctx.translate(0, ay);\n    tRNA(ax, aCod, xfer < 1 ? aAA : null, inK, xfer >= 1);\n    ctx.restore();\n    if (ph >= 0.35) anchorX = pX + (aX - pX) * xfer;\n  } else if (T.pause <= 0) {\n    const inK = ease(clamp(ph / 0.35, 0, 1));\n    blob(ctx, aX + (1 - inK) * sp * 6, my - sp * 3, sp * 2.6, sp * 4.2, '#ff6f7d', 0.5 * inK);\n    if (L.sim.w > 420) sciTag(ctx, 'Release factor ·\\u0020' + aCod, aX, my - sp * 8.5, C.ink, C.bg, clamp(L.s * 0.014, 8, 11), 'center');\n  }\n  /* Nascent chain out of the exit tunnel. */\n  const drawChain = function (ch, x0, y0, a, drift) {\n    let x = x0, y = y0;\n    ctx.globalAlpha = a;\n    const r = sp * 0.5;\n    for (let k = ch.length - 1; k >= 0; k--) {\n      const j = ch.length - 1 - k;\n      const ang = j < 3 ? 0 - Math.PI / 2 : 0 - Math.PI / 2 - 0.6 + (noise2(j * 0.25 + S.age * 0.2 + drift, 4.2) - 0.5) * 2.4;\n      ball(ctx, aaColor(ch[k]), x, y, r);\n      if (r >= 6) letter(ctx, ch[k], x, y, r * 1.1, '#0b0d10');\n      x += Math.cos(ang) * r * 2.05; y += Math.sin(ang) * r * 2.05;\n    }\n    ctx.globalAlpha = 1;\n  };\n  if (T.pause <= 0) drawChain(T.chain, anchorX, anchorY, 1, 0);\n  for (let k = T.free.length - 1; k >= 0; k--) {\n    const f = T.free[k];\n    f.age += dt;\n    drawChain(f.chain, rx - f.age * sp * 2, anchorY - f.age * sp * 3, clamp(1 - f.age / 2.2, 0, 1), f.age);\n    if (f.age > 2.2) T.free.splice(k, 1);\n  }\n  if (hovN >= 0) {\n    const ci = hovN - ((((hovN - T.start) % 3) + 3) % 3);\n    const cc = cod(ci), a = translate1(cc);\n    sciTag(ctx, cc + '\\u0020→\\u0020' + AA3[a] + '\\u0020·\\u0020' + AAN[a] + (hovN >= T.pos + 3 ? '\\u0020· click to edit' : ''), X(ci + 1), my + sp * 2.4, C.ink, C.bg, clamp(L.s * 0.014, 8, 11), 'center');\n  }\n  const hyd = [];\n  for (let k = 0; k < T.chain.length; k++) { let s = 0, c = 0; for (let q = Math.max(0, k - 2); q <= Math.min(T.chain.length - 1, k + 2); q++) { s += KD[T.chain[q]] || 0; c++; } hyd.push(s / c); }\n  return {\n    R: [['A-site codon', aCod, '', C.B], ['Amino acid', AA3[aAA] || '—', '', aaColor(aAA)], ['Chain', T.chain.length, 'aa'],\n      ['Mass', sciFmt(T.chain.length * 0.11, 1), 'kDa'], ['Proteins', T.made, ''], ['Rate', sciFmt(v.cps, 1), 'codon/s']],\n    G: { title: 'Hydropathy\\u0020(Kyte–Doolittle)', series: [{ d: hyd, c: '#ffc35c', name: 'hydrophobic ↑' }], y0: -4.5, y1: 4.5 },\n  };\n}\n\n/* ---------- 5. CRISPR-Cas9 ---------- */\nfunction modeCrispr(ctx, env, L, st, F, C, dt) {\n  const v = F.v;\n  const sp = clamp(st.w / 66, 6, 17);\n  const N = Math.max(48, Math.min(72, Math.floor(st.w / sp) - 2));\n  const guide = cleanSeq(inp(env, 'guide', PRESET.guide), PRESET.guide).slice(0, 20);\n  if (!S.K || S.K.N !== N || S.K.guide !== guide || F.fired === 'reset') {\n    const r = rng(1234);\n    let s = randSeq(r, N).split('GG').join('GA');\n    const put = function (at, str) { s = s.slice(0, at) + str + s.slice(at + str.length); };\n    const off = guide.split('');\n    off[3] = off[3] === 'A' ? 'C' : 'A'; off[11] = off[11] === 'G' ? 'T' : 'G';\n    put(4, off.join('') + 'TGG');\n    put(Math.min(N - 24, 34), guide + 'AGG');\n    S.K = { N: N, guide: guide, seq: s, orig: s, at: 0, site: -1, state: 'scan', t: 0, cuts: 0, off: 0, edits: 0, checked: 0, edit: [] };\n    S.hist = {};\n  }\n  const K = S.K;\n  const sites = [];\n  for (let i = 21; i + 2 < K.seq.length; i++) if (K.seq[i + 1] === 'G' && K.seq[i + 2] === 'G') sites.push(i);\n  const mm = function (i) { let c = 0; for (let k = 0; k < 20; k++) if (K.seq[i - 20 + k] !== K.guide[k]) c++; return c; };\n  K.t += dt;\n  const target = sites.find(function (i) { return i > K.at + 0.5; });\n  if (K.state === 'scan') {\n    if (target === undefined) { K.at = 0; K.state = 'scan'; }\n    else { K.at = Math.min(target, K.at + dt * v.scan); if (K.at >= target) { K.site = target; K.state = 'check'; K.t = 0; K.checked++; } }\n  } else if (K.state === 'check' && K.t > 0.6) {\n    K.state = mm(K.site) <= v.tol ? 'unwind' : 'release'; K.t = 0;\n  } else if (K.state === 'unwind' && K.t > 1.2) { K.state = 'cut'; K.t = 0; K.cuts++; if (mm(K.site) > 0) K.off++; }\n  else if (K.state === 'cut' && K.t > 0.8) {\n    /* Repair at the cut, 3 bp upstream of the PAM. */\n    const c = K.site - 3;\n    let ins;\n    if (Math.round(v.repair) === 1) ins = 'GAATTC';\n    else { const r = Math.random(); ins = r < 0.5 ? '' : 'ACGT'[Math.floor(Math.random() * 4)]; }\n    const del = Math.round(v.repair) === 1 ? 0 : 1 + Math.floor(Math.random() * 2);\n    K.seq = (K.seq.slice(0, c) + ins + K.seq.slice(c + del) + randSeq(Math.random, 8)).slice(0, K.N);\n    K.edit = [c, c + Math.max(1, ins.length)];\n    K.edits++;\n    K.state = 'repair'; K.t = 0;\n  } else if ((K.state === 'repair' && K.t > 1.0) || (K.state === 'release' && K.t > 0.3)) { K.state = 'scan'; K.at = K.site + 1; }\n  const cy = st.y + st.h * 0.62, g = sp * 2.2;\n  const X = function (i) { return st.x + (i + 1) * sp; };\n  const pos = K.state === 'scan' ? K.at : K.site;\n  const openK = K.state === 'unwind' ? clamp(K.t / 0.8, 0, 1) : K.state === 'cut' ? 1 : K.state === 'repair' ? clamp(1 - K.t / 0.6, 0, 1) : 0;\n  const openAt = function (i) { return K.state !== 'scan' && i >= K.site - 20 && i < K.site ? openK * clamp((K.site - i) / 3, 0, 1) : 0; };\n  /* PAMs underline. */\n  for (let k = 0; k < sites.length; k++) {\n    const i = sites[k];\n    ctx.fillStyle = rgba(C.B, 0.85);\n    ctx.fillRect(X(i) - sp / 2, cy - g * 2.4, sp * 3, 2);\n    if (sp >= 9) { ctx.font = '600\\u0020' + Math.min(sp * 0.5, 10) + 'px\\u0020' + MONO; ctx.fillStyle = rgba(C.B, 0.8); ctx.textAlign = 'left'; ctx.fillText('PAM', X(i) - sp / 2, cy - g * 2.4 - 4); }\n  }\n  if (K.edit.length && K.state !== 'unwind') { ctx.fillStyle = rgba(Math.round(v.repair) === 1 ? '#46d39a' : '#ff6f7d', 0.18); ctx.fillRect(X(K.edit[0]) - sp / 2, cy - g * 1.8, (K.edit[1] - K.edit[0]) * sp, g * 3.6); }\n  duplex(ctx, { n: K.N, sp: sp, x: X, yt: function (i) { return cy - g / 2 - openAt(i) * sp * 2.6; }, yb: function (i) { return cy + g / 2 + openAt(i) * sp * 0.4; },\n    top: function (i) { return K.seq[i]; }, bot: function (i) { return COMP[K.seq[i]]; }, c1: mix(C.bg, C.ink, 0.6), c2: mix(C.bg, C.ink, 0.45), ink: C.ink, hb: true, letters: true });\n  /* Guide RNA pairing with the target strand inside the R-loop. */\n  const cx = X(pos - 9);\n  if (openK > 0) {\n    for (let k = 0; k < 20; k++) {\n      const i = K.site - 20 + k;\n      const ok = K.seq[i] === K.guide[k];\n      const gb = K.guide[k] === 'T' ? 'U' : K.guide[k];\n      ctx.globalAlpha = openK;\n      ctx.strokeStyle = ok ? BC[gb] : '#ff3b3b';\n      ctx.lineWidth = Math.max(1.5, sp * 0.4);\n      ctx.beginPath(); ctx.moveTo(X(i), cy + g * 0.05); ctx.lineTo(X(i), cy + g * 0.35); ctx.stroke();\n      if (!ok) glow(ctx, '#ff3b3b', X(i), cy + g * 0.2, sp, 0.6);\n      ctx.globalAlpha = 1;\n    }\n    ctx.strokeStyle = rgba('#c08bff', openK);\n    ctx.lineWidth = Math.max(1.2, sp * 0.16);\n    ctx.beginPath(); ctx.moveTo(X(K.site - 20), cy); ctx.lineTo(X(K.site - 1), cy); ctx.stroke();\n  }\n  blob(ctx, cx, cy - g * 0.2, sp * 26, g * 4.2, K.state === 'cut' ? '#ff6f7d' : C.A, 0.22);\n  if (K.state === 'cut') {\n    const x = X(K.site - 3) - sp / 2;\n    glow(ctx, '#ffffff', x, cy, sp * 3, 1 - K.t);\n    ctx.strokeStyle = '#ffffff';\n    ctx.lineWidth = 2;\n    ctx.setLineDash([3, 3]);\n    ctx.beginPath(); ctx.moveTo(x, cy - g * 2); ctx.lineTo(x, cy + g * 2); ctx.stroke();\n    ctx.setLineDash([]);\n  }\n  const fs = clamp(L.s * 0.014, 8, 11);\n  const lab = { scan: 'Cas9 · scanning for NGG', check: 'PAM found · checking\\u0020' + mm(K.site) + '\\u0020mismatches', unwind: 'R-loop · guide pairing', cut: 'Double-strand break', repair: Math.round(v.repair) === 1 ? 'HDR · donor inserted\\u0020(EcoRI site)' : 'NHEJ · indel', release: 'Too many mismatches · released' }[K.state];\n  sciTag(ctx, lab, cx, cy - g * 3.6, C.ink, C.bg, fs, 'center');\n  /* Guide sequence above. */\n  ctx.font = '600\\u0020' + fs + 'px\\u0020' + MONO;\n  ctx.textAlign = 'left';\n  ctx.fillStyle = rgba('#c08bff', 0.9);\n  ctx.fillText('sgRNA\\u0020\\u00205′-' + K.guide.split('T').join('U') + '-3′', st.x, st.y + fs);\n  const bars = sites.map(mm);\n  return {\n    R: [['PAM sites', sites.length, ''], ['Checked', K.checked, ''], ['On-target cuts', K.cuts - K.off, '', '#46d39a'],\n      ['Off-target cuts', K.off, '', K.off ? '#ff6f7d' : C.ink], ['Mismatches here', K.site >= 0 ? mm(K.site) : '—', ''], ['Edits', K.edits, '', C.A]],\n    G: { title: 'Mismatches at each PAM', series: [{ bars: bars, c: C.A, name: 'sites' }], y0: 0, y1: 20, xl: '5′', yl: 'tolerance\\u0020' + v.tol },\n  };\n}\n\n/* ---------- 6. Point mutations ---------- */\nfunction modeMutation(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  const ref = cleanSeq(inp(env, 'sequence', PRESET.seq), PRESET.seq);\n  if (!S.Mu || S.Mu.ref !== ref || F.fired === 'reset') { S.Mu = { ref: ref, seq: ref, n: 0, last: 'Original', flash: 0, at: -1, auto: 0 }; S.hist = {}; }\n  const M = S.Mu;\n  const refP = protein(ref, true);\n  const mutate = function (i, kind) {\n    const ord = 'ACGT';\n    if (kind === 0) M.seq = M.seq.slice(0, i) + ord[(ord.indexOf(M.seq[i]) + 1 + Math.floor(Math.random() * 3)) % 4] + M.seq.slice(i + 1);\n    else if (kind === 1) M.seq = M.seq.slice(0, i) + ord[Math.floor(Math.random() * 4)] + M.seq.slice(i);\n    else if (M.seq.length > 6) M.seq = M.seq.slice(0, i) + M.seq.slice(i + 1);\n    M.n++; M.at = i; M.flash = 1;\n    const np = protein(M.seq, true);\n    const shift = (M.seq.length - ref.length) % 3 !== 0;\n    M.last = np === refP ? 'Silent' : shift ? 'Frameshift' : np.indexOf('*') >= 0 && np.indexOf('*') < refP.length - 1 ? 'Nonsense' : 'Missense';\n  };\n  if (F.fired === 'random') mutate(Math.floor(Math.random() * M.seq.length), Math.round(v.kind));\n  if (F.fired === 'sickle') {\n    /* HBB c.20A>T: GAG (Glu) to GTG (Val) at codon 7 including Met, the sickle-cell allele. */\n    M.seq = ref.slice(0, 19) + 'T' + ref.slice(20); M.n++; M.at = 19; M.flash = 1;\n    M.last = 'Missense · Glu6Val';\n  }\n  if (v.rate > 0) { M.auto += dt * v.rate / 10; if (M.auto >= 1) { M.auto = 0; mutate(Math.floor(Math.random() * M.seq.length), Math.floor(Math.random() * 3)); } }\n  M.flash = Math.max(0, M.flash - dt);\n  const prot = protein(M.seq, false);\n  /* Layout: codon groups that wrap into rows, protein chips under each. */\n  const nc = Math.ceil(M.seq.length / 3);\n  const per = clamp(Math.floor(st.w / 110), 4, 10);\n  const rows = Math.ceil(nc / per);\n  const tw = Math.min((st.w - (per - 1) * 12) / (per * 3), (st.h / rows) * 0.28, 34);\n  const gw = tw * 3 + 12;\n  const rowH = Math.min(st.h / rows, tw * 3.8);\n  const x0 = st.x + (st.w - (per * gw - 12)) / 2, y0 = st.y + (st.h - rows * rowH) / 2 + tw * 0.4;\n  let hov = -1;\n  let stopSeen = false;\n  for (let c = 0; c < nc; c++) {\n    const gx = x0 + (c % per) * gw, gy = y0 + Math.floor(c / per) * rowH;\n    for (let k = 0; k < 3; k++) {\n      const i = c * 3 + k;\n      if (i >= M.seq.length) break;\n      const x = gx + k * tw, b = M.seq[i];\n      const changed = b !== ref[i];\n      const h = p.over && p.x >= x && p.x < x + tw && p.y >= gy && p.y < gy + tw;\n      if (h) hov = i;\n      ctx.fillStyle = rgba(BC[b], stopSeen ? 0.12 : h ? 0.5 : 0.28);\n      roundRect(ctx, x + 1, gy, tw - 2, tw, tw * 0.18);\n      ctx.fill();\n      if (changed) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); }\n      if (i === M.at && M.flash > 0) glow(ctx, '#ffffff', x + tw / 2, gy + tw / 2, tw * 1.2, M.flash * 0.6);\n      letter(ctx, b, x + tw / 2, gy + tw / 2, tw * 0.5, stopSeen ? rgba(C.ink, 0.3) : C.ink);\n    }\n    const a = prot[c] || '';\n    if (a && c * 3 + 2 < M.seq.length) {\n      const ra = refP[c];\n      const same = a === ra;\n      const cyc = gy + tw * 1.75;\n      ctx.fillStyle = stopSeen ? rgba(C.ink, 0.05) : rgba(aaColor(a), same ? 0.22 : 0.6);\n      roundRect(ctx, gx + tw * 0.2, cyc - tw * 0.45, tw * 2.6, tw * 0.9, tw * 0.45);\n      ctx.fill();\n      ctx.font = '600\\u0020' + tw * 0.42 + 'px\\u0020' + SANS;\n      ctx.fillStyle = stopSeen ? rgba(C.ink, 0.25) : C.ink;\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText(AA3[a] || '?', gx + tw * 1.5, cyc + 0.5);\n      ctx.textBaseline = 'alphabetic';\n      if (!same && ra && !stopSeen) { ctx.font = '500\\u0020' + tw * 0.3 + 'px\\u0020' + MONO; ctx.fillStyle = rgba(C.ink, 0.45); ctx.fillText('was\\u0020' + AA3[ra], gx + tw * 1.5, cyc + tw * 0.85); }\n      if (a === '*') stopSeen = true;\n    }\n  }\n  if (hov >= 0 && p.click) mutate(hov, Math.round(v.kind));\n  const fs = clamp(L.s * 0.014, 8, 11);\n  const kindName = ['Substitute', 'Insert', 'Delete'][Math.round(v.kind)];\n  if (hov >= 0) sciTag(ctx, 'Click:\\u0020' + kindName.toLowerCase() + '\\u0020base\\u0020' + (hov + 1) + '\\u0020(' + M.seq[hov] + ')', p.x, p.y - fs * 2.4, C.ink, C.bg, fs, 'center');\n  const pp = protein(M.seq, true);\n  let same = 0;\n  for (let i = 0; i < refP.length; i++) if (pp[i] === refP[i]) same++;\n  const ident = (100 * same) / refP.length;\n  const lastCol = M.last.indexOf('Silent') === 0 ? '#46d39a' : M.last.indexOf('Missense') === 0 ? '#ffc35c' : M.last === 'Original' ? C.ink : '#ff6f7d';\n  const hi = hist('id', ident);\n  return {\n    R: [['Last mutation', M.last, '', lastCol], ['Identity', sciFmt(ident, 0), '%'], ['Protein', pp.split('*')[0].length, 'aa'],\n      ['Changed', refP.length - same, 'residues'], ['Reading frame', '+' + ((((M.seq.length - ref.length) % 3) + 3) % 3), ''], ['Mutations', M.n, '']],\n    G: { title: 'Protein identity', series: [{ d: hi, c: C.A, name: '%\\u0020identical', n: 160, dot: true }], y0: 0, y1: 105 },\n  };\n}\n\n/* ---------- 7. Protein folding (HP model, Langevin dynamics) ---------- */\nfunction modeFolding(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  let hp = String(inp(env, 'sequence', PRESET.seq)).toUpperCase().split('').filter(function (c) { return c === 'H' || c === 'P'; }).join('');\n  if (hp.length < 6) hp = PRESET.seq;\n  hp = hp.slice(0, 48);\n  const n = hp.length;\n  if (!S.Fo || S.Fo.hp !== hp || F.fired === 'reset' || F.fired === 'unfold') {\n    const x = [], y = [], vx = [], vy = [];\n    for (let i = 0; i < n; i++) { x.push((i - (n - 1) / 2) * 1.0); y.push(Math.sin(i * 0.7) * 0.15); vx.push(0); vy.push(0); }\n    S.Fo = { hp: hp, x: x, y: y, vx: vx, vy: vy, e: 0, cnt: 0, grab: -1, cx: 0, cy: 0, ann: -1 };\n    S.hist = {};\n  }\n  const P = S.Fo;\n  if (F.fired === 'anneal') P.ann = 0;\n  if (P.ann >= 0) { P.ann += dt; S.ui.v.temp = Number(Math.max(0.15, 1.6 - P.ann * 0.12).toFixed(2)); if (P.ann > 12) P.ann = -1; }\n  const T = S.ui.v.temp;\n  const sc = Math.min(st.w, st.h) / Math.max(9, Math.sqrt(n) * 2.6);\n  P.cx += ((P.x.reduce(function (a, b) { return a + b; }, 0) / n) - P.cx) * Math.min(1, dt * 2);\n  P.cy += ((P.y.reduce(function (a, b) { return a + b; }, 0) / n) - P.cy) * Math.min(1, dt * 2);\n  const ox = st.x + st.w / 2, oy = st.y + st.h / 2;\n  const toX = function (x) { return ox + (x - P.cx) * sc; }, toY = function (y) { return oy + (y - P.cy) * sc; };\n  const wx = P.cx + (p.x - ox) / sc, wy = P.cy + (p.y - oy) / sc;\n  if (p.click) { let b = -1, bd = 1; for (let i = 0; i < n; i++) { const d = Math.hypot(P.x[i] - wx, P.y[i] - wy); if (d < bd) { bd = d; b = i; } } P.grab = b; }\n  if (!p.down) P.grab = -1;\n  const h = 0.006, sub = Math.round(14 * clamp(dt * 60, 0.2, 3)), gam = 2;\n  const eHH = v.hstrength;\n  let E = 0, cnt = 0;\n  const fx = new Array(n), fy = new Array(n);\n  for (let s = 0; s < sub; s++) {\n    for (let i = 0; i < n; i++) { fx[i] = 0; fy[i] = 0; }\n    E = 0; cnt = 0;\n    for (let i = 0; i < n - 1; i++) {\n      const dx = P.x[i + 1] - P.x[i], dy = P.y[i + 1] - P.y[i], d = Math.sqrt(dx * dx + dy * dy) || 1e-6;\n      const f = 300 * (d - 1);\n      fx[i] += (f * dx) / d; fy[i] += (f * dy) / d; fx[i + 1] -= (f * dx) / d; fy[i + 1] -= (f * dy) / d;\n    }\n    for (let i = 0; i < n; i++) for (let j = i + 2; j < n; j++) {\n      const dx = P.x[j] - P.x[i], dy = P.y[j] - P.y[i], r2 = dx * dx + dy * dy;\n      const hh = hp[i] === 'H' && hp[j] === 'H';\n      const cut = hh ? 2.2 : 1.12;\n      if (r2 > cut * cut) continue;\n      const q = Math.max(r2, 0.5), s6 = 1 / (q * q * q);\n      const e = hh ? eHH : 1;\n      let f = (24 * s6 * (2 * s6 - (hh ? 1 : 1))) / q * e;\n      if (!hh && r2 > 1.2544) f = 0;\n      f = clamp(f, -200, 400);\n      if (hh) { E += 4 * e * s6 * (s6 - 1); if (r2 < 1.6 * 1.6) cnt++; }\n      fx[i] -= f * dx; fy[i] -= f * dy; fx[j] += f * dx; fy[j] += f * dy;\n    }\n    if (P.grab >= 0) { fx[P.grab] += (wx - P.x[P.grab]) * 80; fy[P.grab] += (wy - P.y[P.grab]) * 80; }\n    const noise = Math.sqrt((2 * gam * T) / h);\n    for (let i = 0; i < n; i++) {\n      P.vx[i] += h * (fx[i] - gam * P.vx[i] + noise * gauss2());\n      P.vy[i] += h * (fy[i] - gam * P.vy[i] + noise * gauss2());\n      P.x[i] += P.vx[i] * h; P.y[i] += P.vy[i] * h;\n    }\n  }\n  P.e += (E - P.e) * 0.1;\n  /* Draw: hydration shells, bonds, H–H contacts, beads. */\n  for (let i = 0; i < n; i++) if (hp[i] === 'P') { ctx.strokeStyle = rgba(C.A, 0.08); ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(toX(P.x[i]), toY(P.y[i]), sc * 0.95, 0, Math.PI * 2); ctx.stroke(); }\n  ctx.strokeStyle = rgba('#ffc35c', 0.5);\n  ctx.setLineDash([3, 3]);\n  ctx.lineWidth = 1;\n  for (let i = 0; i < n; i++) for (let j = i + 2; j < n; j++) if (hp[i] === 'H' && hp[j] === 'H' && Math.hypot(P.x[j] - P.x[i], P.y[j] - P.y[i]) < 1.6) { ctx.beginPath(); ctx.moveTo(toX(P.x[i]), toY(P.y[i])); ctx.lineTo(toX(P.x[j]), toY(P.y[j])); ctx.stroke(); }\n  ctx.setLineDash([]);\n  ctx.strokeStyle = rgba(C.ink, 0.55);\n  ctx.lineWidth = Math.max(2, sc * 0.2);\n  ctx.lineJoin = 'round';\n  ctx.beginPath();\n  for (let i = 0; i < n; i++) if (i) ctx.lineTo(toX(P.x[i]), toY(P.y[i])); else ctx.moveTo(toX(P.x[i]), toY(P.y[i]));\n  ctx.stroke();\n  for (let i = 0; i < n; i++) {\n    const col = hp[i] === 'H' ? '#ffc35c' : C.A;\n    if (i === P.grab) glow(ctx, col, toX(P.x[i]), toY(P.y[i]), sc, 0.7);\n    ball(ctx, col, toX(P.x[i]), toY(P.y[i]), sc * 0.42);\n    if (sc > 22) letter(ctx, hp[i], toX(P.x[i]), toY(P.y[i]), sc * 0.32, '#0b0d10');\n  }\n  ctx.font = '600\\u0020' + clamp(sc * 0.3, 8, 11) + 'px\\u0020' + MONO;\n  ctx.fillStyle = rgba(C.ink, 0.5);\n  ctx.textAlign = 'center';\n  ctx.fillText('N', toX(P.x[0]), toY(P.y[0]) - sc * 0.7);\n  ctx.fillText('C', toX(P.x[n - 1]), toY(P.y[n - 1]) - sc * 0.7);\n  let rg = 0;\n  for (let i = 0; i < n; i++) rg += (P.x[i] - P.cx) * (P.x[i] - P.cx) + (P.y[i] - P.cy) * (P.y[i] - P.cy);\n  rg = Math.sqrt(rg / n);\n  let hcount = 0;\n  for (let i = 0; i < n; i++) if (hp[i] === 'H') hcount++;\n  const he = hist('e', P.e, 200);\n  return {\n    R: [['Energy', sciFmt(P.e, 1), 'ε', C.A], ['H–H contacts', cnt, '', '#ffc35c'], ['Radius Rg', sciFmt(rg * 0.38, 2), 'nm'],\n      ['Temperature', sciFmt(T, 2), 'kT/ε'], ['Hydrophobic', hcount + '/' + n, ''], ['State', rg < Math.sqrt(n) * 0.45 ? 'Folded' : 'Unfolded', '']],\n    G: { title: 'Folding energy', series: [{ d: he, c: C.A, name: 'E\\u0020(ε)', n: 200, dot: true }] },\n  };\n}\nfunction gauss2() { let u = 0; while (u === 0) u = Math.random(); return Math.sqrt(0 - 2 * Math.log(u)) * Math.cos(6.283185 * Math.random()); }\n\n/* ---------- 8. DNA melting ---------- */\nfunction modeMelting(ctx, env, L, st, F, C, dt) {\n  const v = F.v, p = F.p;\n  const base = cleanSeq(inp(env, 'sequence', PRESET.seq), PRESET.seq).slice(0, 60);\n  if (!S.Me || S.Me.base !== base || F.fired === 'reset') { S.Me = { base: base, seq: base, o: base.split('').map(function () { return 0; }), sep: 0, samples: [] }; S.hist = {}; }\n  const M = S.Me;\n  const n = M.seq.length;\n  const na = v.salt / 1000;\n  const Tm = tmOf(M.seq, na);\n  const T = v.temp;\n  /* Zipper model: local stability from base pair, neighbours and fraying ends. */\n  const w = 2.2;\n  for (let i = 0; i < n; i++) {\n    const gc = M.seq[i] === 'G' || M.seq[i] === 'C';\n    const nbo = ((i > 0 ? M.o[i - 1] : 1) + (i < n - 1 ? M.o[i + 1] : 1)) / 2;\n    const end = Math.min(i, n - 1 - i);\n    const local = Tm + (gc ? 4 : -4) - (end < 3 ? (3 - end) * 3 : 0) - (nbo - 0.5) * 8 + (noise2(i * 0.7, S.age * 0.8) - 0.5) * 3;\n    const tgt = 1 / (1 + Math.exp((local - T) / w));\n    M.o[i] += (tgt - M.o[i]) * Math.min(1, dt * 3);\n  }\n  const frac = M.o.reduce(function (a, b) { return a + b; }, 0) / n;\n  M.sep += ((frac > 0.92 ? 1 : 0) - M.sep) * Math.min(1, dt * 1.2);\n  const sp = clamp(st.w / (n + 4), 6, 24);\n  const x0 = st.x + (st.w - (n - 1) * sp) / 2, cy = st.y + st.h * 0.52, g = sp * 2.3;\n  const X = function (i) { return x0 + i * sp; };\n  const wav = function (i) { return Math.sin(i * 0.6 + S.age * 1.2) * sp * 0.15; };\n  const yt = function (i) { return cy - g / 2 - M.o[i] * sp * 1.8 - M.sep * st.h * 0.18 + wav(i); };\n  const yb = function (i) { return cy + g / 2 + M.o[i] * sp * 1.8 + M.sep * st.h * 0.18 + wav(i); };\n  let hov = -1;\n  if (p.over) { const i = Math.round((p.x - x0) / sp); if (i >= 0 && i < n && Math.abs(p.y - cy) < g * 2.5 + M.sep * st.h * 0.2) hov = i; }\n  if (hov >= 0 && p.click) {\n    const flip = { A: 'G', T: 'C', G: 'A', C: 'T' };\n    M.seq = M.seq.slice(0, hov) + flip[M.seq[hov]] + M.seq.slice(hov + 1);\n  }\n  duplex(ctx, { n: n, sp: sp, x: X, yt: yt, yb: yb, top: function (i) { return M.seq[i]; }, bot: function (i) { return COMP[M.seq[i]]; },\n    c1: C.A, c2: C.B, ink: C.ink, hb: v.hbonds, letters: true });\n  if (hov >= 0) {\n    const b = M.seq[hov];\n    sciTag(ctx, b + (b === 'G' || b === 'C' ? '≡' : '=') + COMP[b] + '\\u0020· click to swap\\u0020' + (b === 'G' || b === 'C' ? 'GC → AT' : 'AT → GC'), X(hov), yt(hov) - sp * 2.4, C.ink, C.bg, clamp(L.s * 0.014, 8, 11), 'center');\n  }\n  let hb = 0;\n  for (let i = 0; i < n; i++) hb += (1 - M.o[i]) * (M.seq[i] === 'G' || M.seq[i] === 'C' ? 3 : 2);\n  if (S.frame % 4 === 0) { M.samples.push([T, frac * 100]); if (M.samples.length > 90) M.samples.shift(); }\n  const curve = [];\n  for (let t = 20; t <= 100; t += 1) curve.push([t, 100 / (1 + Math.exp((Tm - t) / 2.6))]);\n  return {\n    R: [['Tm', sciFmt(Tm, 1), '°C', C.B], ['Melted', sciFmt(frac * 100, 0), '%', C.A], ['GC content', sciFmt(gcOf(M.seq) * 100, 0), '%'],\n      ['H-bonds', Math.round(hb), 'intact'], ['Na⁺', v.salt, 'mM'], ['Length', n, 'bp']],\n    G: { title: 'Melting curve', series: [{ pts: curve, c: rgba(C.ink, 0.45), name: 'two-state', dash: true, w: 1.2 }, { pts: M.samples, c: C.A, name: 'measured' }], x0: 20, x1: 100, y0: 0, y1: 100, marker: T, xl: '20 °C', yl: '100 °C' },\n  };\n}\n\nconst MODES = { helix: modeHelix, replication: modeReplication, transcription: modeTranscription, translation: modeTranslation, crispr: modeCrispr, mutation: modeMutation, folding: modeFolding, melting: modeMelting };\n\nfunction render(ctx, env) {\n  const W = env.width, H = env.height;\n  if (!S) S = { frame: 0, hist: {} };\n  const dt = tick(S) * clamp(inp(env, 'speed', 1), 0.1, 3);\n  S.frame++;\n  const L = sciLayout(W, H);\n  const F = sciFrame(S, env, L);\n  const C = { A: inp(env, 'accent', '#5ee0ff'), B: inp(env, 'accent2', '#c08bff'), bg: inp(env, 'bg', '#06080b'), ink: inp(env, 'text', '#e6edf3') };\n  const st = sciStage(L);\n  ctx.save();\n  ctx.translate(0 - W / 2, 0 - H / 2);\n  sciBack(ctx, L, C.bg, C.ink);\n  ctx.save();\n  ctx.beginPath(); ctx.rect(L.sim.x, L.sim.y, L.sim.w, L.sim.h); ctx.clip();\n  const out = MODES[MODE](ctx, env, L, st, F, C, dt);\n  ctx.restore();\n  sciHeader(ctx, L, { eyebrow: PRESET.eyebrow, title: String(inp(env, 'title', PRESET.title)), sub: PRESET.sub, hint: PRESET.hint, ink: C.ink, accent: C.A });\n  sciPanel(ctx, S, L, { ink: C.ink, accent: C.A, bg: C.bg, readouts: out.R, graph: out.G });\n  ctx.restore();\n}\n"
          }
        ]
      },
      "layoutProps": {
        "layoutSizingHorizontal": "fill",
        "layoutSizingVertical": "fill"
      },
      "zIndex": 1,
      "interactions": []
    }
  ]
}
