// 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 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
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 (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