Tengo Gzirishvili
Rename vague nav tabs to descriptive tool names
93b09d3
// Digital Evolution Engine β€” frontend controller.
// Single global state object; no framework. Talks to the Flask API at /api/*.
const state = {
sessionId: null,
preview: null,
jobId: null,
pollHandle: null,
chosenCds: null,
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ============================================================== DISCLAIMERS
// Two-layer "research use only" consent:
//
// 1. First-use modal (localStorage) β€” shown on first visit to a browser,
// blocks the app until the user accepts. Pairs with the
// research-strip under the topbar that's always visible. This is the
// durable "I understand this is a research tool" record.
//
// 2. Per-session synthesis confirmation (sessionStorage) β€” interposes
// on every Synthesize / Copy designed insert action UNTIL the user
// has ticked "I have visually verified this sequence" once in this
// tab. After that, synth actions skip the modal but the RE-warn
// confirms (and any future per-action gates) still fire when needed.
//
// Both are part of the legal narrative: a dispute over "your tool
// shipped wrong DNA" runs into a documented click-through where the
// user actively acknowledged responsibility for verifying the sequence.
const FIRST_USE_KEY = 'turingdna.firstuse.accepted.v1';
const SYNTH_VERIFIED_KEY = 'turingdna.synth.verified.v1';
function hasAcceptedFirstUse() {
try { return localStorage.getItem(FIRST_USE_KEY) === '1'; }
catch { return false; }
}
function recordFirstUseAcceptance() {
try {
localStorage.setItem(FIRST_USE_KEY, '1');
localStorage.setItem(FIRST_USE_KEY + '.at', new Date().toISOString());
} catch {}
}
function hasSynthVerifiedThisSession() {
try { return sessionStorage.getItem(SYNTH_VERIFIED_KEY) === '1'; }
catch { return false; }
}
function recordSynthVerified() {
try {
sessionStorage.setItem(SYNTH_VERIFIED_KEY, '1');
sessionStorage.setItem(SYNTH_VERIFIED_KEY + '.at', new Date().toISOString());
} catch {}
}
function openFirstUseModal() {
const modal = document.getElementById('firstUseModal');
if (!modal) return;
modal.hidden = false;
// ESC + backdrop deliberately don't dismiss this β€” must click Accept.
const accept = document.getElementById('firstUseAccept');
if (accept) {
accept.onclick = () => {
recordFirstUseAcceptance();
modal.hidden = true;
};
}
}
// Returns a promise that resolves true if the user confirmed, false if
// they cancelled. Each call shows the modal with the supplied details.
function openSynthConfirmModal(detailsHtml) {
return new Promise((resolve) => {
const modal = document.getElementById('synthConfirmModal');
const details = document.getElementById('synthConfirmDetails');
const check = document.getElementById('synthConfirmCheck');
const cancel = document.getElementById('synthConfirmCancel');
const proceed = document.getElementById('synthConfirmProceed');
if (!modal || !details || !check || !cancel || !proceed) {
// Modal markup missing β€” fail open with a native confirm so
// we don't silently allow the action without ANY gate.
resolve(window.confirm('Confirm this synthesis action.'));
return;
}
// Reset state every open so a previous tick / cancel doesn't carry over.
details.innerHTML = detailsHtml;
check.checked = false;
proceed.disabled = true;
modal.hidden = false;
const onCheckChange = () => { proceed.disabled = !check.checked; };
const cleanup = () => {
check.removeEventListener('change', onCheckChange);
cancel.onclick = null;
proceed.onclick = null;
modal.hidden = true;
};
check.addEventListener('change', onCheckChange);
cancel.onclick = () => { cleanup(); resolve(false); };
proceed.onclick = () => {
if (!check.checked) return;
recordSynthVerified();
cleanup();
resolve(true);
};
});
}
/**
* Gate any synthesize / copy-designed-insert action through the per-session
* verification modal. Returns true if the action should proceed, false if
* the user cancelled. Skips the modal (and returns true immediately) once
* the user has acknowledged at least once in this session.
*
* detailsHtml: a short inline summary the modal renders inside its info
* block β€” variant ID, vendor name, length, GC%, etc. Use this to make the
* confirm explicit about WHAT they're about to ship.
*/
async function ensureSynthVerified(detailsHtml) {
if (hasSynthVerifiedThisSession()) return true;
return openSynthConfirmModal(detailsHtml);
}
// Wire the first-use gate as early as possible β€” before any user action
// can fire. The modal blocks pointer events on the rest of the page via
// its fixed backdrop, but rendering the workspace underneath is fine.
if (!hasAcceptedFirstUse()) openFirstUseModal();
// "Why?" link in the always-visible research strip re-opens the modal
// for users who want to re-read the disclaimer later.
const _whyBtn = document.getElementById('researchStripWhy');
if (_whyBtn) _whyBtn.addEventListener('click', openFirstUseModal);
// ============================================================== TAB SWITCHING
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
$$('.tab').forEach((t) => t.classList.remove('active'));
$$('.tab-panel').forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
document.querySelector(`.tab-panel[data-panel="${tab.dataset.tab}"]`)
.classList.add('active');
});
});
// ============================================================== PASTE TEXTAREA
// Line-numbered paste area. Gutter renders one <span> per source line, scrolls
// in sync with the textarea, and can highlight a specific line when the
// server reports an error at a known nucleotide position.
const pasteArea = $('#pasteArea');
const pasteGutter = $('#pasteGutter');
const pasteStats = $('#pasteStats');
// ── Base-ruler layout shared by both sequence paste editors (directed-
// evolution #pasteArea and CRISPR #crisprInput). The sequence is wrapped to
// a whole number of 10-base blocks that fit the box (_fitBasesPerLine), and
// the gutter is numbered by base POSITION (1, 21, 41 … β€” step = line width)
// instead of line index, so it reads as a real nucleotide ruler that adapts
// to the screen. FASTA headers (>) are kept verbatim, take no base number,
// and reset the counter (per-record numbering). Above SEQ_REWRAP_CAP chars
// we force a 60-wide wrap so a giant paste can't spawn 100k gutter nodes.
const BASES_PER_LINE = 10; // fallback/min line width (used when the box isn't measurable)
const SEQ_REWRAP_CAP = 30000;
function _seqLayout(raw, width) {
const out = [], starts = [];
let basePos = 1, pending = '';
const flush = () => {
for (let i = 0; i < pending.length; i += width) {
out.push(pending.slice(i, i + width));
starts.push(basePos + i);
}
basePos += pending.length;
pending = '';
};
for (const line of raw.split('\n')) {
if (line[0] === '>') { flush(); out.push(line); starts.push(null); basePos = 1; }
else { pending += line.replace(/\s+/g, ''); }
}
flush();
if (out.length === 0) { out.push(''); starts.push(1); }
return { text: out.join('\n'), starts };
}
// Count chars that survive into the normalized text (header chars + non-
// whitespace sequence chars; newlines excluded) in raw[0, upTo) β€” lets us
// keep the caret on the same base across a reflow.
function _seqSig(raw, upTo) {
let n = 0, idx = 0;
const lines = raw.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li], header = line[0] === '>';
for (let ci = 0; ci < line.length; ci++) {
if (idx >= upTo) return n;
if (header || !/\s/.test(line[ci])) n++;
idx++;
}
if (li < lines.length - 1) { if (idx >= upTo) return n; idx++; }
}
return n;
}
function _seqCaret(text, target) {
if (target <= 0) return 0;
let n = 0, idx = 0;
const lines = text.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li], header = line[0] === '>';
for (let ci = 0; ci < line.length; ci++) {
if (header || !/\s/.test(line[ci])) { n++; if (n === target) return idx + 1; }
idx++;
}
if (li < lines.length - 1) idx++;
}
return text.length;
}
function _paintSeqGutter(gutterEl, starts) {
if (!gutterEl) return;
const frag = document.createDocumentFragment();
for (let i = 0; i < starts.length; i++) {
const s = document.createElement('span');
s.dataset.line = i + 1; // 1-based line β€” the error highlighter keys off this
s.textContent = starts[i] === null ? 'β€Ί' : starts[i]; // β€Ί marks a FASTA header
frag.appendChild(s);
}
gutterEl.innerHTML = '';
gutterEl.appendChild(frag);
}
// Measure the editor's monospace character width once (cached probe) so we can
// fit the line to the box.
let _charProbe = null;
function _monoCharWidth(font) {
if (!_charProbe) {
_charProbe = document.createElement('span');
_charProbe.setAttribute('aria-hidden', 'true');
_charProbe.style.cssText = 'position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;padding:0;border:0;margin:0';
document.body.appendChild(_charProbe);
}
_charProbe.style.font = font;
_charProbe.textContent = 'ACGT'.repeat(25); // 100 chars
const w = _charProbe.getBoundingClientRect().width / 100;
return w > 0 ? w : 0;
}
// How many bases fit on one line of THIS textarea, rounded down to a whole
// block of 10 and clamped to [10, 60]. Adapts to the device: a wide desktop
// box gets ~50–60/line, a phone ~20–30/line β€” so the sequence is never more
// than ~viewport-wide (no horizontal scroll) and a long gene isn't hundreds of
// 10-char lines. Falls back to BASES_PER_LINE when the box isn't laid out yet
// (hidden tab) β€” a later input/resize re-fits it.
function _fitBasesPerLine(ta) {
const cs = getComputedStyle(ta);
const avail = ta.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
if (!(avail > 0)) return BASES_PER_LINE;
const cw = _monoCharWidth(cs.font || `${cs.fontSize} ${cs.fontFamily}`);
if (!(cw > 0)) return BASES_PER_LINE;
const fit = Math.floor((avail - cw) / cw); // -1 char safety margin
return Math.max(10, Math.min(60, Math.floor(fit / 10) * 10));
}
// Reflow a sequence textarea in place + repaint its gutter as a base ruler.
// Line width adapts to the box (multiple of 10). Returns the base count.
function reflowSeqEditor(ta, gutterEl) {
const raw = ta.value;
const width = raw.length > SEQ_REWRAP_CAP ? 60 : _fitBasesPerLine(ta);
const { text, starts } = _seqLayout(raw, width);
if (text !== raw) {
const sig = _seqSig(raw, ta.selectionStart);
ta.value = text;
const pos = _seqCaret(text, sig);
try { ta.setSelectionRange(pos, pos); } catch (e) {}
}
_paintSeqGutter(gutterEl, starts);
let bases = 0;
for (const line of text.split('\n')) if (line[0] !== '>') bases += line.length;
return bases;
}
function renderGutter() {
const bases = reflowSeqEditor(pasteArea, pasteGutter);
pasteStats.textContent = bases === 0 ? '0 chars' : `${bases.toLocaleString()} chars`;
}
pasteArea.addEventListener('input', renderGutter);
pasteArea.addEventListener('scroll', () => {
pasteGutter.scrollTop = pasteArea.scrollTop;
});
let _deGutterResizeT;
window.addEventListener('resize', () => {
clearTimeout(_deGutterResizeT);
_deGutterResizeT = setTimeout(renderGutter, 150); // re-fit line width to the new box width
});
renderGutter();
// ====================================================== HASH-BASED ROUTING
// Sidebar nav links use #design / #library / #history / #docs. On hashchange
// (and on initial load), show the matching <section data-view="…"> and hide
// the others; update the sidebar active state to match. Library + History
// pages lazy-fetch their data the first time they're shown.
// Library + History routes removed 2026-05-27 β€” the local-filesystem
// endpoints they depended on (/api/library) were removed in audit fix C2.
// Per-user saved libraries now live on the landing site's /dashboard/
// page, backed by Supabase Storage with RLS-gated reads (task #98).
const ROUTES = ['design', 'crispr', 'primers', 'plasmid', 'docs'];
function currentRoute() {
const hash = (window.location.hash || '').replace('#', '').toLowerCase();
return ROUTES.includes(hash) ? hash : 'design';
}
function showRoute(name) {
document.querySelectorAll('[data-view]').forEach((el) => {
el.hidden = el.dataset.view !== name;
});
document.querySelectorAll('.nav-item').forEach((a) => {
const href = (a.getAttribute('href') || '').replace('#', '').toLowerCase();
a.classList.toggle('active', href === name);
});
// Breadcrumb was removed during the AI-bloat strip (nothing to
// navigate back to above "Design"). The lookup below is left as
// a defensive no-op so if the topbar gets re-extended later the
// hookup is one line away. .crumb-active no longer exists in the
// DOM, so the if-guard short-circuits cleanly.
const crumb = document.querySelector('.crumb-active');
if (crumb) {
crumb.textContent = {
design: 'New library',
docs: 'Documentation',
}[name] || name;
}
window.scrollTo({ top: 0, behavior: 'instant' });
}
window.addEventListener('hashchange', () => showRoute(currentRoute()));
showRoute(currentRoute());
// "My designs" sidebar nav item (2026-05-30). Opens the saved-designs
// modal from anywhere β€” no need to run a design first. Switches to the
// CRISPR route, then triggers the existing #crisprDesignsBtn handler
// (which fetches the list and routes anonymous users to the sign-in
// modal). The button lives in the hidden results card, but a programmatic
// .click() fires its listener regardless of visibility.
(function initMyDesignsNav() {
const navMyDesigns = document.getElementById('navMyDesigns');
if (!navMyDesigns) return;
function open() {
showRoute('crispr');
if (location.hash !== '#crispr') {
try { history.replaceState(null, '', '#crispr'); } catch (_) { location.hash = 'crispr'; }
}
const btn = document.getElementById('crisprDesignsBtn');
if (btn) btn.click();
}
navMyDesigns.addEventListener('click', open);
navMyDesigns.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); }
});
})();
// ─────────────────────────── THEME TOGGLE (light / dark) ──────────────────
// The initial theme is already applied by the anti-FOUC inline script in
// index.html <head> (dark by default). Here we wire the topbar button to
// flip + persist the choice.
(function initThemeToggle() {
const btn = document.getElementById('themeToggle');
const root = document.documentElement;
const current = () => (root.getAttribute('data-theme') === 'light' ? 'light' : 'dark');
function apply(theme) {
root.setAttribute('data-theme', theme);
try { localStorage.setItem('td-theme', theme); } catch (_) {}
if (btn) btn.setAttribute('aria-pressed', String(theme === 'dark'));
}
if (btn) {
btn.setAttribute('aria-pressed', String(current() === 'dark'));
btn.addEventListener('click', () => apply(current() === 'dark' ? 'light' : 'dark'));
}
})();
// ─────────────────────────── LIVING LOGO ──────────────────────────────────
// The sidebar brand mark is a closed circular plasmid that twists into a
// double helix and relaxes back β€” ONE continuous loop, never breaking into
// free ends (the two strands wind an integer number of turns so they always
// close). The centreline is a closed ellipse that morphs round (plasmid) ↔
// tall-and-narrow (helix). Theme-aware (re-reads --accent on theme change),
// pauses in background tabs (rAF), honours prefers-reduced-motion.
(function initPlasmidLogo() {
let reduce = false; try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
const N = 132, TURNS = 3;
const smooth = x => { x = x < 0 ? 0 : x > 1 ? 1 : x; return x * x * (3 - 2 * x); };
function rgba(c, a) {
c = ('' + c).trim();
if (c[0] === '#') { if (c.length === 4) c = '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3]; return 'rgba(' + parseInt(c.substr(1, 2), 16) + ',' + parseInt(c.substr(3, 2), 16) + ',' + parseInt(c.substr(5, 2), 16) + ',' + a + ')'; }
const mm = c.match(/^rgba?\(([^)]+)\)/); if (mm) return 'rgba(' + mm[1].split(',').slice(0, 3).join(',') + ',' + a + ')';
return c;
}
function mount(cv) {
const ctx = cv.getContext && cv.getContext('2d');
if (!ctx) return;
let W, H, DPR;
function size() {
const r = cv.getBoundingClientRect();
W = r.width || 32; H = r.height || 32;
DPR = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
}
size(); window.addEventListener('resize', size);
let color = '#1E3A8A';
const readColor = () => { try { color = (getComputedStyle(document.documentElement).getPropertyValue('--accent') || '#1E3A8A').trim(); } catch (e) {} };
readColor();
try { new MutationObserver(readColor).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } catch (e) {}
function frame(now) {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
// Choreographed morph β€” only ONE transformation at a time, so it
// never tangles: hold ring → STRETCH round→tall (no twist) → TWIST
// into helix β†’ hold helix β†’ untwist β†’ unstretch. stretch shapes the
// closed ellipse; twist scales the helical winding. period 11s.
const t = now / 1000, period = 11, ph = (t % period) / period, TW = 0.12;
let stretch, twist;
if (ph < 0.10) { stretch = 0; twist = TW; }
else if (ph < 0.32) { stretch = smooth((ph - 0.10) / 0.22); twist = TW; }
else if (ph < 0.44) { stretch = 1; twist = TW + (1 - TW) * smooth((ph - 0.32) / 0.12); }
else if (ph < 0.56) { stretch = 1; twist = 1; }
else if (ph < 0.68) { stretch = 1; twist = 1 - (1 - TW) * smooth((ph - 0.56) / 0.12); }
else if (ph < 0.90) { stretch = 1 - smooth((ph - 0.68) / 0.22); twist = TW; }
else { stretch = 0; twist = TW; }
const spin = t * 0.8, cx = W / 2, cy = H / 2, u = Math.min(W, H), R = u * 0.30, amp = u * 0.185;
const a = R * (1 - 0.80 * stretch), b = R * (1 + 0.52 * stretch);
ctx.clearRect(0, 0, W, H);
const bb = [];
for (let i = 0; i <= N; i++) { const s = i / N, th = 2 * Math.PI * s; bb.push([cx + a * Math.sin(th), cy - b * Math.cos(th)]); }
const A = [], B = [], dep = [];
for (let i = 0; i <= N; i++) {
const p0 = bb[(i - 1 + N) % N], p1 = bb[(i + 1) % N];
const tx = p1[0] - p0[0], ty = p1[1] - p0[1], L = Math.hypot(tx, ty) || 1, nx = -ty / L, ny = tx / L;
const s = i / N, w = s * TURNS * 2 * Math.PI + spin, off = Math.sin(w) * amp * twist;
A.push([bb[i][0] + nx * off, bb[i][1] + ny * off]); B.push([bb[i][0] - nx * off, bb[i][1] - ny * off]); dep.push(Math.cos(w));
}
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (let i = 4; i < N; i += 8) { ctx.strokeStyle = rgba(color, 0.22 * Math.min(1, twist * 1.6)); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(A[i][0], A[i][1]); ctx.lineTo(B[i][0], B[i][1]); ctx.stroke(); }
ctx.shadowColor = color; ctx.shadowBlur = 3;
const strand = (P, sign) => { for (let i = 0; i < N; i++) { const d = sign * dep[i], f = (d + 1) / 2; ctx.strokeStyle = rgba(color, 0.5 + 0.5 * f); ctx.lineWidth = 1.5 + 0.8 * f; ctx.beginPath(); ctx.moveTo(P[i][0], P[i][1]); ctx.lineTo(P[i + 1][0], P[i + 1][1]); ctx.stroke(); } };
strand(B, -1); strand(A, 1); ctx.shadowBlur = 0;
if (!reduce) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
document.querySelectorAll('.brand-mark-canvas').forEach(mount);
})();
// ====================================================== LIBRARY + HISTORY
// Removed 2026-05-27 along with the /api/library endpoints they called
// (audit C2). Per-user saved libraries now live on the landing site's
// /dashboard/ page (task #98), backed by Supabase Storage with RLS-gated
// reads. The dead-code below previously listed files from the
// Space-shared ~/.dee/output/ directory β€” fine for a single-user desktop
// app, broken for a multi-tenant deployment.
const EMPTY_ICON_SVG = '';
// ====================================================== MOBILE NAV DRAWER
// On phones the sidebar is hidden off-screen by default and slides in when
// the hamburger button in the topbar is tapped. A scrim overlay covers the
// workspace while the drawer is open; tapping the scrim (or any nav-item,
// or pressing Escape) closes the drawer.
(function initMobileNavDrawer() {
const toggle = document.getElementById('navToggle');
const scrim = document.getElementById('sidebarScrim');
const sidebar = document.getElementById('sidebar');
if (!toggle || !scrim || !sidebar) return;
function open() {
document.body.classList.add('sidebar-open');
toggle.setAttribute('aria-expanded', 'true');
// Lock body scroll while the drawer is open so the page underneath
// doesn't scroll along with momentum touches in the drawer.
document.body.style.overflow = 'hidden';
}
function close() {
document.body.classList.remove('sidebar-open');
toggle.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
function isOpen() { return document.body.classList.contains('sidebar-open'); }
toggle.addEventListener('click', () => (isOpen() ? close() : open()));
scrim.addEventListener('click', close);
// Tapping a nav item should navigate AND close the drawer in one motion.
sidebar.querySelectorAll('.nav-item').forEach((item) => {
item.addEventListener('click', () => {
if (isOpen()) close();
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) close();
});
// If the viewport grows past the breakpoint while the drawer is open
// (rotation, devtools toggle), reset state so desktop layout doesn't
// inherit a stuck `sidebar-open` class.
const mql = window.matchMedia('(min-width: 960px)');
const onMql = () => { if (mql.matches && isOpen()) close(); };
if (mql.addEventListener) mql.addEventListener('change', onMql);
else if (mql.addListener) mql.addListener(onMql);
})();
// Map a 0-indexed nucleotide position (in the cleaned/whitespace-stripped
// sequence) back to a {line, col} in the original pasted text. Mirrors what
// the engine's _clean() does: strips ANY whitespace, ignores FASTA header
// lines (those starting with ">").
function mapNtPositionToLineCol(text, ntPos) {
let count = 0;
let line = 1;
let col = 1;
let inHeader = false;
let atLineStart = true;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n') {
line++;
col = 1;
inHeader = false;
atLineStart = true;
continue;
}
if (atLineStart && ch === '>') inHeader = true;
atLineStart = false;
if (inHeader) {
col++;
continue;
}
if (/\s/.test(ch)) {
col++;
continue;
}
// ch counts toward the cleaned sequence; ntPos refers to its 0-indexed
// position there. If we're about to consume the (ntPos)-th nucleotide,
// that's our target.
if (count === ntPos) {
return { line, col, charIdx: i };
}
count++;
col++;
}
return null;
}
function highlightPasteLine(line) {
pasteGutter.querySelectorAll('span').forEach((s) => s.classList.remove('error-line'));
const target = pasteGutter.querySelector(`span[data-line="${line}"]`);
if (target) {
target.classList.add('error-line');
// Scroll the textarea to put the offending line in view.
const lineHeight = parseFloat(getComputedStyle(pasteArea).lineHeight) || 20;
const padTop = parseFloat(getComputedStyle(pasteArea).paddingTop) || 0;
pasteArea.scrollTop = Math.max(0, (line - 3) * lineHeight) + padTop;
pasteGutter.scrollTop = pasteArea.scrollTop;
}
}
function clearPasteHighlight() {
pasteGutter.querySelectorAll('span').forEach((s) => s.classList.remove('error-line'));
}
// ============================================================== INPUT
const dropzone = $('#dropzone');
const fileInput = $('#fileInput');
// NOTE: dropzone is a <label> wrapping fileInput, so clicking it natively
// triggers the file picker. Adding a manual fileInput.click() here causes the
// event to fire twice β€” first click opens the picker, second click closes
// it, requiring the user to click again. Trust the native label behavior.
['dragenter', 'dragover'].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
dropzone.classList.add('drag');
});
});
['dragleave', 'drop'].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
dropzone.classList.remove('drag');
});
});
dropzone.addEventListener('drop', (e) => {
const file = e.dataTransfer.files[0];
if (file) submitFile(file);
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) submitFile(file);
});
$('#pasteSubmit').addEventListener('click', () => {
const text = $('#pasteArea').value.trim();
if (!text) {
showError('Paste a sequence first.');
return;
}
submitText(text, 'pasted');
});
async function submitFile(file) {
clearError();
clearPasteHighlight();
const fd = new FormData();
fd.append('file', file);
setDropzoneBusy(`Reading ${file.name}…`);
try {
const res = await fetch('/api/preview', { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw makeValidationError(data);
handlePreview(data);
} catch (err) {
resetDropzone();
showError(err.message, err.detail);
}
}
async function submitText(text, name) {
clearError();
clearPasteHighlight();
try {
const res = await fetch('/api/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, name }),
});
const data = await res.json();
if (!res.ok) throw makeValidationError(data, text);
// Persist the input so it survives the engine reload that happens
// when an anonymous user gets bounced through /signin (trial timer
// expired) and lands back on /app. Without this they lose their
// sequence and have to re-paste. Cleared once they actually start
// a run that completes, or when they explicitly upload a new one.
_saveSessionResume({ pastedText: text, pastedName: name });
handlePreview(data);
} catch (err) {
showError(err.message, err.detail);
}
}
// ─── Session resume across reloads + signin bounce ────────────────────
// Why: when a trial expires mid-session, /api/run returns 402 and auth.js
// redirects the top window to /signin β†’ user authenticates β†’ back to /app
// β†’ iframe reloads from scratch. Without persistence, the user's pasted
// sequence is gone and they have to paste it again β€” annoying, easily
// avoided.
//
// What we persist: pasted text + name only. File uploads (drag-drop) are
// not preserved β€” bringing back a 5 MB FASTA from localStorage doesn't
// scale. The user will see a hint to re-upload in that case.
//
// Stored in sessionStorage (per-tab) so:
// - Resume works across iframe reloads + signin redirects
// - Doesn't leak between tabs / browser windows
// - Doesn't outlive the browser session
const RESUME_KEY = 'td_engine_resume';
function _saveSessionResume(payload) {
try {
sessionStorage.setItem(RESUME_KEY, JSON.stringify(payload));
} catch (_) { /* private mode / quota full β€” silently drop */ }
}
function _readSessionResume() {
try {
const raw = sessionStorage.getItem(RESUME_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function _clearSessionResume() {
try { sessionStorage.removeItem(RESUME_KEY); } catch (_) {}
}
// Restore on engine boot. Fires once the DOM is ready. If we find a
// pasted sequence from a prior visit (same browser tab), drop it into
// the paste area + switch to the Paste tab so the user sees it.
// Doesn't auto-submit /api/preview β€” the user may want to edit first
// or pick a different option. A "We saved your sequence β€” pick up
// where you left off?" hint would be nicer; defer until we ship the
// dashboard.
function _maybeRestoreSessionResume() {
const saved = _readSessionResume();
if (!saved || !saved.pastedText) return;
const ta = document.getElementById('pasteArea');
if (!ta) return;
ta.value = saved.pastedText;
// Switch to Paste tab if it isn't already. The tab logic uses
// .active classes on .tab and .tab-panel siblings.
document.querySelectorAll('.tab').forEach((t) => {
t.classList.toggle('active', t.dataset.tab === 'paste');
});
document.querySelectorAll('.tab-panel').forEach((p) => {
p.classList.toggle('active', p.dataset.panel === 'paste');
});
// Auto-submit so the preview re-renders without an extra click β€”
// matches the user expectation of "I'm back where I was".
submitText(saved.pastedText, saved.pastedName || 'restored');
}
// Run on DOMContentLoaded; if the DOM is already past that point
// (script loaded with `defer`), fire immediately.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _maybeRestoreSessionResume);
} else {
_maybeRestoreSessionResume();
}
// Build a nicer Error object that carries server-supplied details, mapping
// a nucleotide position to a {line, col} pair when the paste textarea content
// is available so the banner can say "line 5, column 24" instead of just "nt 294".
function makeValidationError(data, pasteText) {
const e = new Error(data.error || 'Could not parse sequence.');
e.detail = { code: data.code, nt_position: data.nt_position };
if (typeof data.nt_position === 'number' && pasteText) {
const where = mapNtPositionToLineCol(pasteText, data.nt_position);
if (where) {
e.detail.line = where.line;
e.detail.col = where.col;
highlightPasteLine(where.line);
}
}
return e;
}
function setDropzoneBusy(msg) {
dropzone.querySelector('.dz-copy strong').textContent = msg;
}
function resetDropzone() {
dropzone.querySelector('.dz-copy strong').textContent = 'Drop a sequence file here';
}
// ============================================================== PREVIEW
function handlePreview(data) {
state.sessionId = data.session_id;
state.preview = data;
state.chosenCds = null;
$('#metaIdent').textContent = data.identifier;
$('#metaKind').textContent = data.detected_kind.replace('_', ' ');
if (data.detected_kind === 'multi_orf') {
const orfCount = (data.cds_options || []).length;
$('#metaLen').textContent = `${orfCount} ORFs found`;
$('#metaPreview').hidden = true;
} else {
$('#metaLen').textContent = data.protein_length
? `${data.protein_length} aa`
: 'β€”';
$('#metaPreview').textContent = data.protein_preview;
$('#metaPreview').hidden = false;
}
$('#preview').hidden = false;
const opts = data.cds_options || [];
if (opts.length > 1 || data.detected_kind === 'multi_orf' || data.detected_kind === 'plasmid') {
renderCdsPicker(opts, data.detected_kind);
} else {
$('#cdsPicker').hidden = true;
}
updateRunButton();
resetDropzone();
// Reset BLAST UI for the new sequence; user can re-trigger if they want.
const idPanel = document.getElementById('identifyPanel');
if (idPanel) { idPanel.hidden = true; idPanel.innerHTML = ''; }
bindIdentifyButton();
}
function renderCdsPicker(options, detectedKind) {
const list = $('#cdsList');
list.innerHTML = '';
const markerPattern = /\b(aph|neo|npt|kan|bla|amp|cat|cmr|chlor|tet|hph|hyg|ble|zeo|pac|puro|sm|sptr|aada)\b/i;
if (!options.length) {
$('#cdsPicker').hidden = true;
return;
}
const isOrf = detectedKind === 'multi_orf';
// Update the picker's heading + hint to match what we found.
const heading = $('#cdsPicker').querySelector('h3');
const hint = $('#cdsPicker').querySelector('.muted');
if (isOrf) {
heading.textContent = `${options.length} open reading frames detected β€” pick one to evolve`;
hint.textContent = "No annotated CDS features (you pasted raw DNA). I scanned all 6 reading frames for ATG…stop ORFs β‰₯ 50 aa. Picking the longest is usually right, but check the frame.";
} else {
heading.textContent = 'Multiple CDS features detected β€” pick the gene to evolve';
hint.textContent = 'The longest CDS in a plasmid is usually the antibiotic resistance gene. Pick your gene of interest.';
}
// Default selection: longest non-marker, falling back to longest overall.
const ranked = [...options].sort((a, b) => b.length_nt - a.length_nt);
const nonMarker = ranked.find((o) => !markerPattern.test(o.label));
const defaultPick = nonMarker || ranked[0];
state.chosenCds = defaultPick.label;
ranked.forEach((opt) => {
const aa = opt.protein_length != null
? opt.protein_length
: Math.max(0, Math.floor(opt.length_nt / 3) - 1);
const frameLabel = opt.frame
? `frame ${opt.frame > 0 ? '+' : ''}${opt.frame} Β· `
: '';
const item = document.createElement('div');
item.className = 'cds-item';
if (markerPattern.test(opt.label)) item.classList.add('marker');
if (opt.label === state.chosenCds) item.classList.add('selected');
item.innerHTML = `
<span class="cds-name">${escapeHtml(opt.label)}</span>
<span class="cds-length">${frameLabel}${opt.length_nt} nt Β· ${aa} aa</span>
`;
item.addEventListener('click', () => {
state.chosenCds = opt.label;
$$('.cds-item').forEach((el) => el.classList.remove('selected'));
item.classList.add('selected');
updateRunButton();
});
list.appendChild(item);
});
$('#cdsPicker').hidden = false;
}
// ============================================================== RUN
function updateRunButton() {
const btn = $('#runBtn');
if (state.sessionId) {
btn.disabled = false;
btn.querySelector('.primary-sub').textContent =
state.preview.detected_kind === 'plasmid'
? `Will evolve "${state.chosenCds || 'auto'}"`
: `${state.preview.protein_length} aa Β· ESM-2 β†’ SA β†’ reverse-translate`;
} else {
btn.disabled = true;
btn.querySelector('.primary-sub').textContent = 'Click after loading a sequence';
}
}
$('#runBtn').addEventListener('click', startRun);
/**
* Render shimmer placeholders inside the results card while the pipeline
* is running. The skeleton lives directly inside the existing
* .result-table-wrap so the layout doesn't jump when real data lands β€”
* we just replace the <table> with the actual sortable variant table.
*
* Eight skeleton rows is enough to fill the typical first-screen height
* without scrolling; if the user's k > 8, real rows will extend below.
* The empty stats strip + run-meta + table toolbar stay hidden during
* skeleton state so the user's focus is on the loading region itself.
*/
function renderSkeletonResultsTable() {
const card = $('#resultsCard');
if (!card) return;
card.hidden = false;
// Hide the toolbar, stats strip, and run meta β€” they'd render empty
// and look broken next to the live skeleton.
const toolbar = card.querySelector('.table-toolbar');
const strip = document.getElementById('statsStrip');
const meta = document.getElementById('runMeta');
if (toolbar) toolbar.style.visibility = 'hidden';
if (strip) strip.hidden = true;
if (meta) meta.hidden = true;
const wrap = card.querySelector('.result-table-wrap');
if (!wrap) return;
// Build 8 skeleton rows. The CSS .skeleton class supplies the shimmer
// animation; we just need a grid of placeholder bars in the right
// shape so the layout feels stable.
const rows = Array.from({ length: 8 }, () => `
<div class="skeleton-row">
<div class="skeleton" style="width: 32px"></div>
<div class="skeleton" style="width: 80%"></div>
<div class="skeleton" style="width: 40px"></div>
<div class="skeleton" style="width: 36px"></div>
<div class="skeleton" style="width: 40px"></div>
<div class="skeleton" style="width: 44px"></div>
<div class="skeleton" style="width: 60px"></div>
</div>
`).join('');
wrap.innerHTML = `<div class="skeleton-table" aria-label="Building variant library…">${rows}</div>`;
}
/**
* Reverse of the above β€” when real results are about to render, restore
* the real <table> structure and un-hide the toolbar / stats / meta.
* Called from renderResults before the first row is appended.
*/
function teardownSkeleton() {
const card = $('#resultsCard');
if (!card) return;
const wrap = card.querySelector('.result-table-wrap');
if (!wrap) return;
const isSkeleton = wrap.querySelector('.skeleton-table');
if (!isSkeleton) return;
// Rebuild the real <table> shell. renderVariantRows will populate <tbody>.
wrap.innerHTML = `
<table class="result-table" id="resultTable">
<thead>
<tr>
<th class="sortable" data-sort="rank" title="Rank by predicted fitness (1 = best).">Rank <span class="sort-ind">↕</span></th>
<th class="sortable" data-sort="mutations" title="Substitutions vs. the wild-type protein, in the format WT_aa-position-new_aa.">Mutations <span class="sort-ind">↕</span></th>
<th class="num sortable" data-sort="fitness" title="Cumulative ΣΔLL from ESM-2: sum of log-likelihood improvements over WT across all substitutions in this variant.">Fitness <span class="sort-ind">↕</span></th>
<th class="num sortable" data-sort="gc" title="GC content of the codon-optimized DNA, %.">GC% <span class="sort-ind">↕</span></th>
<th class="num sortable" data-sort="tm" title="Lower of the two primer Tm values (Β°C).">Tm (Β°C) <span class="sort-ind">↕</span></th>
<th class="num sortable" data-sort="length" title="Length of the codon-optimized DNA, bp.">bp <span class="sort-ind">↕</span></th>
<th class="row-actions-h"></th>
</tr>
</thead>
<tbody></tbody>
</table>
`;
const toolbar = card.querySelector('.table-toolbar');
const strip = document.getElementById('statsStrip');
if (toolbar) toolbar.style.visibility = '';
if (strip) strip.hidden = false;
}
async function startRun() {
if (!state.sessionId) return;
clearError();
// Show the results card immediately with a skeleton table so the user
// sees something taking shape during the 30-90s pipeline run, not just
// a progress bar and an empty void below it. Real rows replace the
// skeleton when /api/result returns.
renderSkeletonResultsTable();
$('#progressShell').hidden = false;
$('#runBtn').disabled = true;
setProgress(0.02, 'Starting…', 'Queued.');
const settings = {
k: parseInt($('#settingK').value, 10),
max_mutations: parseInt($('#settingMax').value, 10),
percentile: parseFloat($('#settingPct').value),
host: $('#settingHost').value,
model: $('#settingModel').value,
};
const body = {
session_id: state.sessionId,
cds_feature: state.chosenCds,
settings,
};
try {
const res = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
// Audit M7: special-case the "needs_cds_choice" response so
// the user sees the chooser they didn't pick from, not just
// a generic error toast. The server returned 400 + the
// existing cds_options list β€” we re-render the CDS chooser
// and scroll it into view so the user can pick one and retry.
if (data.kind === 'needs_cds_choice' && Array.isArray(data.cds_options)) {
if (typeof renderCdsChooser === 'function') {
renderCdsChooser({ cds_options: data.cds_options });
}
const chooser = document.getElementById('cdsChooser');
if (chooser) chooser.scrollIntoView({ behavior: 'smooth', block: 'center' });
$('#runBtn').disabled = false;
showError('Pick which CDS to design against β€” auto-selection is disabled to avoid silently designing the wrong gene.');
return;
}
throw new Error(data.error || 'Could not start run.');
}
state.jobId = data.job_id;
pollStatus();
} catch (err) {
$('#runBtn').disabled = false;
showError(err.message);
}
}
function pollStatus() {
if (state.pollHandle) clearTimeout(state.pollHandle);
state.pollHandle = setTimeout(async () => {
try {
const res = await fetch(`/api/status/${state.jobId}`);
// 404 means the backend doesn't know about this job β€” usually
// because the HF Space rebuilt and dropped its in-memory job
// dict. Stop polling and tell the user to start fresh rather
// than crashing on a malformed response.
if (res.status === 404) {
state.pollHandle = null;
$('#runBtn').disabled = false;
$('#progressShell').hidden = true;
showError(
'Connection to your job was lost (server restarted). ' +
'Click "Design library" to start a fresh run.'
);
return;
}
// Treat any other non-2xx as a transient blip β€” let the catch
// block below handle reconnection. parsing the response body
// as JSON for a 5xx would normally throw a SyntaxError; we
// skip that path entirely.
if (!res.ok) {
throw new Error(`status ${res.status}`);
}
const data = await res.json();
// Defensive guards: every field on the status payload can
// legally be missing during the brief window between when
// we enqueue a job and when the worker thread populates the
// JobState. Read with fallbacks so the UI never crashes.
const status = data.status || 'queued';
const progress = typeof data.progress === 'number' ? data.progress : 0;
const elapsed = (typeof data.elapsed_seconds === 'number')
? data.elapsed_seconds.toFixed(1)
: '0.0';
setProgress(
progress,
`${capitalize(status)} Β· ${elapsed}s`,
data.message || '',
);
if (status === 'done') {
await loadResults();
} else if (status === 'error') {
$('#runBtn').disabled = false;
showError(data.error || data.message || 'The pipeline failed.');
$('#progressShell').hidden = true;
// Pull the skeleton table down too β€” leaving shimmer running
// next to an error message reads as "still loading" and is
// confusing. Hide the whole results card; the user will
// re-run with adjusted settings.
const card = $('#resultsCard');
if (card) card.hidden = true;
} else {
pollStatus();
}
} catch (err) {
// Transient network blip or a momentary 5xx during HF Space
// GPU-warming. Don't show a panicky "Lost connection" banner
// for every dropped poll β€” just back off and retry. If the
// disconnection persists, the user will notice the progress
// bar isn't moving and can reload.
console.warn('[poll] transient error, retrying:', err?.message || err);
pollStatus();
}
}, 800);
}
function setProgress(frac, status, message) {
$('#progressFill').style.width = `${Math.max(2, Math.min(100, frac * 100))}%`;
$('#progressStatus').textContent = status;
$('#progressElapsed').textContent = '';
$('#progressMessage').textContent = message || '';
}
// ============================================================== RESULTS
async function loadResults() {
try {
const res = await fetch(`/api/result/${state.jobId}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not fetch results.');
renderResults(data);
$('#progressShell').hidden = true;
$('#runBtn').disabled = false;
} catch (err) {
showError(err.message);
}
}
// Sort + filter state for the variant table. Re-rendered any time the user
// clicks a header or types into the filter input.
const tableState = {
sortKey: 'rank',
sortDir: 'asc',
filter: '',
variants: [], // canonical (unsorted) copy
wtProtein: '', // needed for the diff view
};
function renderResults(data) {
const card = $('#resultsCard');
card.hidden = false;
// Swap the skeleton placeholder for the real table shell BEFORE we
// render rows; otherwise renderVariantRows would write tbody.innerHTML
// into the skeleton wrapper and the rows wouldn't appear in a real
// table layout.
if (typeof teardownSkeleton === 'function') teardownSkeleton();
const empty = document.getElementById('emptyState');
if (empty) empty.hidden = true;
// Audit H2: /api/result no longer returns server filesystem paths
// (csv_path / desktop_path). Library is downloaded via /api/download
// and, for signed-in users, also persisted to Supabase Storage.
const evolvedCount = (data.variants || []).filter(v => v.Variant_ID !== 'WT').length;
const hasWt = (data.variants || []).some(v => v.Variant_ID === 'WT');
$('#resultSummary').innerHTML =
`<strong>${evolvedCount}</strong> variants${hasWt ? ' + wild type' : ''} of <strong>${escapeHtml(data.wt_identifier)}</strong> Β· ${data.wt_protein.length} aa Β· ready to download`;
renderStatsStrip(data);
renderMutationMap(data);
renderRunMeta(data);
tableState.variants = data.variants || [];
tableState.wtProtein = data.wt_protein || '';
tableState.sortKey = 'rank';
tableState.sortDir = 'asc';
tableState.filter = '';
const filterEl = document.getElementById('variantFilter');
if (filterEl) filterEl.value = '';
wireTableControls();
renderVariantRows();
// navLibCount badge was removed from the sidebar 2026-05-27 along with
// the Library tab. Leaving the lookup as a defensive no-op so any
// future re-introduction of a counter is one selector change away.
wireDownloadMenu();
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ---- Sort / filter / render -------------------------------------------------
const COL_SORT = {
rank: v => Number(v.Variant_ID.replace(/[^0-9]/g, '')) || 0,
mutations: v => (v.Mutations_AA || '').split(',').filter(Boolean).length,
fitness: v => Number(v.Predicted_Fitness_Score) || 0,
gc: v => Number(v.GC_Percent) || 0,
tm: v => Math.min(Number(v.Primer_Fwd_Tm_C) || Infinity, Number(v.Primer_Rev_Tm_C) || Infinity),
bp: v => Number(v.Length_bp) || 0,
};
function wireTableControls() {
// Sort handlers (once).
document.querySelectorAll('.result-table th.sortable').forEach((th) => {
if (th.dataset.wired === '1') return;
th.dataset.wired = '1';
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (tableState.sortKey === key) {
tableState.sortDir = tableState.sortDir === 'asc' ? 'desc' : 'asc';
} else {
tableState.sortKey = key;
tableState.sortDir = key === 'fitness' ? 'desc' : 'asc';
}
renderVariantRows();
});
});
// Filter handler (once).
const filterEl = document.getElementById('variantFilter');
const clearEl = document.getElementById('filterClear');
if (filterEl && filterEl.dataset.wired !== '1') {
filterEl.dataset.wired = '1';
filterEl.addEventListener('input', (e) => {
tableState.filter = e.target.value.trim().toLowerCase();
if (clearEl) clearEl.hidden = tableState.filter.length === 0;
renderVariantRows();
});
}
if (clearEl && clearEl.dataset.wired !== '1') {
clearEl.dataset.wired = '1';
clearEl.addEventListener('click', () => {
tableState.filter = '';
if (filterEl) { filterEl.value = ''; filterEl.focus(); }
clearEl.hidden = true;
renderVariantRows();
});
}
}
function variantMatchesFilter(v, filter) {
if (!filter) return true;
const fields = [
v.Variant_ID || '',
v.Mutations_AA || '',
String(v.GC_Percent ?? ''),
String(v.Length_bp ?? ''),
].join(' ').toLowerCase();
// Numeric range filter: "gc>50" / "gc<60" / "tm>58"
const rangeMatch = filter.match(/^(gc|tm|fitness|bp)\s*(>|<|>=|<=|=)\s*(-?\d+(?:\.\d+)?)$/);
if (rangeMatch) {
const [, col, op, valStr] = rangeMatch;
const val = parseFloat(valStr);
const getter = { gc: 'GC_Percent', tm: 'Primer_Fwd_Tm_C', fitness: 'Predicted_Fitness_Score', bp: 'Length_bp' }[col];
const actual = Number(v[getter]);
if (!Number.isFinite(actual)) return false;
return { '>': actual > val, '<': actual < val, '>=': actual >= val, '<=': actual <= val, '=': actual === val }[op];
}
return fields.includes(filter);
}
function renderVariantRows() {
const tbody = document.querySelector('#resultTable tbody');
if (!tbody) return;
// Update sort indicators in column headers.
document.querySelectorAll('.result-table th.sortable').forEach((th) => {
const ind = th.querySelector('.sort-ind');
if (!ind) return;
if (th.dataset.sort === tableState.sortKey) {
th.classList.add('sort-active');
ind.textContent = tableState.sortDir === 'asc' ? '↑' : '↓';
} else {
th.classList.remove('sort-active');
ind.textContent = '↕';
}
});
const isWt = (v) => v.Variant_ID === 'WT';
const filtered = tableState.variants.filter(v => variantMatchesFilter(v, tableState.filter));
const sorter = COL_SORT[tableState.sortKey] || COL_SORT.rank;
const dir = tableState.sortDir === 'asc' ? 1 : -1;
// WT is always pinned to row 0, regardless of the chosen sort column. The
// user-driven sort applies only to the evolved variants below it.
const wtRow = filtered.find(isWt);
const evolved = filtered.filter(v => !isWt(v));
evolved.sort((a, b) => {
const av = sorter(a), bv = sorter(b);
if (typeof av === 'string') return av.localeCompare(bv) * dir;
return (av - bv) * dir;
});
const sorted = wtRow ? [wtRow, ...evolved] : evolved;
// Counter readout. WT is shown separately so the user sees how many
// evolved variants the library contains.
const counter = document.getElementById('tableCount');
if (counter) {
const evolvedTotal = tableState.variants.filter(v => !isWt(v)).length;
const evolvedShown = sorted.filter(v => !isWt(v)).length;
const wtSuffix = wtRow ? ' + WT' : '';
counter.textContent = evolvedShown === evolvedTotal
? `${evolvedTotal} variants${wtSuffix}`
: `${evolvedShown} of ${evolvedTotal} variants${wtSuffix}`;
}
tbody.innerHTML = '';
sorted.forEach((v, idx) => {
const tr = document.createElement('tr');
const wt = isWt(v);
// Row-level red border + chip when the variant still has
// unresolvable forbidden restriction sites β€” gives the user an
// at-a-glance signal in the table view before they ever expand
// the detail panel. Detail panel has the full explanation.
const reUnresolvedRow = Number(v.Restriction_Sites_Unresolved) || 0;
const baseClass = wt ? 'expandable variant-wt-row' : 'expandable';
tr.className = reUnresolvedRow > 0
? `${baseClass} variant-row-re-warn`
: baseClass;
const gc = Number(v.GC_Percent);
const gcOutOfRange = !Number.isNaN(gc) && (gc < 40 || gc > 60);
const minTm = Math.min(
Number(v.Primer_Fwd_Tm_C) || Infinity,
Number(v.Primer_Rev_Tm_C) || Infinity,
);
const tmDisplay = Number.isFinite(minTm) ? minTm.toFixed(1) : 'β€”';
// Audit H5: a WT_Error sentinel means the server couldn't build the
// WT pseudo-row (codon optimization or RE-site scrub failed on the
// user's protein). Render an explicit error badge instead of the
// normal "wild type" badge so the user knows their reference sample
// is missing AND why, rather than silently noticing later that the
// WT row has empty DNA.
const mutHtml = wt
? (v.WT_Error
? `<span class="wt-badge wt-badge-error" title="${escapeHtml(v.WT_Error)}">WT BUILD FAILED</span>`
: '<span class="wt-badge" title="Unmutated wild-type sequence β€” order this to baseline alongside the evolved library.">wild type</span>')
: renderMutationTokens(v.Mutations_AA || '');
const fitnessCell = wt ? '<span class="muted">β€”</span>' : Number(v.Predicted_Fitness_Score).toFixed(3);
const idCell = wt
? `<span class="rank-wt">WT</span>`
: escapeHtml(v.Variant_ID);
tr.innerHTML = `
<td class="rank">${idCell}</td>
<td class="mutations">${mutHtml}</td>
<td class="num">${fitnessCell}</td>
<td class="num">${Number.isNaN(gc) ? 'β€”' : gc.toFixed(1)}${gcOutOfRange ? '<span class="gc-warn">!</span>' : ''}</td>
<td class="num">${tmDisplay}</td>
<td class="num">${escapeHtml(String(v.Length_bp ?? 'β€”'))}</td>
<td class="row-actions">
${(() => {
// Two structure engines, side by side:
// β€’ ESMFold folds THIS exact variant, but its free public
// endpoint caps at ~400 aa β€” so it's pre-gated (disabled
// with a tooltip) for longer proteins instead of dead-
// ending on click.
// β€’ AlphaFold has NO length cap, but can't fold a novel
// sequence: it shows the closest KNOWN structure found by
// BLAST β†’ AlphaFold-DB. That makes it the path for large
// proteins ESMFold can't handle, so we flag it as such.
const aaLen = (v.Mutant_AA_Seq || '').length;
const tooLong = aaLen > ESMFOLD_MAX_RES;
const esm = tooLong
? `<button class="fold-btn ghost-btn fold-btn-disabled" data-idx="${idx}" type="button"
disabled aria-label="Sequence too long for ESMFold"
title="ESMFold folds the exact variant but its free endpoint caps at ${ESMFOLD_MAX_RES} aa (this protein is ${aaLen} aa). Use AlphaFold β†’ for larger proteins.">ESMFold</button>`
: `<button class="fold-btn ghost-btn" data-idx="${idx}" type="button"
aria-label="Fold this exact variant with ESMFold (≀${ESMFOLD_MAX_RES} aa)"
title="ESMFold Β· folds this exact variant (≀${ESMFOLD_MAX_RES} aa)">ESMFold</button>`;
const af = `<button class="alphafold-btn ghost-btn${tooLong ? ' alphafold-btn-suggested' : ''}" data-idx="${idx}" type="button"
aria-label="Closest known structure via AlphaFold-DB β€” no length cap, best for large proteins"
title="AlphaFold Β· closest known structure (BLAST β†’ AlphaFold-DB). No length cap β€” the path for proteins too large for ESMFold.">AlphaFold</button>`;
return esm + af;
})()}
<button class="expand-toggle" data-idx="${idx}" aria-label="Show PCR details">β€Ί</button>
</td>
`;
// --row-i feeds the staggered fade-in animation in app.css. The CSS
// caps the multiplier at 20 so libraries with hundreds of rows
// don't take 5 seconds to finish appearing.
tr.style.setProperty('--row-i', String(idx));
tbody.appendChild(tr);
const detail = document.createElement('tr');
detail.className = 'detail-row';
detail.hidden = true;
detail.innerHTML = `<td colspan="7"></td>`;
detail.dataset.populated = '0';
tbody.appendChild(detail);
tr.addEventListener('click', (e) => {
// The :not(.expand-toggle) filter already skips the Fold button
// (it's not .expand-toggle), so the row's expand/collapse only
// fires for empty-cell clicks. Fold has its own handler below.
if (e.target.matches('a, button:not(.expand-toggle), input, select')) return;
toggleDetailRow(idx, tr, detail, v);
});
// Per-row Fold button β†’ ESMFold modal. stopPropagation keeps the
// row click from also expanding the detail panel.
const foldBtn = tr.querySelector('.fold-btn');
if (foldBtn) {
foldBtn.addEventListener('click', (e) => {
e.stopPropagation();
openFoldModal(v);
});
}
// Per-row AlphaFold button β†’ closest-known-structure modal (BLAST β†’
// AlphaFold-DB). The structure path for proteins too large for ESMFold.
const afBtn = tr.querySelector('.alphafold-btn');
if (afBtn) {
afBtn.addEventListener('click', (e) => {
e.stopPropagation();
openAlphaFoldModal(v);
});
}
});
}
// ---- BLOSUM62 mutation classification --------------------------------------
// Minimal BLOSUM62 substitution lookup. Used to color each X→Y substitution:
// β‰₯ 0 β†’ conservative (chemically similar AA)
// -1 to -2 β†’ moderate
// ≀ -3 β†’ drastic (e.g., Dβ†’W)
const BLOSUM62 = (() => {
const aas = 'ARNDCQEGHILKMFPSTWYV';
// Standard BLOSUM62 matrix, 20Γ—20, indexed by aas above.
const rows = [
" 4-1-2-2 0-1-1 0-2-1-1-1-1-2-1 1 0-3-2 0", // A
"-1 5 0-2-3 1 0-2 0-3-2 2-1-3-2-1-1-3-2-3", // R
"-2 0 6 1-3 0 0 0 1-3-3 0-2-3-2 1 0-4-2-3", // N
"-2-2 1 6-3 0 2-1-1-3-4-1-3-3-1 0-1-4-3-3", // D
" 0-3-3-3 9-3-4-3-3-1-1-3-1-2-3-1-1-2-2-1", // C
"-1 1 0 0-3 5 2-2 0-3-2 1 0-3-1 0-1-2-1-2", // Q
"-1 0 0 2-4 2 5-2 0-3-3 1-2-3-1 0-1-3-2-2", // E
" 0-2 0-1-3-2-2 6-2-4-4-2-3-3-2 0-2-2-3-3", // G
"-2 0 1-1-3 0 0-2 8-3-3-1-2-1-2-1-2-2 2-3", // H
"-1-3-3-3-1-3-3-4-3 4 2-3 1 0-3-2-1-3-1 3", // I
"-1-2-3-4-1-2-3-4-3 2 4-2 2 0-3-2-1-2-1 1", // L
"-1 2 0-1-3 1 1-2-1-3-2 5-1-3-1 0-1-3-2-2", // K
"-1-1-2-3-1 0-2-3-2 1 2-1 5 0-2-1-1-1-1 1", // M
"-2-3-3-3-2-3-3-3-1 0 0-3 0 6-4-2-2 1 3-1", // F
"-1-2-2-1-3-1-1-2-2-3-3-1-2-4 7-1-1-4-3-2", // P
" 1-1 1 0-1 0 0 0-1-2-2 0-1-2-1 4 1-3-2-2", // S
" 0-1 0-1-1-1-1-2-2-1-1-1-1-2-1 1 5-2-2 0", // T
"-3-3-4-4-2-2-3-2-2-3-2-3-1 1-4-3-2 11 2-3", // W
"-2-2-2-3-2-1-2-3 2-1-1-2-1 3-3-2-2 2 7-1", // Y
" 0-3-3-3-1-2-2-3-3 3 1-2 1-1-2-2 0-3-1 4", // V
];
const m = {};
for (let i = 0; i < aas.length; i++) {
// Parse each row of width-3 fixed fields including sign.
const raw = rows[i];
const cells = [];
for (let j = 0; j < 20; j++) {
cells.push(parseInt(raw.slice(j * 3, j * 3 + 3).trim(), 10));
}
for (let j = 0; j < aas.length; j++) {
m[aas[i] + aas[j]] = cells[j];
}
}
return m;
})();
function blosumScore(wt, mut) {
return BLOSUM62[wt + mut] ?? 0;
}
function classifyMutation(wt, mut) {
const s = blosumScore(wt, mut);
if (s >= 0) return 'mut-cons';
if (s >= -2) return 'mut-mod';
return 'mut-drastic';
}
function renderMutationTokens(mutString) {
if (!mutString) return '';
return mutString.split(',').map((raw) => {
const m = raw.trim().match(/^([A-Z])(\d+)([A-Z\*])$/);
if (!m) return `<span class="mut-token mut-cons">${escapeHtml(raw)}</span>`;
const [, wt, pos, mut] = m;
const klass = classifyMutation(wt, mut);
const s = blosumScore(wt, mut);
const tip = `BLOSUM62 ${wt}β†’${mut} = ${s} (${
s >= 0 ? 'conservative' : s >= -2 ? 'moderate' : 'drastic'
})`;
return `<span class="mut-token ${klass}" title="${escapeHtml(tip)}">${escapeHtml(raw)}</span>`;
}).join('');
}
function toggleDetailRow(idx, headRow, detailRow, v) {
const open = !detailRow.hidden;
const toggle = headRow.querySelector('.expand-toggle');
if (open) {
detailRow.hidden = true;
toggle.classList.remove('open');
return;
}
if (detailRow.dataset.populated !== '1') {
detailRow.querySelector('td').innerHTML = renderDetailPanel(v);
wireCopyButtons(detailRow);
wireCloningControls(detailRow);
wireDnaEdit(detailRow, v);
detailRow.dataset.populated = '1';
}
detailRow.hidden = false;
toggle.classList.add('open');
}
// Catalog of synthesis vendors the "Synthesize DNA" button can redirect to.
// User selects one in the Results-card header; choice persists in
// localStorage so it sticks across page loads and across runs.
// Vendor URLs pinned to each company's homepage rather than a deep
// gene-synthesis path. Vendor sites reorganize their product pages often
// enough that deep links break (e.g. genscript.com/gene-synthesis.html now
// 404s); homepages are durable, and the user already has the sequence on
// the clipboard so one extra in-site click is acceptable.
const VENDORS = {
genscript: {
name: 'GenScript',
url: 'https://www.genscript.com/',
hint: 'Click "Quick Order" or REAGENT SERVICES β†’ Gene Synthesis.',
},
twist: {
name: 'Twist',
url: 'https://www.twistbioscience.com/',
hint: 'Click Products β†’ Genes.',
},
idt: {
name: 'IDT',
url: 'https://www.idtdna.com/',
hint: 'Click Products β†’ Genes & Gene Fragments.',
},
};
// =============================================================== CLONING
// Designer β€” multi-step picker (method β†’ vector β†’ enzymes β†’ tags β†’ linker)
// driven by the curated reference data in cloning_db.js (window.ENZYMES,
// window.VECTORS, window.CLONING_METHODS, window.TAGS, window.LINKERS).
//
// Everything is client-side. The Designer state is per-variant so two
// expanded rows in the table can have different configurations at once.
//
// Section coloring in the live preview:
// - vector_5p / vector_3p β†’ amber (vector arm + RE site)
// - n_tag / c_tag β†’ magenta (fusion tag)
// - linker_n / linker_c β†’ blue (linker, often a cleavage site)
// - cds β†’ brand teal (codon-optimized ORF)
const STOP_CODONS = new Set(['TAA', 'TAG', 'TGA']);
const stripLeadingAtg = (cds) => cds.startsWith('ATG') ? cds.slice(3) : cds;
const stripTrailingStop = (cds) => STOP_CODONS.has(cds.slice(-3).toUpperCase()) ? cds.slice(0, -3) : cds;
// Per-variant Designer state: which method, vector, enzymes, tags, linkers.
const cloningState = new Map(); // variantId -> { method, vectorId, enzyme5p, enzyme3p, ggEnzyme, gg5pOverhang, gg3pOverhang, nTag, cTag, linker, bareCds }
function defaultDesignerState(bareCds) {
return {
method: 'type_ii_restriction',
vectorId: 'pet28a',
enzyme5p: 'NdeI',
enzyme3p: 'XhoI',
ggEnzyme: 'BsaI',
gg5pOverhang: 'AATG', // MoClo CDS module 5' overhang
gg3pOverhang: 'GCTT', // MoClo CDS module 3' overhang
nTag: 'none',
cTag: 'his6',
linker: 'none',
gibsonArm: 20,
bareCds: bareCds || '',
};
}
// --- Insert builder --------------------------------------------------------
//
// Given the Designer state for one variant, returns:
// { sections: [{kind, label, dna}, ...], full: string, warnings: [...] }
// where sections preserve the order on the final linear insert so the
// preview can color-code them.
// Full IUPAC complement table β€” used when reverse-complementing a Type IIS
// enzyme's recognition site. The original lookup ({A:'T', T:'A', G:'C', C:'G'})
// silently passed ambiguity codes through, producing an INCORRECT reverse
// complement for any enzyme entry using degenerate bases. Most Type IIS
// enzymes are unambiguous, but if anyone ever adds a Type IIB or a
// degenerate-site enzyme to the cloning database, the silent miscomputation
// would assemble Golden Gate constructs with the wrong 3' end.
const IUPAC_COMPLEMENT = {
A: 'T', T: 'A', G: 'C', C: 'G', U: 'A',
R: 'Y', Y: 'R', S: 'S', W: 'W', K: 'M', M: 'K',
B: 'V', V: 'B', D: 'H', H: 'D', N: 'N',
};
function reverseComplementIupac(seq) {
return seq.toUpperCase().split('').reverse()
.map((b) => IUPAC_COMPLEMENT[b] || b)
.join('');
}
function buildInsert(state) {
const cds = state.bareCds || '';
const method = (window.CLONING_METHODS || {})[state.method] || {};
const vector = (window.VECTORS || {})[state.vectorId];
const nTag = (window.TAGS || {})[state.nTag];
const cTag = (window.TAGS || {})[state.cTag];
const linker = (window.LINKERS || {})[state.linker];
// Always start by trimming the start codon and stop codon β€” the chosen
// vector / tags / overhangs will provide them. (For "Bare CDS" with the
// dummy method we leave them; that case is handled in 'none' method below.)
let cdsInner = cds;
let willProvideStart = false;
let willProvideStop = false;
// Build 5'/3' wrappers depending on method.
const sections = [];
// Frame-correctness ledger. Sections marked translated:true are part of
// the in-frame ORF and their lengths must each be multiples of 3 (and
// their cumulative sum must be a multiple of 3 too). Vector arms and
// restriction-site flanks aren't translated and don't carry a frame
// requirement at this layer. We collect violations into a list and
// surface them as HIGH warnings further down.
const frameViolations = [];
function pushSec(kind, label, dna, opts = {}) {
if (!dna) return;
const translated = !!opts.translated;
if (translated && dna.length % 3 !== 0) {
frameViolations.push({
kind, label,
length: dna.length,
detail: `length ${dna.length} bp is not a multiple of 3`,
});
}
sections.push({ kind, label, dna, translated });
}
// Helper: encode a tag's DNA (for now we use the canonical DNA in the TAGS
// table; full MBP/GST/SUMO are left as placeholders since their sequences
// are too large to embed and depend on a backbone β€” flag the user).
function tagDna(tag) {
if (!tag) return '';
return tag.dna || '';
}
// Track tags the user selected but the layout silently suppressed (e.g.
// a vector arm already supplies the ATG, so we drop the N-tag). The
// user has no visual signal for this, so we surface every suppression
// as a HIGH warning further down.
const suppressedTags = [];
if (state.method === 'type_ii_restriction' || state.method === 'blunt') {
const e5 = (window.ENZYMES || {})[state.enzyme5p];
const e3 = (window.ENZYMES || {})[state.enzyme3p];
// 5' end: 4 nt buffer + RE site. Not translated.
if (e5) pushSec('vector_5p', `5' ${e5.name} site`, 'AAAA' + e5.recognition);
// N-term tag (start codon comes from tag if present, else we keep CDS's ATG)
if (nTag && nTag.dna) {
pushSec('n_tag', `N-term ${nTag.name}`, 'ATG' + nTag.dna, { translated: true });
willProvideStart = true;
if (linker && linker.dna) pushSec('linker_n', `Linker (${linker.name})`, linker.dna, { translated: true });
cdsInner = stripLeadingAtg(cdsInner);
}
// CDS body β€” strip start if the RE site provides ATG (NcoI / NdeI),
// and strip stop if a C-term tag is going to add one.
if (e5 && (e5.recognition === 'CCATGG' || e5.recognition === 'CATATG') && !willProvideStart) {
cdsInner = stripLeadingAtg(cdsInner);
willProvideStart = true;
}
if (cTag && cTag.dna) {
cdsInner = stripTrailingStop(cdsInner);
}
pushSec('cds', 'CDS', cdsInner, { translated: true });
// C-term tag
if (cTag && cTag.dna) {
if (linker && linker.dna) pushSec('linker_c', `Linker (${linker.name})`, linker.dna, { translated: true });
// tag DNA + TAA stop; the stop codon makes the whole c_tag a
// multiple of 3 iff the tag's own DNA was.
pushSec('c_tag', `C-term ${cTag.name}`, cTag.dna + 'TAA', { translated: true });
willProvideStop = true;
}
// 3' end: RE site + 4 nt buffer. Not translated.
if (e3) pushSec('vector_3p', `3' ${e3.name} site`, e3.recognition + 'AAAA');
} else if (state.method === 'golden_gate') {
const e = (window.ENZYMES || {})[state.ggEnzyme];
if (e) {
pushSec('vector_5p', `${e.name} (5')`, 'AAA' + e.recognition + 'A' + (state.gg5pOverhang || 'AATG'));
}
if (nTag && nTag.dna) {
pushSec('n_tag', `N-term ${nTag.name}`, nTag.dna, { translated: true });
if (linker && linker.dna) pushSec('linker_n', `Linker (${linker.name})`, linker.dna, { translated: true });
}
cdsInner = stripLeadingAtg(stripTrailingStop(cdsInner));
pushSec('cds', 'CDS', cdsInner, { translated: true });
if (cTag && cTag.dna) {
if (linker && linker.dna) pushSec('linker_c', `Linker (${linker.name})`, linker.dna, { translated: true });
pushSec('c_tag', `C-term ${cTag.name}`, cTag.dna, { translated: true });
}
if (e) {
// 3' BsaI cassette on the reverse strand. Uses the full IUPAC
// complement table so adding a degenerate-site enzyme to the DB
// won't silently produce a wrong RC.
const rc = reverseComplementIupac(e.recognition);
pushSec('vector_3p', `${e.name} (3', RC)`, (state.gg3pOverhang || 'GCTT') + 'A' + rc + 'AAA');
}
} else if (state.method === 'gibson' || state.method === 'in_fusion' || state.method === 'slic') {
const arm = state.gibsonArm || 20;
if (vector && vector.flanking_5p_max) {
const fp = vector.flanking_5p_max.slice(-arm);
pushSec('vector_5p', `${vector.name} 5' arm (${fp.length} bp)`, fp);
}
// Vector arm may already supply ATG (NdeI/NcoI-style cassettes).
// If it does AND the user picked an N-term tag, the previous
// version silently DROPPED the tag with no signal. We still drop
// the tag in that case (correct cloning) but now record the
// suppression so the warnings list can name it.
const fivePrimeEndsInAtg = vector && vector.flanking_5p_max && vector.flanking_5p_max.toUpperCase().endsWith('ATG');
if (fivePrimeEndsInAtg) cdsInner = stripLeadingAtg(cdsInner);
if (vector && vector.flanking_3p_max) cdsInner = stripTrailingStop(cdsInner);
if (nTag && nTag.dna && !fivePrimeEndsInAtg) {
pushSec('n_tag', `N-term ${nTag.name}`, 'ATG' + nTag.dna, { translated: true });
cdsInner = stripLeadingAtg(cdsInner);
} else if (nTag && nTag.dna && fivePrimeEndsInAtg) {
suppressedTags.push({
where: 'N-term',
name: nTag.name,
reason: `${vector ? vector.name : 'the chosen vector'} already supplies a start codon (ATG) from its 5' arm, so an N-term fusion tag from this Designer would land BEFORE the vector ATG and never be translated. Pick a vector without an integrated ATG cassette, or add the tag at the C-terminus.`,
});
}
pushSec('cds', 'CDS', cdsInner, { translated: true });
if (cTag && cTag.dna) {
if (linker && linker.dna) pushSec('linker_c', `Linker (${linker.name})`, linker.dna, { translated: true });
pushSec('c_tag', `C-term ${cTag.name}`, cTag.dna, { translated: true });
}
if (vector && vector.flanking_3p_max) {
const tp = vector.flanking_3p_max.slice(0, arm);
pushSec('vector_3p', `${vector.name} 3' arm (${tp.length} bp)`, tp);
}
} else {
// Fallback / "none" β€” bare CDS
pushSec('cds', 'CDS', cdsInner, { translated: true });
}
const full = sections.map((s) => s.dna).join('');
// ---- Warnings ----------------------------------------------------------
const warnings = [];
// Frame-correctness: every section we marked translated:true must be a
// multiple of 3, and their cumulative length must also be a multiple
// of 3. Either failure shifts every downstream codon and the encoded
// protein silently becomes garbage. Emit one HIGH warning per offending
// section, plus one for the cumulative sum if it's wrong despite each
// individual section being OK (catches the case where, e.g., a 6-bp
// linker is fine on its own but the user accidentally selected
// mismatched n/c linker lengths that don't sum cleanly).
frameViolations.forEach((v) => {
warnings.push({
severity: 'high',
text: `Frame broken: ${v.label} (${v.kind}) ${v.detail} β€” the encoded protein downstream of this section will be a different sequence than expected.`,
});
});
const translatedLen = sections
.filter((s) => s.translated)
.reduce((sum, s) => sum + s.dna.length, 0);
if (translatedLen > 0 && translatedLen % 3 !== 0 && frameViolations.length === 0) {
warnings.push({
severity: 'high',
text: `Frame broken: the translated region (ATG β†’ stop, ${translatedLen} bp) is not a multiple of 3. Re-check the tag and linker selections.`,
});
}
// Silently-suppressed tags β€” see the suppression path in the Gibson
// branch. The user clicked a tag in the dropdown; if we drop it from
// the assembly without saying so they think they're getting it.
suppressedTags.forEach((t) => {
warnings.push({
severity: 'high',
text: `${t.where} ${t.name} tag was DROPPED from the assembly: ${t.reason}`,
});
});
// Internal restriction sites for the chosen enzymes
const enzymeIds = [state.enzyme5p, state.enzyme3p, state.ggEnzyme]
.filter((id) => id && (window.ENZYMES || {})[id]);
const hits = (window.scanForInternalSites || (() => []))(cdsInner, enzymeIds);
hits.forEach((h) => {
warnings.push({
severity: 'high',
text: `Internal ${h.enzymeId} site at nt ${h.position + 1} (${h.motif}) inside the CDS β€” this enzyme will cut your insert during digestion.`,
});
});
// GC % range check
const gc = (window.gcPercent || (() => 50))(full);
if (gc < 35) warnings.push({ severity: 'medium', text: `GC content ${gc.toFixed(1)}% is below 35% β€” synthesis vendors flag low-GC stretches.` });
if (gc > 65) warnings.push({ severity: 'medium', text: `GC content ${gc.toFixed(1)}% is above 65% β€” long high-GC stretches are harder to synthesize.` });
// Length range check
if (full.length < 100) warnings.push({ severity: 'low', text: `Final length ${full.length} bp is very short β€” most vendors have a 100 bp minimum.` });
if (full.length > 3500) warnings.push({ severity: 'medium', text: `Final length ${full.length} bp is over 3.5 kb β€” synthesis pricing rises sharply past this length.` });
return { sections, full, warnings, gc };
}
function formattedDnaFor(variantId) {
const state = cloningState.get(variantId);
if (!state) return null;
const built = buildInsert(state);
return { full: built.full, sections: built.sections };
}
// CSS.escape polyfill β€” defensive against arbitrary variant IDs.
function cssEscape(s) {
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
// ---- Sequence preview with multi-section coloring ----
function renderSectionedSequenceMulti(sections) {
const sectionMap = [];
let pos = 0;
sections.forEach((s) => {
sectionMap.push({ start: pos, end: pos + s.dna.length, kind: s.kind, label: s.label });
pos += s.dna.length;
});
const full = sections.map((s) => s.dna).join('').toLowerCase();
const padWidth = String(full.length).length;
const charsPerLine = 60;
const blockSize = 10;
const out = [];
for (let i = 0; i < full.length; i += charsPerLine) {
const num = String(i + 1).padStart(padWidth, ' ');
let lineHtml = '';
for (let j = 0; j < charsPerLine && i + j < full.length; j++) {
if (j > 0 && j % blockSize === 0) lineHtml += ' ';
const idx = i + j;
const section = sectionMap.find((s) => idx >= s.start && idx < s.end);
const cls = section ? `sec-${section.kind}` : 'sec-cds';
lineHtml += `<span class="${cls}">${escapeHtml(full[idx])}</span>`;
}
out.push(`<span class="seq-num">${escapeHtml(num)}</span> ${lineHtml}`);
}
return out.join('\n');
}
// ---- Designer rendering ----
function renderDesigner(variantId) {
const root = document.querySelector(`.cloning-designer[data-variant="${cssEscape(variantId)}"]`);
if (!root) return;
let state = cloningState.get(variantId);
if (!state) return;
const methods = window.CLONING_METHODS || {};
const method = methods[state.method] || {};
const vectors = window.VECTORS || {};
const tags = window.TAGS || {};
const linkers = window.LINKERS || {};
// List vectors compatible with the chosen host context (currently unconstrained).
const allVectors = Object.entries(vectors);
// List enzymes filtered by the method's allowed types.
const enzymeOptions = (window.enzymesForMethod || (() => []))(state.method);
// Build the controls HTML.
const controlsHtml = `
<!-- STEP 1: METHOD ----------------------------------------------- -->
<div class="designer-step">
<label class="designer-label">Cloning method</label>
<select data-designer="method">
${Object.values(methods).map((m) => `
<option value="${escapeHtml(m.id)}"${m.id === state.method ? ' selected' : ''}>${escapeHtml(m.name)}</option>
`).join('')}
</select>
<div class="designer-hint">${escapeHtml(method.description || '')}</div>
</div>
<!-- STEP 2: VECTOR ----------------------------------------------- -->
<div class="designer-step">
<label class="designer-label">Destination vector</label>
<select data-designer="vectorId">
${allVectors.map(([id, v]) => `
<option value="${escapeHtml(id)}"${id === state.vectorId ? ' selected' : ''}>${escapeHtml(v.name)} Β· ${escapeHtml(v.host)} Β· ${escapeHtml(v.promoter)}</option>
`).join('')}
</select>
${vectors[state.vectorId] ? `
<div class="designer-hint">
<strong>${escapeHtml(vectors[state.vectorId].name)}</strong> Β· ${vectors[state.vectorId].size_bp} bp Β· ${escapeHtml(vectors[state.vectorId].selection.join(', '))} Β·
N-tag: ${escapeHtml(vectors[state.vectorId].fusion_tag.n_term)} Β· C-tag: ${escapeHtml(vectors[state.vectorId].fusion_tag.c_term)}
</div>
` : ''}
</div>
<!-- STEP 3: ENZYMES / OVERHANGS (varies by method) --------------- -->
${(state.method === 'type_ii_restriction' || state.method === 'blunt') ? `
<div class="designer-step designer-step-row">
<div>
<label class="designer-label">5' enzyme</label>
<select data-designer="enzyme5p">
${enzymeOptions.map(([id, e]) => `
<option value="${escapeHtml(id)}"${id === state.enzyme5p ? ' selected' : ''}>${escapeHtml(e.name)} (${escapeHtml(e.recognition)})</option>
`).join('')}
</select>
</div>
<div>
<label class="designer-label">3' enzyme</label>
<select data-designer="enzyme3p">
${enzymeOptions.map(([id, e]) => `
<option value="${escapeHtml(id)}"${id === state.enzyme3p ? ' selected' : ''}>${escapeHtml(e.name)} (${escapeHtml(e.recognition)})</option>
`).join('')}
</select>
</div>
</div>
` : ''}
${state.method === 'golden_gate' ? `
<div class="designer-step designer-step-row">
<div>
<label class="designer-label">Type IIS enzyme</label>
<select data-designer="ggEnzyme">
${enzymeOptions.map(([id, e]) => `
<option value="${escapeHtml(id)}"${id === state.ggEnzyme ? ' selected' : ''}>${escapeHtml(e.name)} (${escapeHtml(e.recognition)})</option>
`).join('')}
</select>
</div>
<div>
<label class="designer-label">5' overhang (4 nt)</label>
<input type="text" maxlength="4" minlength="4" pattern="[ACGTacgt]{4}" data-designer="gg5pOverhang" value="${escapeHtml(state.gg5pOverhang)}" />
</div>
<div>
<label class="designer-label">3' overhang (4 nt)</label>
<input type="text" maxlength="4" minlength="4" pattern="[ACGTacgt]{4}" data-designer="gg3pOverhang" value="${escapeHtml(state.gg3pOverhang)}" />
</div>
</div>
` : ''}
${(state.method === 'gibson' || state.method === 'in_fusion' || state.method === 'slic') ? `
<div class="designer-step">
<label class="designer-label">Homology arm length (bp)</label>
<select data-designer="gibsonArm">
${[15, 20, 25, 30, 40].map((n) => `<option value="${n}"${n === state.gibsonArm ? ' selected' : ''}>${n} bp</option>`).join('')}
</select>
</div>
` : ''}
<!-- STEP 4: TAGS + LINKER ---------------------------------------- -->
<div class="designer-step designer-step-row">
<div>
<label class="designer-label">N-terminal tag</label>
<select data-designer="nTag">
${Object.entries(tags).filter(([, t]) => !t.positions || t.positions.includes('n') || t.name === 'β€” none β€”').map(([id, t]) => `
<option value="${escapeHtml(id)}"${id === state.nTag ? ' selected' : ''}>${escapeHtml(t.name)}</option>
`).join('')}
</select>
</div>
<div>
<label class="designer-label">C-terminal tag</label>
<select data-designer="cTag">
${Object.entries(tags).filter(([, t]) => !t.positions || t.positions.includes('c') || t.name === 'β€” none β€”').map(([id, t]) => `
<option value="${escapeHtml(id)}"${id === state.cTag ? ' selected' : ''}>${escapeHtml(t.name)}</option>
`).join('')}
</select>
</div>
<div>
<label class="designer-label">Linker / cleavage site</label>
<select data-designer="linker">
${Object.entries(linkers).map(([id, l]) => `
<option value="${escapeHtml(id)}"${id === state.linker ? ' selected' : ''}>${escapeHtml(l.name)}</option>
`).join('')}
</select>
</div>
</div>
`;
// Build the preview + warnings
const built = buildInsert(state);
const previewHtml = renderSectionedSequenceMulti(built.sections);
const sectionsLegend = `
<span class="legend-swatch legend-vector"></span>vector arm + RE site
<span class="legend-swatch legend-tag"></span>fusion tag
<span class="legend-swatch legend-linker"></span>linker / cleavage site
<span class="legend-swatch legend-cdsBrand"></span>codon-optimized CDS
`;
const warningsHtml = built.warnings.length ? `
<div class="designer-warnings">
<h5>Warnings Β· ${built.warnings.length}</h5>
<ul>
${built.warnings.map((w) => `
<li class="warn-${escapeHtml(w.severity)}">${escapeHtml(w.text)}</li>
`).join('')}
</ul>
</div>
` : `<div class="designer-warnings warn-pass">βœ“ No design warnings.</div>`;
root.innerHTML = `
<div class="designer-controls">${controlsHtml}</div>
<div class="designer-preview">
<div class="cloning-meta">
<span><strong>${built.full.length}</strong> bp final insert</span>
<span><strong>${built.gc.toFixed(1)}%</strong> GC</span>
<span class="cloning-legend">${sectionsLegend}</span>
</div>
<div class="cloning-preview-block">${previewHtml}</div>
${warningsHtml}
</div>
`;
}
function wireCloningControls(scope) {
scope.querySelectorAll('.cloning-designer').forEach((root) => {
const variantId = root.dataset.variant;
if (!cloningState.has(variantId)) {
const variant = (tableState.variants || []).find((v) => v.Variant_ID === variantId);
cloningState.set(variantId, defaultDesignerState(variant?.Optimized_DNA_Seq || ''));
}
renderDesigner(variantId);
root.addEventListener('change', (e) => {
const field = e.target?.dataset?.designer;
if (!field) return;
const state = cloningState.get(variantId);
if (!state) return;
let value = e.target.value;
if (field === 'gibsonArm') value = parseInt(value, 10);
if ((field === 'gg5pOverhang' || field === 'gg3pOverhang') &&
!/^[ACGTacgt]{4}$/.test(value)) {
// Reject invalid overhang and revert to old value
e.target.value = state[field];
return;
}
state[field] = value;
renderDesigner(variantId);
});
// Also re-render on `input` for text fields so the preview updates
// as the user types valid 4-mers.
root.addEventListener('input', (e) => {
const field = e.target?.dataset?.designer;
if (field !== 'gg5pOverhang' && field !== 'gg3pOverhang') return;
if (!/^[ACGTacgt]{4}$/.test(e.target.value)) return;
const state = cloningState.get(variantId);
state[field] = e.target.value;
renderDesigner(variantId);
});
});
}
async function synthesizeDna(dnaSeq, variantId, vendorKey) {
// Per-session verification gate. First synth-action of the session
// shows the disclaimer modal with this variant's details; the user
// must tick "I have visually verified this sequence" to proceed.
// After that single acknowledgment, subsequent synth actions in the
// same tab go through without the modal (researcher friction would
// make the tool unusable otherwise).
const vendorName = (VENDORS[vendorKey] && VENDORS[vendorKey].name) || vendorKey;
const len = dnaSeq ? dnaSeq.length : 0;
const verifyOk = await ensureSynthVerified(`
<strong>${escapeHtml(variantId)}</strong> &middot;
${escapeHtml(String(len))} bp &middot;
sending to <strong>${escapeHtml(vendorName)}</strong>
`);
if (!verifyOk) return;
// All three vendors (Twist, IDT, GenScript) are external homepage
// redirects: we copy the DNA to clipboard, then open the vendor's
// site in a new tab so the user pastes into their order form.
//
// The local Twist Gateway in-app flow was removed when we shipped
// the public site β€” it required a localhost FastAPI service that
// doesn't exist in production. The full gateway code is preserved
// in git history if we ever wire up the real Twist API.
//
// SAFETY: if the clipboard write fails we MUST NOT open the vendor
// tab β€” the user would paste whatever was on the clipboard before
// (likely a different variant) into the vendor's order form. Wrong
// DNA shipped, hard to detect.
const vendor = VENDORS[vendorKey] || VENDORS.genscript;
const ok = await copyToClipboardOrToast(dnaSeq, `${variantId} DNA for ${vendor.name}`);
if (!ok) {
// copyToClipboardOrToast already showed an error toast naming the
// failure mode. Do NOT open the vendor tab β€” wrong DNA would land
// in their order form.
return;
}
showToast(
`${variantId}'s DNA sequence copied. Opening ${vendor.name} β€” ${vendor.hint}`,
'info',
);
setTimeout(() => {
window.open(vendor.url, '_blank', 'noopener,noreferrer');
}, 1100);
}
// Download dropdown. Trigger toggles a menu of formats; clicking an item
// kicks off a real browser download via a hidden <a download> link.
function wireDownloadMenu() {
const trigger = $('#downloadTrigger');
const menu = $('#downloadMenuItems');
if (!trigger || !menu) return;
const closeMenu = () => { menu.hidden = true; trigger.classList.remove('open'); };
const openMenu = () => { menu.hidden = false; trigger.classList.add('open'); };
// Bind once; re-renders of the result table don't recreate the trigger.
if (trigger.dataset.wired === '1') return;
trigger.dataset.wired = '1';
trigger.addEventListener('click', (e) => {
e.stopPropagation();
menu.hidden ? openMenu() : closeMenu();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('#downloadMenu')) closeMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !menu.hidden) closeMenu();
});
// Fire the browser's native download (server sends Content-Disposition:
// attachment so this drops a file into ~/Downloads, not a navigation).
const doDownload = (fmt) => {
const a = document.createElement('a');
a.href = `/api/download/${state.jobId}?format=${encodeURIComponent(fmt)}`;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
menu.querySelectorAll('.download-item').forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
const fmt = item.dataset.format;
if (!state.jobId || !fmt) return;
closeMenu();
// Red Team gate: run the synthesis checks on the full evolved
// constructs before the library leaves the app. (No essential-gene
// check here β€” directed evolution isn't a knockout.) Bypassable.
const seqs = (typeof tableState !== 'undefined' ? (tableState.variants || []) : [])
.map(v => v.Optimized_DNA_Seq).filter(Boolean);
if (!seqs.length || typeof runOracle !== 'function') { doDownload(fmt); return; }
runOracle({
seqs: seqs, gene: '',
actionLabel: 'Before exporting your variant library (' + fmt.toUpperCase() + ')',
proceed: () => doDownload(fmt),
});
});
});
}
// =============================================================== STATS STRIP
// Compact KPI tiles above the variants table. Researchers (and reviewers
// reading this on behalf of synthesis vendors) should read the library
// quality in three seconds.
function renderStatsStrip(data) {
const strip = document.getElementById('statsStrip');
if (!strip) return;
// Stats describe the evolved library only β€” the WT pseudo-row would skew
// every aggregate (zero mutations, zero fitness, etc.).
const variants = (data.variants || []).filter(v => v.Variant_ID !== 'WT');
if (!variants.length) { strip.innerHTML = ''; return; }
const fitness = variants.map(v => Number(v.Predicted_Fitness_Score)).filter(Number.isFinite);
const gc = variants.map(v => Number(v.GC_Percent)).filter(Number.isFinite);
const tms = variants.flatMap(v => [Number(v.Primer_Fwd_Tm_C), Number(v.Primer_Rev_Tm_C)]).filter(Number.isFinite);
const lengths = variants.map(v => Number(v.Length_bp)).filter(Number.isFinite);
const nMutations = variants.map(v => (v.Mutations_AA || '').split(',').filter(Boolean).length);
// Library composition: position coverage = how many distinct WT residues
// are touched at least once; diversity = unique (position, mut_aa) pairs.
const positionsTouched = new Set();
const uniqueSubs = new Set();
variants.forEach(v => {
(v.Mutations_AA || '').split(',').forEach(m => {
const match = m.trim().match(/^([A-Z])(\d+)([A-Z\*])$/);
if (match) {
positionsTouched.add(match[2]);
uniqueSubs.add(match[2] + match[3]);
}
});
});
const mean = (a) => a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0;
const max = (a) => a.length ? Math.max(...a) : 0;
const min = (a) => a.length ? Math.min(...a) : 0;
const wtLen = (data.wt_protein || '').length;
const coveragePct = wtLen ? (positionsTouched.size / wtLen * 100).toFixed(1) : 'β€”';
const tiles = [
{ label: 'Variants', val: variants.length, sub: `${min(nMutations)}–${max(nMutations)} mutations each Β· mean ${mean(nMutations).toFixed(1)}` },
{ label: 'Top fitness', val: max(fitness).toFixed(2), sub: `mean ${mean(fitness).toFixed(2)} ΣΔLL` },
{ label: 'Position coverage', val: `${positionsTouched.size}`, sub: `${coveragePct}% of WT (${uniqueSubs.size} unique subs)` },
{ label: 'Mean GC', val: `${mean(gc).toFixed(1)}%`, sub: `${min(gc).toFixed(1)}–${max(gc).toFixed(1)}% range` },
{ label: 'Cooler primer Tm', val: `${min(tms).toFixed(1)}Β°`, sub: `mean ${mean(tms).toFixed(1)}Β°C` },
{ label: 'Amplicon', val: `${lengths[0] || 'β€”'} bp`, sub: 'all variants same length' },
];
strip.innerHTML = tiles.map(t => `
<div class="stat-tile">
<span class="stat-label">${escapeHtml(t.label)}</span>
<strong class="stat-val">${escapeHtml(String(t.val))}</strong>
<span class="stat-sub">${escapeHtml(t.sub)}</span>
</div>
`).join('');
// Tick the count-up animation on every stat-val so the numbers feel
// like they "land" instead of just appearing. CSS class auto-removes
// after the keyframe completes (~280ms).
strip.querySelectorAll('.stat-val').forEach((el) => {
el.classList.remove('count-up');
// Force reflow so the re-added class re-triggers the animation.
void el.offsetWidth;
el.classList.add('count-up');
setTimeout(() => el.classList.remove('count-up'), 320);
});
}
// =============================================================== RUN META
// Pill row of the actual parameters the engine ran with + a copy-paste
// methods paragraph for the Materials & Methods section of a paper.
function renderRunMeta(data) {
const box = document.getElementById('runMeta');
if (!box) return;
const s = data.settings_used || {};
if (!Object.keys(s).length) { box.hidden = true; return; }
box.hidden = false;
const modelLabel = { small: 'ESM-2 35M', medium: 'ESM-2 650M', large: 'ESM-2 3B' }[s.model] || s.model;
const hostLabel = { e_coli: 'E. coli', yeast: 'S. cerevisiae', human: 'H. sapiens' }[s.host] || s.host;
const elapsed = data.elapsed_seconds != null ? `${data.elapsed_seconds}s` : 'β€”';
const pills = [
['Model', modelLabel],
['Percentile', `top ${(100 - s.percentile).toFixed(0)}% (β‰₯ p${s.percentile})`],
['K variants', s.k],
['Max muts/variant', s.max_mutations],
['Min muts/variant', s.min_mutations],
['SA restarts', s.restarts],
['SA steps/restart', s.steps_per_restart],
['Host', hostLabel],
['Device', s.device],
['Seed', s.seed != null ? s.seed : 'random'],
['Wall time', elapsed],
];
const pillHtml = pills.map(([k, v]) => `<span class="run-pill">${escapeHtml(k)} <strong>${escapeHtml(String(v))}</strong></span>`).join('');
// Auto-generated methods paragraph β€” copy-paste into a manuscript.
const wtLen = (data.wt_protein || '').length;
const wtId = data.wt_identifier || 'wild-type';
const methods = `Variants of ${wtId} (${wtLen} aa) were designed in silico with ESM-2 ${modelLabel.replace('ESM-2 ', '')} (Lin et al., <cite>Science</cite> 2022) using the wild-type marginal log-likelihood scoring scheme of Meier et al. (<cite>Adv. Neural Inf. Process. Syst.</cite> 2021). Single-point substitutions in the top ${(100 - s.percentile).toFixed(0)}% by Ξ”LL were retained as the combinatorial search space. ${s.k} multi-mutant variants with ${s.min_mutations}–${s.max_mutations} simultaneous substitutions were generated by simulated annealing (${s.restarts} restarts Γ— ${s.steps_per_restart} steps, geometric cooling) maximizing cumulative Ξ”LL with stop-codon and duplicate-position penalties. Optimized DNA was reverse-translated using ${hostLabel} codon-usage frequencies and synonymously cleaned of BsaI, BsmBI, and NotI recognition sites for Golden Gate compatibility.`;
box.innerHTML = `
<div class="run-meta-pills">${pillHtml}</div>
<div class="run-methods">
<div class="run-methods-head">
<h4>Materials &amp; methods (copy-paste)</h4>
<button class="copy-btn" data-copy="${escapeHtml(methods.replace(/<[^>]+>/g, ''))}">Copy</button>
</div>
<div class="run-methods-body">${methods}</div>
</div>
`;
wireCopyButtons(box);
}
// =============================================================== MUTATION MAP
// Lollipop chart: x = residue position, y = how many variants share a mutation
// at that position. Dots colored by mean Ξ”LL strength. Standard genomics
// visualization (cBioPortal / ProteinPaint).
function renderMutationMap(data) {
const root = document.getElementById('mutmapCanvas');
if (!root) return;
root.innerHTML = '';
const wtLen = (data.wt_protein || '').length;
if (!wtLen) return;
// Aggregate mutation counts and signed Ξ”LL contribution per position.
const counts = new Map(); // position -> count
const strength = new Map(); // position -> sum of fitness contribution
let maxCount = 0;
(data.variants || []).forEach((v) => {
const muts = (v.Mutations_AA || '').split(',').map(s => s.trim()).filter(Boolean);
// Per-variant fitness divided by mutation count is our crude per-mut weight.
const w = Number(v.Predicted_Fitness_Score) / Math.max(1, muts.length);
muts.forEach((m) => {
const numMatch = m.match(/[0-9]+/);
if (!numMatch) return;
const pos = parseInt(numMatch[0], 10);
counts.set(pos, (counts.get(pos) || 0) + 1);
strength.set(pos, (strength.get(pos) || 0) + w);
if (counts.get(pos) > maxCount) maxCount = counts.get(pos);
});
});
if (!counts.size) {
root.innerHTML = '<div class="muted" style="padding:16px;text-align:center;">No mutations to plot.</div>';
return;
}
// SVG geometry
const W = root.clientWidth || 800;
const H = 140;
const pad = { top: 14, right: 16, bottom: 28, left: 36 };
const innerW = W - pad.left - pad.right;
const innerH = H - pad.top - pad.bottom;
const xScale = (p) => pad.left + (p - 1) / Math.max(1, wtLen - 1) * innerW;
const yScale = (c) => pad.top + innerH - (c / maxCount) * innerH;
// Compute color by strength: brand-200 (low) β†’ brand (mid) β†’ brand-deep (high)
const strengths = [...strength.values()];
const sMax = Math.max(...strengths);
const sMin = Math.min(...strengths);
const colorFor = (s) => {
if (sMax === sMin) return '#0E7C9A';
const t = (s - sMin) / (sMax - sMin);
if (t < 0.33) return '#A7D8E7';
if (t < 0.66) return '#0E7C9A';
return '#0A5F77';
};
// Ticks every ~50 residues, with at least 4 ticks.
const tickStep = Math.max(10, Math.ceil(wtLen / Math.max(4, Math.min(12, Math.floor(wtLen / 25)))));
const ticks = [];
for (let i = 1; i <= wtLen; i += tickStep) ticks.push(i);
if (ticks[ticks.length - 1] !== wtLen) ticks.push(wtLen);
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'none');
// Axis baseline (residue track)
const baseline = document.createElementNS(svgNS, 'line');
baseline.setAttribute('x1', pad.left);
baseline.setAttribute('x2', pad.left + innerW);
baseline.setAttribute('y1', pad.top + innerH);
baseline.setAttribute('y2', pad.top + innerH);
baseline.setAttribute('class', 'mutmap-baseline');
svg.appendChild(baseline);
// X axis labels
const axis = document.createElementNS(svgNS, 'g');
axis.setAttribute('class', 'mutmap-axis');
ticks.forEach((t) => {
const x = xScale(t);
const tick = document.createElementNS(svgNS, 'line');
tick.setAttribute('x1', x);
tick.setAttribute('x2', x);
tick.setAttribute('y1', pad.top + innerH);
tick.setAttribute('y2', pad.top + innerH + 4);
axis.appendChild(tick);
const lbl = document.createElementNS(svgNS, 'text');
lbl.setAttribute('x', x);
lbl.setAttribute('y', pad.top + innerH + 16);
lbl.setAttribute('text-anchor', 'middle');
lbl.textContent = String(t);
axis.appendChild(lbl);
});
// Y axis labels (count)
const yLabels = [maxCount, Math.ceil(maxCount / 2), 1];
yLabels.forEach((c) => {
const y = yScale(c);
const lbl = document.createElementNS(svgNS, 'text');
lbl.setAttribute('x', pad.left - 8);
lbl.setAttribute('y', y + 3);
lbl.setAttribute('text-anchor', 'end');
lbl.textContent = String(c);
axis.appendChild(lbl);
});
svg.appendChild(axis);
// Lollipops
const sorted = [...counts.entries()].sort((a, b) => a[0] - b[0]);
sorted.forEach(([pos, c]) => {
const x = xScale(pos);
const y = yScale(c);
const stem = document.createElementNS(svgNS, 'line');
stem.setAttribute('x1', x);
stem.setAttribute('x2', x);
stem.setAttribute('y1', pad.top + innerH);
stem.setAttribute('y2', y);
stem.setAttribute('class', 'mutmap-stem');
svg.appendChild(stem);
const dot = document.createElementNS(svgNS, 'circle');
dot.setAttribute('cx', x);
dot.setAttribute('cy', y);
dot.setAttribute('r', Math.max(3, 3 + c * 0.4));
dot.setAttribute('fill', colorFor(strength.get(pos)));
dot.setAttribute('class', 'mutmap-dot');
const title = document.createElementNS(svgNS, 'title');
title.textContent = `Position ${pos} Β· ${c} variant${c === 1 ? '' : 's'} mutate here Β· Ξ£ contribution ${strength.get(pos).toFixed(2)}`;
dot.appendChild(title);
svg.appendChild(dot);
});
root.appendChild(svg);
}
// =============================================================== COMMAND PALETTE
// Stub: ⌘K / Ctrl-K opens a minimal prompt. Full palette is a follow-up.
document.addEventListener('keydown', (e) => {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k';
if (!isCmdK) return;
e.preventDefault();
const trigger = document.getElementById('cmdTrigger');
if (trigger) {
trigger.style.borderColor = 'var(--brand)';
trigger.style.boxShadow = '0 0 0 3px var(--brand-ring)';
setTimeout(() => {
trigger.style.borderColor = '';
trigger.style.boxShadow = '';
}, 400);
}
// Focus paste area as the most common action.
const ta = document.getElementById('pasteArea');
if (ta) {
document.querySelector('.tab[data-tab="paste"]')?.click();
ta.focus();
}
});
// =============================================================== IDENTIFY
// NCBI BLAST integration. User clicks "Identify via NCBI BLAST" β†’ backend
// submits the WT protein to NCBI's public web BLAST, we poll until done,
// then render the top hits. Results cached server-side by sequence hash.
let identifyPollHandle = null;
function bindIdentifyButton() {
const btn = document.getElementById('identifyBtn');
if (!btn || btn.dataset.wired === '1') return;
btn.dataset.wired = '1';
btn.addEventListener('click', startIdentify);
}
// ----------------------------------------------------------------------------
// BLAST consent gate
// ----------------------------------------------------------------------------
// NCBI BLAST sends the wild-type protein sequence to ncbi.nlm.nih.gov, where
// it is logged for operational + security purposes. That's fine for published
// reference proteins and a privacy / IP risk for anything pre-publication or
// proprietary. We require an explicit per-session consent click before any
// such request leaves the machine β€” same shape as the ESMFold consent below.
// sessionStorage (not localStorage) so consent doesn't silently persist
// across days; closing the tab resets the gate.
const BLAST_CONSENT_KEY = 'turingdna.blast.consent.v1';
function hasBlastConsent() {
try { return sessionStorage.getItem(BLAST_CONSENT_KEY) === '1'; }
catch { return false; }
}
function grantBlastConsent() {
try { sessionStorage.setItem(BLAST_CONSENT_KEY, '1'); } catch {}
}
// Show the BLAST consent modal (#blastConsentModal in index.html).
//
// Was previously rendered inline inside the #identifyPanel div that lives
// at the top of the preview card. Problem: when the user scrolls down to
// click "Identify via NCBI BLAST" from further down the page (CDS picker,
// settings, results), the consent panel pops in ABOVE the viewport β€” they
// can't see it without scrolling back up. Refactored to a fixed-position
// .modal so the consent always appears centered on whatever the user is
// looking at right now. Markup lives in index.html so the modal can be
// reused if we ever need to re-prompt.
function renderBlastConsentPanel(_unusedPanel, btn) {
const modal = document.getElementById('blastConsentModal');
if (!modal) {
// Shouldn't happen in shipped builds β€” the markup is in index.html.
// Fall through to the BLAST request anyway rather than blocking the
// user behind a missing modal.
grantBlastConsent();
startIdentify();
return;
}
modal.hidden = false;
document.body.style.overflow = 'hidden';
const close = () => {
modal.hidden = true;
document.body.style.overflow = '';
if (btn) btn.disabled = false;
document.removeEventListener('keydown', onEsc);
};
const onEsc = (e) => {
if (e.key === 'Escape') close();
};
// Wire all cancel surfaces (backdrop, Γ— button, explicit Cancel button).
// Use .once so re-opening the modal in the same session doesn't stack
// multiple listeners.
modal.querySelectorAll('[data-blast-cancel]').forEach((el) => {
el.addEventListener('click', close, { once: true });
});
// Wire the Continue button.
const continueBtn = modal.querySelector('#blastConsentContinue');
if (continueBtn) {
continueBtn.addEventListener('click', () => {
grantBlastConsent();
close();
// Re-enter the flow now that consent is recorded β€” the gate above
// is satisfied and the BLAST submission proceeds normally.
startIdentify();
}, { once: true });
}
// Esc to dismiss.
document.addEventListener('keydown', onEsc);
// Focus the primary action for keyboard users.
if (continueBtn) {
try { continueBtn.focus({ preventScroll: true }); } catch (_) {}
}
}
async function startIdentify() {
if (!state.sessionId) {
showToast('Load a sequence first.', 'warn');
return;
}
const btn = document.getElementById('identifyBtn');
const panel = document.getElementById('identifyPanel');
if (!btn || !panel) return;
// First-time gate: BLAST sends the sequence to NCBI. Show the consent
// panel and bail out β€” the Continue button re-enters this function with
// consent recorded, so the second pass falls through to submission.
if (!hasBlastConsent()) {
btn.disabled = true;
renderBlastConsentPanel(panel, btn);
return;
}
btn.disabled = true;
panel.hidden = false;
panel.innerHTML = `
<div class="identify-status">
<span class="identify-spinner"></span>
<span>Submitting to NCBI…</span>
</div>
`;
try {
const res = await fetch('/api/identify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.sessionId,
cds_feature: state.chosenCds,
// Server-side BLAST consent gate (audit H1). The consent
// modal in renderBlastConsentPanel sets the sessionStorage
// flag; we forward it here so the backend can verify
// the user actually opted in. Anyone bypassing the modal
// and calling /api/identify directly gets a 403.
blast_consent: hasBlastConsent(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'BLAST submission failed.');
if (data.cached && data.status === 'done') {
renderIdentifyResult(data);
btn.disabled = false;
return;
}
pollIdentify(data.job_id);
} catch (err) {
panel.innerHTML = `<div class="identify-error">${escapeHtml(err.message)}</div>`;
btn.disabled = false;
}
}
function pollIdentify(jobId) {
if (identifyPollHandle) clearTimeout(identifyPollHandle);
identifyPollHandle = setTimeout(async () => {
try {
const res = await fetch(`/api/identify/${jobId}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Lost the BLAST job.');
const panel = document.getElementById('identifyPanel');
const elapsed = data.elapsed_seconds != null ? data.elapsed_seconds.toFixed(1) : '0.0';
const phaseLabel = {
pending: 'Queued',
submitting: 'Submitting to NCBI',
running: 'NCBI is searching nr',
parsing: 'Parsing results',
done: 'Done',
error: 'Error',
}[data.status] || data.status;
if (data.status === 'done') {
renderIdentifyResult(data);
document.getElementById('identifyBtn').disabled = false;
return;
}
if (data.status === 'error') {
panel.innerHTML = `<div class="identify-error">BLAST failed: ${escapeHtml(data.error || 'unknown error')}</div>`;
document.getElementById('identifyBtn').disabled = false;
return;
}
panel.innerHTML = `
<div class="identify-status">
<span class="identify-spinner"></span>
<span>${escapeHtml(phaseLabel)} Β· ${escapeHtml(elapsed)}s elapsed (NCBI queues can take 30–90 s)</span>
</div>
`;
pollIdentify(jobId);
} catch (err) {
const panel = document.getElementById('identifyPanel');
if (panel) panel.innerHTML = `<div class="identify-error">${escapeHtml(err.message)}</div>`;
document.getElementById('identifyBtn').disabled = false;
}
}, 2000);
}
function renderIdentifyResult(data) {
const panel = document.getElementById('identifyPanel');
if (!panel) return;
const hits = data.hits || [];
if (!hits.length) {
panel.innerHTML = `
<div class="identify-status">
<span>No significant NCBI matches at E ≀ 1e-5. This sequence may be de novo / heavily engineered / chimeric, or simply too short for confident hits.</span>
</div>
`;
return;
}
const top = hits[0];
const idPct = Number(top.identity_pct);
let idClass = 'id-low';
if (idPct >= 80) idClass = '';
else if (idPct >= 40) idClass = 'id-mid';
const ncbiBase = 'https://www.ncbi.nlm.nih.gov/protein/';
const topNcbiUrl = top.accession ? `${ncbiBase}${encodeURIComponent(top.accession)}` : null;
const hitRows = hits.slice(0, 5).map((h) => {
const accLink = h.accession
? `<a class="identify-hit-acc" href="${ncbiBase}${encodeURIComponent(h.accession)}" target="_blank" rel="noopener">${escapeHtml(h.accession)}</a>`
: `<span class="identify-hit-acc">β€”</span>`;
return `
<div class="identify-hit">
${accLink}
<div>
<div class="identify-hit-desc">${escapeHtml(h.description)}</div>
${h.organism ? `<div class="identify-hit-org">${escapeHtml(h.organism)}</div>` : ''}
</div>
<div class="identify-hit-stats">
${h.identity_pct}% id<br>
cov ${h.coverage_pct}%<br>
E ${h.evalue.toExponential(1)}
</div>
</div>
`;
}).join('');
// If the top hit (or any of the top-5) has a UniProt accession, prepare
// an AlphaFold structure embed slot. We pick the first UniProt-mapped
// hit in case the very top is RefSeq-only.
const afHit = hits.slice(0, 5).find(h => h.uniprot && h.alphafold_url);
// When the accession came from the Swiss-Prot homolog fallback (nr buried
// the curated entry), label it honestly: it's the closest *reviewed*
// protein with a model, not necessarily the exact nr top hit.
const afHomolog = afHit && afHit.uniprot_via === 'swissprot_homolog';
const afOrg = afHit ? (afHomolog ? (afHit.uniprot_organism || afHit.organism || '') : (afHit.organism || '')) : '';
const afMeta = afHit
? ('UniProt ' + escapeHtml(afHit.uniprot) + (afOrg ? ' Β· ' + escapeHtml(afOrg) : '')
+ (afHomolog && afHit.uniprot_identity_pct != null
? ' Β· closest reviewed homolog (' + escapeHtml(String(afHit.uniprot_identity_pct)) + '% id)' : ''))
: '';
const afHtml = afHit
? `
<div class="alphafold-wrap">
<div class="alphafold-head">
<h4>AlphaFold predicted structure</h4>
<span class="alphafold-meta">${afMeta}</span>
</div>
<div class="alphafold-viewer" id="alphafoldViewer" data-pdb-url="${escapeHtml(afHit.alphafold_url)}" data-uniprot="${escapeHtml(afHit.uniprot)}">
<div class="alphafold-loading">
<span class="identify-spinner"></span>
Loading AlphaFold-DB structure…
</div>
</div>
<div class="alphafold-disclaimer">
Predicted structure from <a href="${escapeHtml(afHit.alphafold_page)}" target="_blank" rel="noopener">AlphaFold-DB</a>.
${afHomolog ? 'Resolved as the closest reviewed (Swiss-Prot) protein β€” a reference scaffold for this family. ' : ''}Drag to rotate Β· scroll to zoom Β· double-click to fit.
Backbone confidence colored by pLDDT (blue&nbsp;=&nbsp;high, orange&nbsp;=&nbsp;low).
</div>
</div>
`
: '';
panel.innerHTML = `
<div class="identify-headline">
<strong>${escapeHtml(top.description || 'Top NCBI match')}</strong>
${top.organism ? `<span class="organism">${escapeHtml(top.organism)}</span>` : ''}
<span class="id-pct ${idClass}">${idPct}% identity</span>
</div>
<div class="identify-hits">${hitRows}</div>
<div class="identify-actions">
${topNcbiUrl ? `<a class="ghost" target="_blank" rel="noopener" href="${topNcbiUrl}">Open ${escapeHtml(top.accession)} on NCBI</a>` : ''}
${afHit ? `<a class="ghost" target="_blank" rel="noopener" href="${escapeHtml(afHit.alphafold_page)}">Open AlphaFold entry</a>` : ''}
</div>
${afHtml}
`;
if (afHit) mountAlphaFoldViewer(afHit);
}
// AlphaFold-DB bumps its model version DB-wide over time (…v4 β†’ v6 β†’ …), so
// any hardcoded "model_vN" URL eventually 404s. Critically, Mol*'s
// loadStructureFromUrl does NOT throw on a 404 β€” it loads the error body,
// ends up with zero structures, removes the loading overlay, and silently
// shows an empty viewer ("won't show predicted models"). So we resolve the
// CURRENT file URL from AlphaFold's prediction API using the accession
// embedded in whatever URL we were handed, with the original as fallback.
// CORS-enabled; works from inside the /app/ iframe.
function _afFormat(url) { return /\.cif(\?|$)/i.test(url || '') ? 'mmcif' : 'pdb'; }
// True only if a structure actually made it into the hierarchy β€” guards the
// silent-404 case (Mol* doesn't throw on a 404) so we can surface a real
// message instead of a blank viewer.
function _afHasStructure(viewer) {
try { return (viewer.plugin.managers.structure.hierarchy.current.structures.length || 0) > 0; }
catch (_) { return false; }
}
// fetch() with an abort timeout + one retry on transient failure (network
// error, 5xx, 429). 404 and other 4xx are returned as-is (not retried).
async function _afFetch(url, tries = 2, timeoutMs = 8000) {
let lastErr = null;
for (let i = 0; i < tries; i++) {
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, { signal: ctrl.signal });
clearTimeout(to);
if (r.ok || (r.status >= 400 && r.status < 500 && r.status !== 429)) return r;
lastErr = new Error('HTTP ' + r.status); // 5xx / 429 β†’ retry
} catch (e) { clearTimeout(to); lastErr = e; }
if (i < tries - 1) await new Promise(s => setTimeout(s, 450 * (i + 1)));
}
throw lastErr || new Error('fetch failed');
}
// Resolve an AF model reference into: the ordered list of candidate model
// URLs to try, plus whether the entry is known to exist. AlphaFold-DB bumps
// model versions per-entry (v4β†’v6→…) and occasionally has CDN blips, so we
// (1) ask the prediction API for the *current* model URL, then (2) build a
// fallback ladder β€” alternate format (.cif) and older versions β€” so a single
// stale/blipping URL doesn't blank the viewer. `exists` is true/false when the
// API answered, null when it couldn't be reached (treated as transient).
async function _afResolve(originalUrl) {
const acc = ((originalUrl || '').match(/AF-([A-Za-z0-9]+)-F\d+/) || [])[1];
const out = { candidates: [], exists: null };
const push = (u) => { if (u && out.candidates.indexOf(u) === -1) out.candidates.push(u); };
let resolved = originalUrl;
if (acc) {
try {
const r = await _afFetch('https://alphafold.ebi.ac.uk/api/prediction/' + acc, 2, 8000);
if (r.ok) {
const j = await r.json();
out.exists = Array.isArray(j) && j.length > 0;
const e = (j && j[0]) || {};
resolved = e.pdbUrl || e.cifUrl || originalUrl;
} else if (r.status === 404) {
out.exists = false;
}
} catch (_) { /* unreachable β†’ exists stays null (transient) */ }
}
push(resolved);
if (resolved) push(resolved.replace(/\.pdb(\?|$)/i, '.cif$1')); // alt format
if (acc) [6, 5, 4].forEach(v => push(`https://alphafold.ebi.ac.uk/files/AF-${acc}-F1-model_v${v}.pdb`));
return out;
}
// Try each candidate URL until one actually puts a structure in the hierarchy.
async function _afTryLoad(viewer, candidates) {
for (const u of candidates) {
try {
await viewer.loadStructureFromUrl(u, _afFormat(u));
if (_afHasStructure(viewer)) return true;
} catch (_) { /* try the next candidate */ }
}
return false;
}
// Wait up to ~8s for the (async-loaded) Mol* global. Returns it or null.
async function _afWaitMolstar() {
for (let i = 0; i < 80; i++) {
if (typeof window.molstar !== 'undefined') return window.molstar;
await new Promise(r => setTimeout(r, 100));
}
return null;
}
const _AF_VIEWER_OPTS = {
layoutIsExpanded: false, layoutShowControls: false, layoutShowRemoteState: false,
layoutShowSequence: false, layoutShowLog: false, layoutShowLeftPanel: false,
viewportShowExpand: true, viewportShowSelectionMode: false, viewportShowAnimation: false,
pdbProvider: 'pdbe', emdbProvider: 'pdbe',
};
// Mol* paints the WebGL canvas on its OWN clear color (default near-white) β€”
// CSS can't recolor a WebGL canvas, and Viewer.create silently ignores an
// {r,g,b} backgroundColor. Set it through the canvas3d renderer API instead
// (Mol*'s Color is a plain 0xRRGGBB int at runtime) so the 3-D view is dark
// like the rest of the app rather than a white box inside a dark modal.
const _AF_BG = 0x0E141B;
function _afApplyBg(viewer) {
try { viewer.plugin.canvas3d.setProps({ renderer: { backgroundColor: _AF_BG } }); } catch (_) {}
}
// Honest failure UI. A transient EBI outage gets a "temporarily unreachable +
// Retry" affordance (the model DOES exist, EBI just blipped); a genuine
// absence says so plainly. Never again claim "model doesn't exist" when the
// truth is "EBI was momentarily down."
function _afShowError(host, transient, retry) {
if (!host) return;
const wrap = document.createElement('div');
wrap.className = 'alphafold-loading';
wrap.textContent = transient
? 'AlphaFold-DB is temporarily unreachable β€” this usually clears in a moment.'
: 'No AlphaFold model is available for this protein entry.';
if (transient && typeof retry === 'function') {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Retry';
btn.style.cssText = 'display:block;margin:12px auto 0;padding:8px 20px;border:1px solid currentColor;border-radius:9px;background:transparent;color:inherit;font:inherit;cursor:pointer;min-height:44px';
btn.addEventListener('click', () => {
host.innerHTML = '<div class="alphafold-loading">Loading AlphaFold-DB structure…</div>';
retry();
});
wrap.appendChild(btn);
}
host.innerHTML = '';
host.appendChild(wrap);
}
// Mol* viewer mount. Loads the AlphaFold model from the EBI CDN and renders
// it inside #alphafoldViewer. We wait for the molstar global to appear
// (script is loaded async) before mounting.
async function mountAlphaFoldViewer(hit, host) {
// `host` defaults to the page-level identify embed, but the per-variant
// AlphaFold modal passes its own host (#foldViewer) so the same resilient
// loader serves both embeds.
host = host || document.getElementById('alphafoldViewer');
if (!host) return;
const pdbUrl = host.dataset.pdbUrl || (hit && hit.alphafold_url);
if (!pdbUrl) return;
const molstar = await _afWaitMolstar();
if (!molstar) {
host.innerHTML = '<div class="alphafold-loading">Mol* viewer failed to load. Refresh the page and try again.</div>';
return;
}
try {
const viewer = await molstar.Viewer.create(host, _AF_VIEWER_OPTS);
_afApplyBg(viewer);
const { candidates, exists } = await _afResolve(pdbUrl);
if (!await _afTryLoad(viewer, candidates)) {
// exists === false β†’ genuinely no model; otherwise treat as a
// transient EBI blip and offer a retry.
_afShowError(host, exists !== false, () => mountAlphaFoldViewer(hit, host));
return;
}
const overlay = host.querySelector('.alphafold-loading');
if (overlay) overlay.remove();
} catch (err) {
_afShowError(host, true, () => mountAlphaFoldViewer(hit, host));
}
}
// ============================================================================
// ESMFold β€” per-variant structure prediction
// ----------------------------------------------------------------------------
// Triggered by the .fold-btn on each variant row. POSTs the variant's
// amino-acid sequence to Meta's public ESM Atlas API and renders the
// returned PDB inside #foldViewer using the same Mol* configuration as
// mountAlphaFoldViewer above, so the two embeds look identical.
//
// Endpoint : https://api.esmatlas.com/foldSequence/v1/pdb/
// Method : POST
// Body : raw amino-acid sequence (plain text, no JSON wrapper)
// Response : raw PDB text
// Constraints: ~400-residue cap on the public endpoint, shared rate limit
// (returns 429 when throttled).
//
// All client-side β€” no server changes required for Hostinger / HF static.
// ============================================================================
const ESMFOLD_ENDPOINT = 'https://api.esmatlas.com/foldSequence/v1/pdb/';
const ESMFOLD_MAX_RES = 400;
// Tracks the variant ID of the in-flight fold so duplicate clicks coalesce
// and a stale response from a previous variant doesn't overwrite a newer one.
let foldInFlightVariant = null;
async function foldSequenceWithESMFold(sequence) {
if (!sequence || typeof sequence !== 'string') {
throw new Error('No protein sequence on this variant.');
}
// ESMFold rejects gaps, stops, and lowercase. Strip whitespace and any
// trailing stop codon symbol; keep only canonical AA letters.
const cleaned = sequence
.replace(/\s+/g, '')
.replace(/\*+$/g, '')
.toUpperCase()
.replace(/[^ACDEFGHIKLMNPQRSTVWY]/g, '');
if (!cleaned) throw new Error('Sequence is empty after cleaning.');
if (cleaned.length > ESMFOLD_MAX_RES) {
// Actionable error: tell the user the cap is on Meta's public
// endpoint (not on us), explain there's a fallback path, and
// point at the existing BLAST β†’ AlphaFold flow which works for
// long sequences with no length cap and no Meta data exposure.
throw new Error(
`This sequence is ${cleaned.length} aa, but Meta's free ESMFold ` +
`endpoint accepts only up to ${ESMFOLD_MAX_RES} aa. ` +
`For longer proteins, click "Identify via NCBI BLAST" β€” if a ` +
`UniProt match is found, the AlphaFold reference structure will ` +
`load automatically and you can examine it alongside the variant.`,
);
}
let response;
try {
response = await fetch(ESMFOLD_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: cleaned,
});
} catch (networkErr) {
// Anything that prevented the request from leaving the browser β€”
// CORS preflight failure, DNS, offline, etc. β€” lands here as TypeError.
throw new Error('Network error reaching the ESMFold API. Check your connection and try again.');
}
if (response.status === 429) {
throw new Error('Rate-limited by the public ESMFold API. Wait a minute and try again.');
}
if (response.status === 413) {
throw new Error(`Sequence too long for the public ESMFold endpoint (~${ESMFOLD_MAX_RES} aa cap).`);
}
if (response.status >= 500) {
throw new Error(
`ESMFold server error (${response.status}). The free endpoint is sometimes overloaded β€” retry in a moment.`
);
}
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`ESMFold request failed (${response.status}). ${body.slice(0, 140)}`);
}
const pdbText = await response.text();
// A real PDB always begins with one of these record types. Guard against
// the endpoint returning an HTML error page with a 200 (it has, before).
if (!/^(HEADER|ATOM|MODEL|TITLE|REMARK)/m.test(pdbText)) {
throw new Error('ESMFold returned an unexpected response (not a PDB).');
}
return pdbText;
}
// Opens #foldModal, fires the fold, mounts Mol* with the returned PDB.
// Resilient to: duplicate clicks (coalesced via foldInFlightVariant),
// the user closing the modal mid-fold, and the user folding a second
// variant before the first one returns (the stale response is dropped).
// Per-session opt-in for sending sequences to Meta's public ESMFold API.
// Mirrors the BLAST / AlphaFold opt-in pattern: once the user has consented
// in a browser session, subsequent Fold clicks proceed without re-prompting.
// Stored in sessionStorage (not localStorage) so it expires when the tab is
// closed β€” consent doesn't silently persist across days.
const FOLD_CONSENT_KEY = 'turingdna.fold.consent.v1';
function hasFoldConsent() {
try { return sessionStorage.getItem(FOLD_CONSENT_KEY) === '1'; }
catch { return false; }
}
function grantFoldConsent() {
try { sessionStorage.setItem(FOLD_CONSENT_KEY, '1'); } catch {}
}
async function openFoldModal(variant) {
const modal = document.getElementById('foldModal');
const sub = document.getElementById('foldModalSub');
const host = document.getElementById('foldViewer');
if (!modal || !host) return;
// The modal is shared with the AlphaFold path β€” reset its chrome to
// ESMFold and cancel any AlphaFold BLAST poll that was still running.
_setFoldModalMode('esm', null);
if (alphaFoldPollHandle) { clearTimeout(alphaFoldPollHandle); alphaFoldPollHandle = null; }
const aaLen = (variant.Mutant_AA_Seq || '').length;
// First-time gate: ESMFold sends the variant's protein sequence to Meta.
// Show an editorial consent panel inside the modal before any network
// call. Once accepted, this branch is skipped for the rest of the session.
if (!hasFoldConsent()) {
if (sub) sub.textContent =
`${variant.Variant_ID} Β· ${aaLen} aa Β· awaiting consent`;
host.innerHTML = `
<div class="fold-consent">
<p class="fold-consent-kicker">&sect; Consent</p>
<h3 class="fold-consent-title"><em>Send sequence to Meta?</em></h3>
<p class="fold-consent-body">
Predicting a 3-D structure with ESMFold requires sending this
variant's amino-acid sequence to <code>api.esmatlas.com</code>,
run by Meta AI. The request travels over HTTPS, but the
sequence is visible to Meta's servers.
</p>
<p class="fold-consent-body">
<em>Don't fold variants that are confidential or pre-publication
IP.</em> The on-server pipeline (scoring, search, codon
optimization) keeps sequences local; only ESMFold and the
opt-in BLAST identifier leave the machine.
</p>
<div class="fold-consent-actions">
<button class="ghost-btn" type="button" data-fold-cancel>Cancel</button>
<button class="primary" type="button" data-fold-consent>
Continue &middot; remember for this session
</button>
</div>
</div>
`;
modal.hidden = false;
bindFoldModalDismiss();
const cancelBtn = host.querySelector('[data-fold-cancel]');
const consentBtn = host.querySelector('[data-fold-consent]');
if (cancelBtn) cancelBtn.addEventListener('click', closeFoldModal);
if (consentBtn) consentBtn.addEventListener('click', () => {
grantFoldConsent();
// Re-enter the modal flow now that consent is recorded; the gate
// above is now satisfied and the fold proceeds normally.
openFoldModal(variant);
});
return;
}
// Reset the modal to its loading state every time it opens β€” Mol* leaves
// canvas children behind that we'd otherwise stack on top of.
host.innerHTML = `
<div class="alphafold-loading">
<span class="identify-spinner"></span>
Submitting sequence to ESMFold…
</div>
`;
if (sub) sub.textContent = `Folding ${variant.Variant_ID} (${aaLen} aa)…`;
modal.hidden = false;
bindFoldModalDismiss();
// If the same variant is already in flight, just show the modal β€”
// don't fire a second request.
if (foldInFlightVariant === variant.Variant_ID) return;
foldInFlightVariant = variant.Variant_ID;
try {
const pdbText = await foldSequenceWithESMFold(variant.Mutant_AA_Seq || '');
// If the user opened a different variant mid-fold, drop this result.
if (foldInFlightVariant !== variant.Variant_ID) return;
if (sub) sub.textContent =
`${variant.Variant_ID} Β· ${aaLen} aa Β· predicted by ESMFold`;
await mountESMFoldViewer(host, pdbText);
} catch (err) {
host.innerHTML = `
<div class="alphafold-loading">
<strong>Couldn't fold this variant.</strong><br>
<span style="opacity:.75">${escapeHtml(err.message || String(err))}</span>
</div>
`;
if (sub) sub.textContent = 'Folding failed';
// Toast surfaces the error even if the user has scrolled past the modal.
if (typeof showToast === 'function') showToast(err.message || 'Folding failed.', 'error');
} finally {
if (foldInFlightVariant === variant.Variant_ID) foldInFlightVariant = null;
}
}
// Mounts Mol* into the given host using raw PDB text rather than a URL.
// Mirrors mountAlphaFoldViewer's options exactly so both embeds match.
async function mountESMFoldViewer(host, pdbText) {
// Wait up to 8 s for the Mol* global; it's loaded async in index.html.
let molstar = null;
for (let i = 0; i < 80; i++) {
if (typeof window.molstar !== 'undefined') { molstar = window.molstar; break; }
await new Promise(r => setTimeout(r, 100));
}
if (!molstar) {
host.innerHTML = '<div class="alphafold-loading">Mol* viewer failed to load. Refresh the page and try again.</div>';
return;
}
// Clear the placeholder before Mol* mounts its own canvas + UI into the host.
host.innerHTML = '';
try {
const viewer = await molstar.Viewer.create(host, _AF_VIEWER_OPTS);
_afApplyBg(viewer); // dark WebGL background, matching the app
// loadStructureFromData takes raw text directly β€” second arg is the
// format ('pdb'), third arg is the isBinary flag.
await viewer.loadStructureFromData(pdbText, 'pdb', false);
} catch (err) {
host.innerHTML =
`<div class="alphafold-loading">Mol* couldn't render the structure (${escapeHtml(err.message || String(err))}).</div>`;
}
}
// Wires the #foldModal backdrop, Γ— button, and Esc key to closeFoldModal.
// Idempotent β€” safe to call on every open.
function bindFoldModalDismiss() {
document.querySelectorAll('#foldModal [data-close-modal]').forEach((el) => {
if (el.dataset.wired === '1') return;
el.dataset.wired = '1';
el.addEventListener('click', closeFoldModal);
});
if (!bindFoldModalDismiss._esc) {
bindFoldModalDismiss._esc = true;
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('foldModal');
if (e.key === 'Escape' && modal && !modal.hidden) closeFoldModal();
});
}
}
function closeFoldModal() {
const modal = document.getElementById('foldModal');
if (modal) modal.hidden = true;
// Mark the in-flight fold as orphaned so its response (if it eventually
// arrives) won't try to mount into a hidden host.
foldInFlightVariant = null;
if (alphaFoldPollHandle) { clearTimeout(alphaFoldPollHandle); alphaFoldPollHandle = null; }
}
// ── AlphaFold path: closest known structure, for proteins ESMFold can't fold ─
// ESMFold (above) folds the EXACT variant but its public endpoint caps at
// ~400 aa. AlphaFold-DB can't fold a novel sequence at all β€” it serves only
// precomputed models keyed by UniProt accession. So for large (or any)
// proteins we BLAST the wild-type to find the nearest KNOWN protein and show
// ITS AlphaFold-DB model: a reference scaffold for the family, at any length β€”
// honestly NOT a fold of this variant's specific mutations. We reuse the
// /api/identify BLAST job (cached by sequence hash, so a prior Identify run
// returns instantly) and the same version-resilient Mol* loader as the
// page-level embed.
let alphaFoldPollHandle = null;
// Swap the shared #foldModal between its two engines (chrome only β€” title +
// disclaimer). `subText === null` leaves the sub-line for the caller to manage.
function _setFoldModalMode(mode, subText) {
const title = document.getElementById('foldModalTitle');
const sub = document.getElementById('foldModalSub');
const disc = document.getElementById('foldModalDisclaimer');
if (mode === 'alphafold') {
if (title) title.textContent = 'Closest known structure Β· AlphaFold-DB';
if (disc) disc.innerHTML =
'Closest known protein to your <strong>wild-type</strong>, found via ' +
'<a href="https://blast.ncbi.nlm.nih.gov" target="_blank" rel="noopener">NCBI BLAST</a> ' +
'and rendered from <a href="https://alphafold.ebi.ac.uk" target="_blank" rel="noopener">AlphaFold-DB</a>. ' +
'No length cap β€” the structure path for proteins too large for ESMFold. ' +
'<strong>This is a reference scaffold for the protein family, not a fold of this variant’s mutations.</strong> ' +
'Drag to rotate Β· scroll to zoom Β· double-click to fit. ' +
'Backbone confidence colored by pLDDT (blue&nbsp;=&nbsp;high, orange&nbsp;=&nbsp;low).';
} else {
if (title) title.textContent = 'Predicted structure Β· ESMFold';
if (disc) disc.innerHTML =
'Predicted by <a href="https://esmatlas.com/about" target="_blank" rel="noopener">ESMFold</a> (Meta AI). ' +
'<strong>The variant’s amino-acid sequence is sent to <code>api.esmatlas.com</code></strong> ' +
'over HTTPS β€” encrypted in transit, visible to Meta’s servers. Do not fold ' +
'confidential or pre-publication sequences. ' +
'Drag to rotate Β· scroll to zoom Β· double-click to fit. ' +
'Backbone confidence colored by pLDDT (blue&nbsp;=&nbsp;high, orange&nbsp;=&nbsp;low).';
}
if (sub && subText != null) sub.textContent = subText;
}
async function openAlphaFoldModal(variant) {
const modal = document.getElementById('foldModal');
const host = document.getElementById('foldViewer');
if (!modal || !host) return;
if (!state.sessionId) {
showToast('Load and run a sequence first β€” AlphaFold needs the wild-type to search.', 'warn');
return;
}
// BLAST consent (same per-session opt-in as the page-level Identify): the
// wild-type sequence is sent to NCBI. Show an in-modal consent step the
// first time, then remember it for the session.
if (!hasBlastConsent()) {
_setFoldModalMode('alphafold', 'awaiting consent');
host.innerHTML = `
<div class="fold-consent">
<p class="fold-consent-kicker">&sect; Consent</p>
<h3 class="fold-consent-title"><em>Search NCBI for a known structure?</em></h3>
<p class="fold-consent-body">
AlphaFold-DB only holds models for <em>known</em> proteins, so to show
one we send your <strong>wild-type</strong> protein sequence to
<code>blast.ncbi.nlm.nih.gov</code> to find the closest match, then load
its structure from <code>alphafold.ebi.ac.uk</code>. Takes ~30–90&nbsp;s.
</p>
<p class="fold-consent-body">
<em>Skip this if your sequence is confidential or pre-publication IP.</em>
Scoring, search and codon optimization stay on-server; only this opt-in
BLAST leaves the machine.
</p>
<div class="fold-consent-actions">
<button class="ghost-btn" type="button" data-af-cancel>Cancel</button>
<button class="primary" type="button" data-af-consent>
Continue &middot; remember for this session
</button>
</div>
</div>
`;
modal.hidden = false;
bindFoldModalDismiss();
const cancelBtn = host.querySelector('[data-af-cancel]');
const consentBtn = host.querySelector('[data-af-consent]');
if (cancelBtn) cancelBtn.addEventListener('click', closeFoldModal);
if (consentBtn) consentBtn.addEventListener('click', () => {
grantBlastConsent();
openAlphaFoldModal(variant); // re-enter now that consent is recorded
});
return;
}
// Orphan any pending ESMFold response so it can't mount over this one.
foldInFlightVariant = null;
if (alphaFoldPollHandle) { clearTimeout(alphaFoldPollHandle); alphaFoldPollHandle = null; }
_setFoldModalMode('alphafold', 'Searching NCBI for the closest known structure…');
host.innerHTML = `
<div class="alphafold-loading">
<span class="identify-spinner"></span>
Searching NCBI for the closest known protein… (30–90 s)
</div>
`;
modal.hidden = false;
bindFoldModalDismiss();
try {
const res = await fetch('/api/identify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: state.sessionId,
cds_feature: state.chosenCds,
blast_consent: hasBlastConsent(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'BLAST submission failed.');
if (data.cached && data.status === 'done') { _renderFoldAlphaFold(data, host); return; }
_pollAlphaFoldModal(data.job_id, host);
} catch (err) {
host.innerHTML =
`<div class="alphafold-loading"><strong>Couldn’t reach NCBI BLAST.</strong><br>` +
`<span style="opacity:.75">${escapeHtml(err.message || String(err))}</span></div>`;
}
}
function _pollAlphaFoldModal(jobId, host) {
if (alphaFoldPollHandle) clearTimeout(alphaFoldPollHandle);
alphaFoldPollHandle = setTimeout(async () => {
const modal = document.getElementById('foldModal');
if (!modal || modal.hidden) return; // user closed the modal β€” stop
try {
const res = await fetch(`/api/identify/${jobId}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Lost the BLAST job.');
if (data.status === 'done') { _renderFoldAlphaFold(data, host); return; }
if (data.status === 'error') {
host.innerHTML =
`<div class="alphafold-loading"><strong>BLAST failed.</strong><br>` +
`<span style="opacity:.75">${escapeHtml(data.error || 'unknown error')}</span></div>`;
return;
}
const elapsed = data.elapsed_seconds != null ? data.elapsed_seconds.toFixed(0) : '0';
const phase = { pending: 'Queued', submitting: 'Submitting to NCBI',
running: 'NCBI is searching nr', parsing: 'Parsing results' }[data.status] || data.status;
host.innerHTML =
`<div class="alphafold-loading"><span class="identify-spinner"></span> ` +
`${escapeHtml(phase)} Β· ${escapeHtml(elapsed)}s (NCBI queues can take 30–90 s)</div>`;
_pollAlphaFoldModal(jobId, host);
} catch (err) {
host.innerHTML =
`<div class="alphafold-loading"><strong>Lost the BLAST job.</strong><br>` +
`<span style="opacity:.75">${escapeHtml(err.message || String(err))}</span></div>`;
}
}, 2000);
}
function _renderFoldAlphaFold(data, host) {
const hits = (data && data.hits) || [];
// First UniProt-mapped hit in the top 5 (the very top may be RefSeq-only).
const afHit = hits.slice(0, 5).find(h => h.uniprot && h.alphafold_url);
const sub = document.getElementById('foldModalSub');
if (!afHit) {
const top = hits[0];
host.innerHTML = `
<div class="alphafold-loading">
<strong>No known structure to show.</strong><br>
<span style="opacity:.8">${hits.length
? 'The closest NCBI match' + (top && top.description ? ' (' + escapeHtml(top.description) + ')' : '') +
' has no UniProt / AlphaFold-DB entry. '
: 'No significant NCBI matches β€” this sequence may be de novo or heavily engineered. '}
For sequences ≀ ${ESMFOLD_MAX_RES} aa you can fold the exact variant with <em>ESMFold</em>.</span>
</div>`;
if (sub) sub.textContent = 'No AlphaFold model available';
return;
}
const afHomolog = afHit.uniprot_via === 'swissprot_homolog';
const afOrg = afHomolog ? (afHit.uniprot_organism || afHit.organism || '') : (afHit.organism || '');
if (sub) sub.textContent =
`Closest known: UniProt ${afHit.uniprot}${afOrg ? ' Β· ' + afOrg : ''}` +
(afHomolog && afHit.uniprot_identity_pct != null ? ` Β· ${afHit.uniprot_identity_pct}% id` : '');
// Mount straight into #foldViewer (it already carries .alphafold-viewer).
host.dataset.pdbUrl = afHit.alphafold_url;
host.dataset.uniprot = afHit.uniprot;
host.innerHTML =
'<div class="alphafold-loading"><span class="identify-spinner"></span> Loading AlphaFold-DB structure…</div>';
mountAlphaFoldViewer(afHit, host);
}
function showToast(message, level = 'info') {
const existing = document.getElementById('dee-toast');
if (existing) existing.remove();
const t = document.createElement('div');
t.id = 'dee-toast';
t.className = `toast toast-${level}`;
// Errors get an "!" glyph instead of the success "βœ“" β€” small but means
// a colorblind user can still tell info / warn / error apart at a glance.
const glyph = level === 'error' ? '!' : (level === 'warn' ? '⚠' : 'βœ“');
t.innerHTML = `
<span class="toast-check">${glyph}</span>
<span class="toast-msg">${escapeHtml(message)}</span>
`;
document.body.appendChild(t);
requestAnimationFrame(() => t.classList.add('toast-in'));
// High-stakes failures stay on screen longer so the user actually sees
// them. 2.4 s is fine for info; clipboard / save errors get 7 s.
const dwell = level === 'error' ? 7000 : (level === 'warn' ? 4200 : 2400);
setTimeout(() => {
t.classList.remove('toast-in');
t.classList.add('toast-out');
setTimeout(() => t.remove(), 320);
}, dwell);
}
function renderDetailPanel(v) {
const isWt = v.Variant_ID === 'WT';
const gc = Number(v.GC_Percent);
const gcWarn = !Number.isNaN(gc) && (gc < 40 || gc > 60)
? `<span class="gc-warn">${gc < 40 ? 'low GC' : 'high GC'}</span>`
: '';
const annealing = v.Annealing_Temp_C != null ? `${v.Annealing_Temp_C} Β°C` : 'β€”';
const fwdTm = v.Primer_Fwd_Tm_C != null ? `${v.Primer_Fwd_Tm_C} Β°C` : 'β€”';
const revTm = v.Primer_Rev_Tm_C != null ? `${v.Primer_Rev_Tm_C} Β°C` : 'β€”';
const fwdGc = v.Primer_Fwd_GC_Percent != null ? `${v.Primer_Fwd_GC_Percent}%` : 'β€”';
const revGc = v.Primer_Rev_GC_Percent != null ? `${v.Primer_Rev_GC_Percent}%` : 'β€”';
// 1-indexed AA positions mutated in this variant β€” drives the highlight
// overlays in the mutant-protein and DNA blocks below. Empty for WT.
const mutPositionList = [];
const mutPositionSet = new Set();
(v.Mutations_AA || '').split(',').forEach((m) => {
const match = m.trim().match(/^[A-Z](\d+)[A-Z\*]$/);
if (match) {
const pos = parseInt(match[1], 10);
mutPositionList.push(pos);
mutPositionSet.add(pos);
}
});
const mutationsDisplay = isWt
? '<span class="muted">β€” (unmutated)</span>'
: escapeHtml(v.Mutations_AA || '');
const proteinHeader = isWt
? `Wild-type protein (${(v.Mutant_AA_Seq || '').length} aa)`
: `Mutant protein (${(v.Mutant_AA_Seq || '').length} aa) Β· mutations highlighted in brand color`;
const dnaHeader = isWt
? `Codon-optimized wild-type DNA (${v.Length_bp} bp)`
: `Optimized DNA sequence (${v.Length_bp} bp) Β· mutated codons highlighted`;
// Restriction-site honesty banner. The server's ``Restriction_Sites_Unresolved``
// is the count of forbidden recognition sites STILL PRESENT in the final DNA
// after synonymous scrubbing (or unchanged if the user manually edited the
// sequence). Anything > 0 means a Golden Gate / Type IIS assembly will fail
// when the enzyme cuts inside the insert. We surface this prominently AND
// gate the Synthesize buttons so a hurried click can't ship a broken insert.
const reUnresolved = Number(v.Restriction_Sites_Unresolved) || 0;
const synthBlocked = reUnresolved > 0;
const restrictionBanner = synthBlocked
? `
<div class="re-warn-banner" role="alert">
<strong>⚠ ${reUnresolved} forbidden restriction site${reUnresolved > 1 ? 's' : ''} still present</strong>
<p>This DNA still contains ${reUnresolved} BsaI / BsmBI / NotI recognition site${reUnresolved > 1 ? 's' : ''} that synonymous scrubbing couldn't remove. A Golden Gate / Type IIS assembly will be cut at these positions and the insert will fragment. Edit the DNA to break the offending codons before ordering, or pick a vector that doesn't rely on these enzymes.</p>
</div>
`
: '';
return `
<div class="detail-panel${isWt ? ' detail-panel-wt' : ''}${synthBlocked ? ' detail-panel-re-warn' : ''}">
${restrictionBanner}
<div class="detail-section">
<h4>PCR setup</h4>
<div class="pcr-summary">
<div><span class="pcr-label">Amplicon length</span><span class="pcr-val">${v.Length_bp} bp</span></div>
<div><span class="pcr-label">GC content</span><span class="pcr-val">${Number(v.GC_Percent).toFixed(1)}% ${gcWarn}</span></div>
<div><span class="pcr-label">Annealing temp</span><span class="pcr-val">${annealing}</span></div>
<div><span class="pcr-label">Mutations</span><span class="pcr-val">${mutationsDisplay}</span></div>
</div>
</div>
<div class="detail-section">
<h4>Primers</h4>
<div class="primer-grid">
<div class="primer-card">
<div class="primer-card-head">
<span class="primer-label">Forward</span>
<span class="primer-direction">5β€² β†’ 3β€²</span>
</div>
<div class="primer-seq" data-copy="${escapeHtml(v.Primer_Fwd)}">${escapeHtml(v.Primer_Fwd)}</div>
<div class="primer-stats">
<span>Tm <strong>${fwdTm}</strong></span>
<span>GC <strong>${fwdGc}</strong></span>
<span>Length <strong>${(v.Primer_Fwd || '').length} bp</strong></span>
</div>
<div class="primer-actions">
<button class="copy-btn" data-copy="${escapeHtml(v.Primer_Fwd)}">Copy</button>
</div>
</div>
<div class="primer-card">
<div class="primer-card-head">
<span class="primer-label">Reverse</span>
<span class="primer-direction">5β€² β†’ 3β€² (RC of 3β€² end)</span>
</div>
<div class="primer-seq" data-copy="${escapeHtml(v.Primer_Rev)}">${escapeHtml(v.Primer_Rev)}</div>
<div class="primer-stats">
<span>Tm <strong>${revTm}</strong></span>
<span>GC <strong>${revGc}</strong></span>
<span>Length <strong>${(v.Primer_Rev || '').length} bp</strong></span>
</div>
<div class="primer-actions">
<button class="copy-btn" data-copy="${escapeHtml(v.Primer_Rev)}">Copy</button>
</div>
</div>
</div>
<div class="primer-actions" style="margin-top:8px">
<button class="copy-btn" data-copy="${escapeHtml((v.Primer_Fwd || '') + '\t' + (v.Primer_Rev || ''))}">Copy primer pair (TSV)</button>
</div>
</div>
<div class="detail-section">
<h4>${proteinHeader}</h4>
<div class="sequence-block">${formatProteinDiff(v.Mutant_AA_Seq || '', tableState.wtProtein || '', mutPositionSet)}</div>
</div>
<!--
Editable codon-optimized DNA. The textarea is hidden by default
(read-only view shows the highlighted formatted block); clicking
"Edit DNA" reveals it, and onInput it validates + persists back
to tableState.variants[i].Optimized_DNA_Seq so the Cloning
Designer + Synthesize buttons all see the edited sequence.
-->
<div class="detail-section dna-edit-section" data-variant-id="${escapeHtml(v.Variant_ID)}">
<div class="dna-edit-head">
<h4>${dnaHeader}</h4>
<div class="dna-edit-actions">
<button class="ghost-btn dna-edit-toggle" data-variant="${escapeHtml(v.Variant_ID)}" type="button">Edit DNA</button>
<button class="ghost-btn dna-edit-reset" data-variant="${escapeHtml(v.Variant_ID)}" type="button" hidden>Revert</button>
</div>
</div>
<div class="dna-view" data-variant="${escapeHtml(v.Variant_ID)}">
<div class="sequence-block">${formatDna(v.Optimized_DNA_Seq, mutPositionList)}</div>
</div>
<div class="dna-edit-wrap" data-variant="${escapeHtml(v.Variant_ID)}" hidden>
<!--
spellcheck/autocorrect/autocapitalize all OFF so iOS
Safari doesn't replace 'ACGT' runs with dictionary
words or capitalize the first letter. inputmode=text
keeps the on-screen keyboard out of URL/decimal mode.
-->
<textarea class="dna-edit-textarea" data-variant="${escapeHtml(v.Variant_ID)}"
spellcheck="false" autocorrect="off" autocapitalize="off"
autocomplete="off" inputmode="text"
rows="8">${escapeHtml(v.Optimized_DNA_Seq || '')}</textarea>
<div class="dna-edit-stats" data-variant="${escapeHtml(v.Variant_ID)}"></div>
</div>
</div>
<div class="detail-section cloning-section" data-variant-id="${escapeHtml(v.Variant_ID)}">
<h4>Cloning Designer Β· prepare for synthesis</h4>
<p class="cloning-hint">
Pick a cloning method, destination vector, restriction enzymes (or
Type IIS overhangs), and optional fusion tags. The Designer
assembles the full insert in real time, validates it against the
vector's MCS, and flags internal restriction-site clashes that
would break your digest.
</p>
<!-- The Designer container; renderDesigner() fills it. -->
<div class="cloning-designer" data-variant="${escapeHtml(v.Variant_ID)}"></div>
<div class="synth-row">
<span class="synth-row-label">Synthesize via</span>
${Object.entries(VENDORS).map(([key, vendor]) => `
<button class="copy-btn synth-btn${synthBlocked ? ' synth-btn-warn' : ''}"
data-synth-id="${escapeHtml(v.Variant_ID)}"
data-synth-vendor="${escapeHtml(key)}"
data-synth-blocked="${synthBlocked ? '1' : '0'}"
${synthBlocked ? `title="This sequence contains ${reUnresolved} unresolvable restriction site(s). Click to override."` : ''}>
<span>${escapeHtml(vendor.name)}</span>
<svg class="synth-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14">
<path d="M7 7h10v10"/><path d="M7 17 17 7"/>
</svg>
</button>
`).join('')}
</div>
<div class="primer-actions" style="margin-top:10px">
<button class="copy-btn${synthBlocked ? ' synth-btn-warn' : ''}"
data-cloning-copy="formatted" data-variant="${escapeHtml(v.Variant_ID)}"
data-synth-blocked="${synthBlocked ? '1' : '0'}"
${synthBlocked ? `title="Contains ${reUnresolved} unresolvable restriction site(s)."` : ''}>Copy designed insert</button>
<!--
These two buttons MUST resolve from live tableState at
click time. The previous implementation baked
v.Optimized_DNA_Seq / v.Mutant_AA_Seq into data-copy
when the panel rendered, so the user could edit the
DNA, save, and still copy the pre-edit sequence into
their vendor order form. data-copy-live + a variant
id keeps the buttons honest β€” wireCopyButtons reads
tableState.variants at the click.
-->
<button class="copy-btn" data-copy-live="dna" data-variant="${escapeHtml(v.Variant_ID)}">Copy bare CDS</button>
<button class="copy-btn" data-copy-live="protein" data-variant="${escapeHtml(v.Variant_ID)}">Copy protein</button>
</div>
</div>
</div>
`;
}
function formatDna(dna, highlightAaPositions = []) {
// GenBank/EMBOSS-style layout with optional mutation highlighting:
// 1 atgccgaaa ttaggccct gaacgtttt gcaggcgaa cggtttcca aagttaata
// 61 tcgggcatg ...
// Each line is 6 blocks of 10 nt = 60 nt. AA position N highlights nt
// positions (N-1)*3 .. (N-1)*3 + 2 (its codon).
if (!dna) return '';
const lower = dna.toLowerCase();
const charsPerLine = 60;
const blockSize = 10;
const padWidth = String(lower.length).length;
// Build a set of nt indices to highlight.
const hi = new Set();
for (const aaPos of highlightAaPositions) {
const start = (aaPos - 1) * 3;
hi.add(start); hi.add(start + 1); hi.add(start + 2);
}
const lines = [];
for (let i = 0; i < lower.length; i += charsPerLine) {
const num = String(i + 1).padStart(padWidth, ' ');
// Build the line char-by-char so we can wrap highlighted nt in spans.
let lineHtml = '';
for (let j = 0; j < charsPerLine && i + j < lower.length; j++) {
if (j > 0 && j % blockSize === 0) lineHtml += ' ';
const nt = lower[i + j];
if (hi.has(i + j)) {
lineHtml += `<span class="pos-mut">${escapeHtml(nt)}</span>`;
} else {
lineHtml += escapeHtml(nt);
}
}
lines.push(`<span class="seq-num">${escapeHtml(num)}</span> ${lineHtml}`);
}
return lines.join('\n');
}
function formatProteinDiff(mutantProtein, wtProtein, mutPositions) {
// Render mutant protein with mutated residues highlighted, tooltip showing
// WT residue. mutPositions is a Set of 1-indexed AA positions.
if (!mutantProtein) return '';
const charsPerLine = 60;
const blockSize = 10;
const padWidth = String(mutantProtein.length).length;
const lines = [];
for (let i = 0; i < mutantProtein.length; i += charsPerLine) {
const num = String(i + 1).padStart(padWidth, ' ');
let lineHtml = '';
for (let j = 0; j < charsPerLine && i + j < mutantProtein.length; j++) {
if (j > 0 && j % blockSize === 0) lineHtml += ' ';
const pos = i + j + 1; // 1-indexed
const aa = mutantProtein[i + j];
if (mutPositions.has(pos)) {
const wt = wtProtein[i + j] || '?';
lineHtml += `<span class="pos-mut" title="WT ${wt}${pos}${aa}">${escapeHtml(aa)}</span>`;
} else {
lineHtml += escapeHtml(aa);
}
}
lines.push(`<span class="seq-num">${escapeHtml(num)}</span> ${lineHtml}`);
}
return lines.join('\n');
}
// Try to write to the clipboard with a user-visible failure path. When
// the clipboard API rejects (iframe missing permission, insecure context,
// focus lost, browser policy) we previously swallowed the error silently
// β€” a click on Synthesize would do nothing, the user would open the
// vendor tab themselves, and the WRONG previously-copied bytes would
// land in their order form. Surfacing the failure as a high-visibility
// toast is the bare minimum so the user knows nothing was copied. The
// caller's `await` resolves false on failure so they can also abort
// whatever follow-on action (vendor redirect, modal open) was queued.
async function copyToClipboardOrToast(text, label = 'sequence') {
if (!text) {
showToast(`Nothing to copy (${label} is empty).`, 'warn');
return false;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
const detail = err?.message ? ` (${err.message})` : '';
showToast(
`Clipboard write blocked${detail}. The ${label} was NOT copied. ` +
`Select the sequence in the panel and use ⌘C / Ctrl-C, or grant ` +
`clipboard permission to this page and retry.`,
'error',
);
return false;
}
}
function wireCopyButtons(scope) {
// Plain "copy this string to clipboard" buttons. Static payload baked
// into data-copy at render time. Safe for things that never change
// (primer sequences, mutation labels, methods text). For anything the
// user can edit, use data-copy-live below so the click reads live state.
scope.querySelectorAll('[data-copy]').forEach((el) => {
if (!el.classList.contains('copy-btn')) return;
el.addEventListener('click', async (e) => {
e.stopPropagation();
const text = el.dataset.copy;
const ok = await copyToClipboardOrToast(text, 'value');
if (!ok) return;
const original = el.innerHTML;
el.classList.add('copied');
el.textContent = 'Copied βœ“';
setTimeout(() => {
el.classList.remove('copied');
el.innerHTML = original;
}, 1400);
});
});
// Live-resolving copy buttons β€” read tableState.variants at click time
// so a user edit committed via persistEditedDna is reflected immediately.
// data-copy-live="dna|protein" + data-variant="<Variant_ID>"
scope.querySelectorAll('[data-copy-live]').forEach((el) => {
if (!el.classList.contains('copy-btn')) return;
el.addEventListener('click', async (e) => {
e.stopPropagation();
const which = el.dataset.copyLive;
const variantId = el.dataset.variant;
const v = (tableState.variants || []).find(x => x.Variant_ID === variantId);
if (!v) return;
const text = which === 'dna'
? (v.Optimized_DNA_Seq || '')
: (v.Mutant_AA_Seq || '');
const label = which === 'dna' ? `${variantId} DNA` : `${variantId} protein`;
const ok = await copyToClipboardOrToast(text, label);
if (!ok) return;
const original = el.innerHTML;
el.classList.add('copied');
el.textContent = `Copied βœ“ (${text.length} ${which === 'dna' ? 'bp' : 'aa'})`;
setTimeout(() => {
el.classList.remove('copied');
el.innerHTML = original;
}, 1400);
});
});
// "Copy formatted DNA" β€” reads the live cloning-state for this variant
// so the user gets the currently-previewed format (not stale data baked
// into a data-copy attribute at render time). Honors data-synth-blocked
// so a sequence with unresolvable restriction sites prompts for
// confirmation before the bytes hit the clipboard.
scope.querySelectorAll('[data-cloning-copy]').forEach((el) => {
if (!el.classList.contains('copy-btn')) return;
el.addEventListener('click', async (e) => {
e.stopPropagation();
const variantId = el.dataset.variant;
const blocked = el.dataset.synthBlocked === '1';
const formatted = formattedDnaFor(variantId);
if (!formatted) return;
// Per-session verification gate. First copy-insert action of the
// session shows the disclaimer modal; subsequent copies in the
// same tab skip it (a research-tool would be unusable with a
// modal on every click).
const verifyOk = await ensureSynthVerified(`
<strong>${escapeHtml(variantId)}</strong> &middot;
${escapeHtml(String(formatted.full.length))} bp designed insert &middot;
to clipboard for vendor order form
`);
if (!verifyOk) return;
// RE-warn confirm still runs AFTER the verification gate so the
// user sees both pieces of information when both apply.
if (blocked) {
const ok = window.confirm(
`${variantId} still contains unresolvable restriction sites. ` +
`Copying the designed insert anyway?`,
);
if (!ok) return;
}
const ok = await copyToClipboardOrToast(formatted.full, `${variantId} designed insert`);
if (!ok) return;
const original = el.innerHTML;
el.classList.add('copied');
el.textContent = `Copied βœ“ (${formatted.full.length} bp)`;
setTimeout(() => {
el.classList.remove('copied');
el.innerHTML = original;
}, 1600);
});
});
// Synthesize-via-X buttons β€” one per vendor, each carrying its vendor
// key in data-synth-vendor. Sends the *currently-selected* formatted
// DNA (per the cloning section's dropdown), not the bare CDS, so the
// vendor receives an order with proper cloning ends already attached.
//
// If data-synth-blocked="1" we interpose a native confirm() before any
// network call or clipboard write. The dialog names the specific
// problem (unresolvable restriction sites) so a hurried click doesn't
// ship an insert that the vendor's Golden Gate / Type IIS workflow
// will fragment on enzyme digestion.
scope.querySelectorAll('.synth-btn').forEach((el) => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const variantId = el.dataset.synthId;
const vendorKey = el.dataset.synthVendor;
const blocked = el.dataset.synthBlocked === '1';
if (blocked) {
const ok = window.confirm(
`${variantId} still contains unresolvable restriction sites ` +
`(BsaI / BsmBI / NotI). Golden Gate or Type IIS assemblies ` +
`using these enzymes will cut inside the insert and the ` +
`construct will fragment.\n\nProceed anyway?`,
);
if (!ok) return;
}
const formatted = formattedDnaFor(variantId);
// Fall back to the bare CDS in the unlikely event the cloning
// state wasn't seeded (e.g. user clicked the synth button
// before the dropdown wired up).
let dna = formatted ? formatted.full : '';
if (!dna) {
const v = (tableState.variants || []).find(x => x.Variant_ID === variantId);
dna = v?.Optimized_DNA_Seq || '';
}
synthesizeDna(dna, variantId, vendorKey);
const original = el.innerHTML;
el.classList.add('copied');
el.innerHTML = 'Copied βœ“';
setTimeout(() => {
el.classList.remove('copied');
el.innerHTML = original;
}, 2200);
});
});
}
// ============================================================== DNA EDIT
// In-place editing of any variant's codon-optimized DNA. The persistence
// model is atomic: client-side keystrokes update only the live validation
// pills, and the explicit "Save & close" button POSTs the textarea contents
// to /api/variants/<job>/<id>/dna. The server re-derives every column from
// those bytes (translation, mutations diff vs WT, PCR metrics, restriction
// scan), rewrites the library CSV on disk atomically, and returns the new
// row. We replace tableState.variants[i] with that response so what the
// user sees on screen, what /api/download streams, and what gets ordered
// are guaranteed to be the same string. No edit ever lives client-only.
//
// Revert posts the original snapshot through the same endpoint so the
// server matches the screen after a roll-back.
// Snapshot of the server-supplied DNA per variant β€” taken on first edit so
// Revert always has the pristine sequence even after many save cycles.
const originalDnaSnapshots = new Map();
// Strict ACGT sanitizer + validator. Module-scoped so wireDnaEdit and any
// future caller share one implementation. Returns { clean, dropped, gc, len, warnings }.
const STOP_CODONS_SET = new Set(['TAA', 'TAG', 'TGA']);
function validateDnaEdit(raw) {
// RNA β†’ DNA before anything else, mirror the server's _clean() so a
// paste from an mRNA-centric tool doesn't silently lose every U.
const upper = (raw || '').toUpperCase().replace(/U/g, 'T');
// Track WHERE each non-ACGT character was so we can tell the user
// exactly what was dropped and at which 1-indexed position in their
// typed input (post-whitespace). Zero-width Unicode (ZWSP, BOM, soft
// hyphen, word joiner) often hides inside PDF/Word pastes and the
// user will never spot it from a "removed 1 character" warning alone.
// Strip visible whitespace AND the most common invisible/zero-width
// characters that PDF/Word copy-paste injects between residues. Codepoints
// spelled out so future readers can see what we're filtering β€” these are
// the ones that have actually burned us in the field:
// \s β€” visible whitespace (space, tab, newline, etc.)
// U+00A0 NO-BREAK SPACE U+00AD SOFT HYPHEN
// U+200B ZERO-WIDTH SPACE U+200C ZERO-WIDTH NON-JOINER
// U+200D ZERO-WIDTH JOINER U+2060 WORD JOINER
// U+202F NARROW NO-BREAK SPACE U+FEFF BYTE ORDER MARK
const STRIP_RE = /[\s\u00a0\u00ad\u200b\u200c\u200d\u2060\u202f\ufeff]/;
const dropped = [];
const cleanChars = [];
let meaningfulPos = 0;
for (let i = 0; i < upper.length; i++) {
const ch = upper[i];
if (STRIP_RE.test(ch)) continue;
meaningfulPos += 1;
if (ch === 'A' || ch === 'C' || ch === 'G' || ch === 'T') {
cleanChars.push(ch);
} else {
dropped.push({ char: ch, codePoint: ch.codePointAt(0), pos: meaningfulPos });
}
}
const clean = cleanChars.join('');
const warnings = [];
if (dropped.length) {
// HIGH severity β€” any non-ACGT character is a potential silent
// frame-shift. Show position + identity (with Unicode codepoint for
// invisibles like ZWSP) so the user can find and fix the typo.
const names = dropped.slice(0, 6).map((d) => {
const visible = (d.codePoint >= 0x20 && d.codePoint < 0x7F)
? `'${d.char}'`
: `U+${d.codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
return `${visible}@${d.pos}`;
}).join(', ');
const more = dropped.length > 6 ? ` (+${dropped.length - 6} more)` : '';
warnings.push({
severity: 'high',
text: `Rejected ${dropped.length} non-ACGT character${dropped.length > 1 ? 's' : ''}: ${names}${more}. Save is blocked until these are removed or replaced. (U β†’ T is done automatically; IUPAC codes like R/Y/N must be resolved manually.)`,
});
}
if (clean.length === 0) {
warnings.push({ severity: 'high', text: 'Sequence is empty.' });
}
if (clean.length && clean.length % 3 !== 0) {
warnings.push({ severity: 'high', text: `Length ${clean.length} bp is not a multiple of 3 β€” frame will be broken. Save is blocked.` });
}
// Premature-stop check: any stop codon BEFORE the last codon.
let firstPrematureStop = -1;
for (let i = 0; i + 3 <= clean.length - 3; i += 3) {
const codon = clean.slice(i, i + 3);
if (STOP_CODONS_SET.has(codon)) { firstPrematureStop = i; break; }
}
if (firstPrematureStop >= 0) {
// A premature stop is a legitimate nonsense / truncation mutation
// (e.g. a single-base SNP that creates a stop, or an intentional
// knockout). Warn, don't block β€” the variant encodes the truncated
// product and synthesis is unaffected.
const aaPos = Math.floor(firstPrematureStop / 3) + 1;
warnings.push({ severity: 'medium', text: `Premature stop (${clean.slice(firstPrematureStop, firstPrematureStop + 3)}) at codon ${aaPos} β€” truncates the protein (nonsense mutation). Allowed.` });
}
// Terminal stop check β€” if the final codon isn't TAA/TAG/TGA, warn but
// don't block (some users edit out the stop intentionally for fusion
// constructs that read into downstream sequence).
if (clean.length >= 3 && clean.length % 3 === 0) {
const lastCodon = clean.slice(-3);
if (!STOP_CODONS_SET.has(lastCodon)) {
warnings.push({ severity: 'medium', text: `Last codon (${lastCodon}) is not a stop β€” translation will read through into anything appended downstream.` });
}
}
const gc = clean.length
? ((clean.match(/[GC]/g) || []).length / clean.length) * 100
: 0;
if (clean.length && (gc < 35 || gc > 65)) {
warnings.push({ severity: 'medium', text: `GC ${gc.toFixed(1)}% is outside the 35–65% sweet spot.` });
}
return { clean, dropped, gc, len: clean.length, warnings };
}
// Pure "can this be saved?" check. Save button is disabled when this is false.
function isDnaSaveable(result) {
return result.warnings.every((w) => w.severity !== 'high');
}
function wireDnaEdit(scope, variant) {
const variantId = variant.Variant_ID;
const toggle = scope.querySelector(`.dna-edit-toggle[data-variant="${cssEscape(variantId)}"]`);
const reset = scope.querySelector(`.dna-edit-reset[data-variant="${cssEscape(variantId)}"]`);
const view = scope.querySelector(`.dna-view[data-variant="${cssEscape(variantId)}"]`);
const wrap = scope.querySelector(`.dna-edit-wrap[data-variant="${cssEscape(variantId)}"]`);
const ta = scope.querySelector(`.dna-edit-textarea[data-variant="${cssEscape(variantId)}"]`);
const stats = scope.querySelector(`.dna-edit-stats[data-variant="${cssEscape(variantId)}"]`);
if (!toggle || !ta || !wrap || !view) return;
if (!originalDnaSnapshots.has(variantId)) {
originalDnaSnapshots.set(variantId, variant.Optimized_DNA_Seq || '');
}
let inFlight = false;
function renderStats(result, extra) {
const sevClass = (s) => `warn-${s}`;
const summary = `
<span class="dna-stat-pill"><strong>${result.len}</strong> bp</span>
<span class="dna-stat-pill">GC <strong>${result.gc.toFixed(1)}%</strong></span>
<span class="dna-stat-pill">${result.len % 3 === 0 ? 'in-frame βœ“' : '<span class="warn-high">frame broken</span>'}</span>
`;
const warningsHtml = result.warnings.length
? `<ul class="dna-edit-warnings">${result.warnings.map(w => `<li class="${sevClass(w.severity)}">${escapeHtml(w.text)}</li>`).join('')}</ul>`
: '<div class="dna-edit-ok">Looks good for synthesis. βœ“</div>';
const extraHtml = extra ? `<div class="dna-edit-extra">${extra}</div>` : '';
stats.innerHTML = summary + warningsHtml + extraHtml;
// Save button reflects current saveability AND in-flight state.
toggle.disabled = inFlight || (!wrap.hidden && !isDnaSaveable(result));
}
// Initial stats render so the user sees current GC/length pre-edit.
renderStats(validateDnaEdit(ta.value));
// Toggle button: opens the textarea, or β€” when already open β€” sends the
// current textarea contents through the atomic save endpoint.
toggle.addEventListener('click', async (e) => {
e.stopPropagation();
const editing = !wrap.hidden;
if (editing) {
const result = validateDnaEdit(ta.value);
if (!isDnaSaveable(result)) {
renderStats(result, '<span class="warn-high">Fix the high-severity issues above before saving.</span>');
return;
}
inFlight = true;
renderStats(result, '<span class="dna-edit-saving">Saving β€” re-deriving translation, primers, RE scan…</span>');
try {
await persistEditedDna(variantId, result.clean);
// The persistEditedDna call replaces tableState.variants[i]
// with the server's freshly-derived row and re-renders the
// table. The detail panel for this row is wiped by the
// re-render, so the user sees the update reflected in the
// table cells. A toast confirms the save.
} catch (err) {
inFlight = false;
renderStats(result, `<span class="warn-high">Save failed: ${escapeHtml(err.message || String(err))}</span>`);
}
} else {
view.hidden = true;
wrap.hidden = false;
toggle.textContent = 'Save & close';
reset.hidden = false;
ta.focus();
const v = ta.value;
ta.setSelectionRange(v.length, v.length);
renderStats(validateDnaEdit(ta.value));
}
});
// Revert: post the original snapshot through the same atomic endpoint
// so the server matches the screen after roll-back.
reset.addEventListener('click', async (e) => {
e.stopPropagation();
if (inFlight) return;
const original = originalDnaSnapshots.get(variantId) || '';
ta.value = original;
const result = validateDnaEdit(original);
if (!isDnaSaveable(result)) {
// The original snapshot itself failed validation β€” that's a bug
// upstream, but show it rather than silently doing nothing.
renderStats(result, '<span class="warn-high">Snapshot of the original DNA failed validation β€” please refresh the page.</span>');
return;
}
inFlight = true;
renderStats(result, '<span class="dna-edit-saving">Reverting to the original sequence…</span>');
try {
await persistEditedDna(variantId, result.clean);
} catch (err) {
inFlight = false;
renderStats(result, `<span class="warn-high">Revert failed: ${escapeHtml(err.message || String(err))}</span>`);
}
});
// Live local validation β€” keystrokes only update the pills + warnings.
// No network call until the explicit Save button. This keeps the edit
// experience snappy and avoids spamming /api/variants while the user
// is mid-paste.
ta.addEventListener('input', () => {
renderStats(validateDnaEdit(ta.value));
});
}
/**
* Persist an edited DNA string atomically.
*
* 1. POST the edited bytes to /api/variants/<job>/<id>/dna.
* 2. Server re-derives the full row (translation, mutations diff,
* PCR metrics, RE-site scan) and rewrites the library CSV on disk
* atomically. Returns the new row.
* 3. Replace tableState.variants[i] with the server's row.
* 4. Drop cloningState[variantId] so the Cloning Designer rebuilds.
* 5. Re-render the variant table (detail panel collapses; user re-opens
* to verify). Toast confirms the save with the new metrics.
*
* Throws on 4xx/5xx with a user-readable message so the caller can show
* it inline next to the textarea.
*/
async function persistEditedDna(variantId, newDna) {
if (!state.jobId) throw new Error('No active job β€” refresh the page.');
const url = `/api/variants/${encodeURIComponent(state.jobId)}/${encodeURIComponent(variantId)}/dna`;
let res, body;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dna: newDna }),
});
body = await res.json();
} catch (netErr) {
throw new Error(`Network error talking to the server: ${netErr?.message || netErr}`);
}
if (!res.ok && res.status !== 207) {
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
}
// 207 = "saved in memory but on-disk CSV rewrite failed". The row was
// still returned and the in-memory state is correct, but downloads may
// temporarily diverge. Surface that as a non-fatal warning toast.
if (res.status === 207) {
showToast(`Saved on screen but CSV rewrite failed: ${body?.error || ''}`, 'warn');
}
const row = body?.row;
if (!row) throw new Error('Server response missing the updated row.');
const variants = tableState.variants || [];
const idx = variants.findIndex(v => v.Variant_ID === variantId);
if (idx >= 0) variants[idx] = row;
// Drop the Cloning Designer state for this variant so it rebuilds from
// the new CDS the next time the detail panel opens.
if (typeof cloningState !== 'undefined') cloningState.delete(variantId);
// Re-render the table so the row cells (mutations, GC, length) reflect
// the new state. This collapses the detail panel β€” by design β€” so the
// user gets clear visual feedback that the save committed.
if (typeof renderVariantRows === 'function') renderVariantRows();
const reCount = Number(row.Restriction_Sites_Unresolved) || 0;
const reHint = reCount > 0 ? ` · ⚠ ${reCount} RE site${reCount > 1 ? 's' : ''}` : '';
showToast(
`${variantId} saved Β· ${row.Length_bp} bp Β· GC ${Number(row.GC_Percent).toFixed(1)}%${reHint}`,
reCount > 0 ? 'warn' : 'info',
);
return row;
}
// ============================================================== UTIL
function showError(msg, detail) {
clearError();
const banner = document.createElement('div');
banner.className = 'error-banner';
banner.id = 'errorBanner';
let locator = '';
if (detail && typeof detail.line === 'number') {
locator = `<div>In your paste: <code>line ${detail.line}, column ${detail.col}</code>${
typeof detail.nt_position === 'number'
? ` (nucleotide #${detail.nt_position + 1})`
: ''
}.</div>`;
} else if (detail && typeof detail.nt_position === 'number') {
locator = `<div>At <code>nucleotide #${detail.nt_position + 1}</code>.</div>`;
}
let hint = '';
if (detail && detail.code === 'premature_stop') {
hint = `<div class="error-hint">If your sequence really is a clean CDS, check the reading frame (make sure your paste starts exactly at the ATG) and that you aren't on the antisense strand. Otherwise, the ORF picker below will surface real ORFs from your DNA.</div>`;
} else if (detail && detail.code === 'bad_length') {
hint = `<div class="error-hint">Trim leading/trailing non-coding sequence so the total length is a multiple of 3.</div>`;
} else if (detail && detail.code === 'invalid_char') {
hint = `<div class="error-hint">Looks like there's a non-IUPAC character (a digit, punctuation, or stray Unicode). Often this is GenBank-style line numbers from a copy-paste.</div>`;
}
banner.innerHTML = `<strong>Couldn't read your sequence</strong>${escapeHtml(msg).replace(/\n/g, '<br>')}${locator}${hint}`;
$('#inputCard').appendChild(banner);
banner.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearError() {
const old = $('#errorBanner');
if (old) old.remove();
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function capitalize(s) {
if (!s) return s;
return s[0].toUpperCase() + s.slice(1);
}
function basename(p) {
return (p || '').split('/').pop();
}
// ============================================================== QUIT
// Stop-server button removed from the UI as part of the AI-bloat audit
// (it was developer debug surface exposed in production hosting where
// stopping the server doesn't make sense). The /api/shutdown endpoint
// still exists for local CLI use; if a re-introduction is wanted, gate
// it behind a settings page or a localhost-only check, not a permanent
// button in the production sidebar.
const _quitBtn = document.getElementById('quitBtn');
if (_quitBtn) {
_quitBtn.addEventListener('click', async () => {
if (!confirm('Stop the TuringDNA engine server?')) return;
try { await fetch('/api/shutdown', { method: 'POST' }); } catch (_) {}
document.body.innerHTML = '<div style="padding:48px;font-family:system-ui;color:#4A4742">Server stopped. You can close this tab.</div>';
});
}
// ════════════════════════════════════════════════════════════════════════
// CRISPR view controller β€” Cas9 knockout gRNA designer.
// ────────────────────────────────────────────────────────────────────────
// Mirrors the directed-evolution view's shape: paste β†’ submit β†’ table.
// Sign-in gated server-side; UI shows a sign-in notice when anonymous.
// ════════════════════════════════════════════════════════════════════════
(function crisprController() {
const ta = document.getElementById('crisprInput');
const stats = document.getElementById('crisprInputStats');
const btn = document.getElementById('crisprDesignBtn');
const notice = document.getElementById('crisprSigninNotice');
const dlBtn = document.getElementById('crisprDownloadBtn');
const resCard = document.getElementById('crisprResultsCard');
const resSub = document.getElementById('crisprResultsSub');
const tbody = document.getElementById('crisprTableBody');
const errBox = document.getElementById('crisprError');
const modal = document.getElementById('crisprSigninModal');
// Phase 2C-1 β€” vector picker + vendor buttons (created from
// /api/crispr/vectors at first design click; we don't pre-fetch
// so the picker doesn't appear at all for cached old builds).
const vectorRow = document.getElementById('crisprVectorRow');
const vectorSelect = document.getElementById('crisprVector');
const vectorMeta = document.getElementById('crisprVectorMeta');
const vendorRow = document.getElementById('crisprVendorRow');
if (!ta || !btn) return; // CRISPR view not in DOM (older cached build)
// ─── Phase 2C-2 / 3: base-ruler gutter (matches the design page) ──
// CRISPR textarea gutter β€” uses the shared base-ruler reflow: the sequence
// is wrapped to 10 bases/line and the gutter is numbered by base position
// (1, 11, 21 …). reflowSeqEditor handles wrapping (incl. on paste, via the
// input event), caret preservation, and the gutter numbers in one pass.
const crisprGutter = document.getElementById('crisprPasteGutter');
function renderCrisprGutter() {
if (crisprGutter) reflowSeqEditor(ta, crisprGutter);
}
if (crisprGutter) {
ta.addEventListener('input', renderCrisprGutter);
ta.addEventListener('scroll', () => {
crisprGutter.scrollTop = ta.scrollTop;
});
let _crGutterResizeT;
window.addEventListener('resize', () => {
clearTimeout(_crGutterResizeT);
_crGutterResizeT = setTimeout(renderCrisprGutter, 150); // re-fit on width change
});
renderCrisprGutter();
}
let lastSubmittedSequence = null; // for CSV download
// Cached lookup: vector_id β†’ vector metadata; populated by the
// first successful /api/crispr/vectors call.
let vectorIndex = {};
let vendorsCache = {}; // vendor_id β†’ { name, url, hint }
let lastGuides = []; // most recent guides[] β€” needed for "Copy all
// oligos" button on vendor row.
// ─── Phase 3 (M1): base-editing mode controller ─────────────────
// Toggling to "Base edit" reveals the base-editor dropdown (lazy-
// loaded from /api/crispr/base-editors), and β€” since base editing is
// SpCas9-only β€” locks the nuclease to Cas9 and disables the Cas12a
// radio. Toggling back restores the Cas12a option.
const beRow = document.getElementById('crisprBaseEditorRow');
const beSelectEl = document.getElementById('crisprBaseEditor');
const beMeta = document.getElementById('crisprBaseEditorMeta');
let _baseEditorsLoaded = false;
async function ensureBaseEditorsLoaded() {
if (_baseEditorsLoaded || !beSelectEl) return;
try {
const r = await fetch('/api/crispr/base-editors');
if (!r.ok) throw new Error('base-editors fetch failed');
const data = await r.json();
beSelectEl.innerHTML = '';
(data.editors || []).forEach((e) => {
const o = document.createElement('option');
o.value = e.id;
o.textContent = `${e.name} Β· ${e.target_base}β†’${e.result_base} Β· window ${e.window[0]}–${e.window[1]}`;
o.dataset.note = e.note || '';
beSelectEl.appendChild(o);
});
if (data.default) beSelectEl.value = data.default;
_baseEditorsLoaded = true;
updateBaseEditorMeta();
} catch (_) { /* soft fail β€” dropdown just stays empty */ }
}
function updateBaseEditorMeta() {
if (!beMeta || !beSelectEl) return;
const opt = beSelectEl.options[beSelectEl.selectedIndex];
const note = opt ? (opt.dataset.note || '') : '';
beMeta.textContent = note
? note + ' Amino-acid consequences appear when a gene symbol (human/mouse) is set or the pasted sequence is a coding sequence (ATG…stop).'
: '';
}
function applyMode(mode) {
const isBE = mode === 'base_edit';
if (beRow) beRow.hidden = !isBE;
// Lock nuclease to Cas9 for base editing.
const cas9Radio = document.querySelector('input[name="crisprEnzyme"][value="cas9"]');
const cas12Radio = document.querySelector('input[name="crisprEnzyme"][value="cas12a"]');
const cas12Label = cas12Radio && cas12Radio.closest('.crispr-enzyme-opt');
if (isBE) {
if (cas9Radio) cas9Radio.checked = true;
if (cas12Radio) cas12Radio.disabled = true;
if (cas12Label) cas12Label.classList.add('crispr-opt-disabled');
ensureBaseEditorsLoaded();
} else {
if (cas12Radio) cas12Radio.disabled = false;
if (cas12Label) cas12Label.classList.remove('crispr-opt-disabled');
}
}
document.querySelectorAll('input[name="crisprMode"]').forEach((r) => {
r.addEventListener('change', () => { if (r.checked) applyMode(r.value); });
});
if (beSelectEl) beSelectEl.addEventListener('change', updateBaseEditorMeta);
// ─── Phase 3 (M2): paste-anything resolver ──────────────────────
// Type a gene symbol / accession β†’ fill the paste box with the
// resolved sequence + set the gene context. Raw paste is unchanged.
const resolveInput = document.getElementById('crisprResolveInput');
const resolveBtn = document.getElementById('crisprResolveBtn');
const resolveChip = document.getElementById('crisprResolveChip');
function _showResolveChip(html, isError) {
if (!resolveChip) return;
resolveChip.hidden = false;
resolveChip.className = 'crispr-resolve-chip' + (isError ? ' crispr-resolve-chip-error' : '');
resolveChip.innerHTML = html;
}
async function _doResolve() {
const text = (resolveInput && resolveInput.value.trim()) || '';
if (!text) return;
const organismEl = document.getElementById('crisprOrganism');
const organism = (organismEl && organismEl.value) || '';
if (resolveBtn) { resolveBtn.disabled = true; resolveBtn.textContent = 'Fetching…'; }
try {
const res = await fetch('/api/crispr/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, organism }),
});
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') {
_openSigninModal();
return;
}
if (!data.ok) {
_showResolveChip(escapeHtml(data.error || 'Could not resolve that input.'), true);
return;
}
// Fill the paste box + refresh gutter / stats / button state.
if (ta) {
ta.value = data.sequence;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
// Resolved a named gene β†’ set the gene-symbol field so exon
// context + base-edit AA consequences get the reading frame.
if (data.kind === 'gene' && data.gene_symbol) {
const geneEl = document.getElementById('crisprGeneSymbol');
if (geneEl) geneEl.value = data.gene_symbol;
}
_showResolveChip(
'<span class="crispr-resolve-ok">&check;</span> ' + escapeHtml(data.label),
false);
} catch (err) {
_showResolveChip(escapeHtml(err.message || 'Network error.'), true);
} finally {
if (resolveBtn) { resolveBtn.disabled = false; resolveBtn.textContent = 'Fetch'; }
}
}
if (resolveBtn) resolveBtn.addEventListener('click', _doResolve);
if (resolveInput) resolveInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); _doResolve(); }
});
// ─── Phase 2C-1: vector + vendor loader ─────────────────────────
// Fetches /api/crispr/vectors on first design call, caches in memory
// for the rest of the session, populates the picker + vendor row.
let _vectorsLoaded = false;
let _vectorsLoading = null;
async function ensureVectorsLoaded(enzyme) {
if (_vectorsLoaded && vectorSelect && vectorSelect.dataset.enzyme === enzyme) return;
if (_vectorsLoading) return _vectorsLoading;
_vectorsLoading = (async () => {
try {
const r = await fetch('/api/crispr/vectors?enzyme=' + encodeURIComponent(enzyme || ''));
if (!r.ok) throw new Error('vectors fetch failed');
const data = await r.json();
vendorsCache = data.vendors || {};
vectorIndex = {};
if (vectorSelect) {
vectorSelect.innerHTML = '';
// Default first option = no vector (skip oligo gen).
const noopt = document.createElement('option');
noopt.value = '';
noopt.textContent = 'β€” skip cloning oligos β€”';
vectorSelect.appendChild(noopt);
(data.vectors || []).forEach(v => {
vectorIndex[v.id] = v;
const o = document.createElement('option');
o.value = v.id;
// Name + Addgene + enzyme + selection β€” packed into
// the dropdown label so users don't need to expand
// a tooltip to pick.
o.textContent = `${v.name} Β· Addgene ${v.addgene_id} Β· ${v.enzyme}`;
vectorSelect.appendChild(o);
});
if (data.default && vectorIndex[data.default]) {
vectorSelect.value = data.default;
}
vectorSelect.dataset.enzyme = enzyme || 'cas9';
if (vectorRow) vectorRow.hidden = false;
updateVectorMeta();
}
renderVendorButtons();
_vectorsLoaded = true;
} catch (e) {
// Soft fail β€” vector picker just doesn't appear, oligos
// stay empty in the table.
} finally {
_vectorsLoading = null;
}
})();
return _vectorsLoading;
}
function updateVectorMeta() {
if (!vectorSelect || !vectorMeta) return;
const v = vectorIndex[vectorSelect.value];
vectorMeta.textContent = v
? `${v.description} Β· selection: ${v.selection}`
: '';
}
if (vectorSelect) {
vectorSelect.addEventListener('change', updateVectorMeta);
}
// ── Header bar: "Order ALL guides from X" ────────────────────────
// Same gradient-blue treatment as the DE page's .synth-row .synth-btn,
// so vendors read as primary actions instead of ghost buttons.
// Each click copies EVERY guide's oligo pair as TSV β†’ opens vendor.
function renderVendorButtons() {
if (!vendorRow) return;
const ids = Object.keys(vendorsCache);
if (!ids.length) { vendorRow.hidden = true; return; }
vendorRow.innerHTML =
'<span class="crispr-vendor-label">Order all guides from</span>' +
ids.map(k => {
const v = vendorsCache[k];
// Gradient blue style + β†— arrow svg matches DE synth-btn.
return `<button type="button" class="crispr-vendor-btn crispr-vendor-btn-bulk" data-vendor="${escapeHtml(k)}" title="${escapeHtml(v.hint || '')}">
<span>${escapeHtml(v.name)}</span>
<svg class="synth-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="13" height="13">
<path d="M7 7h10v10"/><path d="M7 17 17 7"/>
</svg>
</button>`;
}).join('');
vendorRow.hidden = false;
vendorRow.querySelectorAll('.crispr-vendor-btn-bulk').forEach(btn => {
btn.addEventListener('click', () => {
const vid = btn.getAttribute('data-vendor');
const vendor = vendorsCache[vid];
if (!vendor) return;
const lines = ['Name\tSequence'];
lastGuides.forEach(g => {
if (!g.sense_oligo || !g.antisense_oligo) return;
lines.push(`g${g.rank}_sense\t${g.sense_oligo}`);
lines.push(`g${g.rank}_antisense\t${g.antisense_oligo}`);
});
if (lines.length === 1) {
_showError('No cloning oligos yet β€” pick a vector and click Design first.');
return;
}
_vendorOrderClick(btn, vendor, lines.join('\n'),
`all ${lastGuides.length} guides`);
});
});
}
// ── Per-row vendor button helper ─────────────────────────────────
// Builds the inline-button HTML for the new "Order" table column.
// 3 compact gradient buttons (Twist / IDT / Synthego), each click
// copies THIS guide's sense+antisense oligo pair + opens vendor.
// Empty cell when the guide has no oligos (no vector picked).
function _renderRowVendors(g) {
if (!g.sense_oligo || !g.antisense_oligo) {
return '<span class="muted">β€”</span>';
}
const ids = Object.keys(vendorsCache);
if (!ids.length) return '<span class="muted">β€”</span>';
// Short labels so 3 buttons fit a narrow column. Two-letter
// abbreviations make the column ~140 px which is bearable.
const SHORT = { twist: 'TWST', idt: 'IDT', synthego: 'SYN' };
return '<div class="crispr-row-vendors">' + ids.map(vid => {
const v = vendorsCache[vid];
const lbl = SHORT[vid] || v.name.slice(0, 4).toUpperCase();
return `<button type="button" class="crispr-vendor-btn crispr-vendor-btn-row"
data-vendor="${escapeHtml(vid)}"
data-row-rank="${g.rank}"
title="Order this guide's oligo pair from ${escapeHtml(v.name)} β€” copies sense + antisense to clipboard, opens ${escapeHtml(v.name)} in a new tab.">${lbl}</button>`;
}).join('') + '</div>';
}
// ── Shared vendor-click action ───────────────────────────────────
// Writes `tsv` to clipboard, flashes the button to "Copied" with
// the success color, opens the vendor URL in a new tab after a
// brief delay so the user sees the confirmation feedback.
function _vendorOrderClick(btn, vendor, tsv, contextLabel) {
// Red Team gate before routing oligos to ANY synthesis vendor (Twist,
// IDT, Synthego, …). The synthesis checks apply regardless of vendor.
const seqs = (tsv || '').split('\n').slice(1)
.map(l => (l.split('\t')[1] || '').trim()).filter(Boolean);
const gene = ((document.getElementById('crisprGeneSymbol') || {}).value || '').trim();
runOracle({
seqs: seqs, gene: gene,
actionLabel: 'Before sending to ' + ((vendor && vendor.name) || 'your synthesis vendor'),
proceed: () => _vendorOrderDo(btn, vendor, tsv, contextLabel),
});
}
function _vendorOrderDo(btn, vendor, tsv, contextLabel) {
navigator.clipboard.writeText(tsv).then(() => {
// Visual confirmation β€” uses the same .copied class the DE
// page's .synth-row .synth-btn uses so the green flash
// reads as "this worked" everywhere in the product.
btn.classList.add('copied');
const orig = btn.innerHTML;
btn.innerHTML = 'βœ“ Copied';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = orig;
}, 1800);
// Toast β€” same component the DE synthesizeDna flow uses, so
// users get the same "what just happened" voice everywhere.
if (typeof showToast === 'function') {
showToast(
`${contextLabel} copied to clipboard. Opening ${vendor.name} β€” ${vendor.hint || 'paste into their oligo order form.'}`,
'info'
);
}
}).catch(() => {
_showError('Copy blocked β€” your browser may need clipboard permission. Try clicking inside the page first, then retry.');
});
// Small delay before opening the vendor tab so the toast/flash
// is visible before the user's focus shifts. Same 1.1s the DE
// synthesizeDna() uses.
setTimeout(() => {
window.open(vendor.url, '_blank', 'noopener,noreferrer');
}, 1100);
}
// ─── Sign-in modal ───────────────────────────────────────────────
// Fires on either:
// (a) anon user clicks the CRISPR sidebar tab β€” proactive nudge,
// lets them sign in before they invest effort pasting a seq.
// (b) backend returns 403 signin_required on /api/crispr/design β€”
// reactive catch in case (a) missed (frontend can't always
// tell who's signed in; auth.js's sessionStorage view can be
// stale).
//
// Dismissable so signed-in users whose JWT is temporarily out of
// sync can close it and try again rather than being stuck.
function _openSigninModal() {
if (!modal) return;
modal.hidden = false;
document.body.style.overflow = 'hidden';
const close = () => {
modal.hidden = true;
document.body.style.overflow = '';
document.removeEventListener('keydown', onEsc);
};
const onEsc = (e) => { if (e.key === 'Escape') close(); };
modal.querySelectorAll('[data-crispr-signin-close]').forEach((el) => {
el.addEventListener('click', close, { once: true });
});
document.addEventListener('keydown', onEsc);
}
// (a) Tab-click hook: any sidebar nav-item linking to #crispr OR
// #primers (both tools are members-only). Uses a lightweight
// isSignedIn() heuristic β€” if it returns false, proactively open the
// modal. If it returns true (or is wrong and returns false for a
// signed-in user), the user can dismiss and proceed; case (b) below
// catches false-positives via the authoritative backend 403.
document.querySelectorAll('a[href="#crispr"], a[href="#primers"], a[href="#plasmid"]').forEach((a) => {
a.addEventListener('click', () => {
const signedIn = (window.TuringAuth && window.TuringAuth.isSignedIn && window.TuringAuth.isSignedIn());
if (!signedIn) {
// Defer so the route switch finishes first; otherwise
// the body-overflow lock fights with the view swap.
setTimeout(_openSigninModal, 50);
}
});
});
// (b) Global event from auth.js fetch wrapper. Fires when ANY
// /api/* call comes back with 403 signin_required.
window.addEventListener('td:signin-required', _openSigninModal);
function _cleanedLength(s) {
return (s || '').replace(/[^ACGTNacgtn]/g, '').length;
}
function _updateStats() {
const nt = _cleanedLength(ta.value);
stats.textContent = nt ? `${nt.toLocaleString()} nt` : '0 nt';
// Trust the backend, not a frontend sessionStorage check. The
// previous version pre-disabled the button + showed a sign-in
// notice when window.TuringAuth.isSignedIn() returned false β€”
// but sessionStorage in the iframe can be empty for legitimately
// signed-in users (race condition: iframe loaded before the
// parent wrapper's session-refresh completed, or sessionStorage
// got cleared by a wrapper reload). The signed-in cookie / JWT
// that the backend cares about is independent of our
// sessionStorage view.
//
// New behavior: enable the button whenever the user has enough
// sequence. If they're genuinely anonymous, the backend returns
// 403 + kind=signin_required and we surface the notice + error
// banner at THAT point (handled in the submit handler). UX cost:
// one wasted click for unauthenticated users; UX win: no false
// "please sign in" for authenticated users.
if (notice) notice.hidden = true;
const canSubmit = nt >= 23;
btn.disabled = !canSubmit;
const sub = btn.querySelector('.primary-sub');
if (sub) {
sub.textContent = nt < 23
? 'Paste at least 23 nt'
: `${nt.toLocaleString()} nt ready`;
}
}
function _clearError() {
if (!errBox) return;
errBox.hidden = true;
errBox.textContent = '';
}
function _showError(msg) {
if (!errBox) return;
errBox.hidden = false;
errBox.textContent = msg;
errBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
ta.addEventListener('input', _updateStats);
// Persist the user's pasted sequence so it survives the iframe
// reload that fires when auth.js detects an expired JWT (403 β†’
// window.top.location.reload). Same pattern as the directed-
// evolution resume system; uses a different sessionStorage key.
const CRISPR_RESUME_KEY = 'td_crispr_resume';
function _saveCrisprResume(seq) {
try { sessionStorage.setItem(CRISPR_RESUME_KEY, seq); } catch (_) {}
}
function _restoreCrisprResume() {
try {
const saved = sessionStorage.getItem(CRISPR_RESUME_KEY);
if (saved && !ta.value) {
ta.value = saved;
_updateStats();
}
} catch (_) {}
}
function _clearCrisprResume() {
try { sessionStorage.removeItem(CRISPR_RESUME_KEY); } catch (_) {}
}
_restoreCrisprResume();
btn.addEventListener('click', async () => {
_clearError();
const seq = ta.value;
if (!seq.trim()) return;
// Stash BEFORE the API call so that if the call's 403 triggers
// a parent reload (via auth.js's signin_required handler), the
// user comes back to /app with their sequence already in the
// paste box. Cleared on successful design or explicit clear.
_saveCrisprResume(seq);
btn.disabled = true;
const labelEl = btn.querySelector('.primary-label');
const origLabel = labelEl ? labelEl.textContent : '';
if (labelEl) labelEl.textContent = 'Designing…';
try {
// Read the enzyme picker (Phase 1). Defaults to cas9 if the
// picker isn't in the DOM (cached old build).
const enzymeRadio = document.querySelector('input[name="crisprEnzyme"]:checked');
// Phase 3 (M1): editing mode + base editor. Base editing is
// SpCas9-only, so the mode overrides the nuclease.
const modeRadio = document.querySelector('input[name="crisprMode"]:checked');
const mode = (modeRadio && modeRadio.value) || 'knockout';
const enzyme = mode === 'base_edit'
? 'cas9'
: ((enzymeRadio && enzymeRadio.value) || 'cas9');
const beSelect = document.getElementById('crisprBaseEditor');
const base_editor = (mode === 'base_edit' && beSelect && beSelect.value) || '';
// Phase 2B-1: optional organism + gene symbol for genome
// off-target + exon-aware annotation. Both safe to omit.
const organismEl = document.getElementById('crisprOrganism');
const geneEl = document.getElementById('crisprGeneSymbol');
const target_organism = (organismEl && organismEl.value) || '';
const gene_symbol = (geneEl && geneEl.value.trim()) || '';
// Phase 2C-2: progress shell. Indeterminate shimmer + status
// text + elapsed-time counter. The status copy is tailored
// to the slowest step the request will hit so the user knows
// what's taking time (esp. for the ~60-90 s first build of
// the human genome index).
const progressShell = document.getElementById('crisprProgressShell');
const progressStatus = document.getElementById('crisprProgressStatus');
const progressElapsed = document.getElementById('crisprProgressElapsed');
const progressMessage = document.getElementById('crisprProgressMessage');
let progressTimer = null;
if (progressShell) {
progressShell.hidden = false;
if (progressStatus) {
const orgNice = target_organism === 'human' ? 'human exome'
: target_organism === 'mouse' ? 'mouse exome'
: target_organism === 'ecoli' ? 'E. coli genome'
: '';
progressStatus.textContent = orgNice
? `Designing guides Β· checking against the ${orgNice}…`
: 'Designing guides…';
}
if (progressMessage) {
progressMessage.textContent =
target_organism === 'human' || target_organism === 'mouse'
? 'First run with this organism takes about a minute while we prepare the reference. Every design after this one in the same session is instant.'
: (gene_symbol
? `Looking up ${gene_symbol} exon structure for splice-site and NMD-zone context…`
: 'Scoring on-target activity, off-target risk, predicted indel outcomes, base-editor compatibility, and cloning oligos.');
}
const t0 = Date.now();
if (progressElapsed) progressElapsed.textContent = '0.0s';
progressTimer = setInterval(() => {
if (progressElapsed) {
const secs = (Date.now() - t0) / 1000;
progressElapsed.textContent = `${secs.toFixed(1)}s`;
}
}, 100);
}
// Wrap in a try/finally so the spinner ALWAYS hides, even
// on early returns (signin modal, error, etc.).
const _hideProgress = () => {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null; }
if (progressShell) progressShell.hidden = true;
};
// Phase 2C-1: lazy-load the vector catalog on first design
// click (subsequent clicks reuse the cache). Picker only
// gets populated AFTER the load, so the first fetch sends
// an empty vector_id and the columns render without oligos β€”
// user picks a vector and re-clicks Design to get oligos.
// Subsequent designs honor the chosen vector.
await ensureVectorsLoaded(enzyme);
const vector_id = (vectorSelect && vectorSelect.value) || '';
const res = await fetch('/api/crispr/design', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sequence: seq, enzyme,
target_organism, gene_symbol,
vector_id,
mode, base_editor,
max_results: 50,
}),
});
const data = await res.json();
if (!res.ok) {
if (data && data.kind === 'signin_required') {
// auth.js will also fire the td:signin-required
// event which opens the modal β€” but we also pop it
// directly here in case the event handler hasn't
// bound yet (e.g., this is the first /api/* call
// and the global wrapper raced us).
_openSigninModal();
return; // no inline error β€” modal IS the message
}
throw new Error(data.error || 'CRISPR design failed.');
}
lastSubmittedSequence = seq;
_clearCrisprResume(); // success β€” don't restore stale text next time
_renderResults(data);
} catch (err) {
_showError(err.message || String(err));
} finally {
btn.disabled = false;
if (labelEl) labelEl.textContent = origLabel;
_updateStats();
_hideProgress();
}
});
// Phase 2D: small banner above the table, shown only when the
// genome index isn't ready yet. Inserted just under the results
// header so it sits between the card-sub text and the table.
function _renderGenomeStatusBanner(data) {
let el = document.getElementById('crisprGenomeStatusBanner');
const status = data && data.genome_index_status;
const org = data && data.genome_organism;
if (status !== 'building') {
if (el) el.remove();
return;
}
if (!el) {
el = document.createElement('div');
el.id = 'crisprGenomeStatusBanner';
el.className = 'crispr-banner crispr-banner-info';
// Insert just below the results header so it reads as a
// continuation of the "ranked sgRNAs" line.
const tableWrap = resCard.querySelector('.result-table-wrap');
if (tableWrap && tableWrap.parentNode) {
tableWrap.parentNode.insertBefore(el, tableWrap);
} else {
resCard.appendChild(el);
}
}
const orgName = org === 'human' ? 'Homo sapiens exome'
: org === 'mouse' ? 'Mus musculus exome'
: (org || 'genome');
el.innerHTML =
`<strong>Preparing the ${escapeHtml(orgName)} reference.</strong> ` +
'This takes about a minute the first time it&rsquo;s used, then it&rsquo;s instant for the rest of your session. ' +
'Your guides are scored normally &mdash; the <em>Genome off</em> column will fill in after you click <strong>Design</strong> again in a minute or two.';
}
// ─── Phase 3 (M3): results UX β€” sort / filter / explain ─────────
// The design response is cached so header-click sorting and filter
// toggles re-render the table WITHOUT re-fetching from the backend.
let crisprData = null;
let crisprSort = { key: 'composite_score', dir: 'desc' };
const crisprFilters = { hideFlagged: false, uniqueOnly: false, stopOnly: false };
let _crisprSortWired = false;
const _SORT_NUMERIC = {
position: g => g.position,
composite_score: g => (typeof g.composite_score === 'number' ? g.composite_score : g.on_target_score),
on_target_score: g => g.on_target_score || 0,
cfd_max_offtarget: g => g.cfd_max_offtarget || 0,
ko_efficacy: g => g.ko_efficacy || 0,
frameshift_pct: g => g.frameshift_pct || 0,
top_dominance_pct: g => g.top_dominance_pct || 0,
be_editability: g => g.be_editability || 0,
gc_pct: g => g.gc_pct || 0,
};
function _passesCrisprFilters(g) {
if (crisprFilters.hideFlagged && (g.flag_high_gc || g.flag_low_gc || g.flag_polyT)) return false;
if (crisprFilters.uniqueOnly) {
const selfHit = (g.cfd_max_offtarget || 0) > 0.1;
const genomeHit = !!g.genome_organism && (g.genome_offtarget_count || 0) > 0;
if (selfHit || genomeHit) return false;
}
if (crisprFilters.stopOnly && !g.be_creates_stop) return false;
return true;
}
function _crisprSortCmp(a, b) {
const getv = _SORT_NUMERIC[crisprSort.key] || _SORT_NUMERIC.composite_score;
const dir = crisprSort.dir === 'asc' ? 1 : -1;
const d = (getv(a) - getv(b)) * dir;
return d !== 0 ? d : (a.position - b.position); // stable tiebreak
}
// Plain-English "why this guide" explanation, built from the guide's
// own scores so non-experts can read the table without a glossary.
function _crisprWhy(g) {
const isBE = crisprData && crisprData.mode === 'base_edit';
const p = [];
p.push(`Targets the <strong>${g.strand === '+' ? 'sense' : 'antisense'}</strong> strand at position <strong>${g.position.toLocaleString()}</strong>.`);
if (isBE && g.be_editor) {
if (g.be_edits && g.be_edits.length) {
p.push(`With ${escapeHtml(g.be_editor.toUpperCase())} it installs <strong>${escapeHtml(g.be_outcome_label)}</strong>`
+ (g.be_aa_change ? `, predicted to cause <strong>${escapeHtml(g.be_aa_change)}</strong>` : '') + '.');
if (g.be_creates_stop) p.push('This introduces a premature stop codon β€” a DSB-free knockout (CRISPR-STOP).');
else if (g.be_has_bystander) p.push('More than one editable base sits in the window, so expect bystander edits.');
} else {
p.push('No editable base falls in this editor&rsquo;s window for this guide.');
}
p.push(`Editability is <strong>${(g.be_editability || 0).toFixed(2)}</strong> (higher = the edit is more likely).`);
} else {
p.push(`On-target activity <strong>${(g.on_target_score || 0).toFixed(2)}</strong>, predicted knockout efficacy <strong>${(g.ko_efficacy || 0).toFixed(2)}</strong>${g.ko_reasoning ? ' β€” ' + escapeHtml(g.ko_reasoning) : ''}.`);
if (typeof g.frameshift_pct === 'number') {
p.push(`About <strong>${g.frameshift_pct.toFixed(0)}%</strong> of predicted repair outcomes cause a frameshift; the most likely single outcome is <strong>${escapeHtml(g.top_indel_label || 'β€”')}</strong>.`);
}
}
const selfCfd = g.cfd_max_offtarget || 0;
if (selfCfd > 0.1) p.push(`Caution: a possible off-target sits within your input (CFD ${selfCfd.toFixed(2)}${g.offtarget_count > 1 ? `, ${g.offtarget_count} sites` : ''}).`);
else p.push('No significant off-target within the pasted sequence.');
if (g.genome_organism) {
p.push((g.genome_offtarget_count || 0) === 0
? `No coding-region off-target in the ${escapeHtml(g.genome_organism)} genome.`
: `${g.genome_offtarget_count} genome off-target site(s) (worst CFD ${(g.genome_offtarget_max_cfd || 0).toFixed(2)}).`);
}
if (g.exon_context_summary) p.push(escapeHtml(g.exon_context_summary) + '.');
const watch = [];
if (g.flag_high_gc) watch.push('high GC (synthesis-difficult)');
if (g.flag_low_gc) watch.push('low GC (often less active)');
if (g.flag_polyT) watch.push('a TTTT run (U6 may terminate early)');
if (watch.length) p.push('Watch-outs: ' + watch.join(', ') + '.');
p.push('<em>Run your top guides through CRISPOR for whole-genome off-target before ordering.</em>');
// Phase 3 (M5): structure-view button when the cut maps to a residue
// AND a human/mouse gene symbol is set (needed to resolve UniProt).
// When the context is missing, show a hint telling the user exactly
// what to add to unlock the 3-D view β€” so the feature is discoverable
// even on a raw-sequence run where it can't render yet.
const actions = [];
const geneEl = document.getElementById('crisprGeneSymbol');
const orgEl = document.getElementById('crisprOrganism');
const gene = geneEl ? geneEl.value.trim() : '';
const org = orgEl ? orgEl.value : '';
const orgOk = (org === 'human' || org === 'mouse');
if (!isBE) {
if (g.cut_residue && gene && orgOk) {
actions.push(`<button type="button" class="ghost crispr-struct-btn" data-struct-residue="${g.cut_residue}">View 3-D structure Β· cut at residue ${g.cut_residue}</button>`);
} else {
const need = (!gene && !orgOk) ? 'a human or mouse gene symbol and organism'
: !gene ? 'a gene symbol'
: !orgOk ? 'a human or mouse organism'
: 'a sequence that aligns to the gene&rsquo;s coding region';
actions.push(`<span class="crispr-struct-hint">Add ${need} above, then re-run, to map this cut onto the 3-D AlphaFold structure.</span>`);
}
}
// Per-guide IP radar β€” every variation can be checked, not just #1.
let radarInline = '';
if (g.spacer) {
actions.push(`<button type="button" class="ghost crispr-radar-btn" data-radar-rank="${g.rank}">βŒ– IP radar Β· check guide #${g.rank}</button>`);
radarInline = `<div class="ip-radar-inline" data-radar-banner="${g.rank}" hidden></div>`;
}
const actionsHtml = actions.length ? `<div class="crispr-why-actions">${actions.join('')}</div>` : '';
return '<div class="crispr-why">' + p.join(' ') + actionsHtml + radarInline + '</div>';
}
// Filter chips above the table (created once, then updated in place).
function _renderCrisprFilterBar(isBaseEdit) {
let bar = document.getElementById('crisprFilterBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'crisprFilterBar';
bar.className = 'crispr-filter-bar';
const tableWrap = resCard.querySelector('.result-table-wrap');
if (tableWrap && tableWrap.parentNode) tableWrap.parentNode.insertBefore(bar, tableWrap);
else resCard.appendChild(bar);
bar.innerHTML =
'<span class="crispr-filter-label">Filter</span>'
+ '<label class="crispr-filter-chip"><input type="checkbox" data-filter="hideFlagged"> hide flagged (GC / poly-T)</label>'
+ '<label class="crispr-filter-chip"><input type="checkbox" data-filter="uniqueOnly"> unique only (no off-target)</label>'
+ '<label class="crispr-filter-chip crispr-filter-be" data-be-only><input type="checkbox" data-filter="stopOnly"> creates stop only</label>'
+ '<span class="crispr-filter-count" id="crisprFilterCount"></span>';
bar.querySelectorAll('input[data-filter]').forEach((cb) => {
cb.addEventListener('change', () => {
crisprFilters[cb.getAttribute('data-filter')] = cb.checked;
if (crisprData) _renderResults(crisprData, true);
});
});
}
// The "creates stop only" chip is base-edit specific.
const beChip = bar.querySelector('[data-be-only]');
if (beChip) beChip.style.display = isBaseEdit ? '' : 'none';
if (!isBaseEdit) {
crisprFilters.stopOnly = false;
const cb = bar.querySelector('input[data-filter="stopOnly"]');
if (cb) cb.checked = false;
}
}
function _updateCrisprFilterCount(shown, total) {
const el = document.getElementById('crisprFilterCount');
if (el) el.textContent = shown < total ? `showing ${shown} of ${total}` : '';
}
// Click-to-sort on table headers carrying data-sort-key (wired once).
function _wireCrisprSortHeaders() {
if (_crisprSortWired) return;
const table = tbody.closest('table');
if (!table) return;
table.querySelectorAll('th[data-sort-key]').forEach((th) => {
th.classList.add('crispr-sortable');
th.addEventListener('click', () => {
const key = th.getAttribute('data-sort-key');
if (crisprSort.key === key) {
crisprSort.dir = crisprSort.dir === 'desc' ? 'asc' : 'desc';
} else {
crisprSort = { key, dir: 'desc' };
}
if (crisprData) _renderResults(crisprData, true);
});
});
_crisprSortWired = true;
}
function _updateCrisprSortIndicators() {
const table = tbody.closest('table');
if (!table) return;
table.querySelectorAll('th[data-sort-key]').forEach((th) => {
const active = th.getAttribute('data-sort-key') === crisprSort.key;
th.setAttribute('aria-sort', active ? (crisprSort.dir === 'asc' ? 'ascending' : 'descending') : 'none');
th.classList.toggle('crispr-sort-active', active);
th.classList.toggle('crispr-sort-asc', active && crisprSort.dir === 'asc');
});
}
// ─── Phase 3 (M5): structure viewer (Mol* + AlphaFold-DB) ───────
const structModal = document.getElementById('crisprStructureModal');
const _structCache = {}; // "org/GENE" β†’ resolved {uniprot, alphafold_url, …}
if (structModal) {
structModal.querySelectorAll('[data-crispr-struct-close]').forEach((el) =>
el.addEventListener('click', () => { structModal.hidden = true; }));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !structModal.hidden) structModal.hidden = true;
});
}
async function _showCrisprStructure(residue) {
if (!structModal) return;
const geneEl = document.getElementById('crisprGeneSymbol');
const orgEl = document.getElementById('crisprOrganism');
const gene = geneEl ? geneEl.value.trim() : '';
const org = orgEl ? orgEl.value : '';
if (!gene || (org !== 'human' && org !== 'mouse')) return;
const meta = document.getElementById('crisprStructureMeta');
const host = document.getElementById('crisprStructureViewer');
const disc = document.getElementById('crisprStructureDisclaimer');
structModal.hidden = false;
if (meta) meta.innerHTML = `${escapeHtml(gene)} Β· cut at residue <strong>${residue}</strong>`;
if (host) host.innerHTML = '<div class="alphafold-loading">Resolving structure…</div>';
const key = org + '/' + gene.toUpperCase();
let info = _structCache[key];
if (!info) {
try {
const res = await fetch('/api/crispr/structure', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ organism: org, gene_symbol: gene }),
});
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') {
structModal.hidden = true; _openSigninModal(); return;
}
if (!data.ok) {
if (host) host.innerHTML = `<div class="alphafold-loading">${escapeHtml(data.error || 'Could not resolve a structure for this gene.')}</div>`;
return;
}
info = data; _structCache[key] = info;
} catch (err) {
if (host) host.innerHTML = `<div class="alphafold-loading">${escapeHtml(err.message || 'Network error.')}</div>`;
return;
}
}
if (meta) meta.innerHTML = `${escapeHtml(gene)} Β· UniProt ${escapeHtml(info.uniprot)} Β· cut at residue <strong>${residue}</strong>`;
if (disc) disc.innerHTML = `Predicted structure from <a href="${escapeHtml(info.alphafold_page)}" target="_blank" rel="noopener">AlphaFold-DB</a>. The highlighted residue (${residue}) is the Cas9 cut site mapped through the CDS β€” approximate, not experimentally determined.`;
if (host) host.innerHTML = '<div class="alphafold-loading">Loading AlphaFold-DB structure…</div>';
await _mountCrisprStructure(host, info.alphafold_url, residue);
}
async function _mountCrisprStructure(host, pdbUrl, residue) {
if (!host || !pdbUrl) return;
const molstar = await _afWaitMolstar();
if (!molstar) { host.innerHTML = '<div class="alphafold-loading">Mol* viewer failed to load. Refresh and try again.</div>'; return; }
try {
const viewer = await molstar.Viewer.create(host, _AF_VIEWER_OPTS);
_afApplyBg(viewer);
const { candidates, exists } = await _afResolve(pdbUrl);
if (!await _afTryLoad(viewer, candidates)) {
_afShowError(host, exists !== false, () => _mountCrisprStructure(host, pdbUrl, residue));
return;
}
const ov = host.querySelector('.alphafold-loading'); if (ov) ov.remove();
_highlightResidue(molstar, viewer, residue);
} catch (err) {
_afShowError(host, true, () => _mountCrisprStructure(host, pdbUrl, residue));
}
}
// Best-effort residue highlight. Mol*'s low-level selection API is
// version-sensitive, so this is wrapped: a failure never blanks the
// structure (which still renders) and the residue is named in the
// header. We deliberately do NOT move the camera (focusLoci) β€” a tight
// single-residue zoom can clip the model; instead we select the
// residue so it's marked while the whole protein stays in view, and
// the user can zoom with the built-in Mol* controls.
function _highlightResidue(molstar, viewer, residue) {
try {
const plugin = viewer.plugin;
const structs = plugin.managers.structure.hierarchy.current.structures;
if (!structs || !structs.length) return;
const data = structs[0].cell.obj.data;
const sel = molstar.Script.getStructureSelection((Q) =>
Q.struct.generator.atomGroups({
'residue-test': Q.core.rel.eq([Q.struct.atomProperty.macromolecular.auth_seq_id(), residue]),
}), data);
const loci = molstar.StructureSelection.toLociWithSourceUnits(sel);
plugin.managers.structure.selection.fromLoci('set', loci);
plugin.managers.interactivity.lociHighlights.highlightOnly({ loci });
} catch (_) { /* best-effort β€” structure + header still inform */ }
}
// ─── Live Collision IP Radar (opt-in, per run) ──────────────────
// Scans recent literature (Europe PMC) for the TOP guide's exact spacer +
// gene context. Async β€” never blocks the design or Mol* flow; the result
// is cached server-side. Only the gene + 20-mer leave the Space.
const radarBtn = document.getElementById('crisprRadarBtn');
const radarBanner = document.getElementById('crisprRadarBanner');
function _radarTopGuide() {
const guides = (lastGuides && lastGuides.length)
? lastGuides : ((crisprData && crisprData.guides) || []);
if (!guides.length) return null;
return guides.find(g => g.rank === 1) || guides[0];
}
function _radarList(rows) {
return '<ul class="ip-radar-list">' + rows.map(r => {
const t = r.url
? '<a href="' + escapeHtml(r.url) + '" target="_blank" rel="noopener">' + escapeHtml(r.title || 'Untitled') + '</a>'
: escapeHtml(r.title || 'Untitled');
return '<li><span class="ip-radar-match-title">' + t + '</span>' +
'<span class="ip-radar-cite">' + escapeHtml(r.cite || '') + '</span></li>';
}).join('') + '</ul>';
}
function _renderRadar(data, top, bannerEl) {
bannerEl = bannerEl || radarBanner;
if (!bannerEl) return;
const matches = (data && data.matches) || [];
const patents = (data && data.patents) || [];
const close = '<button type="button" class="ip-radar-x" aria-label="Dismiss">Γ—</button>';
if (!matches.length && !patents.length) {
bannerEl.innerHTML =
'<div class="ip-radar-banner ip-radar-clear">' +
'<span class="ip-radar-ico" aria-hidden="true">βœ“</span>' +
'<div class="ip-radar-body"><strong>No literature overlap found</strong> for guide #' + top.rank +
'. Scanned recent preprints &amp; papers (Europe PMC). ' +
'<span class="ip-radar-note">A clean scan isn\'t a freedom-to-operate opinion.</span></div>' +
close + '</div>';
} else {
let body =
'<strong>Possible prior art β€” review before you publish or file.</strong>' +
'<p class="ip-radar-sub">Guide&nbsp;#' + top.rank + ' (<code>' + escapeHtml(top.spacer || '') +
'</code>) overlaps existing work. A heads-up, <em>not</em> a freedom-to-operate or infringement ' +
'opinion β€” confirm with a patent professional.</p>';
if (matches.length) {
const rows = matches.slice(0, 3).map(m => {
const when = (m.days_ago != null && m.days_ago <= 120)
? (m.days_ago + ' day' + (m.days_ago === 1 ? '' : 's') + ' ago')
: (m.year || (m.date || '').slice(0, 4) || '');
return { url: m.url, title: m.title,
cite: [m.authors || '', m.source || '', when].filter(Boolean).join(' Β· ') };
});
body += '<div class="ip-radar-section">Literature</div>' + _radarList(rows) +
(matches.length > 3 ? '<div class="ip-radar-more">+ ' + (matches.length - 3) + ' more</div>' : '');
}
if (patents.length) {
const rows = patents.slice(0, 3).map(p => ({
url: p.url, title: p.title,
cite: [p.assignee || '', p.year || ''].filter(Boolean).join(' Β· '),
}));
body += '<div class="ip-radar-section">Related patents in this space</div>' + _radarList(rows);
}
bannerEl.innerHTML =
'<div class="ip-radar-banner ip-radar-hit">' +
'<span class="ip-radar-ico" aria-hidden="true">⚠</span>' +
'<div class="ip-radar-body">' + body + '</div>' + close + '</div>';
}
bannerEl.hidden = false;
const x = bannerEl.querySelector('.ip-radar-x');
if (x) x.addEventListener('click', () => { bannerEl.hidden = true; bannerEl.innerHTML = ''; });
}
// Run the radar for ANY guide (not just #1). `triggerBtn` is the button
// that launched it (top "IP radar" button OR a per-guide button in a why
// panel); `bannerEl` is where the result renders (the top banner, or the
// inline banner beneath a per-guide button). Defaults keep the top button
// working exactly as before.
async function _runRadarFor(guide, triggerBtn, bannerEl) {
triggerBtn = triggerBtn || radarBtn;
bannerEl = bannerEl || radarBanner;
if (!guide) { _showError('Design guides first, then run the IP radar.'); return; }
if (!bannerEl) return;
const gene = ((document.getElementById('crisprGeneSymbol') || {}).value || '').trim();
const mode = (crisprData && crisprData.mode) || 'knockout';
const orig = triggerBtn ? triggerBtn.innerHTML : '';
if (triggerBtn) {
triggerBtn.disabled = true;
triggerBtn.innerHTML = '<span class="ip-radar-spinner" aria-hidden="true"></span> Scanning…';
}
bannerEl.hidden = false;
bannerEl.innerHTML =
'<div class="ip-radar-banner ip-radar-scanning">' +
'<span class="ip-radar-spinner" aria-hidden="true"></span> ' +
'Scanning recent literature &amp; preprints for overlap with guide #' + guide.rank + '…</div>';
try {
const res = await fetch('/api/crispr/ip_radar', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spacer: guide.spacer || '', gene_symbol: gene, mode: mode }),
});
if (res.status === 403) { bannerEl.hidden = true; bannerEl.innerHTML = ''; _openSigninModal(); return; }
const data = await res.json();
_renderRadar(data, guide, bannerEl);
} catch (err) {
bannerEl.innerHTML =
'<div class="ip-radar-banner ip-radar-error">Couldn\'t reach the literature index just now. ' +
'<button type="button" class="ip-radar-retry">Retry</button></div>';
const r = bannerEl.querySelector('.ip-radar-retry');
if (r) r.addEventListener('click', () => _runRadarFor(guide, triggerBtn, bannerEl));
} finally {
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.innerHTML = orig;
}
}
}
if (radarBtn && radarBanner) {
radarBtn.addEventListener('click', () => _runRadarFor(_radarTopGuide(), radarBtn, radarBanner));
}
// ─── Phase 3 (M4): save & revisit designs ───────────────────────
const saveBtn = document.getElementById('crisprSaveBtn');
const designsBtn = document.getElementById('crisprDesignsBtn');
const designsModal = document.getElementById('crisprDesignsModal');
const designsList = document.getElementById('crisprDesignsList');
if (designsModal) {
designsModal.querySelectorAll('[data-crispr-designs-close]').forEach((el) =>
el.addEventListener('click', () => { designsModal.hidden = true; }));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !designsModal.hidden) designsModal.hidden = true;
});
}
function _currentDesignLabel() {
const gene = (document.getElementById('crisprGeneSymbol') || {}).value || '';
const isBE = crisprData && crisprData.mode === 'base_edit';
const bits = [];
if (gene) bits.push(gene.toUpperCase());
bits.push(isBE
? ('base edit' + (crisprData.base_editor ? ' Β· ' + crisprData.base_editor.toUpperCase() : ''))
: 'knockout');
if (crisprData && crisprData.input_length) bits.push(crisprData.input_length.toLocaleString() + ' nt');
return bits.join(' Β· ') || 'Untitled design';
}
if (saveBtn) saveBtn.addEventListener('click', async () => {
if (!crisprData || !(crisprData.guides && crisprData.guides.length)) {
_showError('Run a design first, then save it.');
return;
}
const orig = saveBtn.textContent;
saveBtn.disabled = true; saveBtn.textContent = 'Saving…';
try {
const res = await fetch('/api/crispr/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: _currentDesignLabel(),
mode: crisprData.mode || 'knockout',
enzyme: crisprData.enzyme || '',
base_editor: crisprData.base_editor || '',
organism: (document.getElementById('crisprOrganism') || {}).value || '',
gene_symbol: (document.getElementById('crisprGeneSymbol') || {}).value || '',
input_length: crisprData.input_length || 0,
guides: crisprData.guides,
}),
});
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') { _openSigninModal(); return; }
if (!data.ok) { _showError(data.error || 'Could not save the design.'); return; }
saveBtn.textContent = 'Saved βœ“';
setTimeout(() => { saveBtn.textContent = orig; }, 1600);
} catch (err) {
_showError(err.message || 'Could not save the design.');
} finally {
saveBtn.disabled = false;
if (saveBtn.textContent === 'Saving…') saveBtn.textContent = orig;
}
});
if (designsBtn) designsBtn.addEventListener('click', async () => {
if (!designsModal) return;
designsModal.hidden = false;
if (designsList) designsList.innerHTML = '<p class="muted">Loading…</p>';
try {
const res = await fetch('/api/crispr/designs');
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') { designsModal.hidden = true; _openSigninModal(); return; }
const designs = (data && data.designs) || [];
if (!designs.length) {
if (designsList) designsList.innerHTML = '<p class="muted">No saved designs yet. Run a design and click &ldquo;Save design&rdquo;.</p>';
return;
}
if (designsList) {
designsList.innerHTML = designs.map((d) => {
const when = d.created_at ? new Date(d.created_at).toLocaleDateString() : '';
const tag = d.mode === 'base_edit' ? 'base edit' : 'knockout';
return `<button type="button" class="crispr-design-row" data-design-id="${escapeHtml(d.id)}">
<span class="crispr-design-label">${escapeHtml(d.label || 'Untitled design')}</span>
<span class="crispr-design-meta">${escapeHtml(tag)} Β· ${d.n_guides || 0} guides Β· ${escapeHtml(when)}</span>
</button>`;
}).join('');
designsList.querySelectorAll('.crispr-design-row').forEach((row) =>
row.addEventListener('click', () => _loadDesign(row.getAttribute('data-design-id'))));
}
} catch (err) {
if (designsList) designsList.innerHTML = `<p class="muted">${escapeHtml(err.message || 'Could not load your designs.')}</p>`;
}
});
async function _loadDesign(id) {
try {
const res = await fetch('/api/crispr/designs/' + encodeURIComponent(id));
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') { _openSigninModal(); return; }
if (!data.ok || !data.design) { _showError('Could not open that design.'); return; }
const d = data.design;
// Reflect the saved context in the controls so the table renders
// in the right mode and later Save/Download use the same settings.
const modeRadio = document.querySelector(`input[name="crisprMode"][value="${d.mode === 'base_edit' ? 'base_edit' : 'knockout'}"]`);
if (modeRadio) { modeRadio.checked = true; applyMode(modeRadio.value); }
if (d.base_editor) { ensureBaseEditorsLoaded().then(() => { const be = document.getElementById('crisprBaseEditor'); if (be) be.value = d.base_editor; }); }
if (d.organism) { const o = document.getElementById('crisprOrganism'); if (o) o.value = d.organism; }
if (d.gene_symbol) { const g = document.getElementById('crisprGeneSymbol'); if (g) g.value = d.gene_symbol; }
_renderResults({
input_length: d.input_length || 0,
n_guides: (d.guides || []).length,
enzyme: d.enzyme || 'cas9',
mode: d.mode || 'knockout',
base_editor: d.base_editor || '',
genome_organism: d.organism || '',
genome_index_status: 'n/a',
guides: d.guides || [],
});
// Loaded designs don't carry the source sequence, so block the
// re-design-from-sequence Download until a fresh run.
lastSubmittedSequence = null;
if (designsModal) designsModal.hidden = true;
} catch (err) {
_showError(err.message || 'Could not open that design.');
}
}
function _renderResults(data, _isRerender) {
resCard.hidden = false;
// Phase 3 (M1): swap the table's column set by mode. The base-edit
// class hides knockout-only columns (.col-ko) and reveals the
// base-edit columns (.col-be); knockout mode does the reverse.
const isBaseEdit = data.mode === 'base_edit';
const table = tbody.closest('table');
if (table) table.classList.toggle('crispr-table--base-edit', isBaseEdit);
const editorName = (data.base_editor || '').toUpperCase();
resSub.textContent = isBaseEdit
? `${data.n_guides} guides in ${data.input_length.toLocaleString()} nt Β· base editing${editorName ? ' Β· ' + editorName : ''} Β· ranked by editability`
: `${data.n_guides} guides found in ${data.input_length.toLocaleString()} nt Β· ranked by on-target score`;
// Phase 2D: genome-index status banner. When the user picked
// human/mouse but the kmer index is still building in the
// background, the guides come back without genome off-target
// data. Show a banner so the user knows to refresh in ~2 min
// rather than thinking the genome search "found nothing."
_renderGenomeStatusBanner(data);
// Phase 2C-1: stash guides for the "Copy all oligos" vendor flow,
// and re-render the vendor row (which may have been hidden if the
// previous design had no vector picked).
lastGuides = data.guides || [];
crisprData = data;
renderVendorButtons();
// Phase 3 (M3): filter bar + sortable headers (created once), then
// apply the active filters + sort to a working copy. On a fresh
// design (not a sort/filter re-render) reset to the mode's default
// ranking so the table opens ranked the way the backend intended.
_renderCrisprFilterBar(isBaseEdit);
_wireCrisprSortHeaders();
if (!_isRerender) {
crisprSort = isBaseEdit
? { key: 'be_editability', dir: 'desc' }
: { key: 'composite_score', dir: 'desc' };
}
const working = (data.guides || []).filter(_passesCrisprFilters).sort(_crisprSortCmp);
_updateCrisprFilterCount(working.length, (data.guides || []).length);
_updateCrisprSortIndicators();
// Phase 3 (M5) discoverability: is the 3-D structure view reachable
// for this run? It needs a human/mouse gene symbol (to resolve the
// UniProt accession → AlphaFold model) AND a cut→residue mapping.
// Computed once here so each row can show a visible "βŒ– 3-D" button
// instead of burying it inside the expandable why-panel.
const _sGeneEl = document.getElementById('crisprGeneSymbol');
const _sOrgEl = document.getElementById('crisprOrganism');
const _structOrgOk = _sOrgEl ? (_sOrgEl.value === 'human' || _sOrgEl.value === 'mouse') : false;
const _structCtxOk = !!((_sGeneEl ? _sGeneEl.value.trim() : '') && _structOrgOk && !isBaseEdit);
const rows = working.map((g) => {
const flags = [];
if (g.flag_high_gc) flags.push('high&nbsp;GC');
if (g.flag_low_gc) flags.push('low&nbsp;GC');
if (g.flag_polyT) flags.push('poly-T');
const flagsHtml = flags.length
? flags.map(f => `<span class="crispr-flag">${f}</span>`).join(' ')
: '<span class="muted">β€”</span>';
// Composite drives the row color (it IS the ranking column).
// Backward-compat: if backend hasn't been updated yet, fall
// back to on_target_score so old + new builds both render.
const composite = (typeof g.composite_score === 'number')
? g.composite_score : g.on_target_score;
const scoreClass = composite >= 0.65
? 'crispr-score-good'
: (composite >= 0.40 ? 'crispr-score-ok' : 'crispr-score-weak');
// Self-CFD column color: green if unique (0), amber if a
// weak partial match exists, red if a strong match (>0.5).
const cfd = (typeof g.cfd_max_offtarget === 'number')
? g.cfd_max_offtarget : 0;
const cfdClass = cfd >= 0.5 ? 'crispr-score-weak'
: cfd >= 0.1 ? 'crispr-score-ok'
: 'crispr-score-good';
const cfdLabel = cfd > 0
? `${cfd.toFixed(2)}${g.offtarget_count > 1 ? ' <span class="muted">Β·' + g.offtarget_count + ' sites</span>' : ''}`
: '<span class="muted">unique</span>';
// KO efficacy column: tooltip shows the reasoning so the
// user can see WHY a guide scored well or poorly.
const ko = (typeof g.ko_efficacy === 'number') ? g.ko_efficacy : 0;
const koClass = ko >= 0.55 ? 'crispr-score-good'
: ko >= 0.35 ? 'crispr-score-ok'
: 'crispr-score-weak';
const koTip = g.ko_reasoning ? ` title="${escapeHtml(g.ko_reasoning)}"` : '';
// Phase 2A β€” indel prediction columns. Backward-compat:
// older backend without these fields β†’ empty string display.
const topIndel = g.top_indel_label || '<span class="muted">β€”</span>';
const fsPct = (typeof g.frameshift_pct === 'number') ? g.frameshift_pct : null;
const fsClass = fsPct === null ? ''
: fsPct >= 60 ? 'crispr-score-good'
: fsPct >= 40 ? 'crispr-score-ok'
: 'crispr-score-weak';
const fsDisplay = fsPct === null ? '<span class="muted">β€”</span>'
: `${fsPct.toFixed(0)}%`;
// Build a per-row tooltip with the full predicted-indel
// distribution so the user can hover Top-indel and see the
// full picture, not just the #1 outcome.
let indelTip = '';
if (Array.isArray(g.predicted_indels) && g.predicted_indels.length) {
const lines = g.predicted_indels
.map(p => `${p[0]}: ${(p[1] * 100).toFixed(0)}%`)
.join(' Β· ');
indelTip = ` title="${escapeHtml(lines)}"`;
}
// Dominance: frequency of the single most-likely repair
// outcome. HIGHER = cleaner (one dominant edit product).
// Falls back to the old outcome_diversity field for one
// deploy cycle so cached frontends don't render "β€”" before
// they pick up the new key name.
const dominance = (typeof g.top_dominance_pct === 'number')
? g.top_dominance_pct
: (typeof g.outcome_diversity === 'number' ? null : null);
const divClass = dominance === null ? ''
: dominance >= 40 ? 'crispr-score-good'
: dominance >= 25 ? 'crispr-score-ok'
: 'crispr-score-weak';
const divDisplay = dominance === null ? '<span class="muted">β€”</span>'
: `${dominance.toFixed(0)}%`;
// Base-editor compatibility: show a short chip if at least
// one editor type works, em-dash otherwise.
const beDisplay = g.be_summary
? `<span class="crispr-flag" title="${escapeHtml(g.be_summary)}">${escapeHtml(g.be_summary.replace(/\s*\|\s*/g, ' / ').replace(/(CBE|ABE):\s*/g, ''))}</span>`
: '<span class="muted">β€”</span>';
// Phase 3 (M1) base-edit cells. Populated only in base-edit
// mode (CSS hides them in knockout mode, so stray values are
// harmless). The headline edit, editability, AA consequence,
// and a clean/bystander/STOP outcome tag.
const beEditLabel = g.be_outcome_label
? `<span title="${escapeHtml(g.be_outcome_label)}">${escapeHtml(g.be_outcome_label)}</span>`
: '<span class="muted">β€”</span>';
const beEd = (typeof g.be_editability === 'number') ? g.be_editability : 0;
const beEdClass = !g.be_editor ? ''
: beEd >= 0.7 ? 'crispr-score-good'
: beEd >= 0.4 ? 'crispr-score-ok'
: 'crispr-score-weak';
const beEdDisplay = !g.be_editor ? '<span class="muted">β€”</span>'
: beEd > 0 ? beEd.toFixed(2)
: '<span class="muted">none</span>';
const beAa = g.be_aa_change
? `<span class="crispr-aa${g.be_creates_stop ? ' crispr-aa-stop' : ''}" title="${escapeHtml(g.be_aa_change)}">${escapeHtml(g.be_aa_change)}</span>`
: '<span class="muted">β€”</span>';
let beOutcome = '<span class="muted">β€”</span>';
if (g.be_editor && Array.isArray(g.be_edits) && g.be_edits.length) {
if (g.be_creates_stop) {
beOutcome = '<span class="crispr-be-tag crispr-be-tag-stop" title="Installs a premature stop codon β€” knockout by base editing (CRISPR-STOP).">STOP</span>';
} else if (g.be_has_bystander) {
beOutcome = '<span class="crispr-be-tag crispr-be-tag-bystander" title="More than one editable base in the window β€” expect additional bystander edits.">bystander</span>';
} else {
beOutcome = '<span class="crispr-be-tag crispr-be-tag-clean" title="A single editable base in the window β€” a clean point edit.">clean</span>';
}
}
// Phase 2B-1: genome off-target column. Three render states:
// - empty: no organism was searched
// - "unique": organism searched, no hits above CFD threshold
// - CFD value + count + tooltip with top-hit location
let genomeDisplay = '<span class="muted">β€”</span>';
let genomeClass = '';
if (g.genome_organism) {
if ((g.genome_offtarget_count || 0) === 0) {
genomeDisplay = '<span class="muted">unique</span>';
genomeClass = 'crispr-score-good';
} else {
const maxCfd = g.genome_offtarget_max_cfd || 0;
genomeClass = maxCfd >= 0.5 ? 'crispr-score-weak'
: maxCfd >= 0.1 ? 'crispr-score-ok'
: 'crispr-score-good';
const tip = g.genome_offtarget_top_loc
? ` title="${escapeHtml(g.genome_offtarget_top_loc)}"`
: '';
genomeDisplay = `<span${tip}>${maxCfd.toFixed(2)} <span class="muted">Β· ${g.genome_offtarget_count}</span></span>`;
}
}
// Phase 2B-1: exon context column. Empty unless Ensembl
// returned data AND the input aligned to the gene's CDS.
let exonDisplay = '<span class="muted">β€”</span>';
if (g.exon_context_summary) {
const nmd = g.in_nmd_zone ? ' crispr-score-good' : '';
exonDisplay = `<span class="crispr-exon-ctx${nmd}" title="${escapeHtml(g.exon_context_summary)}">${escapeHtml(g.exon_context_summary)}</span>`;
}
// Phase 2C-1: cloning oligo columns. Empty when no vector
// was selected. Each cell wraps the oligo in <code> for the
// mono font + a Copy button matching the spacer-cell pattern.
const senseOligo = g.sense_oligo || '';
const antisenseOligo = g.antisense_oligo || '';
// Display the oligo single-line with ellipsis on overflow;
// tooltip + Copy button carry the full sequence so nothing
// is hidden from the user.
const senseCell = senseOligo
? `<div class="crispr-oligo-cell">
<code class="crispr-oligo" title="${escapeHtml(senseOligo)}">${escapeHtml(senseOligo)}</code>
<button class="crispr-copy ghost" type="button" data-copy="${escapeHtml(senseOligo)}">Copy</button>
</div>`
: '<span class="muted">β€”</span>';
const antisenseCell = antisenseOligo
? `<div class="crispr-oligo-cell">
<code class="crispr-oligo" title="${escapeHtml(antisenseOligo)}">${escapeHtml(antisenseOligo)}</code>
<button class="crispr-copy ghost" type="button" data-copy="${escapeHtml(antisenseOligo)}">Copy</button>
</div>`
: '<span class="muted">β€”</span>';
// Visible 3-D structure button (M5) β€” shown right in the row when
// the cut maps to a residue and a human/mouse gene is set, so the
// viewer is one click away without expanding the why-panel.
const structMini = (_structCtxOk && g.cut_residue)
? `<button type="button" class="crispr-struct-btn crispr-struct-mini" data-struct-residue="${g.cut_residue}" title="View this cut site on the 3-D AlphaFold structure (residue ${g.cut_residue})" aria-label="View 3-D structure, residue ${g.cut_residue}">βŒ– 3-D</button>`
: '';
return `
<tr class="crispr-row" data-rank="${g.rank}">
<td class="rank"><div class="crispr-rank-cell"><button type="button" class="crispr-why-toggle" data-why="${g.rank}" aria-expanded="false" title="Why this guide?">${g.rank}<span class="crispr-why-caret" aria-hidden="true">β€Ί</span></button>${structMini}</div></td>
<td data-label="Strand">${g.strand === '+' ? 'sense' : 'antisense'}</td>
<td class="num crispr-primary" data-label="Position">${g.position.toLocaleString()}</td>
<td class="crispr-spacer crispr-primary" data-label="Spacer">
<code>${escapeHtml(g.spacer)}</code>
<button class="crispr-copy ghost" type="button" data-copy="${escapeHtml(g.spacer)}">Copy</button>
</td>
<td data-label="PAM"><code>${escapeHtml(g.pam)}</code></td>
<td class="num crispr-primary ${scoreClass}" data-label="Composite">${composite.toFixed(3)}</td>
<td class="num crispr-primary" data-label="On-target">${g.on_target_score.toFixed(3)}</td>
<td class="num ${cfdClass}" data-label="Self-off">${cfdLabel}</td>
<td class="num col-ko crispr-primary ${koClass}" data-label="KO score"${koTip}>${ko.toFixed(2)}</td>
<td class="col-ko" data-label="Top indel"${indelTip}><code class="crispr-indel">${escapeHtml(topIndel)}</code></td>
<td class="num col-ko ${fsClass}" data-label="FS %">${fsDisplay}</td>
<td class="num col-ko ${divClass}" data-label="Dominance">${divDisplay}</td>
<td class="col-ko" data-label="Base editor">${beDisplay}</td>
<td class="col-be" data-label="Edit">${beEditLabel}</td>
<td class="num col-be crispr-primary ${beEdClass}" data-label="Editability">${beEdDisplay}</td>
<td class="col-be" data-label="AA change">${beAa}</td>
<td class="col-be" data-label="Outcome">${beOutcome}</td>
<td class="num ${genomeClass}" data-label="Genome off">${genomeDisplay}</td>
<td data-label="Exon context">${exonDisplay}</td>
<td data-label="Sense oligo">${senseCell}</td>
<td data-label="Antisense oligo">${antisenseCell}</td>
<td data-label="Order">${_renderRowVendors(g)}</td>
<td class="num" data-label="GC %">${g.gc_pct.toFixed(0)}%</td>
<td data-label="Flags">${flagsHtml}</td>
</tr>
<tr class="crispr-why-row" data-why-row="${g.rank}" hidden>
<td colspan="24">${_crisprWhy(g)}</td>
</tr>
`;
});
tbody.innerHTML = rows.join('') ||
'<tr><td colspan="24" class="muted" style="text-align:center;padding:18px">No guides match the current filters.</td></tr>';
// Mobile card view (<=540px) renders each cell as a "LABEL : value"
// row. Tag cells whose only content is an em-dash so the card layout
// can hide them β€” keeps a phone card from listing a dozen empty fields
// (genome/exon/cloning columns are blank unless that context was set).
tbody.querySelectorAll('.crispr-row > td[data-label]').forEach((td) => {
if (td.textContent.trim() === 'β€”') td.classList.add('crispr-cell-empty');
});
// Delegated handler for the per-row vendor buttons. One listener
// on tbody instead of one per button β†’ cheap, also survives
// re-renders without leaking.
tbody.querySelectorAll('.crispr-vendor-btn-row').forEach(btn => {
btn.addEventListener('click', () => {
const vid = btn.getAttribute('data-vendor');
const rank = parseInt(btn.getAttribute('data-row-rank'), 10);
const vendor = vendorsCache[vid];
const g = lastGuides.find(x => x.rank === rank);
if (!vendor || !g || !g.sense_oligo) return;
// TSV for just this one guide β€” sense + antisense rows.
const tsv = [
'Name\tSequence',
`g${g.rank}_sense\t${g.sense_oligo}`,
`g${g.rank}_antisense\t${g.antisense_oligo}`,
].join('\n');
_vendorOrderClick(btn, vendor, tsv, `guide #${g.rank} (${g.spacer})`);
});
});
// Wire copy buttons.
tbody.querySelectorAll('[data-copy]').forEach((b) => {
b.addEventListener('click', async () => {
const text = b.getAttribute('data-copy') || '';
try {
await navigator.clipboard.writeText(text);
const orig = b.textContent;
b.textContent = 'Copied';
setTimeout(() => { b.textContent = orig; }, 1200);
} catch (_) {
_showError('Copy blocked by browser. Select the spacer text manually.');
}
});
});
// Phase 3 (M3): "why this guide" expand toggle. Clicking a rank
// reveals/hides the plain-English explanation row beneath it.
tbody.querySelectorAll('.crispr-why-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const rank = btn.getAttribute('data-why');
const row = tbody.querySelector(`tr.crispr-why-row[data-why-row="${rank}"]`);
const card = btn.closest('tr.crispr-row');
const open = row ? row.hidden : !(card && card.classList.contains('crispr-row--open'));
if (row) row.hidden = !open;
// On mobile the card is collapsed to its primary fields; opening
// reveals the full field list (CSS) alongside the why panel.
if (card) card.classList.toggle('crispr-row--open', open);
btn.setAttribute('aria-expanded', String(open));
btn.classList.toggle('crispr-why-open', open);
});
});
// Phase 3 (M5): "View 3-D structure" buttons inside the why panels.
tbody.querySelectorAll('.crispr-struct-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const residue = parseInt(btn.getAttribute('data-struct-residue'), 10);
if (residue) _showCrisprStructure(residue);
});
});
// Per-guide IP radar buttons inside the why panels β€” runs the radar
// for THAT guide (not just #1), rendering into its own inline banner
// beneath the button so the result stays next to the guide it checked.
tbody.querySelectorAll('.crispr-radar-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const rank = parseInt(btn.getAttribute('data-radar-rank'), 10);
const guides = (lastGuides && lastGuides.length)
? lastGuides : ((crisprData && crisprData.guides) || []);
const guide = guides.find((g) => g.rank === rank);
if (!guide) { _showError('Re-run the design, then check this guide.'); return; }
const banner = tbody.querySelector(`.ip-radar-inline[data-radar-banner="${rank}"]`);
_runRadarFor(guide, btn, banner);
});
});
// Size the (sticky) "why" panels to the table's visible width so
// the explanation reads as a viewport-width block, not a 2000px row.
_sizeCrisprWhyPanels();
// Scroll into view only on a fresh design β€” not on sort/filter
// re-renders, which would yank the viewport on every toggle.
if (!_isRerender) resCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function _sizeCrisprWhyPanels() {
const wrap = resCard.querySelector('.result-table-wrap');
if (wrap) wrap.style.setProperty('--crispr-why-w', wrap.clientWidth + 'px');
}
// Keep the panels viewport-width on resize / orientation change.
window.addEventListener('resize', _sizeCrisprWhyPanels);
// Export menu: CSV (universal, for pipelines/pandas/R) + Excel (.xlsx,
// locale-safe for ;-delimiter Excel). The trigger toggles the menu; each
// item downloads in its format. We re-design from the input on download so
// the file matches exactly what the user saw in the table.
const dlMenuItems = document.getElementById('crisprDownloadItems');
if (dlBtn && dlMenuItems) {
const closeDlMenu = () => { dlMenuItems.hidden = true; dlBtn.setAttribute('aria-expanded', 'false'); };
dlBtn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = dlMenuItems.hidden;
dlMenuItems.hidden = !willOpen;
dlBtn.setAttribute('aria-expanded', String(willOpen));
});
document.addEventListener('click', (e) => {
if (!dlMenuItems.hidden && !e.target.closest('#crisprDownloadMenu')) closeDlMenu();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDlMenu(); });
dlMenuItems.querySelectorAll('.download-item').forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
closeDlMenu();
const fmt = item.dataset.format || 'xlsx';
if (!lastSubmittedSequence) { doCrisprDownload(fmt); return; }
// Red Team gate before the file leaves the app.
const gene = ((document.getElementById('crisprGeneSymbol') || {}).value || '').trim();
runOracle({
seqs: _oracleSeqs(), gene: gene,
actionLabel: 'Before exporting ' + fmt.toUpperCase(),
proceed: () => doCrisprDownload(fmt),
});
});
});
}
async function doCrisprDownload(fmt) {
if (!lastSubmittedSequence) {
_showError('Design guides first, then export.');
return;
}
if (fmt !== 'csv' && fmt !== 'xlsx') fmt = 'xlsx';
try {
const enzymeRadio = document.querySelector('input[name="crisprEnzyme"]:checked');
const modeRadio = document.querySelector('input[name="crisprMode"]:checked');
const mode = (modeRadio && modeRadio.value) || 'knockout';
const enzyme = mode === 'base_edit'
? 'cas9'
: ((enzymeRadio && enzymeRadio.value) || 'cas9');
const beSelect = document.getElementById('crisprBaseEditor');
const base_editor = (mode === 'base_edit' && beSelect && beSelect.value) || '';
const organismEl = document.getElementById('crisprOrganism');
const geneEl = document.getElementById('crisprGeneSymbol');
const target_organism = (organismEl && organismEl.value) || '';
const gene_symbol = (geneEl && geneEl.value.trim()) || '';
const vector_id = (vectorSelect && vectorSelect.value) || '';
const res = await fetch('/api/crispr/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sequence: lastSubmittedSequence, enzyme,
target_organism, gene_symbol,
vector_id,
mode, base_editor,
format: fmt,
max_results: 50,
}),
});
if (!res.ok) {
throw new Error((fmt === 'csv' ? 'CSV' : 'Excel') + ' export failed.');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fmt === 'csv'
? 'turingdna_crispr_guides.csv'
: 'turingdna_crispr_guides.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
_showError(err.message || String(err));
}
}
// Initial state β€” depends on auth being ready. auth.js loads
// before app.js so window.TuringAuth should exist by now.
_updateStats();
// ── Toxicity & Synthesis Oracle wiring (CRISPR) ─────────────────────
// Collect the sequences an export/order would actually ship (cloning
// oligos if a vector is set, else the spacers).
function _oracleSeqs() {
const gs = (lastGuides && lastGuides.length) ? lastGuides
: ((crisprData && crisprData.guides) || []);
const out = [];
gs.forEach(g => {
if (g.sense_oligo) out.push(g.sense_oligo);
if (g.antisense_oligo) out.push(g.antisense_oligo);
if (!g.sense_oligo && g.spacer) out.push(g.spacer);
});
return out;
}
window.__oracleSeqs = _oracleSeqs; // referenced by the export/order intercepts
})();
// ════════════════════════════════════════════════════════════════════════
// Toxicity & Synthesis Oracle (Red Team) β€” pre-export validation gate.
// Pure client-side, ZERO dependencies. Synthesis feasibility (Twist-style
// rules: GC / homopolymer / hairpin) + biological toxicity (essential-gene
// lookup). Opens a traffic-light "Red Team Report" before an Export or a
// Send-to-Twist, with an "Acknowledge & export anyway" bypass.
// ════════════════════════════════════════════════════════════════════════
// High-confidence subset of DepMap/Achilles common-essential genes (Hart
// CEGv2 core families: ribosome, proteasome, RNA Pol II core, core
// spliceosome, basal translation/replication, chaperonins, coatomer, +
// canonical oncogenic essentials). Precision over recall β€” a match is a
// genuine "knocking this out kills the cell" signal. Expandable to the full
// ~2k list. Symbols upper-cased on lookup.
const ESSENTIAL_GENES = new Set([
'MYC','MYCN','KRAS','CTNNB1','CDK1','CDK7','CDK9','CCNK','PLK1','AURKB','KIF11','TPX2','BUB1B','BUB3','CENPA','INCENP','NUF2','NDC80',
'PCNA','POLA1','POLD1','POLE','POLR1A','POLR1B','POLR2A','POLR2B','POLR2C','POLR2D','POLR2E','POLR2F','POLR2G','POLR2H','POLR2I','POLR2L','POLR3A',
'PRIM1','RPA1','RPA2','RPA3','MCM2','MCM3','MCM4','MCM5','MCM6','MCM7','CDC45','GINS1','GINS2','ORC6','RFC2','RFC4','RFC5','TOP1','TOP2A',
'PSMA1','PSMA2','PSMA3','PSMA4','PSMA5','PSMA6','PSMA7','PSMB1','PSMB2','PSMB3','PSMB4','PSMB5','PSMB6','PSMB7','PSMC1','PSMC2','PSMC3','PSMC4','PSMC5','PSMC6','PSMD1','PSMD2','PSMD3','PSMD4','PSMD6','PSMD7','PSMD11','PSMD12','PSMD14',
'SF3B1','SF3B2','SF3B3','SF3B5','SF3A1','SF3A2','SF3A3','PRPF8','PRPF19','PRPF31','PRPF38A','SNRNP200','SNRNP70','EFTUD2','BUD31','RBM22','CDC5L','AQR','PLRG1','U2AF1','U2AF2','SNRPD1','SNRPD2','SNRPD3','SNRPB','SNRPE','SNRPF','SNRPG','XAB2',
'EIF2S1','EIF2S2','EIF2S3','EIF2B1','EIF2B3','EIF2B5','EIF3A','EIF3B','EIF3C','EIF3D','EIF3G','EIF3I','EIF4A3','EIF4G1','EIF5B','EIF6','EEF1A1','EEF2','ETF1','GSPT1','PABPC1',
'NACA','RPL3','RPL4','RPL5','RPL6','RPL7','RPL7A','RPL8','RPL9','RPL10','RPL10A','RPL11','RPL13','RPL13A','RPL14','RPL15','RPL18','RPL18A','RPL19','RPL23','RPL23A','RPL24','RPL27','RPL27A','RPL30','RPL31','RPL32','RPL34','RPL35','RPL35A','RPL36','RPL37','RPL37A','RPL38','RPLP0','RPLP1','RPLP2',
'RPS2','RPS3','RPS3A','RPS4X','RPS5','RPS6','RPS7','RPS8','RPS9','RPS11','RPS13','RPS14','RPS15','RPS15A','RPS16','RPS17','RPS18','RPS19','RPS20','RPS23','RPS24','RPS25','RPS27','RPS27A','RPS28','RPSA',
'NOP56','NOP58','FBL','DKC1','NHP2','RRM1','RRM2','TUBB','TUBA1B','TUBA1C','TUBG1','ACTB','ACTL6A',
'COPA','COPB1','COPB2','COPZ1','ARCN1','SEC13','NUP93','NUP98','RAN','RANGAP1','XPO1','KPNB1','NSF','SEC61A1','SRP54','SRP72',
'HSPA5','HSPA8','HSP90AB1','HSP90AA1','CCT2','CCT3','CCT4','CCT5','CCT6A','CCT7','CCT8','TCP1','VCP','RUVBL1','RUVBL2','PHB','PHB2','GAPDH','ATP5F1A','ATP5F1B',
]);
function _orGC(s){ const m = s.match(/[GC]/g); return s.length ? (m ? m.length : 0) / s.length * 100 : 0; }
function _orLocalGC(s, win){
win = win || 50;
if (s.length < win) { const g = _orGC(s); return { min: g, max: g }; }
const pref = [0];
for (let i = 0; i < s.length; i++) pref.push(pref[i] + (s[i] === 'G' || s[i] === 'C' ? 1 : 0));
let min = 101, max = -1;
for (let i = 0; i + win <= s.length; i++) { const g = (pref[i + win] - pref[i]) / win * 100; if (g < min) min = g; if (g > max) max = g; }
return { min, max };
}
function _orHomopolymer(s){
let best = { base: '', len: 0 }, run = 1;
for (let i = 1; i <= s.length; i++) {
if (i < s.length && s[i] === s[i - 1]) run++;
else { if (run > best.len) best = { base: s[i - 1], len: run }; run = 1; }
}
return best;
}
const _OR_COMP = { A: 'T', T: 'A', G: 'C', C: 'G' };
function _orRevComp(s){ let o = ''; for (let i = s.length - 1; i >= 0; i--) o += (_OR_COMP[s[i]] || 'N'); return o; }
// Lightweight hairpin heuristic: find a k-mer whose reverse-complement occurs
// downstream within a loop window (a stem-loop), then extend to the true stem
// length. O(nΒ·loop) β€” fine for oligos/variants; no ViennaRNA dependency.
function _orHairpin(s, minStem, maxLoop){
minStem = minStem || 10; maxLoop = maxLoop || 40;
const n = s.length; let best = { stem: 0, loop: 0 };
if (n < 2 * minStem + 3) return best;
const k = minStem;
for (let i = 0; i + k <= n; i++) {
const rc = _orRevComp(s.substr(i, k));
const from = i + k + 3, to = Math.min(n - k, i + k + maxLoop);
for (let j = from; j <= to; j++) {
if (s.substr(j, k) === rc) {
let stem = k, a = i - 1, b = j + k;
while (a >= 0 && b < n && _OR_COMP[s[a]] === s[b]) { stem++; a--; b++; }
if (stem > best.stem) best = { stem: stem, loop: j - (i + k) };
break;
}
}
}
return best;
}
function runSynthChecks(seqs){
let gcMin = 101, gcMax = -1, homo = { len: 0, base: '' }, hair = { stem: 0 }, scanned = 0;
(seqs || []).forEach(raw => {
const s = (raw || '').toUpperCase().replace(/[^ACGT]/g, '');
if (s.length < 12) return;
scanned++;
const g = _orGC(s), loc = _orLocalGC(s, 50);
gcMin = Math.min(gcMin, g, loc.min); gcMax = Math.max(gcMax, g, loc.max);
const h = _orHomopolymer(s); if (h.len > homo.len) homo = h;
const hp = _orHairpin(s); if (hp.stem > hair.stem) hair = hp;
});
const flags = [];
if (!scanned) return flags;
if (gcMin < 20) flags.push({ level: 'red', title: 'Extreme low GC content', msg: 'A sequence drops to ' + gcMin.toFixed(0) + '% GC (below 20%). Very low GC is hard to synthesize β€” providers often fail or reject it.' });
if (gcMax > 65) flags.push({ level: 'red', title: 'Extreme high GC content', msg: 'A sequence reaches ' + gcMax.toFixed(0) + '% GC (above 65%). High GC stalls polymerase during synthesis β€” expect difficulty or rejection.' });
if (homo.len > 6) flags.push({ level: 'red', title: 'Homopolymer run', msg: 'A run of ' + homo.len + 'Γ—' + homo.base + ' (>6 identical bases) was found. Long single-base runs cause slippage and are a common synthesis-rejection cause.' });
if (hair.stem >= 12) flags.push({ level: 'red', title: 'Heavy secondary structure', msg: 'Warning: your sequence contains a heavy secondary hairpin structure (' + hair.stem + '-bp stem); synthesis providers are ~90% likely to reject this order.' });
else if (hair.stem >= 10) flags.push({ level: 'yellow', title: 'Moderate hairpin', msg: 'A moderate hairpin (' + hair.stem + '-bp stem) was detected β€” it may lower synthesis yield.' });
return flags;
}
function runEssentialCheck(gene){
const g = (gene || '').trim().toUpperCase();
if (g && ESSENTIAL_GENES.has(g))
return { level: 'yellow', title: 'Targets an essential gene', msg: 'Warning: this sgRNA targets a universally essential gene (' + g + '). Expect near-total cell death in standard lines unless you are running a dropout / viability screen.' };
return null;
}
function _orClose(){ const m = document.getElementById('oracleModal'); if (m) m.remove(); }
function _showOracleModal(opts){
_orClose();
const level = opts.level, flags = opts.flags || [];
const heading = { red: 'Critical synthesis failure', yellow: 'Proceed with caution', green: 'Clear β€” no flags' };
const flagHtml = flags.length ? flags.map(f =>
'<div class="oracle-flag oracle-' + f.level + '"><span class="oracle-flag-dot" aria-hidden="true"></span>' +
'<div><strong>' + escapeHtml(f.title) + '</strong><p>' + escapeHtml(f.msg) + '</p></div></div>'
).join('') :
'<div class="oracle-flag oracle-green"><span class="oracle-flag-dot" aria-hidden="true"></span><div><strong>No synthesis or toxicity flags</strong>' +
'<p>GC content, homopolymers, and secondary structure are within synthesis limits, and the target isn’t a known essential gene.</p></div></div>';
const goLabel = level === 'green' ? 'Export' : 'Acknowledge & export anyway';
const overlay = document.createElement('div');
overlay.id = 'oracleModal'; overlay.className = 'oracle-overlay';
overlay.innerHTML =
'<div class="oracle-backdrop"></div>' +
'<div class="oracle-panel oracle-level-' + level + '" role="dialog" aria-modal="true" aria-labelledby="oracleTitle">' +
'<div class="oracle-head">' +
'<span class="oracle-light oracle-light-' + level + '" aria-hidden="true"></span>' +
'<div class="oracle-head-text"><p class="oracle-kicker">Red Team Report</p>' +
'<h2 id="oracleTitle">' + heading[level] + '</h2>' +
'<p class="oracle-sub">' + escapeHtml(opts.actionLabel || 'Before you export') + '</p></div>' +
'<button type="button" class="oracle-x" aria-label="Cancel">Γ—</button>' +
'</div>' +
'<div class="oracle-flags">' + flagHtml + '</div>' +
'<div class="oracle-foot">' +
'<button type="button" class="ghost oracle-cancel">Cancel</button>' +
'<button type="button" class="primary oracle-go' + (level === 'red' ? ' oracle-go-danger' : '') + '">' + goLabel + '</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const close = () => _orClose();
overlay.querySelector('.oracle-backdrop').addEventListener('click', close);
overlay.querySelector('.oracle-x').addEventListener('click', close);
overlay.querySelector('.oracle-cancel').addEventListener('click', close);
overlay.querySelector('.oracle-go').addEventListener('click', () => { close(); if (typeof opts.proceed === 'function') opts.proceed(); });
document.addEventListener('keydown', function esc(e){ if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } });
}
// Run the Red Team checks and show the report. opts: {seqs, gene, actionLabel, proceed}.
function runOracle(opts){
const bio = runEssentialCheck(opts.gene);
const flags = runSynthChecks(opts.seqs).concat(bio ? [bio] : []);
const level = flags.some(f => f.level === 'red') ? 'red'
: flags.some(f => f.level === 'yellow') ? 'yellow' : 'green';
_showOracleModal({ level: level, flags: flags, actionLabel: opts.actionLabel, proceed: opts.proceed });
}
// ============================================================ PRIMER PICKER
// Batch primer triage. Submits all primers to /api/primers/analyze; on-template
// scoring comes back immediately, the optional NCBI specificity scan streams in
// via polling. The "AI pick" is a transparent fitness rubric (Tm/GC/clamp/
// dimer/uniqueness/specificity) β€” we show the reasoning, never a black box.
(function initPrimerPicker() {
const analyzeBtn = document.getElementById('primerAnalyze');
if (!analyzeBtn) return;
const inputEl = document.getElementById('primerInput');
const templateEl = document.getElementById('primerTemplate');
const scanEl = document.getElementById('primerScanNcbi');
const orgRow = document.getElementById('primerOrganismRow');
const orgEl = document.getElementById('primerOrganism');
const strictEl = document.getElementById('primerStrict3p');
const exampleBtn = document.getElementById('primerExample');
const errEl = document.getElementById('primerError');
const warnEl = document.getElementById('primerWarnings');
const resCard = document.getElementById('primerResultsCard');
const statusEl = document.getElementById('primerScanStatus');
const bestEl = document.getElementById('primerBest');
const ampEl = document.getElementById('primerAmplicons');
const prodEl = document.getElementById('primerProducts');
const listEl = document.getElementById('primerList');
let pollHandle = null;
// The organism selector only matters when the specificity scan is on.
if (scanEl && orgRow) {
const syncOrg = () => { orgRow.hidden = !scanEl.checked; };
scanEl.addEventListener('change', syncOrg);
syncOrg();
}
const ORG_LABEL = { human: 'Homo sapiens', mouse: 'Mus musculus', rat: 'Rattus norvegicus',
zebrafish: 'Danio rerio', yeast: 'S. cerevisiae', ecoli: 'E. coli',
all: 'all sequences (nt)' };
const esc = (s) => (typeof escapeHtml === 'function' ? escapeHtml(String(s)) : String(s));
const pct = (f) => Math.round((Number(f) || 0) * 100);
function showErr(msg) { if (errEl) { errEl.textContent = msg; errEl.hidden = false; } }
function clearErr() { if (errEl) { errEl.hidden = true; errEl.textContent = ''; } }
function fitnessBar(f) {
const p = pct(f);
const cls = p >= 70 ? 'pf-high' : p >= 45 ? 'pf-mid' : 'pf-low';
return `<div class="pf-bar"><span class="pf-fill ${cls}" style="width:${p}%"></span></div>`
+ `<span class="pf-num">${p}</span>`;
}
function templateBadge(p, hasTemplate) {
if (!hasTemplate) return '';
const ot = p.on_template || {};
if (ot.n_strong_sites === 1) return '<span class="pchip pchip-good">unique on template</span>';
if ((ot.n_strong_sites || 0) > 1) return `<span class="pchip pchip-warn">${ot.n_strong_sites} sites on template</span>`;
return '<span class="pchip pchip-bad">no match on template</span>';
}
function specBadge(p) {
const s = p.specificity;
if (!s) return '';
// Off-target PRODUCTS (in-silico PCR) are the real signal.
const n = (s.offtarget_products != null) ? s.offtarget_products : s.strong_offtargets;
if (n === 0) return '<span class="pchip pchip-good">specific Β· 0 off-target products</span>';
if (n <= 2) return `<span class="pchip pchip-warn">${n} off-target product${n === 1 ? '' : 's'}</span>`;
return `<span class="pchip pchip-bad">${n} off-target products</span>`;
}
function renderPrimerCard(p, isBest) {
const role = p.role ? `<span class="pchip pchip-role">${esc(p.role === 'F' ? 'forward' : 'reverse')}</span>` : '';
const flags = [];
if (p.gc_clamp) flags.push('3β€² G/C clamp');
if (p.hairpin >= 6) flags.push(`hairpin (stem ${p.hairpin})`);
if (p.self_dimer >= 4) flags.push(`3β€² self-dimer (${p.self_dimer})`);
const why = (p.why || []).map(w => `<li>${esc(w)}</li>`).join('');
return `
<div class="primer-card${isBest ? ' primer-card-best' : ''}">
<div class="primer-card-head">
<div class="primer-card-id">
${isBest ? '<span class="pchip pchip-best">β˜… Recommended</span>' : ''}
<strong>${esc(p.name)}</strong> ${role}
</div>
<div class="primer-fitness">${fitnessBar(p.fitness)}</div>
</div>
<code class="primer-seq">${esc(p.seq)}</code>
<div class="primer-meta">
<span>${p.length} nt</span><span>GC ${p.gc}%</span>
<span>Tm ${p.tm != null ? p.tm + 'Β°C' : 'β€”'}</span>
${templateBadge(p, _hasTemplate)} ${specBadge(p)}
</div>
${flags.length ? `<div class="primer-flags">${flags.map(f => esc(f)).join(' Β· ')}</div>` : ''}
<details class="primer-why"><summary>Why this score</summary><ul>${why}</ul></details>
</div>`;
}
let _hasTemplate = false;
function render(data) {
const r = data.result || {};
_hasTemplate = !!r.has_template;
resCard.hidden = false;
// Best pick
const best = (r.primers || [])[0];
bestEl.innerHTML = best
? `<div class="primer-best-head">Top pick: <strong>${esc(best.name)}</strong>
<span class="primer-best-score">fitness ${pct(best.fitness)}/100</span></div>`
: '';
// Amplicons (pairs)
if ((r.amplicons || []).length) {
ampEl.hidden = false;
ampEl.innerHTML = '<h3 class="primer-sec-title">Primer pairs</h3>' +
r.amplicons.map((a, i) => `
<div class="amp-row${i === 0 ? ' amp-row-best' : ''}">
<div class="amp-pair">${i === 0 ? 'β˜… ' : ''}${esc(a.fwd)} <span class="amp-x">+</span> ${esc(a.rev)}</div>
<div class="amp-stats">
<span>${a.amplicon_bp != null ? a.amplicon_bp.toLocaleString() + ' bp' : 'no amplicon on template'}</span>
<span>Ξ”Tm ${a.tm_diff != null ? a.tm_diff + 'Β°C' : 'β€”'}</span>
${a.cross_dimer >= 4 ? `<span class="pchip pchip-bad">cross-dimer ${a.cross_dimer}</span>` : '<span class="pchip pchip-good">no cross-dimer</span>'}
<span class="amp-fit">pair ${pct(a.pair_fitness)}/100</span>
</div>
</div>`).join('');
} else { ampEl.hidden = true; ampEl.innerHTML = ''; }
// In-silico PCR results: the user's INTENDED product(s), then any
// genuine off-target products. Redundant database hits for the same
// amplicon are already collapsed server-side, so counts are honest.
if (r.specificity_done) {
const org = ORG_LABEL[r.organism] || r.organism || 'the selected genome';
const intended = r.intended_products || [];
const off = r.offtarget_products || [];
const seenIn = (pr) => (pr.count > 1
? `<span class="prod-seen">seen in ${pr.count} ${esc(org)} records</span>` : '');
const row = (pr, cls) => `
<div class="prod-row ${cls}">
<div class="prod-pair">${esc(pr.fwd_name || pr.fwd)} <span class="amp-x">+</span> ${esc(pr.rev_name || pr.rev)}</div>
<div class="prod-info"><span class="prod-size">${(pr.size || 0).toLocaleString()} bp</span>
<span class="prod-subj">${esc(pr.title || pr.subject)}</span>${seenIn(pr)}</div>
</div>`;
let html = `<h3 class="primer-sec-title">In-silico PCR Β· ${esc(org)}</h3>`;
if (intended.length) {
html += `<div class="primer-intended-head">Intended product${intended.length === 1 ? '' : 's'} ` +
`β€” confirmed in the ${esc(org)} genome:</div>` +
intended.map(pr => row(pr, 'prod-row-ok')).join('');
}
if (!off.length) {
html += `<div class="primer-clean">βœ“ No off-target products predicted in <strong>${esc(org)}</strong>` +
`${intended.length ? ' beyond your intended amplicon' : ''}. These primers look specific.</div>`;
} else {
html += `<div class="primer-prod-note">${off.length} distinct off-target product${off.length === 1 ? '' : 's'} ` +
`your primers could also amplify in ${esc(org)}:</div>` +
off.slice(0, 12).map(pr => row(pr, '')).join('') +
(off.length > 12 ? `<div class="primer-prod-more">+ ${off.length - 12} more</div>` : '');
}
prodEl.hidden = false;
prodEl.innerHTML = html;
} else { prodEl.hidden = true; prodEl.innerHTML = ''; }
// Ranked list
listEl.innerHTML = '<h3 class="primer-sec-title">All primers Β· ranked</h3>' +
(r.primers || []).map((p, i) => renderPrimerCard(p, i === 0)).join('');
}
function setScanStatus(state, elapsed) {
if (!statusEl) return;
if (state === 'scanning') {
statusEl.hidden = false;
statusEl.innerHTML = `<span class="ip-radar-spinner" aria-hidden="true"></span> `
+ `Running in-silico PCR β€” searching the genome for off-target products…${elapsed != null ? ' Β· ' + elapsed + 's' : ''} `
+ `<span class="primer-scan-note">(template scores show now; specificity updates when it finishes)</span>`;
} else if (state === 'done') {
statusEl.hidden = false;
statusEl.innerHTML = '<span class="primer-scan-done">βœ“ In-silico PCR specificity check complete</span>';
setTimeout(() => { if (statusEl) statusEl.hidden = true; }, 4000);
} else if (state === 'error') {
statusEl.hidden = false;
statusEl.innerHTML = '<span class="primer-scan-err">NCBI scan unavailable β€” showing template scores only.</span>';
} else { statusEl.hidden = true; statusEl.innerHTML = ''; }
}
function poll(jobId) {
if (pollHandle) clearTimeout(pollHandle);
pollHandle = setTimeout(async () => {
try {
const res = await fetch('/api/primers/' + jobId);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'lost job');
render(data);
if (data.status === 'done') { setScanStatus('done'); return; }
if (data.status === 'error') { setScanStatus('error'); return; }
setScanStatus('scanning', data.elapsed_seconds);
poll(jobId);
} catch (e) { setScanStatus('error'); }
}, 3000);
}
async function analyze() {
clearErr();
if (warnEl) { warnEl.hidden = true; warnEl.innerHTML = ''; }
const primers = (inputEl.value || '').trim();
if (!primers) { showErr('Paste at least one primer.'); return; }
const template = (templateEl.value || '').trim();
const scan = !!(scanEl && scanEl.checked);
const organism = (orgEl && orgEl.value) || 'human';
const strict3p = !!(strictEl && strictEl.checked);
const orig = analyzeBtn.textContent;
analyzeBtn.disabled = true;
analyzeBtn.textContent = 'Analysing…';
if (pollHandle) { clearTimeout(pollHandle); pollHandle = null; }
try {
const res = await fetch('/api/primers/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ primers, template, scan_ncbi: scan, blast_consent: scan, organism, strict_3p: strict3p }),
});
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') {
window.dispatchEvent(new Event('td:signin-required'));
showErr('Primer Analysis is free with an account β€” sign in to analyse your primers.');
return;
}
if (!res.ok) { showErr(data.error || 'Analysis failed.'); return; }
if ((data.warnings || []).length && warnEl) {
warnEl.hidden = false;
warnEl.innerHTML = '<strong>Note:</strong> ' + data.warnings.map(esc).join(' Β· ');
}
render(data);
if (data.status === 'scanning' && data.job_id) {
setScanStatus('scanning', 0); poll(data.job_id);
} else { setScanStatus('hide'); }
resCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
showErr('Could not reach the analyser. Check your connection and retry.');
} finally {
analyzeBtn.disabled = false;
analyzeBtn.textContent = orig;
}
}
analyzeBtn.addEventListener('click', analyze);
if (exampleBtn) exampleBtn.addEventListener('click', () => {
// EGFP-ish demo: two F candidates + one R, with a template they amplify.
inputEl.value = [
'>EGFP_F1', 'ATGGTGAGCAAGGGCGAGGA',
'>EGFP_F2', 'AGCAAGGGCGAGGAGCTGTT',
'>EGFP_R', 'TTGTGGCGGATCTTGAAGTT',
].join('\n');
templateEl.value =
'ATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCTGCACCACCGGCAAGCTGCCCGTGCCCTGGCCCACCCTCGTGACCACCCTGACCTACGGCGTGCAGTGCTTCAGCCGCTACCCCGACCACATGAAGCAGCACGACTTCTTCAAGTCCGCCATGCCCGAAGGCTACGTCCAGGAGCGCACCATCTTCTTCAAGGACGACGGCAACTACAAGACCCGCGCCGAGGTGAAGTTCGAGGGCGACACCCTGGTGAACCGCATCGAGCTGAAGGGCATCGACTTCAAGGAGGACGGCAACATCCTGGGGCACAAGCTGGAGTACAACTACAACAGCCACAACGTCTATATCATGGCCGACAAGCAGAAGAACGGCATCAAGGTGAACTTCAAGATCCGCCACAACATCGAGGACGGCAGCGTGCAGCTCGCCGACCACTACCAGCAGAACACCCCCATCGGCGACGGCCCCGTGCTGCTGCCCGACAACCACTACCTGAGCACCCAGTCCGCCCTGAGCAAAGACCCCAACGAGAAGCGCGATCACATGGTCCTGCTGGAGTTCGTGACCGCCGCCGGGATCACTCTCGGCATGGACGAGCTGTACAAGTAA';
if (scanEl) scanEl.checked = false;
});
})();
// ============================================================ PLASMID STUDIO
// Import GenBank/FASTA/raw β†’ annotated plasmid β†’ SVG circular + linear map,
// restriction analysis (1088 REBASE enzymes), digest β†’ virtual gel, export,
// and a per-user library. Sign-in gated (tab-click modal + backend 403).
(function initPlasmidStudio() {
const importBtn = document.getElementById('plasmidImport');
if (!importBtn) return;
const $ = (id) => document.getElementById(id);
const inputEl = $('plasmidInput'), fileEl = $('plasmidFile'), errEl = $('plasmidError');
const resCard = $('plasmidResultsCard'), mapWrap = $('plasmidMapWrap');
const featuresEl = $('plasmidFeatures'), nameEl = $('plasmidName'), metaEl = $('plasmidMeta');
const enzGrid = $('plasmidEnzymeGrid'), gelEl = $('plasmidGel');
const libModal = $('plasmidLibraryModal'), libList = $('plasmidLibraryList');
const esc = (s) => (typeof escapeHtml === 'function' ? escapeHtml(String(s)) : String(s));
const pmColor = (c) => (/^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#9AA0AA'); // only safe hex into SVG/style
let plasmid = null; // current plasmid model
let restriction = null; // restriction analysis result
const selected = new Set(); // selected enzyme names
let viewMode = 'circular';
const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.hidden = false; } };
const clearErr = () => { if (errEl) { errEl.hidden = true; errEl.textContent = ''; } };
const signin403 = (data) => (data && data.kind === 'signin_required');
// ---- geometry helpers (circular) ----
const TAU = Math.PI * 2;
function ptAt(pos, len, r, cx, cy) {
const t = (pos / len) * TAU;
return [cx + r * Math.sin(t), cy - r * Math.cos(t)];
}
function arcPath(a, b, len, r, cx, cy) {
if (b < a) b += len;
const span = (b - a) / len;
if (span >= 0.999) { // full circle β†’ two half-arcs
const [x0, y0] = ptAt(a, len, r, cx, cy);
const [xm, ym] = ptAt(a + len / 2, len, r, cx, cy);
return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 1 1 ${xm.toFixed(2)} ${ym.toFixed(2)} A ${r} ${r} 0 1 1 ${x0.toFixed(2)} ${y0.toFixed(2)}`;
}
const [x1, y1] = ptAt(a, len, r, cx, cy);
const [x2, y2] = ptAt(b % len, len, r, cx, cy);
const large = span > 0.5 ? 1 : 0;
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`;
}
function cutSites() {
// union of selected enzymes' sites β†’ [{pos, name}]
if (!restriction) return [];
const out = [];
restriction.enzymes.forEach((e) => {
if (selected.has(e.name)) e.sites.forEach((p) => out.push({ pos: p, name: e.name }));
});
return out;
}
function renderCircular() {
const len = plasmid.length, cx = 210, cy = 210, R = 138;
const feats = plasmid.features || [];
let svg = `<svg viewBox="0 0 420 420" class="plasmid-svg" role="img" aria-label="Circular plasmid map">`;
svg += `<circle cx="${cx}" cy="${cy}" r="${R}" class="pm-backbone"/>`;
// ruler ticks
const ticks = 8;
for (let i = 0; i < ticks; i++) {
const pos = Math.round(len * i / ticks);
const [x1, y1] = ptAt(pos, len, R - 5, cx, cy), [x2, y2] = ptAt(pos, len, R + 5, cx, cy);
const [lx, ly] = ptAt(pos, len, R - 16, cx, cy);
svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-tick"/>`;
svg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" class="pm-tick-lbl" text-anchor="middle" dominant-baseline="middle">${pos === 0 ? '1' : pos.toLocaleString()}</text>`;
}
// feature arcs (+ strand outside, βˆ’ inside)
feats.forEach((f) => {
const r = R + (f.strand >= 0 ? 9 : -9);
const d = arcPath(f.start, f.end, len, r, cx, cy);
svg += `<path d="${d}" class="pm-feat" stroke="${pmColor(f.color)}"><title>${esc(f.name)} Β· ${esc(f.type)} Β· ${f.start + 1}–${f.end} (${f.strand >= 0 ? '+' : 'βˆ’'})</title></path>`;
});
// labels for features big enough to deserve one
feats.forEach((f) => {
if ((f.end - f.start) / len < 0.04) return;
const mid = (f.start + (f.end - f.start) / 2) % len;
const [lx, ly] = ptAt(mid, len, R + 30, cx, cy);
const [ex, ey] = ptAt(mid, len, R + (f.strand >= 0 ? 13 : -13), cx, cy);
const anchor = lx < cx - 6 ? 'end' : (lx > cx + 6 ? 'start' : 'middle');
svg += `<line x1="${ex.toFixed(1)}" y1="${ey.toFixed(1)}" x2="${lx.toFixed(1)}" y2="${ly.toFixed(1)}" class="pm-leader"/>`;
svg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" class="pm-feat-lbl" text-anchor="${anchor}" dominant-baseline="middle">${esc(f.name)}</text>`;
});
// cut sites
cutSites().forEach((c) => {
const [x1, y1] = ptAt(c.pos, len, R - 12, cx, cy), [x2, y2] = ptAt(c.pos, len, R + 20, cx, cy);
const [lx, ly] = ptAt(c.pos, len, R + 30, cx, cy);
const anchor = lx < cx - 6 ? 'end' : (lx > cx + 6 ? 'start' : 'middle');
svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-cut"/>`;
svg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" class="pm-cut-lbl" text-anchor="${anchor}" dominant-baseline="middle">${esc(c.name)} (${c.pos})</text>`;
});
// center
svg += `<text x="${cx}" y="${cy - 8}" class="pm-center-name" text-anchor="middle">${esc((plasmid.name || 'plasmid').slice(0, 22))}</text>`;
svg += `<text x="${cx}" y="${cy + 12}" class="pm-center-sub" text-anchor="middle">${len.toLocaleString()} bp Β· ${esc(plasmid.topology)}</text>`;
svg += `</svg>`;
mapWrap.innerHTML = svg;
}
function renderLinear() {
const len = plasmid.length, W = Math.max(900, 0), H = 150, m = 40, trackY = 80;
const x = (pos) => m + (pos / len) * (W - 2 * m);
const feats = plasmid.features || [];
let svg = `<svg viewBox="0 0 ${W} ${H}" class="plasmid-svg-linear" preserveAspectRatio="xMinYMid meet" role="img" aria-label="Linear plasmid map">`;
svg += `<line x1="${m}" y1="${trackY}" x2="${W - m}" y2="${trackY}" class="pm-backbone"/>`;
const ticks = 10;
for (let i = 0; i <= ticks; i++) {
const pos = Math.round(len * i / ticks), xx = x(pos);
svg += `<line x1="${xx.toFixed(1)}" y1="${trackY - 4}" x2="${xx.toFixed(1)}" y2="${trackY + 4}" class="pm-tick"/>`;
svg += `<text x="${xx.toFixed(1)}" y="${trackY + 18}" class="pm-tick-lbl" text-anchor="middle">${pos === 0 ? '1' : pos.toLocaleString()}</text>`;
}
feats.forEach((f) => {
const x1 = x(f.start), x2 = x(Math.min(f.end, len)), y = f.strand >= 0 ? trackY - 18 : trackY + 6;
svg += `<rect x="${x1.toFixed(1)}" y="${y}" width="${Math.max(2, x2 - x1).toFixed(1)}" height="12" rx="2" class="pm-feat-rect" fill="${pmColor(f.color)}"><title>${esc(f.name)} Β· ${f.start + 1}–${f.end}</title></rect>`;
if ((f.end - f.start) / len >= 0.03)
svg += `<text x="${((x1 + x2) / 2).toFixed(1)}" y="${(f.strand >= 0 ? y - 3 : y + 22)}" class="pm-feat-lbl" text-anchor="middle">${esc(f.name)}</text>`;
});
cutSites().forEach((c) => {
const xx = x(c.pos);
svg += `<line x1="${xx.toFixed(1)}" y1="20" x2="${xx.toFixed(1)}" y2="${trackY + 30}" class="pm-cut"/>`;
svg += `<text x="${xx.toFixed(1)}" y="14" class="pm-cut-lbl" text-anchor="middle">${esc(c.name)}</text>`;
});
svg += `</svg>`;
mapWrap.innerHTML = `<div class="plasmid-linear-scroll">${svg}</div>`;
}
function renderMap() {
const tools = document.getElementById('plasmidSeqTools');
if (viewMode === 'sequence') {
if (tools) tools.hidden = false;
renderSequence();
} else {
if (tools) tools.hidden = true;
viewMode === 'linear' ? renderLinear() : renderCircular();
}
}
function renderFeatures() {
const feats = plasmid.features || [];
if (!feats.length) { featuresEl.innerHTML = '<div class="plasmid-nofeat">No features. (Raw input gets ORF + motif auto-annotation.)</div>'; return; }
featuresEl.innerHTML = feats.map((f, i) => `
<div class="plasmid-feat-item" data-fidx="${i}" title="Click to select in the sequence view">
<span class="pf-swatch" style="background:${pmColor(f.color)}"></span>
<span class="pf-name">${esc(f.name)}</span>
<span class="pf-type">${esc(f.type)}</span>
<span class="pf-pos">${(f.start + 1).toLocaleString()}–${f.end.toLocaleString()} ${f.strand >= 0 ? '(+)' : '(βˆ’)'}</span>
</div>`).join('');
}
function renderEnzymes() {
if (!restriction) { enzGrid.innerHTML = '<span class="field-hint">Loading enzymes…</span>'; return; }
const cutters = restriction.enzymes.filter((e) => e.count > 0);
const non = restriction.enzymes.filter((e) => e.count === 0);
cutters.sort((a, b) => a.count - b.count || a.name.localeCompare(b.name));
enzGrid.innerHTML = cutters.map((e) => {
const cls = e.count === 1 ? 'enz-single' : (e.count === 2 ? 'enz-double' : 'enz-multi');
return `<button type="button" class="plasmid-enz ${cls}${selected.has(e.name) ? ' enz-on' : ''}" data-enz="${esc(e.name)}" title="${e.count} site${e.count === 1 ? '' : 's'} Β· ${esc(e.overhang || '')}">${esc(e.name)}<span class="enz-count">${e.count}</span></button>`;
}).join('') + (non.length ? `<span class="plasmid-noncut">+ ${non.length} non-cutters</span>` : '');
}
function setMeta() {
metaEl.textContent = `${plasmid.length.toLocaleString()} bp Β· ${plasmid.topology} Β· GC ${plasmid.gc}%`
+ ((plasmid.features || []).length ? ` Β· ${plasmid.features.length} features` : '');
}
function showResults() {
resCard.hidden = false;
nameEl.value = plasmid.name || 'plasmid';
setMeta();
sel = null; findHits = []; findIdx = -1; findLen = 0;
const out = $('plasmidSeqOutput'); if (out) { out.hidden = true; out.innerHTML = ''; }
const fc = $('plasmidFindCount'); if (fc) fc.textContent = '';
viewMode = 'circular';
['pvCircular', 'pvLinear', 'pvSequence'].forEach((id) => { const b = $(id); if (b) b.classList.toggle('pv-tab-active', id === 'pvCircular'); });
const tools = $('plasmidSeqTools'); if (tools) tools.hidden = true;
renderMap(); renderFeatures();
gelEl.hidden = true; gelEl.innerHTML = '';
selected.clear();
enzGrid.innerHTML = '<span class="field-hint">Scanning restriction sites…</span>';
loadRestriction();
resCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function loadRestriction() {
try {
const res = await fetch('/api/plasmid/restriction', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: plasmid.sequence, topology: plasmid.topology }),
});
const data = await res.json();
if (res.status === 403 && signin403(data)) { window.dispatchEvent(new Event('td:signin-required')); return; }
if (!res.ok) { enzGrid.innerHTML = '<span class="field-hint">Couldn’t scan enzymes.</span>'; return; }
restriction = data.analysis;
renderEnzymes();
} catch (e) { enzGrid.innerHTML = '<span class="field-hint">Couldn’t reach the enzyme scanner.</span>'; }
}
async function doImport(text) {
clearErr();
if (!text || !text.trim()) { showErr('Paste a sequence, GenBank, or FASTA record.'); return; }
const topo = (document.querySelector('input[name="plasmidTopo"]:checked') || {}).value || 'circular';
const orig = importBtn.textContent; importBtn.disabled = true; importBtn.textContent = 'Importing…';
try {
const res = await fetch('/api/plasmid/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, topology: topo, annotate: true }),
});
const data = await res.json();
if (res.status === 403 && signin403(data)) {
window.dispatchEvent(new Event('td:signin-required'));
showErr('The Plasmid Editor is free with an account β€” sign in to import.'); return;
}
if (!res.ok) { showErr(data.error || 'Import failed.'); return; }
plasmid = data.plasmid;
showResults();
} catch (e) { showErr('Could not reach the importer. Check your connection and retry.'); }
finally { importBtn.disabled = false; importBtn.textContent = orig; }
}
async function doDigest() {
if (!plasmid) return;
const enzymes = [...selected];
if (!enzymes.length) { gelEl.hidden = false; gelEl.innerHTML = '<div class="field-hint">Select one or more enzymes above, then digest.</div>'; return; }
gelEl.hidden = false; gelEl.innerHTML = '<div class="field-hint">Digesting…</div>';
try {
const res = await fetch('/api/plasmid/digest', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: plasmid.sequence, topology: plasmid.topology, enzymes }),
});
const data = await res.json();
if (res.status === 403 && signin403(data)) { window.dispatchEvent(new Event('td:signin-required')); return; }
if (!res.ok) { gelEl.innerHTML = `<div class="field-hint">${esc(data.error || 'Digest failed.')}</div>`; return; }
renderGel(data.digest, enzymes);
} catch (e) { gelEl.innerHTML = '<div class="field-hint">Couldn’t reach the digest engine.</div>'; }
}
function renderGel(dg, enzymes) {
const frags = dg.fragments || [];
const ladder = [10000, 8000, 6000, 5000, 4000, 3000, 2000, 1500, 1000, 750, 500, 250, 100];
const H = 320, top = 24, bot = 300;
const maxbp = Math.max(ladder[0], frags[0] || 1, plasmid.length);
const minbp = 80;
const yOf = (bp) => {
const b = Math.min(Math.max(bp, minbp), maxbp);
const f = (Math.log10(maxbp) - Math.log10(b)) / (Math.log10(maxbp) - Math.log10(minbp));
return top + f * (bot - top);
};
const lane = (x, w, bands, cls) => bands.map((bp) =>
`<rect x="${x}" y="${(yOf(bp) - 1.5).toFixed(1)}" width="${w}" height="3" class="${cls}"/>`
+ `<text x="${x + w + 5}" y="${(yOf(bp) + 3).toFixed(1)}" class="gel-bp">${bp.toLocaleString()}</text>`).join('');
let svg = `<svg viewBox="0 0 360 ${H}" class="gel-svg" role="img" aria-label="Virtual gel">`;
svg += `<rect x="0" y="0" width="360" height="${H}" class="gel-bg"/>`;
svg += `<text x="55" y="16" class="gel-lane-lbl" text-anchor="middle">ladder</text>`;
svg += `<text x="220" y="16" class="gel-lane-lbl" text-anchor="middle">digest</text>`;
svg += lane(30, 50, ladder, 'gel-band-ladder');
svg += frags.map((bp) => `<rect x="180" y="${(yOf(bp) - 2).toFixed(1)}" width="80" height="4" class="gel-band"/>`
+ `<text x="265" y="${(yOf(bp) + 3).toFixed(1)}" class="gel-bp gel-bp-frag">${bp.toLocaleString()} bp</text>`).join('');
svg += `</svg>`;
gelEl.innerHTML =
`<h4 class="gel-title">Digest Β· ${esc(enzymes.join(' + '))} β†’ ${frags.length} fragment${frags.length === 1 ? '' : 's'}</h4>`
+ `<div class="gel-flex"><div class="gel-graphic">${svg}</div>`
+ `<div class="gel-list">${frags.map((b, i) => `<span class="gel-frag-chip">${b.toLocaleString()} bp</span>`).join('')}</div></div>`;
}
async function doExport(format) {
if (!plasmid) return;
try {
const res = await fetch('/api/plasmid/export', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: plasmid.sequence, topology: plasmid.topology,
name: nameEl.value || plasmid.name, features: plasmid.features, format }),
});
if (res.status === 403) { window.dispatchEvent(new Event('td:signin-required')); return; }
if (!res.ok) { showErr('Export failed.'); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const stem = (nameEl.value || plasmid.name || 'plasmid').replace(/[^A-Za-z0-9_.-]/g, '_');
a.href = url; a.download = `${stem}.${format === 'fasta' ? 'fasta' : 'gb'}`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) { showErr('Could not reach the exporter.'); }
}
async function doSave() {
if (!plasmid) return;
const btn = $('plasmidSave'); const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Saving…';
try {
const res = await fetch('/api/plasmid/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameEl.value || plasmid.name, topology: plasmid.topology,
length: plasmid.length, gc: plasmid.gc, source: plasmid.source,
sequence: plasmid.sequence, features: plasmid.features }),
});
const data = await res.json();
if (res.status === 403 && signin403(data)) { window.dispatchEvent(new Event('td:signin-required')); return; }
btn.textContent = data.ok ? 'Saved βœ“' : 'Save failed';
if (!data.ok && data.error) showErr(data.error);
setTimeout(() => { btn.textContent = orig; }, 2500);
} catch (e) { btn.textContent = orig; showErr('Could not reach the server.'); }
finally { btn.disabled = false; }
}
async function openLibrary() {
if (!libModal) return;
libModal.hidden = false; document.body.style.overflow = 'hidden';
libList.innerHTML = '<div class="field-hint">Loading…</div>';
try {
const res = await fetch('/api/plasmid/library');
const data = await res.json();
if (res.status === 403 && signin403(data)) { closeLibrary(); window.dispatchEvent(new Event('td:signin-required')); return; }
const items = (data.plasmids || []);
if (!items.length) { libList.innerHTML = '<div class="field-hint">No saved plasmids yet. Import one and hit Save.</div>'; return; }
libList.innerHTML = items.map((p) => `
<div class="plasmid-lib-item" data-id="${esc(p.id)}">
<div class="lib-main"><strong>${esc(p.name)}</strong>
<span class="lib-meta">${(p.length || 0).toLocaleString()} bp Β· ${esc(p.topology)}${p.gc != null ? ' Β· GC ' + p.gc + '%' : ''}</span></div>
<div class="lib-actions">
<button type="button" class="ghost lib-open" data-id="${esc(p.id)}">Open</button>
<button type="button" class="lib-del" data-id="${esc(p.id)}" aria-label="Delete">Γ—</button>
</div>
</div>`).join('');
} catch (e) { libList.innerHTML = '<div class="field-hint">Couldn’t load your library.</div>'; }
}
function closeLibrary() { if (libModal) { libModal.hidden = true; document.body.style.overflow = ''; } }
async function openSaved(id) {
try {
const res = await fetch('/api/plasmid/library/' + encodeURIComponent(id));
const data = await res.json();
if (!res.ok || !data.ok) { showErr('Couldn’t open that plasmid.'); return; }
const p = data.plasmid;
plasmid = { name: p.name, sequence: p.sequence, length: p.length || (p.sequence || '').length,
topology: p.topology, gc: p.gc, features: p.features || [], source: p.source };
closeLibrary();
if (location.hash !== '#plasmid') location.hash = '#plasmid';
showResults();
} catch (e) { showErr('Couldn’t open that plasmid.'); }
}
async function deleteSaved(id, el) {
try {
const res = await fetch('/api/plasmid/library/' + encodeURIComponent(id), { method: 'DELETE' });
const data = await res.json();
if (data.ok && el) el.remove();
} catch (e) { /* ignore */ }
}
// ---- wiring ----
importBtn.addEventListener('click', () => doImport(inputEl.value));
if (fileEl) fileEl.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => { inputEl.value = String(r.result || ''); doImport(inputEl.value); };
r.readAsText(f);
});
$('plasmidExample').addEventListener('click', () => {
// Demo construct: recognizable motifs (T7/M13/lac β†’ auto-annotated) +
// the AmpR ORF + cloning sites placed so there are real single- AND
// double-cutters for a multi-band virtual gel.
const ampR =
'ATGAGTATTCAACATTTCCGTGTCGCCCTTATTCCCTTTTTTGCGGCATTTTGCCTTCCTGTTTTTGCTCACCCAGAAACGCTGGTGAAAGTAAAAGATGCTGAAGATCAGTTGGGTGCACGAGTGGGTTACATCGAACTGGATCTCAACAGCGGTAAGATCCTTGAGAGTTTTCGCCCCGAAGAACGTTTTCCAATGATGAGCACTTTTAAAGTTCTGCTATGTGGCGCGGTATTATCCCGTATTGACGCCGGGCAAGAGCAACTCGGTCGCCGCATACACTATTCTCAGAATGACTTGGTTGAGTACTCACCAGTCACAGAAAAGCATCTTACGGATGGCATGACAGTAAGAGAATTATGCAGTGCTGCCATAACCATGAGTGATAACACTGCGGCCAACTTACTTCTGACAACGATCGGAGGACCGAAGGAGCTAACCGCTTTTTTGCACAACATGGGGGATCATGTAACTCGCCTTGATCGTTGGGAACCGGAGCTGAATGAAGCCATACCAAACGACGAGCGTGACACCACGATGCCTGTAGCAATGGCAACAACGTTGCGCAAACTATTAACTGGCGAACTACTTACTCTAGCTTCCCGGCAACAATTAATAGACTGGATGGAGGCGGATAAAGTTGCAGGACCACTTCTGCGCTCGGCCCTTCCGGCTGGCTGGTTTATTGCTGATAAATCTGGAGCCGGTGAGCGTGGGTCTCGCGGTATCATTGCAGCACTGGGGCCAGATGGTAAGCCCTCCCGTATCGTAGTTATCTACACGACGGGGAGTCAGGCAACTATGGATGAACGAAATAGACAGATCGCTGAGATAGGTGCCTCACTGATTAAGCATTGGTAA';
inputEl.value =
'GAATTC' + // EcoRI #1
'TAATACGACTCACTATAG' + // T7 promoter
'GTAAAACGACGGCCAGT' + // M13 fwd
'GGATCC' + // BamHI
'TGGAATTGTGAGCGGATAACAATT' + // lac operator
ampR +
'AAGCTT' + // HindIII
'CAGGAAACAGCTATGAC' + // M13 rev
'CTGCAGTCTAGACTCGAG' + // PstI / XbaI / XhoI MCS-ish
'GAATTC' + // EcoRI #2 β†’ EcoRI double-cutter
// filler past the 2nd EcoRI so the two digest fragments are well
// separated on the gel (β‰ˆ970 bp + β‰ˆ220 bp) rather than 973 + 6.
'TGCAACGTTAGCCATGACTGGACTAGGCATTACGGTACCTAAGCGATCCATGGTCAACGTTGACTACGATCGGATCCTAAGCTTACGGATCCAATTGCGTACGATCGTTAGCCATGGACTAGCATTGGCAACGTTACGGATCAGCTAGCTAGCATCGATCGTACGATCAGCTAGCATCGTAGCATCGATCGATCAGCTAGCATCGATCGTAGCTAGCATCGT';
document.querySelector('input[name="plasmidTopo"][value="circular"]').checked = true;
doImport(inputEl.value);
});
nameEl.addEventListener('input', () => { if (plasmid) plasmid.name = nameEl.value; });
function setView(mode) {
viewMode = mode;
[['circular', 'pvCircular'], ['linear', 'pvLinear'], ['sequence', 'pvSequence']].forEach(([m, id]) => {
const b = $(id); if (b) b.classList.toggle('pv-tab-active', m === mode);
});
renderMap();
}
$('pvCircular').addEventListener('click', () => setView('circular'));
$('pvLinear').addEventListener('click', () => setView('linear'));
$('pvSequence').addEventListener('click', () => setView('sequence'));
enzGrid.addEventListener('click', (e) => {
const b = e.target.closest('.plasmid-enz'); if (!b) return;
const name = b.getAttribute('data-enz');
if (selected.has(name)) selected.delete(name); else selected.add(name);
b.classList.toggle('enz-on');
renderMap();
});
$('plasmidDigestBtn').addEventListener('click', doDigest);
$('plasmidClearEnz').addEventListener('click', () => { selected.clear(); renderEnzymes(); renderMap(); gelEl.hidden = true; gelEl.innerHTML = ''; });
$('plasmidSave').addEventListener('click', doSave);
$('plasmidLibraryBtn').addEventListener('click', openLibrary);
if (libModal) {
libModal.querySelectorAll('[data-plasmid-lib-close]').forEach((el) => el.addEventListener('click', closeLibrary));
libList.addEventListener('click', (e) => {
const o = e.target.closest('.lib-open'), d = e.target.closest('.lib-del');
if (o) openSaved(o.getAttribute('data-id'));
else if (d) deleteSaved(d.getAttribute('data-id'), d.closest('.plasmid-lib-item'));
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !libModal.hidden) closeLibrary(); });
}
// Export menu (GenBank / FASTA)
const exportBtn = $('plasmidExportGb');
if (exportBtn) {
const menu = document.createElement('div');
menu.className = 'plasmid-export-menu'; menu.hidden = true;
menu.innerHTML = '<button type="button" data-fmt="genbank">GenBank (.gb)</button><button type="button" data-fmt="fasta">FASTA (.fasta)</button>';
exportBtn.parentNode.appendChild(menu);
// Toggle THIS menu, closing any other toolbar dropdown first (otherwise
// opening the second one leaves the first stuck open + overlapping β€”
// each button's stopPropagation blocked the other's outside-click close).
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
const open = menu.hidden;
document.querySelectorAll('.plasmid-export-menu').forEach((m) => { m.hidden = true; });
menu.hidden = !open;
});
menu.addEventListener('click', (e) => { const b = e.target.closest('[data-fmt]'); if (b) { menu.hidden = true; doExport(b.getAttribute('data-fmt')); } });
document.addEventListener('click', () => { document.querySelectorAll('.plasmid-export-menu').forEach((m) => { m.hidden = true; }); });
}
// ───────────────────────── M3: sequence editor ─────────────────────────
const GCODE = (() => {
const b = 'TCAG', aa = 'FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG';
const m = {}; let k = 0;
for (const a of b) for (const c of b) for (const d of b) m[a + c + d] = aa[k++];
return m;
})();
const SEQ_RENDER_CAP = 60000; // bp β€” bigger than this, show a notice not a wall
const SEQ_LINE = 60;
let sel = null; // {from, to} 1-based inclusive
let findHits = [], findIdx = -1, findLen = 0;
const rcStr = (s) => s.toUpperCase().replace(/[^ACGTN]/g, '')
.split('').reverse().map((c) => ({ A: 'T', T: 'A', G: 'C', C: 'G', N: 'N' }[c] || 'N')).join('');
const gcOf = (s) => { const u = s.toUpperCase(); const n = (u.match(/[ACGT]/g) || []).length;
return n ? Math.round(1000 * (u.match(/[GC]/g) || []).length / n) / 10 : 0; };
function translate(dna) {
const s = dna.toUpperCase(); let out = '';
for (let i = 0; i + 3 <= s.length; i += 3) out += (GCODE[s.substr(i, 3)] || 'X');
return out;
}
function copyText(t, label) {
const done = () => showSeqOut(`Copied ${label} (${t.length} bp) to clipboard.`);
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, () => showSeqOut('Copy blocked by the browser.'));
else showSeqOut('Clipboard not available here.');
}
function showSeqOut(html) { const o = $('plasmidSeqOutput'); if (o) { o.hidden = false; o.innerHTML = html; } }
function _featColorAt(i, feats) { for (const f of feats) if (i >= f.start && i < f.end) return f.color || '#9AA0AA'; return ''; }
function _inFind(i) { for (const h of findHits) if (i >= h && i < h + findLen) return true; return false; }
function renderSequence() {
const seq = plasmid.sequence, len = seq.length, feats = plasmid.features || [];
syncSelInputs();
if (len > SEQ_RENDER_CAP) {
mapWrap.innerHTML = `<div class="seqed-toobig">This sequence is ${len.toLocaleString()} bp β€” too long to render fully. `
+ `Use Find or the From/To fields to work on a region; export to view the full sequence.</div>`;
return;
}
const sF = sel ? sel.from - 1 : -1, sT = sel ? sel.to : -1; // 0-based [sF, sT)
const styleKey = (i) => `${_featColorAt(i, feats)}|${(i >= sF && i < sT) ? 'S' : ''}${_inFind(i) ? 'F' : ''}`;
let html = '<div class="seqed">';
for (let ls = 0; ls < len; ls += SEQ_LINE) {
const le = Math.min(ls + SEQ_LINE, len);
html += `<div class="seqed-line"><span class="seqed-pos">${(ls + 1).toLocaleString()}</span><span class="seqed-bases">`;
let i = ls;
while (i < le) {
const key = styleKey(i); let j = i + 1;
while (j < le && styleKey(j) === key) j++;
const [color, flags] = key.split('|');
const cls = 'seqed-run' + (flags.includes('S') ? ' seqed-sel' : '') + (flags.includes('F') ? ' seqed-find' : '');
// Only emit a known-safe hex colour into the inline style (defends
// against an injection-laden feature colour from a saved plasmid).
const safe = /^#[0-9A-Fa-f]{3,8}$/.test(color) ? color : '';
const st = safe ? ` style="--featc:${safe}"` : '';
html += `<span class="${cls}"${color ? ' data-feat="1"' : ''}${st}>${seq.slice(i, j)}</span>`;
i = j;
}
html += '</span></div>';
}
html += '</div>';
mapWrap.innerHTML = html;
const selRun = mapWrap.querySelector('.seqed-sel');
if (selRun && sel) selRun.scrollIntoView({ block: 'nearest' });
}
function syncSelInputs() {
const f = $('plasmidSelFrom'), t = $('plasmidSelTo'), l = $('plasmidSelLen');
if (sel) { if (f) f.value = sel.from; if (t) t.value = sel.to; if (l) l.textContent = `${(sel.to - sel.from + 1).toLocaleString()} bp selected`; }
else if (l) l.textContent = '';
}
function setSel(from, to) {
if (!plasmid) return;
const len = plasmid.length;
from = Math.max(1, Math.min(len, from | 0)); to = Math.max(1, Math.min(len, to | 0));
if (to < from) [from, to] = [to, from];
sel = { from, to };
if (viewMode === 'sequence') renderSequence(); else setView('sequence');
}
function selSeq() { return sel ? plasmid.sequence.slice(sel.from - 1, sel.to) : ''; }
function runFind() {
const raw = ($('plasmidFind').value || '').toUpperCase().replace(/[^ACGTN]/g, '');
findHits = []; findIdx = -1; findLen = raw.length;
if (raw.length >= 3 && plasmid) {
const seq = plasmid.sequence;
const add = (needle) => { let p = seq.indexOf(needle); while (p !== -1) { findHits.push(p); p = seq.indexOf(needle, p + 1); } };
add(raw); const rc = rcStr(raw); if (rc !== raw) add(rc);
findHits = [...new Set(findHits)].sort((a, b) => a - b);
}
const c = $('plasmidFindCount');
if (c) c.textContent = findLen < 3 ? '' : (findHits.length ? `${findHits.length} match${findHits.length === 1 ? '' : 'es'}` : 'no matches');
if (findHits.length) gotoFind(0); else renderSequence();
}
function gotoFind(idx) {
if (!findHits.length) return;
findIdx = (idx + findHits.length) % findHits.length;
const h = findHits[findIdx];
setSel(h + 1, h + findLen);
const c = $('plasmidFindCount'); if (c) c.textContent = `${findIdx + 1} of ${findHits.length}`;
}
// edits: rewrite the sequence + remap features + rescan restriction.
function applyEdit(newSeq, remap) {
plasmid.sequence = newSeq.toUpperCase(); plasmid.length = newSeq.length;
plasmid.features = (plasmid.features || []).map(remap).filter((f) => f && f.end > f.start);
plasmid.gc = gcOf(newSeq); plasmid.source = plasmid.source || 'raw';
sel = null; findHits = []; restriction = null; selected.clear();
setMeta(); nameEl.value = plasmid.name || nameEl.value;
renderMap(); renderFeatures();
gelEl.hidden = true; gelEl.innerHTML = '';
enzGrid.innerHTML = '<span class="field-hint">Re-scanning restriction sites…</span>';
loadRestriction();
}
function deleteRange(a, b) { // 0-based [a, b)
const seq = plasmid.sequence; const cut = b - a;
const map = (x) => (x <= a ? x : (x >= b ? x - cut : a));
applyEdit(seq.slice(0, a) + seq.slice(b), (f) => ({ ...f, start: map(f.start), end: map(f.end) }));
}
function insertAt(p, ins) { // 0-based position
const seq = plasmid.sequence; const L = ins.length;
applyEdit(seq.slice(0, p) + ins + seq.slice(p),
(f) => ({ ...f, start: f.start < p ? f.start : f.start + L, end: f.end <= p ? f.end : f.end + L }));
}
function revcompAll() {
const seq = plasmid.sequence, len = seq.length;
applyEdit(rcStr(seq), (f) => ({ ...f, start: len - f.end, end: len - f.start, strand: -(f.strand || 1) }));
}
// wiring β€” Find
const findEl = $('plasmidFind');
if (findEl) {
findEl.addEventListener('input', () => { clearTimeout(findEl._t); findEl._t = setTimeout(runFind, 200); });
findEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); gotoFind(findIdx + 1); } });
}
if ($('plasmidFindNext')) $('plasmidFindNext').addEventListener('click', () => gotoFind(findIdx + 1));
if ($('plasmidFindPrev')) $('plasmidFindPrev').addEventListener('click', () => gotoFind(findIdx - 1));
// selection inputs
const onSelInput = () => { const f = parseInt($('plasmidSelFrom').value, 10), t = parseInt($('plasmidSelTo').value, 10);
if (f && t) setSel(f, t); };
if ($('plasmidSelFrom')) $('plasmidSelFrom').addEventListener('change', onSelInput);
if ($('plasmidSelTo')) $('plasmidSelTo').addEventListener('change', onSelInput);
// click a feature β†’ select it (and jump to sequence)
featuresEl.addEventListener('click', (e) => {
const item = e.target.closest('.plasmid-feat-item'); if (!item) return;
const f = (plasmid.features || [])[parseInt(item.getAttribute('data-fidx'), 10)];
if (f) setSel(f.start + 1, f.end);
});
// operations
const needSel = () => { if (!sel) { showSeqOut('Select a region first (From/To, Find, or click a feature).'); return false; } return true; };
if ($('seqCopy')) $('seqCopy').addEventListener('click', () => { if (needSel()) copyText(selSeq(), 'selection'); });
if ($('seqCopyRC')) $('seqCopyRC').addEventListener('click', () => { if (needSel()) copyText(rcStr(selSeq()), 'reverse-complement'); });
if ($('seqTranslate')) $('seqTranslate').addEventListener('click', () => {
const dna = sel ? selSeq() : plasmid.sequence;
const prot = translate(dna);
showSeqOut(`<div class="seqout-h">Translation (${sel ? 'selection' : 'whole sequence'}, frame 1 Β· ${prot.length} aa)</div><code class="seqout-prot">${esc(prot)}</code>`);
});
if ($('seqAnnotate')) $('seqAnnotate').addEventListener('click', () => {
if (!needSel()) return;
const name = prompt('Feature name:', 'my feature'); if (!name) return;
const type = (prompt('Type (CDS, promoter, terminator, primer_bind, misc_feature…):', 'misc_feature') || 'misc_feature').trim();
const colors = { cds: '#6A89E4', gene: '#6A89E4', promoter: '#5FA98A', terminator: '#C77E7E',
rep_origin: '#E4B45F', primer_bind: '#9AA0AA', tag: '#B07ED9' };
(plasmid.features = plasmid.features || []).push({
name: name.slice(0, 60), type: type.toLowerCase(), start: sel.from - 1, end: sel.to, strand: 1,
color: colors[type.toLowerCase()] || '#9AA0AA', notes: 'user-added',
});
plasmid.features.sort((a, b) => a.start - b.start);
setMeta(); renderMap(); renderFeatures();
showSeqOut(`Added feature β€œ${esc(name)}” at ${sel.from}–${sel.to}.`);
});
if ($('seqDelete')) $('seqDelete').addEventListener('click', () => {
if (!needSel()) return;
if (!confirm(`Delete ${sel.to - sel.from + 1} bp (${sel.from}–${sel.to})? Feature coordinates will shift.`)) return;
deleteRange(sel.from - 1, sel.to);
showSeqOut('Region deleted; map + restriction sites updated.');
});
if ($('seqInsert')) $('seqInsert').addEventListener('click', () => {
const at = sel ? sel.from : 1;
const raw = prompt(`Insert sequence BEFORE position ${at} (ACGT):`, ''); if (!raw) return;
const ins = raw.toUpperCase().replace(/[^ACGTN]/g, '');
if (!ins) { showSeqOut('No valid DNA to insert.'); return; }
insertAt(at - 1, ins);
showSeqOut(`Inserted ${ins.length} bp before position ${at}.`);
});
if ($('seqRevComp')) $('seqRevComp').addEventListener('click', () => {
if (!confirm('Reverse-complement the entire plasmid? Features will be remapped.')) return;
revcompAll();
showSeqOut('Whole plasmid reverse-complemented.');
});
// ───────────────────────── M4: cloning / assembly ──────────────────────
let cloneMethod = 'gibson';
let lastAssembly = null;
const cloneErr = (m) => { const e = $('cloneError'); if (e) { e.textContent = m; e.hidden = false; } };
const cloneClearErr = () => { const e = $('cloneError'); if (e) e.hidden = true; };
function parseFragments(text) {
text = (text || '').replace(/\r/g, '');
const frs = [];
if (text.includes('>')) {
let name = null, buf = [];
text.split('\n').forEach((ln) => {
if (ln.startsWith('>')) { if (name !== null) frs.push({ name, seq: buf.join('') }); name = ln.slice(1).trim() || `fragment ${frs.length + 1}`; buf = []; }
else if (ln.trim()) buf.push(ln.trim());
});
if (name !== null) frs.push({ name, seq: buf.join('') });
} else {
text.split('\n').forEach((ln) => {
ln = ln.trim(); if (!ln) return;
const m = ln.match(/^([^:,\t]+)[:,\t]\s*([ACGTNacgtn\s]+)$/);
if (m && !/^[ACGTNacgtn\s]+$/.test(m[1])) frs.push({ name: m[1].trim(), seq: m[2].replace(/\s/g, '') });
else frs.push({ name: `fragment ${frs.length + 1}`, seq: ln.replace(/\s/g, '') });
});
}
return frs.filter((f) => f.seq);
}
function setCloneMethod(m) {
cloneMethod = m;
document.querySelectorAll('.clone-method').forEach((b) => b.classList.toggle('clone-method-active', b.getAttribute('data-method') === m));
$('clonePaneFragments').hidden = (m === 'restriction');
$('clonePaneRestriction').hidden = (m !== 'restriction');
$('cloneCircWrap').hidden = (m !== 'gibson');
$('cloneGgEnzWrap').hidden = (m !== 'golden_gate');
$('cloneFragHint').innerHTML = (m === 'golden_gate')
? 'Paste your <strong>parts</strong> (each flanked by inward-facing Type IIS sites). Matching 4-nt overhangs chain into a circle.'
: 'Paste your fragments β€” FASTA, or one per line as <code>name: SEQUENCE</code>. Overlapping ends assemble scarlessly; bare junctions get homology arms designed into the primers.';
}
document.querySelectorAll('.clone-method').forEach((b) => b.addEventListener('click', () => setCloneMethod(b.getAttribute('data-method'))));
if ($('cloneUseCurrent')) $('cloneUseCurrent').addEventListener('click', () => {
if (!plasmid) { cloneErr('Import a plasmid first to use it as a fragment.'); return; }
const ta = $('cloneFragments');
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + `>${(plasmid.name || 'plasmid')}\n${plasmid.sequence}`;
cloneClearErr();
});
async function runClone() {
cloneClearErr(); lastAssembly = null;
const body = { method: cloneMethod };
if (cloneMethod === 'restriction') {
body.vector = ($('cloneVector').value || '').trim();
body.insert = ($('cloneInsert').value || '').trim();
body.enzyme = ($('cloneReEnzyme').value || '').trim();
body.vector_topology = $('cloneVecCirc').checked ? 'circular' : 'linear';
if (!body.vector || !body.insert) { cloneErr('Paste both a vector and an insert.'); return; }
} else {
const frags = parseFragments($('cloneFragments').value);
if (frags.length < 2) { cloneErr('Provide at least two fragments.'); return; }
body.fragments = frags;
if (cloneMethod === 'gibson') body.circular = $('cloneCircular').checked;
if (cloneMethod === 'golden_gate') body.enzyme = $('cloneGgEnzyme').value;
}
const btn = $('cloneRun'); const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Assembling…';
try {
const res = await fetch('/api/plasmid/clone', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const data = await res.json();
if (res.status === 403 && data.kind === 'signin_required') { window.dispatchEvent(new Event('td:signin-required')); return; }
if (!res.ok) { cloneErr(data.error || 'Assembly failed.'); return; }
if (!data.ok) { cloneErr(data.error || 'Couldn’t assemble these fragments.'); $('cloneResult').hidden = true; return; }
lastAssembly = data;
renderClone(data);
} catch (e) { cloneErr('Could not reach the assembler.'); }
finally { btn.disabled = false; btn.textContent = orig; }
}
function renderClone(d) {
const r = $('cloneResult'); r.hidden = false;
const junc = (d.junctions || []).map((j) => `
<div class="clone-junc">
<span class="cj-pair">${esc(j.left)} β†’ ${esc(j.right)}</span>
<span class="cj-kind cj-${esc(j.kind)}">${esc(j.kind)}</span>
<span class="cj-ov">${j.overlap} bp${j.seq ? ' Β· ' + esc(j.seq.slice(0, 24)) : ''}</span>
</div>`).join('');
let primers = '';
if ((d.primers || []).length) {
primers = '<h4 class="clone-sub">Assembly primers</h4>' + d.primers.map((p) => `
<div class="clone-primer">
<div class="cp-name">${esc(p.fragment)}</div>
<div class="cp-row"><span class="cp-lbl">F</span><code>${esc(p.forward)}</code><span class="cp-meta">${p.forward_len} nt${p.forward_tail ? ' Β· +' + p.forward_tail + ' tail' : ''}${p.forward_tm ? ' Β· Tm ' + p.forward_tm + 'Β°C' : ''}</span></div>
<div class="cp-row"><span class="cp-lbl">R</span><code>${esc(p.reverse)}</code><span class="cp-meta">${p.reverse_len} nt${p.reverse_tail ? ' Β· +' + p.reverse_tail + ' tail' : ''}${p.reverse_tm ? ' Β· Tm ' + p.reverse_tm + 'Β°C' : ''}</span></div>
</div>`).join('');
}
r.innerHTML =
`<div class="clone-summary"><span class="clone-ok">βœ“ Assembled</span>
<strong>${d.length.toLocaleString()} bp</strong> Β· ${esc(d.topology)} Β·
${(d.order || []).map(esc).join(' β†’ ')}</div>`
+ ((d.warnings || []).length ? `<div class="clone-warn">${d.warnings.map(esc).join(' ')}</div>` : '')
+ `<div class="clone-junc-list"><h4 class="clone-sub">Junctions</h4>${junc}</div>`
+ primers
+ `<div class="clone-result-actions">
<button class="primary" type="button" id="cloneOpen">Open assembled plasmid in the studio β†’</button>
</div>`;
const open = $('cloneOpen');
if (open) open.addEventListener('click', () => {
plasmid = { name: 'assembly', sequence: d.assembled, length: d.length,
topology: d.topology, gc: gcOf(d.assembled), features: d.features || [], source: 'raw' };
showResults();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
r.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if ($('cloneRun')) $('cloneRun').addEventListener('click', runClone);
setCloneMethod('gibson');
// ───────────────────── M5: hand off to CRISPR / Primer Picker ──────────
// The other tools are separate controllers, so we drive them through their
// public DOM inputs (set value + fire 'input' so their gutters/stats react)
// and route via the hash. Works on the whole plasmid or a selected region.
function _toast(msg) {
if (typeof showToast === 'function') { showToast(msg, 'info'); return; }
showSeqOut(msg);
}
function sendToCrispr(seq, label) {
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
if (seq.length < 23) { _toast('Need at least ~23 bp to design CRISPR guides.'); return; }
const ta = document.getElementById('crisprInput');
if (!ta) return;
ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
const gene = document.getElementById('crisprGeneSymbol'); if (gene) gene.value = '';
location.hash = '#crispr';
setTimeout(() => { const card = document.getElementById('crisprInputCard'); if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 80);
_toast(`Loaded ${label} (${seq.length.toLocaleString()} bp) into the CRISPR designer β€” hit Design.`);
}
function sendToPrimers(seq, label) {
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
if (!seq) { _toast('Nothing to send.'); return; }
const t = document.getElementById('primerTemplate');
if (!t) return;
t.value = seq; t.dispatchEvent(new Event('input', { bubbles: true }));
location.hash = '#primers';
setTimeout(() => { const el = document.getElementById('primerInput'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.focus(); } }, 80);
_toast(`Set ${label} (${seq.length.toLocaleString()} bp) as the Primer Analysis template β€” paste candidate primers.`);
}
// Toolbar "Use in β–Ύ" menu β€” acts on the whole plasmid.
const useBtn = $('plasmidUseBtn');
if (useBtn) {
const menu = document.createElement('div');
menu.className = 'plasmid-export-menu plasmid-use-menu'; menu.hidden = true;
menu.innerHTML = '<button type="button" data-use="crispr">CRISPR guides</button>'
+ '<button type="button" data-use="primers">Primer Analysis (template)</button>';
useBtn.parentNode.appendChild(menu);
useBtn.addEventListener('click', (e) => {
e.stopPropagation();
const open = menu.hidden;
document.querySelectorAll('.plasmid-export-menu').forEach((m) => { m.hidden = true; });
menu.hidden = !open;
});
menu.addEventListener('click', (e) => {
const b = e.target.closest('[data-use]'); if (!b || !plasmid) return;
menu.hidden = true;
const nm = plasmid.name || 'plasmid';
if (b.getAttribute('data-use') === 'crispr') sendToCrispr(plasmid.sequence, nm);
else sendToPrimers(plasmid.sequence, nm);
});
document.addEventListener('click', () => { document.querySelectorAll('.plasmid-export-menu').forEach((m) => { m.hidden = true; }); });
}
// Sequence-editor region hand-offs β€” act on the current selection (or whole).
if ($('seqToCrispr')) $('seqToCrispr').addEventListener('click', () => {
const seq = sel ? selSeq() : plasmid.sequence;
sendToCrispr(seq, sel ? `region ${sel.from}–${sel.to}` : (plasmid.name || 'plasmid'));
});
if ($('seqToPrimers')) $('seqToPrimers').addEventListener('click', () => {
const seq = sel ? selSeq() : plasmid.sequence;
sendToPrimers(seq, sel ? `region ${sel.from}–${sel.to}` : (plasmid.name || 'plasmid'));
});
})();