syntheogenesis / dee /static /context.js
github-actions[bot]
Deploy ac6b078
10a3564
Raw
History Blame Contribute Delete
18.1 kB
/* ═══════════════════════════════════════════════════════════════════════
TDContext β€” the active construct.
THE INVERSION THIS FILE EXISTS FOR
----------------------------------
Until now a tool was a DESTINATION. Eight routes, eight views, each with
its own empty input box, each asking for the sequence again. The "hand-off"
between them was a string copied from one <textarea> into another, and the
backend never learned which project any run belonged to β€” so "what work
belongs together?" could only be reconstructed afterwards by matching name
strings, which merges every artifact left at its default name and splits a
project the moment you rename something.
Here the construct is the noun and the tools are verbs applied to it. One
selection, held in one place, carried across every view and attached to
every request. Attribution stops being a reconstruction and becomes a
record β€” which is also what makes a real lineage graph possible later,
without hand-instrumenting each hand-off path.
THREE RULES, and each one is a way this could become worse than what it
replaces:
1. SIGNED OUT IT MUST BEHAVE EXACTLY AS BEFORE. Public tool access was a
deliberate decision. A visitor with no account keeps a purely local
selection; nothing here may become a precondition for running a tool.
2. IT MUST NEVER BLOCK. Pasting a new sequence switches the context
silently. The moment this can say "no", it is modal, and modal is worse
than scattered.
3. IT MUST NEVER CLOBBER. Pre-filling only ever touches an input the user
has left EMPTY. Overwriting something they typed would be the single
fastest way to make people distrust the whole idea.
═══════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
var LS_KEY = 'td.construct.v1';
var listeners = [];
var active = null; // {id?, name, dna, protein, identifier, phase}
var known = []; // server-side list, for the switcher
/* ── state ──────────────────────────────────────────────────────── */
function load() {
try {
var raw = localStorage.getItem(LS_KEY);
active = raw ? JSON.parse(raw) : null;
} catch (e) { active = null; }
}
function persist() {
try {
if (active) localStorage.setItem(LS_KEY, JSON.stringify(active));
else localStorage.removeItem(LS_KEY);
} catch (e) { /* private mode β€” the session still works, just not across reloads */ }
}
function notify() {
listeners.forEach(function (fn) {
try { fn(active); } catch (e) { console.error(e); }
});
}
function get() { return active; }
function id() { return active && active.id ? active.id : null; }
/* ── the pending source ──────────────────────────────────────────
A hand-off knows something the save that follows it does not: WHICH
artifact the sequence came from, and which region. That fact is alive
only for the moment between "send this plasmid region to CRISPR" and
"save these guides", so it is parked here and attached to the save.
Without it every edge would point at the construct root β€” true, but the
least useful true thing we could record.
Deliberately cleared whenever the construct changes: a source that
outlives its context would attribute new work to an unrelated parent,
and a confidently wrong edge is worse than a vague one. */
var pendingSource = null;
function setSource(src) {
pendingSource = (src && src.kind && src.id) ? {
kind: String(src.kind), id: String(src.id),
relation: src.relation || 'derived_from',
detail: src.detail || {},
} : null;
}
function source() { return pendingSource; }
function set(c, opts) {
var changed = !active || !c || active.id !== c.id || active.dna !== c.dna;
active = c || null;
if (changed) pendingSource = null; // never let a source outlive its context
persist();
notify();
if (!(opts && opts.quiet) && active) {
toast('Working on ' + active.name);
}
}
function clear() { set(null); notify(); }
function subscribe(fn) { listeners.push(fn); try { fn(active); } catch (e) {} }
function toast(msg) {
if (typeof window.showToast === 'function') window.showToast(msg, 'info');
}
/* ── adopting a sequence ────────────────────────────────────────── */
// Called whenever a sequence enters the app by ANY route β€” pasted, fetched,
// imported, or handed over from another tool. Switches silently; creates
// server-side only when signed in. Rule 2: this never refuses.
function adopt(seq, label, opts) {
opts = opts || {};
var dna = String(seq || '').replace(/\s+/g, '').toUpperCase();
if (dna.length < 12) return Promise.resolve(active);
// Same sequence = same work. Do not spawn a second project for it.
if (active && active.dna === dna) return Promise.resolve(active);
var local = {
id: null,
name: label || defaultName(dna),
dna: dna,
protein: opts.protein || '',
identifier: opts.identifier || '',
phase: 'Design',
};
set(local, { quiet: !!opts.quiet });
// Signed out this is where it ends, and that is a complete experience:
// the selection still threads every view for this session.
return fetch('/api/constructs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sequence_dna: dna, wt_protein: local.protein,
name: local.name, wt_identifier: local.identifier,
}),
}).then(function (r) {
if (!r.ok) return null; // 401 signed out β€” expected, not an error
return r.json();
}).then(function (d) {
if (!d || !d.ok || !d.construct) return active;
// Server wins on identity, the local copy wins on payload: the
// list endpoint deliberately does not ship the sequence back.
active = Object.assign({}, local, {
id: d.construct.id,
name: d.construct.name || local.name,
phase: d.construct.phase || 'Design',
});
persist(); notify(); refreshList();
return active;
}).catch(function () { return active; });
}
function defaultName(dna) {
// Never "Untitled". A name you cannot tell apart from four others is
// the failure mode the name-string grouping already suffers from.
return 'Construct ' + dna.slice(0, 6) + '…' + dna.slice(-4);
}
/* ── the server-side list, for the switcher ─────────────────────── */
function refreshList() {
return fetch('/api/constructs').then(function (r) {
return r.ok ? r.json() : null;
}).then(function (d) {
known = (d && d.constructs) || [];
notify();
return known;
}).catch(function () { return known; });
}
function list() { return known; }
function open(constructId) {
return fetch('/api/constructs/' + encodeURIComponent(constructId))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.construct) return null;
var c = d.construct;
set({
id: c.id, name: c.name, dna: c.sequence_dna || '',
protein: c.wt_protein || '', identifier: c.wt_identifier || '',
phase: c.phase || 'Design',
});
return active;
}).catch(function () { return null; });
}
function rename(name) {
if (!active) return Promise.resolve(null);
active.name = String(name || '').slice(0, 120) || active.name;
persist(); notify();
if (!active.id) return Promise.resolve(active);
return fetch('/api/constructs/' + encodeURIComponent(active.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: active.name }),
}).then(function () { refreshList(); return active; })
.catch(function () { return active; });
}
/* ── the interceptor ────────────────────────────────────────────── */
// ONE place, not twenty. Editing every tool's fetch call by hand would
// have meant instrumenting ~20 sites and still missing whichever one gets
// added next month β€” and a lineage with a silent hole in it is worse than
// no lineage, because you cannot tell which is which.
var TOOL_PREFIXES = ['/api/crispr', '/api/primers', '/api/plasmid',
'/api/design', '/api/dna', '/api/compiler',
'/api/de/', '/api/align', '/api/structure'];
var nativeFetch = window.fetch.bind(window);
function isToolCall(url) {
var u = String(url || '');
for (var i = 0; i < TOOL_PREFIXES.length; i++) {
if (u.indexOf(TOOL_PREFIXES[i]) === 0) return true;
}
return false;
}
window.fetch = function (input, init) {
try {
var url = (typeof input === 'string') ? input
: (input && input.url) || '';
var cid = id();
if (cid && init && init.method &&
String(init.method).toUpperCase() === 'POST' &&
isToolCall(url) && typeof init.body === 'string') {
var body = JSON.parse(init.body);
if (body && typeof body === 'object' && !Array.isArray(body)) {
var touched = false;
if (!body.construct_id) { body.construct_id = cid; touched = true; }
// Only on a SAVE: that is the call that mints the artifact
// the edge will point at. Attaching it to every tool call
// would record an edge per keystroke-triggered request.
if (pendingSource && !body.derived_from &&
/\/save$/.test(String(url).split('?')[0])) {
body.derived_from = pendingSource;
touched = true;
}
if (touched) {
init = Object.assign({}, init, { body: JSON.stringify(body) });
}
}
}
} catch (e) {
// A body that is not JSON, or is not ours to touch. Attribution is
// never worth breaking the request the user actually made.
}
return nativeFetch(input, init);
};
/* ── pre-filling a tool view ────────────────────────────────────── */
// Rule 3: only ever fills an input the user left EMPTY.
var TOOL_INPUTS = {
crispr: 'crisprInput',
primers: 'primerTemplate',
design: 'pasteArea',
compiler: 'tcWindow',
dna: 'dnaReference',
plasmid: 'plasmidInput',
};
function prefill(route) {
if (!active || !active.dna) return false;
var elId = TOOL_INPUTS[route];
if (!elId) return false;
var el = document.getElementById(elId);
if (!el || (el.value || '').trim()) return false; // never clobber
el.value = active.dna;
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
load();
window.TDContext = {
get: get, id: id, set: set, clear: clear, subscribe: subscribe,
adopt: adopt, open: open, rename: rename, list: list,
refreshList: refreshList, prefill: prefill,
setSource: setSource, source: source,
TOOL_INPUTS: TOOL_INPUTS,
};
// The list is only meaningful signed in; a 401 here is normal and silent.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { refreshList(); });
} else { refreshList(); }
}());
/* ═══════════════════════════════════════════════════════════════════════
The chip + switcher.
Rule 3 has a UI corollary: switching and clearing must be one click and
always visible. A context you cannot see or change is worse than no
context β€” you end up editing the wrong object and only find out later.
═══════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
var C = window.TDContext;
if (!C) return;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;' })[m];
});
}
function bp(n) { return n ? n.toLocaleString() + ' bp' : ''; }
function init() {
var chip = document.getElementById('ctxChip');
var menu = document.getElementById('ctxMenu');
if (!chip || !menu) return;
C.subscribe(function (c) {
chip.hidden = !c;
document.body.setAttribute('data-ctx', c ? 'on' : 'off');
if (!c) return;
var nm = document.getElementById('ctxName');
var mt = document.getElementById('ctxMeta');
if (nm) nm.textContent = c.name || 'Construct';
if (mt) {
// The un-saved case is stated, not hidden. A signed-out user
// whose selection lives only in this browser should know that.
mt.textContent = [bp((c.dna || '').length),
c.phase || '',
c.id ? '' : 'this browser only']
.filter(Boolean).join(' Β· ');
}
});
function close() {
menu.hidden = true;
chip.setAttribute('aria-expanded', 'false');
}
function render() {
var cur = C.get();
var rows = C.list() || [];
var h = '<div class="ctx-menu-hd">Constructs</div>';
if (!rows.length) {
h += '<div class="ctx-menu-empty">No saved projects yet. '
+ 'Paste or fetch a sequence and it becomes one.</div>';
}
rows.forEach(function (r) {
var on = cur && cur.id === r.id;
h += '<button class="ctx-item' + (on ? ' is-on' : '') + '" role="menuitem"'
+ ' data-open="' + esc(r.id) + '">'
+ '<span class="ctx-item-nm">' + esc(r.name) + '</span>'
+ '<span class="ctx-item-mt mono">' + esc(r.phase || 'Design')
+ (r.n_plasmids ? ' Β· ' + r.n_plasmids + 'p' : '')
+ (r.n_crispr ? ' Β· ' + r.n_crispr + 'g' : '')
+ (r.n_primers ? ' Β· ' + r.n_primers + 'pr' : '')
+ '</span></button>';
});
h += '<div class="ctx-menu-sep"></div>';
if (cur) {
h += '<button class="ctx-act" role="menuitem" data-rename="1">Rename…</button>';
h += '<button class="ctx-act" role="menuitem" data-clear="1">'
+ 'Work without a construct</button>';
}
menu.innerHTML = h;
}
chip.addEventListener('click', function (e) {
e.stopPropagation();
if (!menu.hidden) { close(); return; }
render();
menu.hidden = false;
chip.setAttribute('aria-expanded', 'true');
C.refreshList().then(function () { if (!menu.hidden) render(); });
});
menu.addEventListener('click', function (e) {
var t = e.target.closest('[data-open],[data-clear],[data-rename]');
if (!t) return;
e.stopPropagation();
if (t.dataset.open) { C.open(t.dataset.open); close(); return; }
if (t.dataset.clear) {
// Deliberately NOT called "delete". Clearing the selection
// must never read as destroying the work.
C.clear(); close(); return;
}
if (t.dataset.rename) {
var cur = C.get();
var next = window.prompt('Rename this construct', cur && cur.name);
if (next) C.rename(next);
close();
}
});
document.addEventListener('click', function () { if (!menu.hidden) close(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.hidden) close();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else { init(); }
}());