Spaces:
Running
Deploy: merged engine (Sam's UX overhaul + Turing driving the panels)
Browse filesSquashed deploy of GitHub main 6fb67d4 onto the Space. Identical application
tree; docs/ux-overhaul-2026-07/ is omitted because the Space's pre-receive
hook rejects binaries over ~100 KB, and the two Structure-viewer captures sit
in Sam's e6a9d5f β in the pushed HISTORY, so recompressing the current files
can't clear it.
The Space is therefore a DEPLOY MIRROR from here on, not a second copy of the
repo: it carries the running app, GitHub carries the full history, the design
docs and the test suite. Future deploys are squashes onto this line, not plain
pushes, since the two histories no longer share a tip.
Contents, in full: Sam's light-mode surface hierarchy, Structure view filling
its canvas with app-owned fullscreen, pill composer, centered bench chip,
theme toggle relocated out of the hidden topbar, the plasmid cloning-log
data-integrity guard, and the first-party behaviour collector; the two defects
that merge shipped (a TypeError killing cockpit init on every phone, and a
collapsed rail nothing could reopen) fixed; and Turing now painting real
results into the Library / Guides / Primers panels instead of leaving them
empty, tool nav restored on Mission Control, one radius language throughout.
Needs migration 0016_agent_runs.sql and 0017_client_events.sql run against
Supabase; both degrade silently until then.
Co-Authored-By: Claude <noreply@anthropic.com>
- dee/auth.py +115 -0
- dee/server.py +30 -0
- dee/static/app.css +67 -56
- dee/static/app.js +168 -9
- dee/static/cockpit.js +14 -16
- dee/static/collector.js +443 -0
- dee/static/index.html +54 -47
- dee/static/telemetry.js +16 -1
- supabase/migrations/0017_client_events.sql +106 -0
|
@@ -773,6 +773,121 @@ def record_ping_async(*, session_id: str, visible_ms: int) -> None:
|
|
| 773 |
threading.Thread(target=_post_supabase, args=("rpc/record_ping", body), daemon=True).start()
|
| 774 |
|
| 775 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
# βββ Library save (signed-in users only) βββββββββββββββββββββββββββββ
|
| 777 |
# After /api/run finishes, the worker thread calls save_library_async()
|
| 778 |
# with the per-job CSV path and a few metadata fields. We upload the CSV
|
|
|
|
| 773 |
threading.Thread(target=_post_supabase, args=("rpc/record_ping", body), daemon=True).start()
|
| 774 |
|
| 775 |
|
| 776 |
+
# βββ First-party UX-behaviour collector ingest ββββββββββββββββββββββββ
|
| 777 |
+
# collector.js batches client-side UX signals (dead/rage clicks, scroll depth,
|
| 778 |
+
# funnel steps, feature clicks, client errors) and POSTs them to /api/collect.
|
| 779 |
+
# We batch-insert them into public.client_events (migration 0017). Same
|
| 780 |
+
# fire-and-forget / daemon-thread / service-role pattern as log_event; no-op if
|
| 781 |
+
# Supabase env is unset.
|
| 782 |
+
#
|
| 783 |
+
# NEVER trust the client's shape. We:
|
| 784 |
+
# β’ whitelist `kind` against _CLIENT_EVENT_KINDS,
|
| 785 |
+
# β’ cap every string length,
|
| 786 |
+
# β’ coerce value_num to a finite float or NULL,
|
| 787 |
+
# β’ sanitize `meta` to a shallow dict of bounded primitives,
|
| 788 |
+
# β’ drop any other keys the client sent.
|
| 789 |
+
# The client sends only an opaque session id + these structural events β the
|
| 790 |
+
# SERVER stamps who: user_id if signed in, else a salted /24-IP+UA fingerprint
|
| 791 |
+
# (never a raw IP), read from g.auth + request headers HERE, synchronously,
|
| 792 |
+
# before the daemon thread (which has no request context).
|
| 793 |
+
|
| 794 |
+
# The only event kinds we persist. Anything else is silently dropped.
|
| 795 |
+
_CLIENT_EVENT_KINDS = frozenset({
|
| 796 |
+
"dead_click", "rage_click", "scroll_depth",
|
| 797 |
+
"funnel_step", "funnel_exit", "feature_click", "client_error",
|
| 798 |
+
})
|
| 799 |
+
_CLIENT_EVENTS_MAX = 100 # rows per request (server-side cap; client also caps)
|
| 800 |
+
_CLIENT_STR_MAX = 200 # selector / route string cap
|
| 801 |
+
_CLIENT_META_KEYS = 20 # max keys kept from a single meta object
|
| 802 |
+
_CLIENT_META_STR_MAX = 120 # per-value string cap inside meta
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
def _sanitize_client_meta(meta: Any) -> Dict[str, Any]:
|
| 806 |
+
"""Coerce a client-supplied meta object into a shallow dict of bounded
|
| 807 |
+
primitives. Drops nested structures, over-long strings, and anything that
|
| 808 |
+
isn't a str/int/float/bool β so no value-bearing blob can ride in."""
|
| 809 |
+
if not isinstance(meta, dict):
|
| 810 |
+
return {}
|
| 811 |
+
out: Dict[str, Any] = {}
|
| 812 |
+
for k, v in list(meta.items())[:_CLIENT_META_KEYS]:
|
| 813 |
+
ks = str(k)[:40]
|
| 814 |
+
if isinstance(v, bool):
|
| 815 |
+
out[ks] = v
|
| 816 |
+
elif isinstance(v, int):
|
| 817 |
+
out[ks] = v
|
| 818 |
+
elif isinstance(v, float):
|
| 819 |
+
# Reject NaN / +-inf (PostgREST/Postgres jsonb can't store them).
|
| 820 |
+
if v == v and v not in (float("inf"), float("-inf")):
|
| 821 |
+
out[ks] = v
|
| 822 |
+
elif isinstance(v, str):
|
| 823 |
+
out[ks] = v[:_CLIENT_META_STR_MAX]
|
| 824 |
+
# else: drop (list/dict/None/other)
|
| 825 |
+
return out
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def log_client_events_async(session_id: str, events: Any) -> None:
|
| 829 |
+
"""Fire-and-forget batch insert into public.client_events. Reads g.auth +
|
| 830 |
+
request headers SYNCHRONOUSLY (to stamp user_id / anon_fingerprint), builds
|
| 831 |
+
sanitized rows, then hands the batch to a daemon thread. No-op if Supabase
|
| 832 |
+
isn't configured, or if nothing survives sanitization. Must be called from
|
| 833 |
+
a request context."""
|
| 834 |
+
if not session_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 835 |
+
return
|
| 836 |
+
if not isinstance(events, list) or not events:
|
| 837 |
+
return
|
| 838 |
+
try:
|
| 839 |
+
auth = get_auth()
|
| 840 |
+
except Exception: # noqa: BLE001 β no request context
|
| 841 |
+
return
|
| 842 |
+
|
| 843 |
+
user_id = auth.user_id
|
| 844 |
+
anon = _anon_fingerprint() if auth.anonymous else None
|
| 845 |
+
sid = str(session_id)[:64]
|
| 846 |
+
|
| 847 |
+
rows = []
|
| 848 |
+
for ev in events[:_CLIENT_EVENTS_MAX]:
|
| 849 |
+
if not isinstance(ev, dict):
|
| 850 |
+
continue
|
| 851 |
+
kind = str(ev.get("kind") or "")[:64]
|
| 852 |
+
if kind not in _CLIENT_EVENT_KINDS:
|
| 853 |
+
continue
|
| 854 |
+
|
| 855 |
+
route = ev.get("route")
|
| 856 |
+
route = str(route)[:_CLIENT_STR_MAX] if isinstance(route, str) and route else None
|
| 857 |
+
|
| 858 |
+
selector = ev.get("selector")
|
| 859 |
+
selector = str(selector)[:_CLIENT_STR_MAX] if isinstance(selector, str) and selector else None
|
| 860 |
+
|
| 861 |
+
value_num = None
|
| 862 |
+
v = ev.get("value_num")
|
| 863 |
+
if isinstance(v, bool):
|
| 864 |
+
value_num = None # bools are ints in Python β reject explicitly
|
| 865 |
+
elif isinstance(v, (int, float)):
|
| 866 |
+
fv = float(v)
|
| 867 |
+
if fv == fv and fv not in (float("inf"), float("-inf")):
|
| 868 |
+
value_num = fv
|
| 869 |
+
|
| 870 |
+
client_ts = ev.get("client_ts")
|
| 871 |
+
client_ts = str(client_ts)[:40] if isinstance(client_ts, str) and client_ts else None
|
| 872 |
+
|
| 873 |
+
rows.append({
|
| 874 |
+
"session_id": sid,
|
| 875 |
+
"user_id": user_id,
|
| 876 |
+
"anon_fingerprint": anon,
|
| 877 |
+
"kind": kind,
|
| 878 |
+
"route": route,
|
| 879 |
+
"selector": selector,
|
| 880 |
+
"value_num": value_num,
|
| 881 |
+
"meta": _sanitize_client_meta(ev.get("meta")),
|
| 882 |
+
"client_ts": client_ts,
|
| 883 |
+
})
|
| 884 |
+
|
| 885 |
+
if not rows:
|
| 886 |
+
return
|
| 887 |
+
# PostgREST bulk-inserts a JSON array in one round-trip.
|
| 888 |
+
threading.Thread(target=_post_supabase, args=("client_events", rows), daemon=True).start()
|
| 889 |
+
|
| 890 |
+
|
| 891 |
# βββ Library save (signed-in users only) βββββββββββββββββββββββββββββ
|
| 892 |
# After /api/run finishes, the worker thread calls save_library_async()
|
| 893 |
# with the per-job CSV path and a few metadata fields. We upload the CSV
|
|
@@ -794,6 +794,11 @@ _RL_RULES = [
|
|
| 794 |
("/api/ping", (60, 60)), # dwell heartbeat β its own
|
| 795 |
# bucket so ~2/min never eats
|
| 796 |
# the shared default budget
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
("/api/orchestrator/poll", (240, 60)), # event poll β spends NOTHING (reads an
|
| 798 |
# in-memory event log). Fires ~1/s while
|
| 799 |
# the agent works, so it needs its own
|
|
@@ -971,6 +976,31 @@ def create_app() -> Flask:
|
|
| 971 |
# 204: no body; sendBeacon ignores the response anyway.
|
| 972 |
return ("", 204)
|
| 973 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
@app.before_request
|
| 975 |
def _enforce_rate_limit() -> Optional[Response]:
|
| 976 |
# Throttle only API calls β the SPA shell + static assets are cheap and
|
|
|
|
| 794 |
("/api/ping", (60, 60)), # dwell heartbeat β its own
|
| 795 |
# bucket so ~2/min never eats
|
| 796 |
# the shared default budget
|
| 797 |
+
("/api/collect", (120, 60)), # UX-behaviour collector β its
|
| 798 |
+
# OWN generous bucket (batched
|
| 799 |
+
# sendBeacon flushes, up to
|
| 800 |
+
# ~1/5s + on hide) so it never
|
| 801 |
+
# eats the shared /api budget
|
| 802 |
("/api/orchestrator/poll", (240, 60)), # event poll β spends NOTHING (reads an
|
| 803 |
# in-memory event log). Fires ~1/s while
|
| 804 |
# the agent works, so it needs its own
|
|
|
|
| 976 |
# 204: no body; sendBeacon ignores the response anyway.
|
| 977 |
return ("", 204)
|
| 978 |
|
| 979 |
+
@app.post("/api/collect")
|
| 980 |
+
def api_collect() -> Response:
|
| 981 |
+
"""First-party UX-behaviour ingest (sibling of /api/ping). The
|
| 982 |
+
collector.js batch POSTs {session_id, events:[β¦]} via sendBeacon β
|
| 983 |
+
dead/rage clicks, scroll depth, funnel steps, feature clicks, client
|
| 984 |
+
errors. STRUCTURE ONLY: the client sends element ids / data-analytics
|
| 985 |
+
labels / tag paths, never sequence content, and never identity β the
|
| 986 |
+
server stamps user_id / anon_fingerprint from g.auth in
|
| 987 |
+
log_client_events_async, which also whitelists `kind`, caps string
|
| 988 |
+
lengths, and drops unexpected keys (never trusts client shape).
|
| 989 |
+
|
| 990 |
+
Auth optional, best-effort β returns 204 fast and never does work on
|
| 991 |
+
the request thread (the writer fires off a daemon thread)."""
|
| 992 |
+
try:
|
| 993 |
+
data = request.get_json(silent=True) or {}
|
| 994 |
+
sid = str(data.get("session_id") or "")[:64]
|
| 995 |
+
events = data.get("events")
|
| 996 |
+
# Cap events per request so a crafted payload can't fan out into a
|
| 997 |
+
# huge batch insert; the writer re-caps + sanitizes each row.
|
| 998 |
+
if sid and isinstance(events, list) and events:
|
| 999 |
+
_auth.log_client_events_async(sid, events[:100])
|
| 1000 |
+
except Exception: # noqa: BLE001 β telemetry never affects the response
|
| 1001 |
+
pass
|
| 1002 |
+
return ("", 204)
|
| 1003 |
+
|
| 1004 |
@app.before_request
|
| 1005 |
def _enforce_rate_limit() -> Optional[Response]:
|
| 1006 |
# Throttle only API calls β the SPA shell + static assets are cheap and
|
|
@@ -46,13 +46,13 @@ html {
|
|
| 46 |
gray-0 (was pure white) is now the warmest paper; gray-9 (was near-black)
|
| 47 |
stays as ink. The progression in between is desaturated and warmed so
|
| 48 |
every surface reads as "off-white paper" rather than "tinted slate". */
|
| 49 |
-
--gray-0: #
|
| 50 |
-
--gray-1: #
|
| 51 |
--gray-2: #EFECE5; /* paper soft */
|
| 52 |
-
--gray-3: #
|
| 53 |
-
--gray-4: #
|
| 54 |
--gray-5: #B8B4AE; /* hairline-strong */
|
| 55 |
-
--gray-6: #
|
| 56 |
--gray-7: #6B6862; /* ink soft */
|
| 57 |
--gray-8: #2A2A29; /* ink */
|
| 58 |
--gray-9: #0A0A0A; /* ink strong */
|
|
@@ -96,7 +96,7 @@ html {
|
|
| 96 |
--ink: var(--gray-8);
|
| 97 |
--ink-soft: var(--gray-7);
|
| 98 |
--ink-faint: var(--gray-6);
|
| 99 |
-
--ink-disabled:
|
| 100 |
|
| 101 |
/* Button-hover fill/text. Deliberately FIXED (not redefined in the dark
|
| 102 |
block) so an action button always inverts to a near-black rectangle with
|
|
@@ -190,6 +190,7 @@ html {
|
|
| 190 |
--gray-7: #B2ACA2; /* ink soft */
|
| 191 |
--gray-8: #D9D4CA; /* ink */
|
| 192 |
--gray-9: #F4F1E9; /* ink strong (off-white)*/
|
|
|
|
| 193 |
|
| 194 |
/* Dark glyphs read on the off-white --ink-strong fill (see light block). */
|
| 195 |
--on-ink: #17150F;
|
|
@@ -1565,19 +1566,25 @@ input:focus, textarea:focus, select:focus {
|
|
| 1565 |
overflow: hidden;
|
| 1566 |
position: relative;
|
| 1567 |
}
|
| 1568 |
-
|
|
|
|
|
|
|
|
|
|
| 1569 |
/* Dark-theme Mol*'s own floating viewport controls. Mol* ships a LIGHT control
|
| 1570 |
* theme β a cream "semi-transparent" backing strip with dark-brown icons β
|
| 1571 |
* which clashes with (and renders nearly invisible on) our dark canvas. Recolor
|
| 1572 |
* the strip dark and the icons light so the whole viewer reads as one dark
|
| 1573 |
* surface that matches the site. */
|
| 1574 |
-
.alphafold-viewer .msp-semi-transparent-background
|
|
|
|
| 1575 |
background: rgba(14, 20, 27, 0.72) !important;
|
| 1576 |
}
|
| 1577 |
-
.alphafold-viewer .msp-viewport-controls-buttons .msp-btn-icon
|
|
|
|
| 1578 |
color: rgba(233, 237, 243, 0.80) !important;
|
| 1579 |
}
|
| 1580 |
-
.alphafold-viewer .msp-viewport-controls-buttons .msp-btn-icon:hover
|
|
|
|
| 1581 |
color: #fff !important;
|
| 1582 |
background: rgba(255, 255, 255, 0.10) !important;
|
| 1583 |
}
|
|
@@ -3553,8 +3560,8 @@ body[data-route="turing"] .workspace { padding: 0; max-width: none; flex: 1; min
|
|
| 3553 |
gap: 10px;
|
| 3554 |
padding: 8px max(env(safe-area-inset-right, 0px), 20px)
|
| 3555 |
8px max(env(safe-area-inset-left, 0px), 20px);
|
| 3556 |
-
background: #
|
| 3557 |
-
border-bottom: 1px solid #
|
| 3558 |
box-shadow: inset 4px 0 0 0 #B47A1F;
|
| 3559 |
font-family: var(--font-body);
|
| 3560 |
font-size: 12.5px;
|
|
@@ -3768,6 +3775,8 @@ body[data-route="turing"] .workspace { padding: 0; max-width: none; flex: 1; min
|
|
| 3768 |
background: var(--brand-50);
|
| 3769 |
}
|
| 3770 |
.theme-toggle svg { width: 18px; height: 18px; }
|
|
|
|
|
|
|
| 3771 |
/* Icon swap β specificity matched (.theme-toggle .theme-icon-*) so neither the
|
| 3772 |
* base `.theme-toggle svg` rule nor the dark override can leak both icons in. */
|
| 3773 |
.theme-toggle .theme-icon-sun { display: none; }
|
|
@@ -6790,26 +6799,12 @@ body[data-ui="bench"] #navTuring { display: none; }
|
|
| 6790 |
.mc-h { font-family: var(--font-display); font-weight: 500; font-size: clamp(26px, 3.4vw, 36px);
|
| 6791 |
letter-spacing: -0.02em; color: var(--ink-strong); margin: 0 0 22px; line-height: 1.05; }
|
| 6792 |
|
| 6793 |
-
/* command
|
| 6794 |
-
|
| 6795 |
-
|
| 6796 |
-
|
| 6797 |
-
|
| 6798 |
-
|
| 6799 |
-
.mc-cmd input::placeholder { color: var(--ink-faint); }
|
| 6800 |
-
.mc-cmd-k { font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); border: 1px solid var(--line-strong);
|
| 6801 |
-
padding: 3px 7px; letter-spacing: 0.05em; flex: none; }
|
| 6802 |
-
.mc-cmd-send { width: 38px; height: 38px; border-radius: 50%; background: var(--ink-strong);
|
| 6803 |
-
color: var(--on-ink); border: 0; cursor: pointer; font-size: 16px; line-height: 1; flex: none; }
|
| 6804 |
-
.mc-cmd-send:hover { background: var(--hover-fill); color: var(--hover-text); }
|
| 6805 |
-
.mc-palette { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; background: var(--bg-card);
|
| 6806 |
-
border: 1px solid var(--line-strong); box-shadow: var(--elev-2, 0 8px 30px rgba(0,0,0,.10)); max-height: 320px; overflow: auto; }
|
| 6807 |
-
.mc-pal-item { display: flex; align-items: baseline; gap: 12px; padding: 11px 16px; cursor: pointer;
|
| 6808 |
-
border-bottom: 1px solid var(--line); }
|
| 6809 |
-
.mc-pal-item:last-child { border-bottom: 0; }
|
| 6810 |
-
.mc-pal-item.sel, .mc-pal-item:hover { background: var(--bg-hover); }
|
| 6811 |
-
.mc-pal-l { font-size: 14px; color: var(--ink-strong); }
|
| 6812 |
-
.mc-pal-h { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; color: var(--ink-faint); margin-left: auto; }
|
| 6813 |
|
| 6814 |
/* engine telemetry strip */
|
| 6815 |
/* Wraps on container width, for the same reason .mc-grid does β the old
|
|
@@ -6873,6 +6868,12 @@ body[data-ui="bench"] #navTuring { display: none; }
|
|
| 6873 |
font-family: inherit; font-size: 15px; color: var(--ink-strong);
|
| 6874 |
}
|
| 6875 |
.finder-in input::placeholder { color: var(--ink-faint); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6876 |
.finder-in kbd {
|
| 6877 |
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
|
| 6878 |
border: 1px solid var(--line-strong); border-radius: 4px; padding: 1px 5px; flex: none;
|
|
@@ -7034,21 +7035,31 @@ body[data-ui="bench"] #navTuring { display: none; }
|
|
| 7034 |
fold_structure tool with a public AlphaFold-DB model. Reuses the existing
|
| 7035 |
.alphafold-loading overlay styling for the load/error states.
|
| 7036 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7037 |
-
.view
|
| 7038 |
-
|
| 7039 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7040 |
gap: 20px; margin-bottom: 18px; }
|
| 7041 |
.struct-head h2 { font-weight: 500; letter-spacing: -0.012em; margin: 2px 0 0; }
|
| 7042 |
-
.struct-
|
|
|
|
| 7043 |
border: 1px solid var(--line-strong); background: #0E141B; overflow: hidden; }
|
|
|
|
|
|
|
|
|
|
| 7044 |
.struct-empty { position: absolute; inset: 0; display: flex; align-items: center;
|
| 7045 |
justify-content: center; text-align: center; padding: 28px; color: rgba(255,255,255,.62);
|
| 7046 |
font-size: 13.5px; line-height: 1.6; max-width: 460px; margin: auto; }
|
| 7047 |
-
.struct-note { margin-top: 12px; font-size: 12px; color: var(--ink-faint); line-height: 1.6; }
|
| 7048 |
@media (max-width: 860px) {
|
| 7049 |
-
.view--structure { padding: 20px 18px 32px; }
|
| 7050 |
.struct-head { flex-direction: column; align-items: flex-start; }
|
| 7051 |
-
.struct-viewer { height: 52vh; }
|
| 7052 |
}
|
| 7053 |
|
| 7054 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -7075,12 +7086,12 @@ body[data-ui="bench"][data-bench="open"] .content > footer { display: none; }
|
|
| 7075 |
|
| 7076 |
/* Top strand: back Β· specimen Β· loop spine */
|
| 7077 |
body[data-ui="bench"][data-bench="open"] .bench-strip {
|
| 7078 |
-
display:
|
| 7079 |
position: fixed; top: 0; left: 0; right: 0; height: var(--bench-strip-h); z-index: 40;
|
| 7080 |
background: var(--bg-app); border-bottom: 1px solid var(--line);
|
| 7081 |
}
|
| 7082 |
.bench-back { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; color: var(--ink-soft);
|
| 7083 |
-
background: none; border: none; cursor: pointer; padding: 4px 6px; }
|
| 7084 |
.bench-back:hover { color: var(--ink-strong); }
|
| 7085 |
.bench-specimen { display: flex; align-items: center; gap: 11px; padding: 5px 13px 5px 7px; border: 1px solid var(--line-strong); }
|
| 7086 |
.bench-spec-thumb { width: 28px; height: 28px; background: #070809; display: flex; align-items: center;
|
|
@@ -7192,12 +7203,12 @@ body[data-ui="bench"][data-bench="open"] .workspace > .view {
|
|
| 7192 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7193 |
body[data-ruo="accepted"] .research-strip {
|
| 7194 |
padding-top: 5px; padding-bottom: 5px;
|
| 7195 |
-
background:
|
| 7196 |
-
box-shadow:
|
| 7197 |
-
opacity:
|
| 7198 |
}
|
| 7199 |
body[data-ruo="accepted"] .research-strip:hover { opacity: 1; }
|
| 7200 |
-
body[data-ruo="accepted"] .research-strip-dot { color: var(--
|
| 7201 |
/* Keep the load-bearing words; drop the sentence that repeats the modal. */
|
| 7202 |
body[data-ruo="accepted"] .research-strip-text .ruo-long { display: none; }
|
| 7203 |
|
|
@@ -7469,24 +7480,24 @@ body[data-cockpit="open"]:not([data-bench="open"]) .cockpit { top: 0; }
|
|
| 7469 |
|
| 7470 |
/* ββ composer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7471 |
.cp-compose {
|
| 7472 |
-
flex: 0 0 auto; display: flex; align-items: flex-end; gap:
|
| 7473 |
-
|
| 7474 |
-
border
|
| 7475 |
-
background: var(--bg-
|
| 7476 |
}
|
|
|
|
| 7477 |
.cp-compose textarea {
|
| 7478 |
-
flex: 1; resize: none; border:
|
| 7479 |
-
padding:
|
| 7480 |
-
background:
|
| 7481 |
font-family: inherit;
|
| 7482 |
}
|
| 7483 |
.cp-compose textarea:focus {
|
| 7484 |
-
outline: none; border
|
| 7485 |
-
box-shadow: 0 0 0 3px var(--brand-ring);
|
| 7486 |
}
|
| 7487 |
.cp-send {
|
| 7488 |
-
flex: 0 0 auto; width:
|
| 7489 |
-
border:
|
| 7490 |
color: var(--on-ink); font-size: 15px; line-height: 1;
|
| 7491 |
}
|
| 7492 |
.cp-send:hover { opacity: 0.86; }
|
|
|
|
| 46 |
gray-0 (was pure white) is now the warmest paper; gray-9 (was near-black)
|
| 47 |
stays as ink. The progression in between is desaturated and warmed so
|
| 48 |
every surface reads as "off-white paper" rather than "tinted slate". */
|
| 49 |
+
--gray-0: #FFFFFF; /* card β pure white so it reads as raised off the warm canvas */
|
| 50 |
+
--gray-1: #EFEBE2; /* warm paper canvas β darkened so white cards separate clearly */
|
| 51 |
--gray-2: #EFECE5; /* paper soft */
|
| 52 |
+
--gray-3: #D9D3C8; /* hairline-soft β strengthened so card borders are visible in light */
|
| 53 |
+
--gray-4: #C8C1B4; /* hairline */
|
| 54 |
--gray-5: #B8B4AE; /* hairline-strong */
|
| 55 |
+
--gray-6: #6E6A65; /* ink faint β darkened from #8A857E for WCAG AA in light */
|
| 56 |
--gray-7: #6B6862; /* ink soft */
|
| 57 |
--gray-8: #2A2A29; /* ink */
|
| 58 |
--gray-9: #0A0A0A; /* ink strong */
|
|
|
|
| 96 |
--ink: var(--gray-8);
|
| 97 |
--ink-soft: var(--gray-7);
|
| 98 |
--ink-faint: var(--gray-6);
|
| 99 |
+
--ink-disabled: #8A857E; /* decoupled from shared --gray-5; darkened for readability */
|
| 100 |
|
| 101 |
/* Button-hover fill/text. Deliberately FIXED (not redefined in the dark
|
| 102 |
block) so an action button always inverts to a near-black rectangle with
|
|
|
|
| 190 |
--gray-7: #B2ACA2; /* ink soft */
|
| 191 |
--gray-8: #D9D4CA; /* ink */
|
| 192 |
--gray-9: #F4F1E9; /* ink strong (off-white)*/
|
| 193 |
+
--ink-disabled: #8C877F; /* decoupled from --gray-5 (#514D47) so meta/disabled text is legible on dark paper */
|
| 194 |
|
| 195 |
/* Dark glyphs read on the off-white --ink-strong fill (see light block). */
|
| 196 |
--on-ink: #17150F;
|
|
|
|
| 1566 |
overflow: hidden;
|
| 1567 |
position: relative;
|
| 1568 |
}
|
| 1569 |
+
/* The same overrides apply to the Structure tab's host (.struct-viewer), which
|
| 1570 |
+
* shares Mol*'s default light control theme and would otherwise show dark-brown
|
| 1571 |
+
* icons on our dark canvas. */
|
| 1572 |
+
.alphafold-viewer .msp-plugin, .struct-viewer .msp-plugin { background: #0E141B !important; }
|
| 1573 |
/* Dark-theme Mol*'s own floating viewport controls. Mol* ships a LIGHT control
|
| 1574 |
* theme β a cream "semi-transparent" backing strip with dark-brown icons β
|
| 1575 |
* which clashes with (and renders nearly invisible on) our dark canvas. Recolor
|
| 1576 |
* the strip dark and the icons light so the whole viewer reads as one dark
|
| 1577 |
* surface that matches the site. */
|
| 1578 |
+
.alphafold-viewer .msp-semi-transparent-background,
|
| 1579 |
+
.struct-viewer .msp-semi-transparent-background {
|
| 1580 |
background: rgba(14, 20, 27, 0.72) !important;
|
| 1581 |
}
|
| 1582 |
+
.alphafold-viewer .msp-viewport-controls-buttons .msp-btn-icon,
|
| 1583 |
+
.struct-viewer .msp-viewport-controls-buttons .msp-btn-icon {
|
| 1584 |
color: rgba(233, 237, 243, 0.80) !important;
|
| 1585 |
}
|
| 1586 |
+
.alphafold-viewer .msp-viewport-controls-buttons .msp-btn-icon:hover,
|
| 1587 |
+
.struct-viewer .msp-viewport-controls-buttons .msp-btn-icon:hover {
|
| 1588 |
color: #fff !important;
|
| 1589 |
background: rgba(255, 255, 255, 0.10) !important;
|
| 1590 |
}
|
|
|
|
| 3560 |
gap: 10px;
|
| 3561 |
padding: 8px max(env(safe-area-inset-right, 0px), 20px)
|
| 3562 |
8px max(env(safe-area-inset-left, 0px), 20px);
|
| 3563 |
+
background: #FCEECC;
|
| 3564 |
+
border-bottom: 1px solid #E3C88A;
|
| 3565 |
box-shadow: inset 4px 0 0 0 #B47A1F;
|
| 3566 |
font-family: var(--font-body);
|
| 3567 |
font-size: 12.5px;
|
|
|
|
| 3775 |
background: var(--brand-50);
|
| 3776 |
}
|
| 3777 |
.theme-toggle svg { width: 18px; height: 18px; }
|
| 3778 |
+
/* Relocated into the sidebar footer, above the account chip. */
|
| 3779 |
+
.sidebar-theme { align-self: center; margin: 4px auto 6px; }
|
| 3780 |
/* Icon swap β specificity matched (.theme-toggle .theme-icon-*) so neither the
|
| 3781 |
* base `.theme-toggle svg` rule nor the dark override can leak both icons in. */
|
| 3782 |
.theme-toggle .theme-icon-sun { display: none; }
|
|
|
|
| 6799 |
.mc-h { font-family: var(--font-display); font-weight: 500; font-size: clamp(26px, 3.4vw, 36px);
|
| 6800 |
letter-spacing: -0.02em; color: var(--ink-strong); margin: 0 0 22px; line-height: 1.05; }
|
| 6801 |
|
| 6802 |
+
/* The Mission Control command bar and its βK palette were deleted with the
|
| 6803 |
+
second composer (one chat, 2026-07-27); .mc-cmd* / .mc-palette / .mc-pal-*
|
| 6804 |
+
had no markup left to style. Sam's focus-ring fix from that block β put
|
| 6805 |
+
focus on the CONTAINER, not the inner input, so it stops reading as a
|
| 6806 |
+
second box nested inside the first β is carried over to .finder-in below,
|
| 6807 |
+
which is the search box that replaced it. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6808 |
|
| 6809 |
/* engine telemetry strip */
|
| 6810 |
/* Wraps on container width, for the same reason .mc-grid does β the old
|
|
|
|
| 6868 |
font-family: inherit; font-size: 15px; color: var(--ink-strong);
|
| 6869 |
}
|
| 6870 |
.finder-in input::placeholder { color: var(--ink-faint); }
|
| 6871 |
+
/* Focus belongs to the whole box, not the inner input β a ring on the input
|
| 6872 |
+
itself reads as a second box nested inside the first. Carried over from the
|
| 6873 |
+
command bar this replaced, which had exactly that bug; here the input was
|
| 6874 |
+
simply left with no focus indication at all, which is the other failure. */
|
| 6875 |
+
.finder-in input:focus, .finder-in input:focus-visible { box-shadow: none; outline: none; }
|
| 6876 |
+
.finder-box:focus-within { border-color: var(--ink-soft); box-shadow: 0 0 0 3px var(--brand-ring), var(--elev-4, 0 24px 64px rgba(0,0,0,.3)); }
|
| 6877 |
.finder-in kbd {
|
| 6878 |
font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint);
|
| 6879 |
border: 1px solid var(--line-strong); border-radius: 4px; padding: 1px 5px; flex: none;
|
|
|
|
| 7035 |
fold_structure tool with a public AlphaFold-DB model. Reuses the existing
|
| 7036 |
.alphafold-loading overlay styling for the load/error states.
|
| 7037 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7038 |
+
/* Fill the canvas. The Structure view is a flex COLUMN that fills the bench
|
| 7039 |
+
canvas height so the Mol* host takes every pixel left below the head/note
|
| 7040 |
+
(head + note stay their intrinsic height; the viewer flexes to fill). The
|
| 7041 |
+
live section carries class `view--structure2`; `.view--structure` is kept
|
| 7042 |
+
as a belt-and-braces alias. min-height:0 lets the flex child actually
|
| 7043 |
+
shrink; overflow:auto is a floor-scroll safety on very short viewports. */
|
| 7044 |
+
.view--structure2, .view--structure { padding: 28px 32px 40px; flex: 1 1 auto; height: 100%; min-height: 0; overflow: auto; }
|
| 7045 |
+
.struct { max-width: none; margin: 0; height: 100%; min-height: min(62vh, 560px);
|
| 7046 |
+
display: flex; flex-direction: column; }
|
| 7047 |
+
.struct-head { flex: 0 0 auto; display: flex; align-items: flex-end; justify-content: space-between;
|
| 7048 |
gap: 20px; margin-bottom: 18px; }
|
| 7049 |
.struct-head h2 { font-weight: 500; letter-spacing: -0.012em; margin: 2px 0 0; }
|
| 7050 |
+
.struct-head-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
|
| 7051 |
+
.struct-viewer { position: relative; flex: 1 1 auto; min-height: 0; width: 100%;
|
| 7052 |
border: 1px solid var(--line-strong); background: #0E141B; overflow: hidden; }
|
| 7053 |
+
/* App-owned fullscreen (Mol*'s native expand is disabled for this host). */
|
| 7054 |
+
.struct-viewer--full { position: fixed; inset: 0; z-index: 60; height: auto; width: auto;
|
| 7055 |
+
border: 0; border-radius: 0; }
|
| 7056 |
.struct-empty { position: absolute; inset: 0; display: flex; align-items: center;
|
| 7057 |
justify-content: center; text-align: center; padding: 28px; color: rgba(255,255,255,.62);
|
| 7058 |
font-size: 13.5px; line-height: 1.6; max-width: 460px; margin: auto; }
|
| 7059 |
+
.struct-note { flex: 0 0 auto; margin-top: 12px; font-size: 12px; color: var(--ink-faint); line-height: 1.6; }
|
| 7060 |
@media (max-width: 860px) {
|
| 7061 |
+
.view--structure2, .view--structure { padding: 20px 18px 32px; }
|
| 7062 |
.struct-head { flex-direction: column; align-items: flex-start; }
|
|
|
|
| 7063 |
}
|
| 7064 |
|
| 7065 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 7086 |
|
| 7087 |
/* Top strand: back Β· specimen Β· loop spine */
|
| 7088 |
body[data-ui="bench"][data-bench="open"] .bench-strip {
|
| 7089 |
+
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 22px; padding: 0 22px;
|
| 7090 |
position: fixed; top: 0; left: 0; right: 0; height: var(--bench-strip-h); z-index: 40;
|
| 7091 |
background: var(--bg-app); border-bottom: 1px solid var(--line);
|
| 7092 |
}
|
| 7093 |
.bench-back { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; color: var(--ink-soft);
|
| 7094 |
+
background: none; border: none; cursor: pointer; padding: 4px 6px; justify-self: start; }
|
| 7095 |
.bench-back:hover { color: var(--ink-strong); }
|
| 7096 |
.bench-specimen { display: flex; align-items: center; gap: 11px; padding: 5px 13px 5px 7px; border: 1px solid var(--line-strong); }
|
| 7097 |
.bench-spec-thumb { width: 28px; height: 28px; background: #070809; display: flex; align-items: center;
|
|
|
|
| 7203 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7204 |
body[data-ruo="accepted"] .research-strip {
|
| 7205 |
padding-top: 5px; padding-bottom: 5px;
|
| 7206 |
+
background: rgba(180, 120, 40, 0.06); border-bottom: 1px solid var(--line);
|
| 7207 |
+
box-shadow: inset 3px 0 0 0 var(--warning); font-size: 11px; color: var(--ink-soft);
|
| 7208 |
+
opacity: 1; transition: opacity var(--t-fast);
|
| 7209 |
}
|
| 7210 |
body[data-ruo="accepted"] .research-strip:hover { opacity: 1; }
|
| 7211 |
+
body[data-ruo="accepted"] .research-strip-dot { color: var(--warning); }
|
| 7212 |
/* Keep the load-bearing words; drop the sentence that repeats the modal. */
|
| 7213 |
body[data-ruo="accepted"] .research-strip-text .ruo-long { display: none; }
|
| 7214 |
|
|
|
|
| 7480 |
|
| 7481 |
/* ββ composer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 7482 |
.cp-compose {
|
| 7483 |
+
flex: 0 0 auto; display: flex; align-items: flex-end; gap: 6px;
|
| 7484 |
+
margin: 10px 12px 12px; padding: 4px 4px 4px 16px;
|
| 7485 |
+
border: 1px solid var(--line-strong); border-radius: 22px;
|
| 7486 |
+
background: var(--bg-app);
|
| 7487 |
}
|
| 7488 |
+
.cp-compose:focus-within { border-color: var(--ink-soft); box-shadow: 0 0 0 3px var(--brand-ring); }
|
| 7489 |
.cp-compose textarea {
|
| 7490 |
+
flex: 1; resize: none; border: 0; border-radius: 0;
|
| 7491 |
+
padding: 8px 0; font: inherit; font-size: 13px; line-height: 1.45;
|
| 7492 |
+
background: none; color: var(--ink); max-height: 160px;
|
| 7493 |
font-family: inherit;
|
| 7494 |
}
|
| 7495 |
.cp-compose textarea:focus {
|
| 7496 |
+
outline: none; border: 0; box-shadow: none;
|
|
|
|
| 7497 |
}
|
| 7498 |
.cp-send {
|
| 7499 |
+
flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
|
| 7500 |
+
border: 0; background: var(--ink-strong);
|
| 7501 |
color: var(--on-ink); font-size: 15px; line-height: 1;
|
| 7502 |
}
|
| 7503 |
.cp-send:hover { opacity: 0.86; }
|
|
@@ -449,6 +449,12 @@ function showRoute(name) {
|
|
| 449 |
// Mission Control is a bench-mode-only view; lazy-load its portfolio the
|
| 450 |
// first time it's shown (reuses the saved-work list endpoints).
|
| 451 |
if (name === 'mission' && window.TDMission) window.TDMission.show();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
// Bench: keep the canvas tab in sync, and close the bench chrome when
|
| 453 |
// navigating away to Mission Control / Turing.
|
| 454 |
if (window.TDBench) window.TDBench.onRoute(name);
|
|
@@ -3504,6 +3510,44 @@ function _afApplyBg(viewer) {
|
|
| 3504 |
try { viewer.plugin.canvas3d.setProps({ renderer: { backgroundColor: _AF_BG } }); } catch (_) {}
|
| 3505 |
}
|
| 3506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3507 |
// Honest failure UI. A transient EBI outage gets a "temporarily unreachable +
|
| 3508 |
// Retry" affordance (the model DOES exist, EBI just blipped); a genuine
|
| 3509 |
// absence says so plainly. Never again claim "model doesn't exist" when the
|
|
@@ -3533,7 +3577,7 @@ function _afShowError(host, transient, retry) {
|
|
| 3533 |
// Mol* viewer mount. Loads the AlphaFold model from the EBI CDN and renders
|
| 3534 |
// it inside #alphafoldViewer. We wait for the molstar global to appear
|
| 3535 |
// (script is loaded async) before mounting.
|
| 3536 |
-
async function mountAlphaFoldViewer(hit, host) {
|
| 3537 |
// `host` defaults to the page-level identify embed, but the per-variant
|
| 3538 |
// AlphaFold modal passes its own host (#foldViewer) so the same resilient
|
| 3539 |
// loader serves both embeds.
|
|
@@ -3544,23 +3588,31 @@ async function mountAlphaFoldViewer(hit, host) {
|
|
| 3544 |
|
| 3545 |
const molstar = await _afWaitMolstar();
|
| 3546 |
if (!molstar) {
|
| 3547 |
-
_afShowError(host, true, () => mountAlphaFoldViewer(hit, host)); // CDN blip β retry, don't tell them to refresh
|
| 3548 |
return;
|
| 3549 |
}
|
| 3550 |
try {
|
| 3551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3552 |
_afApplyBg(viewer);
|
|
|
|
| 3553 |
const { candidates, exists } = await _afResolve(pdbUrl);
|
| 3554 |
if (!await _afTryLoad(viewer, candidates)) {
|
| 3555 |
// exists === false β genuinely no model; otherwise treat as a
|
| 3556 |
// transient EBI blip and offer a retry.
|
| 3557 |
-
_afShowError(host, exists !== false, () => mountAlphaFoldViewer(hit, host));
|
| 3558 |
return;
|
| 3559 |
}
|
| 3560 |
const overlay = host.querySelector('.alphafold-loading');
|
| 3561 |
if (overlay) overlay.remove();
|
|
|
|
| 3562 |
} catch (err) {
|
| 3563 |
-
_afShowError(host, true, () => mountAlphaFoldViewer(hit, host));
|
| 3564 |
}
|
| 3565 |
}
|
| 3566 |
|
|
@@ -8755,13 +8807,17 @@ function runOracle(opts){
|
|
| 8755 |
outcomes: [{ subject, measured_value: parseFloat($('poResult').value), outcome: 'measured' }] };
|
| 8756 |
});
|
| 8757 |
// Cloning assembly logger
|
| 8758 |
-
wire('coSave', 'coStatus', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8759 |
const method = $('coMethod').value;
|
| 8760 |
const frags = parseInt($('coFrags').value || '2', 10);
|
| 8761 |
const overlap = parseInt($('coOverlap').value || '25', 10);
|
| 8762 |
const subject = `${method}|frags=${frags}|overlap=${overlap}`;
|
| 8763 |
return { tool: 'cloning', design_id: uuid(),
|
| 8764 |
-
outcomes: [{ subject, measured_value: parseFloat(
|
| 8765 |
});
|
| 8766 |
})();
|
| 8767 |
|
|
@@ -9616,6 +9672,9 @@ function runOracle(opts){
|
|
| 9616 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9617 |
(function () {
|
| 9618 |
let mountedAcc = null; // don't remount the structure already showing
|
|
|
|
|
|
|
|
|
|
| 9619 |
|
| 9620 |
function el(id) { return document.getElementById(id); }
|
| 9621 |
|
|
@@ -9628,6 +9687,18 @@ function runOracle(opts){
|
|
| 9628 |
} catch (_) { return ''; }
|
| 9629 |
}
|
| 9630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9631 |
function show(info) {
|
| 9632 |
info = info || {};
|
| 9633 |
const url = safeAfUrl(info.alphafold_url);
|
|
@@ -9655,9 +9726,23 @@ function runOracle(opts){
|
|
| 9655 |
|
| 9656 |
if (mountedAcc && mountedAcc === acc) return; // already showing this one
|
| 9657 |
mountedAcc = acc;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9658 |
host.innerHTML = '<div class="alphafold-loading">Loading predicted structureβ¦</div>';
|
| 9659 |
host.dataset.pdbUrl = url;
|
| 9660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9661 |
}
|
| 9662 |
|
| 9663 |
// Show it AND bring the Structure tab forward (only inside an open bench β
|
|
@@ -9670,7 +9755,81 @@ function runOracle(opts){
|
|
| 9670 |
}
|
| 9671 |
}
|
| 9672 |
|
| 9673 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9674 |
})();
|
| 9675 |
|
| 9676 |
|
|
|
|
| 449 |
// Mission Control is a bench-mode-only view; lazy-load its portfolio the
|
| 450 |
// first time it's shown (reuses the saved-work list endpoints).
|
| 451 |
if (name === 'mission' && window.TDMission) window.TDMission.show();
|
| 452 |
+
// Structure tab: if it's opened with no real structure resolved, the
|
| 453 |
+
// controller paints a live rotating demo hero (guarded so it never races
|
| 454 |
+
// or overrides a real gene resolve).
|
| 455 |
+
if (name === 'structure' && window.TDStructure && window.TDStructure.onShown) {
|
| 456 |
+
try { window.TDStructure.onShown(); } catch (e) {}
|
| 457 |
+
}
|
| 458 |
// Bench: keep the canvas tab in sync, and close the bench chrome when
|
| 459 |
// navigating away to Mission Control / Turing.
|
| 460 |
if (window.TDBench) window.TDBench.onRoute(name);
|
|
|
|
| 3510 |
try { viewer.plugin.canvas3d.setProps({ renderer: { backgroundColor: _AF_BG } }); } catch (_) {}
|
| 3511 |
}
|
| 3512 |
|
| 3513 |
+
// ββ Structure-tab runtime dressing (spin / pinned controls / resize) βββββββββ
|
| 3514 |
+
// All best-effort per the telemetry-never-breaks-UX rule: a failed setProps
|
| 3515 |
+
// must never blank the viewer. These are ONLY invoked by the Structure
|
| 3516 |
+
// controller β the identify / ESMFold / CRISPR embeds never call them, so
|
| 3517 |
+
// those keep their exact prior behaviour (no spin, controls off, expand on).
|
| 3518 |
+
function _afSpinOn(viewer) {
|
| 3519 |
+
try { viewer.plugin.canvas3d.setProps({ trackball: { animate: { name: 'spin', params: { speed: 1 } } } }); } catch (_) {}
|
| 3520 |
+
}
|
| 3521 |
+
function _afSpinOff(viewer) {
|
| 3522 |
+
try { viewer.plugin.canvas3d.setProps({ trackball: { animate: { name: 'off', params: {} } } }); } catch (_) {}
|
| 3523 |
+
}
|
| 3524 |
+
// Auto-rotate as a live hero: spin until the user first touches the canvas
|
| 3525 |
+
// (pointer OR wheel), then stop for good. Capture + once so a single gesture
|
| 3526 |
+
// anywhere in the host halts it and the listener cleans itself up.
|
| 3527 |
+
function _afAutoRotate(viewer, hostEl) {
|
| 3528 |
+
if (!viewer || !hostEl) return;
|
| 3529 |
+
_afSpinOn(viewer);
|
| 3530 |
+
const stop = () => { _afSpinOff(viewer); };
|
| 3531 |
+
try {
|
| 3532 |
+
hostEl.addEventListener('pointerdown', stop, { once: true, capture: true });
|
| 3533 |
+
hostEl.addEventListener('wheel', stop, { once: true, capture: true });
|
| 3534 |
+
} catch (_) {}
|
| 3535 |
+
}
|
| 3536 |
+
// Pin Mol*'s controls panel open on the right. On this prebuilt build the
|
| 3537 |
+
// `layoutShowControls` option alone is not enough β the regionState is what
|
| 3538 |
+
// actually reveals the panel.
|
| 3539 |
+
function _afPinControls(viewer) {
|
| 3540 |
+
try {
|
| 3541 |
+
viewer.plugin.layout.setProps({
|
| 3542 |
+
showControls: true,
|
| 3543 |
+
regionState: { left: 'hidden', right: 'full', top: 'full', bottom: 'full' },
|
| 3544 |
+
});
|
| 3545 |
+
} catch (_) {}
|
| 3546 |
+
}
|
| 3547 |
+
function _afResize(viewer) {
|
| 3548 |
+
try { viewer.plugin.canvas3d.handleResize(); } catch (_) {}
|
| 3549 |
+
}
|
| 3550 |
+
|
| 3551 |
// Honest failure UI. A transient EBI outage gets a "temporarily unreachable +
|
| 3552 |
// Retry" affordance (the model DOES exist, EBI just blipped); a genuine
|
| 3553 |
// absence says so plainly. Never again claim "model doesn't exist" when the
|
|
|
|
| 3577 |
// Mol* viewer mount. Loads the AlphaFold model from the EBI CDN and renders
|
| 3578 |
// it inside #alphafoldViewer. We wait for the molstar global to appear
|
| 3579 |
// (script is loaded async) before mounting.
|
| 3580 |
+
async function mountAlphaFoldViewer(hit, host, extraOpts) {
|
| 3581 |
// `host` defaults to the page-level identify embed, but the per-variant
|
| 3582 |
// AlphaFold modal passes its own host (#foldViewer) so the same resilient
|
| 3583 |
// loader serves both embeds.
|
|
|
|
| 3588 |
|
| 3589 |
const molstar = await _afWaitMolstar();
|
| 3590 |
if (!molstar) {
|
| 3591 |
+
_afShowError(host, true, () => mountAlphaFoldViewer(hit, host, extraOpts)); // CDN blip β retry, don't tell them to refresh
|
| 3592 |
return;
|
| 3593 |
}
|
| 3594 |
try {
|
| 3595 |
+
// Per-host option overrides layer OVER the shared defaults so the
|
| 3596 |
+
// Structure tab can turn controls on / native-expand off WITHOUT
|
| 3597 |
+
// changing _AF_VIEWER_OPTS (which the identify/ESMFold/CRISPR 360px
|
| 3598 |
+
// embeds still rely on β controls off, expand on). Callers that pass
|
| 3599 |
+
// nothing get the exact previous behaviour.
|
| 3600 |
+
const opts = Object.assign({}, _AF_VIEWER_OPTS, extraOpts || {});
|
| 3601 |
+
const viewer = await molstar.Viewer.create(host, opts);
|
| 3602 |
_afApplyBg(viewer);
|
| 3603 |
+
host.__mviewer = viewer; // stash so runtime calls (spin/layout/resize) can find it
|
| 3604 |
const { candidates, exists } = await _afResolve(pdbUrl);
|
| 3605 |
if (!await _afTryLoad(viewer, candidates)) {
|
| 3606 |
// exists === false β genuinely no model; otherwise treat as a
|
| 3607 |
// transient EBI blip and offer a retry.
|
| 3608 |
+
_afShowError(host, exists !== false, () => mountAlphaFoldViewer(hit, host, extraOpts));
|
| 3609 |
return;
|
| 3610 |
}
|
| 3611 |
const overlay = host.querySelector('.alphafold-loading');
|
| 3612 |
if (overlay) overlay.remove();
|
| 3613 |
+
return viewer; // hand the instance back so the caller can spin/pin/resize it
|
| 3614 |
} catch (err) {
|
| 3615 |
+
_afShowError(host, true, () => mountAlphaFoldViewer(hit, host, extraOpts));
|
| 3616 |
}
|
| 3617 |
}
|
| 3618 |
|
|
|
|
| 8807 |
outcomes: [{ subject, measured_value: parseFloat($('poResult').value), outcome: 'measured' }] };
|
| 8808 |
});
|
| 8809 |
// Cloning assembly logger
|
| 8810 |
+
wire('coSave', 'coStatus', (set) => {
|
| 8811 |
+
// Require an explicit outcome β a never-touched form must not inject a
|
| 8812 |
+
// fabricated "Gibson β Assembled" datapoint into the shared prior.
|
| 8813 |
+
const outcome = $('coResult').value;
|
| 8814 |
+
if (!outcome) { set('Pick an assembly outcome first.', true); return null; }
|
| 8815 |
const method = $('coMethod').value;
|
| 8816 |
const frags = parseInt($('coFrags').value || '2', 10);
|
| 8817 |
const overlap = parseInt($('coOverlap').value || '25', 10);
|
| 8818 |
const subject = `${method}|frags=${frags}|overlap=${overlap}`;
|
| 8819 |
return { tool: 'cloning', design_id: uuid(),
|
| 8820 |
+
outcomes: [{ subject, measured_value: parseFloat(outcome), outcome: 'measured' }] };
|
| 8821 |
});
|
| 8822 |
})();
|
| 8823 |
|
|
|
|
| 9672 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9673 |
(function () {
|
| 9674 |
let mountedAcc = null; // don't remount the structure already showing
|
| 9675 |
+
let viewerRef = null; // the live Mol* viewer (real structure OR demo hero)
|
| 9676 |
+
let demoActive = false; // true while the PCSK9 demo hero owns the host
|
| 9677 |
+
let busy = false; // a mount (real or demo) is in flight β guards the race
|
| 9678 |
|
| 9679 |
function el(id) { return document.getElementById(id); }
|
| 9680 |
|
|
|
|
| 9687 |
} catch (_) { return ''; }
|
| 9688 |
}
|
| 9689 |
|
| 9690 |
+
// Runtime dressing shared by the real + demo mounts: keep the viewer
|
| 9691 |
+
// reference, auto-rotate as a hero until first touch, pin the controls
|
| 9692 |
+
// panel open, and re-fit the canvas once the panel has taken its width.
|
| 9693 |
+
// Every step is best-effort β a failure here must never blank the viewer.
|
| 9694 |
+
function dress(viewer, host) {
|
| 9695 |
+
if (!viewer) return;
|
| 9696 |
+
viewerRef = viewer;
|
| 9697 |
+
try { _afAutoRotate(viewer, host); } catch (_) {}
|
| 9698 |
+
try { _afPinControls(viewer); } catch (_) {}
|
| 9699 |
+
try { setTimeout(function () { _afResize(viewer); }, 80); } catch (_) {}
|
| 9700 |
+
}
|
| 9701 |
+
|
| 9702 |
function show(info) {
|
| 9703 |
info = info || {};
|
| 9704 |
const url = safeAfUrl(info.alphafold_url);
|
|
|
|
| 9726 |
|
| 9727 |
if (mountedAcc && mountedAcc === acc) return; // already showing this one
|
| 9728 |
mountedAcc = acc;
|
| 9729 |
+
demoActive = false; // a real structure always supersedes the demo hero
|
| 9730 |
+
busy = true;
|
| 9731 |
+
// If the demo hero was spinning, halt it before we replace the host DOM.
|
| 9732 |
+
try { if (viewerRef) _afSpinOff(viewerRef); } catch (_) {}
|
| 9733 |
host.innerHTML = '<div class="alphafold-loading">Loading predicted structureβ¦</div>';
|
| 9734 |
host.dataset.pdbUrl = url;
|
| 9735 |
+
// Structure-only overrides: controls panel ON, Mol*'s broken native
|
| 9736 |
+
// expand OFF (we own fullscreen). _AF_VIEWER_OPTS itself is untouched,
|
| 9737 |
+
// so the identify/ESMFold/CRISPR embeds are unaffected.
|
| 9738 |
+
(async function () {
|
| 9739 |
+
try {
|
| 9740 |
+
const viewer = await mountAlphaFoldViewer({ alphafold_url: url }, host,
|
| 9741 |
+
{ layoutShowControls: true, viewportShowExpand: false });
|
| 9742 |
+
if (viewer) dress(viewer, host);
|
| 9743 |
+
} catch (_) {
|
| 9744 |
+
} finally { busy = false; }
|
| 9745 |
+
})();
|
| 9746 |
}
|
| 9747 |
|
| 9748 |
// Show it AND bring the Structure tab forward (only inside an open bench β
|
|
|
|
| 9755 |
}
|
| 9756 |
}
|
| 9757 |
|
| 9758 |
+
// Empty-state hero. When the Structure tab is opened with NO real structure
|
| 9759 |
+
// resolved (and none loading), mount a public demo model β PCSK9 / Q8NBP7,
|
| 9760 |
+
// straight from AlphaFold-DB via the confirmed loadAlphaFoldDb API β and let
|
| 9761 |
+
// it rotate. This makes the viewer a live hero with no backend and lets it
|
| 9762 |
+
// be seen before any protein is named.
|
| 9763 |
+
//
|
| 9764 |
+
// GUARD HARD: this must NEVER override or race a real gene resolve. We bail
|
| 9765 |
+
// if a real structure is mounted (mountedAcc), a mount is in flight (busy),
|
| 9766 |
+
// the demo is already up (demoActive), or a Mol* plugin already occupies the
|
| 9767 |
+
// host β and we re-check mountedAcc after every await so a resolve that
|
| 9768 |
+
// lands mid-load always wins.
|
| 9769 |
+
async function onShown() {
|
| 9770 |
+
const host = el('structViewer');
|
| 9771 |
+
if (!host) return;
|
| 9772 |
+
if (mountedAcc || busy || demoActive) return;
|
| 9773 |
+
if (host.querySelector('.msp-plugin')) return; // a viewer is already mounted
|
| 9774 |
+
const prevHTML = host.innerHTML; // the empty-state placeholder
|
| 9775 |
+
busy = true; demoActive = true;
|
| 9776 |
+
try {
|
| 9777 |
+
host.innerHTML = '<div class="alphafold-loading">Loading a demo structureβ¦</div>';
|
| 9778 |
+
const molstar = await ensureMolstar();
|
| 9779 |
+
if (!molstar || mountedAcc) { throw new Error('unavailable-or-superseded'); }
|
| 9780 |
+
const opts = Object.assign({}, _AF_VIEWER_OPTS,
|
| 9781 |
+
{ layoutShowControls: true, viewportShowExpand: false });
|
| 9782 |
+
const viewer = await molstar.Viewer.create(host, opts);
|
| 9783 |
+
_afApplyBg(viewer);
|
| 9784 |
+
host.__mviewer = viewer;
|
| 9785 |
+
if (mountedAcc) { return; } // a real resolve won the race
|
| 9786 |
+
await viewer.loadAlphaFoldDb('Q8NBP7'); // PCSK9 β confirmed on this build
|
| 9787 |
+
if (mountedAcc) { return; } // ...check again after the fetch
|
| 9788 |
+
const ov = host.querySelector('.alphafold-loading'); if (ov) ov.remove();
|
| 9789 |
+
dress(viewer, host);
|
| 9790 |
+
} catch (_) {
|
| 9791 |
+
demoActive = false;
|
| 9792 |
+
// Restore the empty-state message if nothing actually mounted.
|
| 9793 |
+
try { if (!host.querySelector('.msp-plugin')) host.innerHTML = prevHTML; } catch (e) {}
|
| 9794 |
+
} finally { busy = false; }
|
| 9795 |
+
}
|
| 9796 |
+
|
| 9797 |
+
// App-owned fullscreen. Mol*'s native expand is disabled for this host
|
| 9798 |
+
// (viewportShowExpand:false), so we toggle a .struct-viewer--full class
|
| 9799 |
+
// (position:fixed; inset:0) and re-fit the canvas. Escape exits.
|
| 9800 |
+
function isFull() {
|
| 9801 |
+
const h = el('structViewer');
|
| 9802 |
+
return !!(h && h.classList.contains('struct-viewer--full'));
|
| 9803 |
+
}
|
| 9804 |
+
function setFull(on) {
|
| 9805 |
+
const host = el('structViewer'); if (!host) return;
|
| 9806 |
+
host.classList.toggle('struct-viewer--full', !!on);
|
| 9807 |
+
const btn = el('structFullBtn');
|
| 9808 |
+
if (btn) {
|
| 9809 |
+
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
| 9810 |
+
btn.textContent = on ? 'Exit fullscreen' : 'Fullscreen';
|
| 9811 |
+
}
|
| 9812 |
+
try { requestAnimationFrame(function () { if (viewerRef) _afResize(viewerRef); }); } catch (_) {}
|
| 9813 |
+
}
|
| 9814 |
+
(function wireFullscreen() {
|
| 9815 |
+
try {
|
| 9816 |
+
const btn = el('structFullBtn');
|
| 9817 |
+
if (btn && btn.dataset.wired !== '1') {
|
| 9818 |
+
btn.dataset.wired = '1';
|
| 9819 |
+
btn.addEventListener('click', function () { try { setFull(!isFull()); } catch (_) {} });
|
| 9820 |
+
}
|
| 9821 |
+
document.addEventListener('keydown', function (e) {
|
| 9822 |
+
if (e.key === 'Escape' && isFull()) { try { setFull(false); } catch (_) {} }
|
| 9823 |
+
});
|
| 9824 |
+
} catch (_) {}
|
| 9825 |
+
})();
|
| 9826 |
+
|
| 9827 |
+
window.TDStructure = { show: show, open: open, onShown: onShown };
|
| 9828 |
+
|
| 9829 |
+
// Cold deep-link: showRoute() runs during initial script execution, BEFORE
|
| 9830 |
+
// this controller is defined, so its #structure hook can't have reached us.
|
| 9831 |
+
// If we loaded straight into the Structure tab, kick the hero here.
|
| 9832 |
+
try { if (document.body.getAttribute('data-route') === 'structure') onShown(); } catch (e) {}
|
| 9833 |
})();
|
| 9834 |
|
| 9835 |
|
|
@@ -368,8 +368,6 @@
|
|
| 368 |
'aria-expanded="false" aria-label="Past runs" title="Past runs">▾</button>' +
|
| 369 |
'<button type="button" class="cp-icon" id="cpExport" ' +
|
| 370 |
'aria-label="Export this run" title="Export run as Markdown">↓</button>' +
|
| 371 |
-
'<button type="button" class="cp-min" id="cpMin" aria-label="Collapse Turing" ' +
|
| 372 |
-
'aria-expanded="true" title="Collapse">−</button>' +
|
| 373 |
'</header>' +
|
| 374 |
'<div class="cp-menu" id="cpMenu" role="menu" hidden></div>' +
|
| 375 |
/* Cost and context are already computed server-side and returned
|
|
@@ -396,7 +394,6 @@
|
|
| 396 |
input: root.querySelector("#cpInput"),
|
| 397 |
stateLbl: root.querySelector("#cpState"),
|
| 398 |
stop: root.querySelector("#cpStop"),
|
| 399 |
-
min: root.querySelector("#cpMin"),
|
| 400 |
jump: root.querySelector("#cpJump"),
|
| 401 |
newBtn: root.querySelector("#cpNew"),
|
| 402 |
histBtn: root.querySelector("#cpHist"),
|
|
@@ -462,9 +459,6 @@
|
|
| 462 |
if (state.stick) showJump(false);
|
| 463 |
}, { passive: true });
|
| 464 |
state.els.jump.addEventListener("click", function () { scrollDown(true); });
|
| 465 |
-
// The whole header is the hit target when collapsed β a 20px glyph is
|
| 466 |
-
// not a reasonable tap target on a phone.
|
| 467 |
-
state.els.min.addEventListener("click", toggleMin);
|
| 468 |
root.querySelector(".cp-head").addEventListener("click", function (e) {
|
| 469 |
if (document.body.getAttribute("data-cockpit") === "min" &&
|
| 470 |
!e.target.closest(".cp-stop")) toggleMin();
|
|
@@ -1180,15 +1174,20 @@
|
|
| 1180 |
}
|
| 1181 |
function close() { document.body.removeAttribute("data-cockpit"); }
|
| 1182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
function toggleMin() {
|
| 1184 |
var min = document.body.getAttribute("data-cockpit") === "min";
|
| 1185 |
document.body.setAttribute("data-cockpit", min ? "open" : "min");
|
| 1186 |
-
if (
|
| 1187 |
-
state.els.min.innerHTML = min ? "−" : "+";
|
| 1188 |
-
state.els.min.setAttribute("aria-expanded", min ? "true" : "false");
|
| 1189 |
-
state.els.min.setAttribute("title", min ? "Collapse" : "Expand");
|
| 1190 |
-
if (min) scrollDown();
|
| 1191 |
-
}
|
| 1192 |
}
|
| 1193 |
|
| 1194 |
function reset() {
|
|
@@ -1548,10 +1547,9 @@
|
|
| 1548 |
// there and let them pull it up. Desktop has room for both.
|
| 1549 |
var narrow = window.matchMedia && window.matchMedia("(max-width: 900px)").matches;
|
| 1550 |
document.body.setAttribute("data-cockpit", narrow ? "min" : "open");
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
}
|
| 1555 |
restore();
|
| 1556 |
}
|
| 1557 |
if (document.readyState !== "loading") boot();
|
|
|
|
| 368 |
'aria-expanded="false" aria-label="Past runs" title="Past runs">▾</button>' +
|
| 369 |
'<button type="button" class="cp-icon" id="cpExport" ' +
|
| 370 |
'aria-label="Export this run" title="Export run as Markdown">↓</button>' +
|
|
|
|
|
|
|
| 371 |
'</header>' +
|
| 372 |
'<div class="cp-menu" id="cpMenu" role="menu" hidden></div>' +
|
| 373 |
/* Cost and context are already computed server-side and returned
|
|
|
|
| 394 |
input: root.querySelector("#cpInput"),
|
| 395 |
stateLbl: root.querySelector("#cpState"),
|
| 396 |
stop: root.querySelector("#cpStop"),
|
|
|
|
| 397 |
jump: root.querySelector("#cpJump"),
|
| 398 |
newBtn: root.querySelector("#cpNew"),
|
| 399 |
histBtn: root.querySelector("#cpHist"),
|
|
|
|
| 459 |
if (state.stick) showJump(false);
|
| 460 |
}, { passive: true });
|
| 461 |
state.els.jump.addEventListener("click", function () { scrollDown(true); });
|
|
|
|
|
|
|
|
|
|
| 462 |
root.querySelector(".cp-head").addEventListener("click", function (e) {
|
| 463 |
if (document.body.getAttribute("data-cockpit") === "min" &&
|
| 464 |
!e.target.closest(".cp-stop")) toggleMin();
|
|
|
|
| 1174 |
}
|
| 1175 |
function close() { document.body.removeAttribute("data-cockpit"); }
|
| 1176 |
|
| 1177 |
+
/* Collapsed β expanded. The header's collapse BUTTON was removed (it set an
|
| 1178 |
+
unstyled state and hid the rail with no way back), but the state itself
|
| 1179 |
+
is still entered on purpose: boot() starts a phone collapsed so the rail
|
| 1180 |
+
doesn't bury the app behind a sheet nobody asked for.
|
| 1181 |
+
|
| 1182 |
+
Guarding this whole function on the removed button made it a no-op β and
|
| 1183 |
+
since the rail is the only way into the product, that left every phone
|
| 1184 |
+
booted into a collapsed rail with no way to expand it: the exact trap the
|
| 1185 |
+
button was removed to avoid, from the other side. Clicking .cp-head is
|
| 1186 |
+
the way back (see the handler in wire(), and the cursor:pointer rule). */
|
| 1187 |
function toggleMin() {
|
| 1188 |
var min = document.body.getAttribute("data-cockpit") === "min";
|
| 1189 |
document.body.setAttribute("data-cockpit", min ? "open" : "min");
|
| 1190 |
+
if (min) scrollDown();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
}
|
| 1192 |
|
| 1193 |
function reset() {
|
|
|
|
| 1547 |
// there and let them pull it up. Desktop has room for both.
|
| 1548 |
var narrow = window.matchMedia && window.matchMedia("(max-width: 900px)").matches;
|
| 1549 |
document.body.setAttribute("data-cockpit", narrow ? "min" : "open");
|
| 1550 |
+
// Nothing to label here any more β the collapse button is gone, and
|
| 1551 |
+
// dereferencing it threw a TypeError on every narrow-viewport boot,
|
| 1552 |
+
// which killed cockpit init on phones outright.
|
|
|
|
| 1553 |
restore();
|
| 1554 |
}
|
| 1555 |
if (document.readyState !== "loading") boot();
|
|
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* TuringDNA first-party UX-behaviour collector.
|
| 2 |
+
*
|
| 3 |
+
* A small, best-effort companion to telemetry.js. Where telemetry.js measures
|
| 4 |
+
* HOW LONG a user stays, this measures WHERE THE UX RUBS: clicks that do
|
| 5 |
+
* nothing (dead_click), frustration bursts (rage_click), how far a view is read
|
| 6 |
+
* (scroll_depth), how users move between tools (funnel_step / funnel_exit),
|
| 7 |
+
* which labelled controls get used (feature_click), and front-end breakage
|
| 8 |
+
* (client_error).
|
| 9 |
+
*
|
| 10 |
+
* PRIVACY (non-negotiable β see PRIVACY.md and #pasteArea/#metaPreview et al.):
|
| 11 |
+
* The product promises proprietary genetic sequences are NEVER sent to a
|
| 12 |
+
* third party. This collector captures element STRUCTURE ONLY β an `id`, a
|
| 13 |
+
* developer-authored `data-analytics` label, or a `tag[role]:nth-of-type`
|
| 14 |
+
* path. It NEVER reads `.value`, `.textContent`, `.innerText`, input contents,
|
| 15 |
+
* or any user-typed string. See describe()/safeSelector() below β those are
|
| 16 |
+
* the single chokepoint, and they touch only tagName / id / role /
|
| 17 |
+
* data-analytics / sibling index.
|
| 18 |
+
*
|
| 19 |
+
* IDENTITY: the client sends ONLY an opaque session id + a list of events. It
|
| 20 |
+
* never sends who the user is. The SERVER stamps user_id / anon_fingerprint
|
| 21 |
+
* from the request (see /api/collect β auth.log_client_events_async). The
|
| 22 |
+
* session id is the SAME persisted `td_session_id` telemetry.js uses, so
|
| 23 |
+
* these events join the existing app_sessions row (which already carries the
|
| 24 |
+
* user_id captured by telemetry's authenticated beats).
|
| 25 |
+
*
|
| 26 |
+
* TRANSPORT: events are batched and flushed via navigator.sendBeacon to
|
| 27 |
+
* /api/collect on whichever comes first β ~10 events, ~5 s, or the tab
|
| 28 |
+
* hiding / unloading. Everything is wrapped in try/catch; telemetry must
|
| 29 |
+
* never throw into, or break, the UX.
|
| 30 |
+
*/
|
| 31 |
+
(function () {
|
| 32 |
+
'use strict';
|
| 33 |
+
|
| 34 |
+
// ββ Shared, persisted session id ββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
// Identical logic to telemetry.js so, whichever script runs first, both
|
| 36 |
+
// read the SAME id from localStorage (key 'td_session_id') and the events
|
| 37 |
+
// join the same app_sessions row. Falls back to an in-memory id if
|
| 38 |
+
// localStorage is unavailable (private mode / sandboxed iframe).
|
| 39 |
+
function uuid() {
|
| 40 |
+
try { if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); } catch (e) {}
|
| 41 |
+
return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
| 42 |
+
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
| 43 |
+
return v.toString(16);
|
| 44 |
+
});
|
| 45 |
+
}
|
| 46 |
+
function sessionId() {
|
| 47 |
+
try {
|
| 48 |
+
var k = 'td_session_id';
|
| 49 |
+
var v = localStorage.getItem(k);
|
| 50 |
+
if (!v) { v = uuid(); localStorage.setItem(k, v); }
|
| 51 |
+
return v;
|
| 52 |
+
} catch (e) { return uuid(); }
|
| 53 |
+
}
|
| 54 |
+
var SID = sessionId();
|
| 55 |
+
|
| 56 |
+
// ββ Batch + flush βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
var MAX_BATCH = 10; // flush when the queue reaches this many events
|
| 58 |
+
var FLUSH_MS = 5000; // β¦or after this long, whichever comes first
|
| 59 |
+
var MAX_QUEUE = 100; // hard cap; drop OLDEST if a burst outruns flush
|
| 60 |
+
var MAX_SEL_LEN = 200; // safe-selector length cap (server re-caps too)
|
| 61 |
+
var queue = [];
|
| 62 |
+
var flushTimer = null;
|
| 63 |
+
|
| 64 |
+
function currentRoute() {
|
| 65 |
+
try { return document.body.getAttribute('data-route') || ''; } catch (e) { return ''; }
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function enqueue(kind, opts) {
|
| 69 |
+
try {
|
| 70 |
+
opts = opts || {};
|
| 71 |
+
var ev = {
|
| 72 |
+
kind: kind,
|
| 73 |
+
route: opts.route != null ? opts.route : currentRoute(),
|
| 74 |
+
selector: opts.selector != null ? String(opts.selector).slice(0, MAX_SEL_LEN) : null,
|
| 75 |
+
value_num: (typeof opts.value_num === 'number' && isFinite(opts.value_num)) ? opts.value_num : null,
|
| 76 |
+
meta: opts.meta || {},
|
| 77 |
+
client_ts: new Date().toISOString()
|
| 78 |
+
};
|
| 79 |
+
queue.push(ev);
|
| 80 |
+
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
| 81 |
+
if (queue.length >= MAX_BATCH) { flush(false); return; }
|
| 82 |
+
if (flushTimer == null) {
|
| 83 |
+
flushTimer = setTimeout(function () { flush(false); }, FLUSH_MS);
|
| 84 |
+
}
|
| 85 |
+
} catch (e) { /* best-effort β never throw into the UX */ }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function flush(force) {
|
| 89 |
+
try {
|
| 90 |
+
if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; }
|
| 91 |
+
if (!queue.length) return;
|
| 92 |
+
var batch = queue;
|
| 93 |
+
queue = [];
|
| 94 |
+
var body = JSON.stringify({ session_id: SID, events: batch });
|
| 95 |
+
var sent = false;
|
| 96 |
+
if (navigator.sendBeacon) {
|
| 97 |
+
try {
|
| 98 |
+
sent = navigator.sendBeacon('/api/collect', new Blob([body], { type: 'application/json' }));
|
| 99 |
+
} catch (e) { sent = false; }
|
| 100 |
+
}
|
| 101 |
+
if (!sent) {
|
| 102 |
+
// Fallback: keepalive fetch (rides auth.js's JWT wrapper, so a
|
| 103 |
+
// size/time flush from a signed-in user can still be attributed
|
| 104 |
+
// server-side). Beacon stays the primary path on unload.
|
| 105 |
+
try {
|
| 106 |
+
fetch('/api/collect', {
|
| 107 |
+
method: 'POST',
|
| 108 |
+
headers: { 'Content-Type': 'application/json' },
|
| 109 |
+
body: body,
|
| 110 |
+
keepalive: true
|
| 111 |
+
}).catch(function () {});
|
| 112 |
+
} catch (e) { /* give up β telemetry is best-effort */ }
|
| 113 |
+
}
|
| 114 |
+
} catch (e) { /* ignore */ }
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ββ Safe element descriptor (STRUCTURE ONLY) ββββββββββββββββββββββββββ
|
| 118 |
+
// The privacy chokepoint. Reads ONLY tagName / id / role / data-analytics
|
| 119 |
+
// / sibling index β never value, textContent, innerText, or attributes
|
| 120 |
+
// that could carry user input. Returns a short, structural path like
|
| 121 |
+
// `#resultsCard>button[role=button]:nth-of-type(2)`.
|
| 122 |
+
function describe(el) {
|
| 123 |
+
try {
|
| 124 |
+
if (!el || el.nodeType !== 1) return null;
|
| 125 |
+
var a = el.getAttribute && el.getAttribute('data-analytics');
|
| 126 |
+
if (a) return '[data-analytics=' + String(a).slice(0, 60) + ']';
|
| 127 |
+
if (el.id) return '#' + String(el.id).slice(0, 80);
|
| 128 |
+
var tag = (el.tagName || '').toLowerCase();
|
| 129 |
+
var role = el.getAttribute && el.getAttribute('role');
|
| 130 |
+
var sel = tag + (role ? '[role=' + String(role).slice(0, 24) + ']' : '');
|
| 131 |
+
var p = el.parentNode;
|
| 132 |
+
if (p && p.children && p.children.length > 1) {
|
| 133 |
+
var same = 0, idx = 0, i;
|
| 134 |
+
for (i = 0; i < p.children.length; i++) {
|
| 135 |
+
var c = p.children[i];
|
| 136 |
+
if (c.tagName === el.tagName) { same++; if (c === el) idx = same; }
|
| 137 |
+
}
|
| 138 |
+
if (same > 1 && idx > 0) sel += ':nth-of-type(' + idx + ')';
|
| 139 |
+
}
|
| 140 |
+
return sel;
|
| 141 |
+
} catch (e) { return null; }
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function safeSelector(el) {
|
| 145 |
+
// Walk up to ~4 crumbs, stopping at the first id / data-analytics
|
| 146 |
+
// anchor (which uniquely locates the node without more context).
|
| 147 |
+
try {
|
| 148 |
+
var crumbs = [], node = el, hops = 0;
|
| 149 |
+
while (node && node.nodeType === 1 && hops < 4) {
|
| 150 |
+
var d = describe(node);
|
| 151 |
+
if (!d) break;
|
| 152 |
+
crumbs.unshift(d);
|
| 153 |
+
if (d.charAt(0) === '#' || d.indexOf('[data-analytics=') === 0) break;
|
| 154 |
+
node = node.parentNode;
|
| 155 |
+
hops++;
|
| 156 |
+
}
|
| 157 |
+
return crumbs.join('>').slice(0, MAX_SEL_LEN);
|
| 158 |
+
} catch (e) { return null; }
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
var INTERACTIVE = 'a,button,input,select,textarea,label,summary,details,option,[role=button],[role=tab],[role=menuitem],[role=option],[tabindex],[contenteditable]';
|
| 162 |
+
|
| 163 |
+
// ββ dead_click ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
// A click that lands on nothing interactive AND causes no observable
|
| 165 |
+
// change β the user thought something was clickable but it wasn't.
|
| 166 |
+
//
|
| 167 |
+
// "Did anything change?" MUST be scoped to the CLICKED ELEMENT'S OWN
|
| 168 |
+
// SUBTREE (PostHog-style). Watching the whole document is defeated by any
|
| 169 |
+
// unrelated background DOM churn β retry loops, live updates, animations:
|
| 170 |
+
// some node almost always mutates within the window, so every click looks
|
| 171 |
+
// live and dead_click never fires (the false-negative bug caught in
|
| 172 |
+
// review). A dead click means *this element and its descendants* didn't
|
| 173 |
+
// change, PLUS no navigation, focus change, or text-selection change.
|
| 174 |
+
function selSnapshot() {
|
| 175 |
+
// A comparable signature of the current selection built from ONLY node
|
| 176 |
+
// references + integer offsets + booleans β NEVER the selected text
|
| 177 |
+
// (getSelection().toString() would expose a proprietary sequence the
|
| 178 |
+
// user highlighted). This lets us detect that the selection CHANGED
|
| 179 |
+
// without ever reading its content.
|
| 180 |
+
try {
|
| 181 |
+
var s = window.getSelection && window.getSelection();
|
| 182 |
+
if (!s) return null;
|
| 183 |
+
return {
|
| 184 |
+
n: s.rangeCount, c: s.isCollapsed,
|
| 185 |
+
an: s.anchorNode, ao: s.anchorOffset,
|
| 186 |
+
fn: s.focusNode, fo: s.focusOffset
|
| 187 |
+
};
|
| 188 |
+
} catch (e) { return null; }
|
| 189 |
+
}
|
| 190 |
+
function selChanged(a, b) {
|
| 191 |
+
if (!a || !b) return false; // indeterminate β don't suppress
|
| 192 |
+
return a.n !== b.n || a.c !== b.c ||
|
| 193 |
+
a.an !== b.an || a.ao !== b.ao ||
|
| 194 |
+
a.fn !== b.fn || a.fo !== b.fo;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function watchForDeadClick(target, x, y) {
|
| 198 |
+
try {
|
| 199 |
+
var hrefBefore = location.href;
|
| 200 |
+
var activeBefore = document.activeElement;
|
| 201 |
+
var selBefore = selSnapshot();
|
| 202 |
+
var mutated = false;
|
| 203 |
+
var mo = null;
|
| 204 |
+
try {
|
| 205 |
+
mo = new MutationObserver(function () { mutated = true; });
|
| 206 |
+
// SCOPED to the clicked element's own subtree β churn anywhere
|
| 207 |
+
// ELSE in the document can no longer set this flag. We only ever
|
| 208 |
+
// flip a boolean; the mutation records are never read.
|
| 209 |
+
mo.observe(target, {
|
| 210 |
+
childList: true, subtree: true, attributes: true, characterData: true
|
| 211 |
+
});
|
| 212 |
+
} catch (e) { mo = null; }
|
| 213 |
+
var hashMoved = false;
|
| 214 |
+
function onHash() { hashMoved = true; }
|
| 215 |
+
window.addEventListener('hashchange', onHash, true);
|
| 216 |
+
|
| 217 |
+
setTimeout(function () {
|
| 218 |
+
try {
|
| 219 |
+
if (mo) mo.disconnect();
|
| 220 |
+
window.removeEventListener('hashchange', onHash, true);
|
| 221 |
+
var changed = mutated || hashMoved ||
|
| 222 |
+
location.href !== hrefBefore ||
|
| 223 |
+
document.activeElement !== activeBefore ||
|
| 224 |
+
selChanged(selBefore, selSnapshot());
|
| 225 |
+
if (!changed) {
|
| 226 |
+
enqueue('dead_click', {
|
| 227 |
+
selector: safeSelector(target),
|
| 228 |
+
meta: { x: Math.round(x), y: Math.round(y) }
|
| 229 |
+
});
|
| 230 |
+
}
|
| 231 |
+
} catch (e2) { /* ignore */ }
|
| 232 |
+
}, 350);
|
| 233 |
+
} catch (e) { /* ignore */ }
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// ββ rage_click ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
+
// >= 3 clicks within 1 s all within a 30 px radius β frustration. Emit at
|
| 238 |
+
// most once per burst (cooldown) so a 6-click flurry is one signal.
|
| 239 |
+
var RAGE_MIN = 3, RAGE_WINDOW_MS = 1000, RAGE_RADIUS = 30, RAGE_COOLDOWN_MS = 1000;
|
| 240 |
+
var clickRing = []; // recent {x,y,t}
|
| 241 |
+
var lastRageAt = 0;
|
| 242 |
+
|
| 243 |
+
function checkRage(target, x, y, t) {
|
| 244 |
+
try {
|
| 245 |
+
clickRing.push({ x: x, y: y, t: t });
|
| 246 |
+
// keep only the last 1 s
|
| 247 |
+
while (clickRing.length && t - clickRing[0].t > RAGE_WINDOW_MS) clickRing.shift();
|
| 248 |
+
if (clickRing.length < RAGE_MIN) return;
|
| 249 |
+
// all recent clicks within the radius of the current point?
|
| 250 |
+
var tight = true;
|
| 251 |
+
for (var i = 0; i < clickRing.length; i++) {
|
| 252 |
+
var dx = clickRing[i].x - x, dy = clickRing[i].y - y;
|
| 253 |
+
if (dx * dx + dy * dy > RAGE_RADIUS * RAGE_RADIUS) { tight = false; break; }
|
| 254 |
+
}
|
| 255 |
+
if (tight && (t - lastRageAt) > RAGE_COOLDOWN_MS) {
|
| 256 |
+
lastRageAt = t;
|
| 257 |
+
enqueue('rage_click', {
|
| 258 |
+
selector: safeSelector(target),
|
| 259 |
+
value_num: clickRing.length,
|
| 260 |
+
meta: { x: Math.round(x), y: Math.round(y), count: clickRing.length }
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
} catch (e) { /* ignore */ }
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// ββ feature_click βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 267 |
+
// Any click within an element carrying a developer-authored data-analytics
|
| 268 |
+
// label. The label is our own text (never user input), safe to record.
|
| 269 |
+
function checkFeatureClick(target) {
|
| 270 |
+
try {
|
| 271 |
+
var el = target && target.closest ? target.closest('[data-analytics]') : null;
|
| 272 |
+
if (!el) return;
|
| 273 |
+
var label = el.getAttribute('data-analytics');
|
| 274 |
+
if (!label) return;
|
| 275 |
+
enqueue('feature_click', {
|
| 276 |
+
selector: safeSelector(el),
|
| 277 |
+
meta: { label: String(label).slice(0, 60) }
|
| 278 |
+
});
|
| 279 |
+
} catch (e) { /* ignore */ }
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Single delegated, capture-phase click handler feeds all three.
|
| 283 |
+
document.addEventListener('click', function (e) {
|
| 284 |
+
try {
|
| 285 |
+
var target = e.target;
|
| 286 |
+
if (!target || target.nodeType !== 1) return;
|
| 287 |
+
var x = (typeof e.clientX === 'number') ? e.clientX : 0;
|
| 288 |
+
var y = (typeof e.clientY === 'number') ? e.clientY : 0;
|
| 289 |
+
var t = Date.now(); // one consistent clock for the rage ring
|
| 290 |
+
|
| 291 |
+
checkFeatureClick(target);
|
| 292 |
+
checkRage(target, x, y, t);
|
| 293 |
+
|
| 294 |
+
var interactive = target.closest ? target.closest(INTERACTIVE) : null;
|
| 295 |
+
if (!interactive) watchForDeadClick(target, x, y);
|
| 296 |
+
} catch (err) { /* best-effort */ }
|
| 297 |
+
}, true);
|
| 298 |
+
|
| 299 |
+
// ββ scroll_depth ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 300 |
+
// Max read depth per route, bucketed 25/50/75/100, once per bucket per
|
| 301 |
+
// route. The app has SEVERAL scroll containers depending on mode (the
|
| 302 |
+
// window in the classic tool views, `.workspace` in an open bench,
|
| 303 |
+
// `.view--mission` / `.view--structure2` which set their own overflow).
|
| 304 |
+
// Rather than guess, we listen on the window in CAPTURE phase β scroll
|
| 305 |
+
// doesn't bubble, but capture still sees scrolls from any descendant
|
| 306 |
+
// container β and read the metrics off whatever element actually scrolled.
|
| 307 |
+
var BUCKETS = [25, 50, 75, 100];
|
| 308 |
+
var firedBuckets = {}; // bucket -> true, for the CURRENT route
|
| 309 |
+
var scrollScheduled = false;
|
| 310 |
+
var lastScrollTarget = null;
|
| 311 |
+
|
| 312 |
+
function scrollMetrics(evTarget) {
|
| 313 |
+
try {
|
| 314 |
+
var el;
|
| 315 |
+
if (!evTarget || evTarget === document || evTarget === window ||
|
| 316 |
+
evTarget === document.documentElement || evTarget === document.body) {
|
| 317 |
+
el = document.scrollingElement || document.documentElement;
|
| 318 |
+
return {
|
| 319 |
+
st: el.scrollTop,
|
| 320 |
+
sh: el.scrollHeight,
|
| 321 |
+
ch: el.clientHeight || window.innerHeight
|
| 322 |
+
};
|
| 323 |
+
}
|
| 324 |
+
return { st: evTarget.scrollTop, sh: evTarget.scrollHeight, ch: evTarget.clientHeight };
|
| 325 |
+
} catch (e) { return null; }
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function measureScroll() {
|
| 329 |
+
scrollScheduled = false;
|
| 330 |
+
try {
|
| 331 |
+
var m = scrollMetrics(lastScrollTarget);
|
| 332 |
+
if (!m) return;
|
| 333 |
+
var scrollable = m.sh - m.ch;
|
| 334 |
+
if (scrollable <= 4) return; // nothing meaningful to scroll
|
| 335 |
+
var pct = ((m.st + m.ch) / m.sh) * 100;
|
| 336 |
+
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
|
| 337 |
+
for (var i = 0; i < BUCKETS.length; i++) {
|
| 338 |
+
var b = BUCKETS[i];
|
| 339 |
+
if (pct >= b && !firedBuckets[b]) {
|
| 340 |
+
firedBuckets[b] = true;
|
| 341 |
+
enqueue('scroll_depth', { value_num: b, meta: { bucket: b } });
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
} catch (e) { /* ignore */ }
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
window.addEventListener('scroll', function (e) {
|
| 348 |
+
try {
|
| 349 |
+
lastScrollTarget = e.target;
|
| 350 |
+
if (!scrollScheduled) {
|
| 351 |
+
scrollScheduled = true;
|
| 352 |
+
if (window.requestAnimationFrame) requestAnimationFrame(measureScroll);
|
| 353 |
+
else setTimeout(measureScroll, 100);
|
| 354 |
+
}
|
| 355 |
+
} catch (err) { /* ignore */ }
|
| 356 |
+
}, true);
|
| 357 |
+
|
| 358 |
+
// ββ funnel_step / funnel_exit βββββββββββββββββββββββββββββββββββββββββ
|
| 359 |
+
// The SPA switches views by stamping body[data-route] (showRoute in
|
| 360 |
+
// app.js). Observing that attribute catches EVERY route change β hashchange
|
| 361 |
+
// navigations, the bench canvas tabs, and programmatic routeTo() alike β
|
| 362 |
+
// without having to hook each path. Each change is a funnel step; the
|
| 363 |
+
// final step on unload is a funnel_exit.
|
| 364 |
+
var lastRoute = currentRoute();
|
| 365 |
+
|
| 366 |
+
function onRouteChange(next) {
|
| 367 |
+
try {
|
| 368 |
+
if (next === lastRoute) return;
|
| 369 |
+
var from = lastRoute;
|
| 370 |
+
lastRoute = next;
|
| 371 |
+
firedBuckets = {}; // scroll depth is per-route
|
| 372 |
+
enqueue('funnel_step', { route: next, meta: { from: from, to: next } });
|
| 373 |
+
} catch (e) { /* ignore */ }
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
try {
|
| 377 |
+
var bodyObserver = new MutationObserver(function () {
|
| 378 |
+
onRouteChange(currentRoute());
|
| 379 |
+
});
|
| 380 |
+
bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['data-route'] });
|
| 381 |
+
} catch (e) {
|
| 382 |
+
// Fallback for no-MutationObserver: at least catch hash navigations.
|
| 383 |
+
window.addEventListener('hashchange', function () {
|
| 384 |
+
setTimeout(function () { onRouteChange(currentRoute()); }, 0);
|
| 385 |
+
}, false);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// ββ client_error ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
+
// Front-end breakage as a signal. We record only the error CLASS, the
|
| 390 |
+
// script source (basename, to avoid leaking query-string data), and the
|
| 391 |
+
// line/column β never the free-text message, which can embed values.
|
| 392 |
+
function sourceBasename(src) {
|
| 393 |
+
try {
|
| 394 |
+
if (!src) return null;
|
| 395 |
+
var s = String(src).split('?')[0].split('#')[0];
|
| 396 |
+
var parts = s.split('/');
|
| 397 |
+
return parts[parts.length - 1].slice(0, 80) || null;
|
| 398 |
+
} catch (e) { return null; }
|
| 399 |
+
}
|
| 400 |
+
function errName(err) {
|
| 401 |
+
try { if (err && err.name) return String(err.name).slice(0, 40); } catch (e) {}
|
| 402 |
+
return 'Error';
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
window.addEventListener('error', function (e) {
|
| 406 |
+
try {
|
| 407 |
+
// Ignore resource-load errors (img/script 404s) β those have no
|
| 408 |
+
// e.error and a target that isn't the window.
|
| 409 |
+
if (e && e.target && e.target !== window && e.target.nodeType === 1) return;
|
| 410 |
+
enqueue('client_error', {
|
| 411 |
+
meta: {
|
| 412 |
+
name: e && e.error ? errName(e.error) : 'Error',
|
| 413 |
+
source: sourceBasename(e && e.filename),
|
| 414 |
+
line: (e && typeof e.lineno === 'number') ? e.lineno : null,
|
| 415 |
+
col: (e && typeof e.colno === 'number') ? e.colno : null
|
| 416 |
+
}
|
| 417 |
+
});
|
| 418 |
+
} catch (err) { /* ignore */ }
|
| 419 |
+
}, true);
|
| 420 |
+
|
| 421 |
+
window.addEventListener('unhandledrejection', function (e) {
|
| 422 |
+
try {
|
| 423 |
+
var reason = e && e.reason;
|
| 424 |
+
enqueue('client_error', {
|
| 425 |
+
meta: {
|
| 426 |
+
name: (reason && reason.name) ? String(reason.name).slice(0, 40) : 'UnhandledRejection',
|
| 427 |
+
source: (reason && reason.fileName) ? sourceBasename(reason.fileName) : null,
|
| 428 |
+
line: (reason && typeof reason.lineNumber === 'number') ? reason.lineNumber : null,
|
| 429 |
+
kind: 'promise'
|
| 430 |
+
}
|
| 431 |
+
});
|
| 432 |
+
} catch (err) { /* ignore */ }
|
| 433 |
+
}, true);
|
| 434 |
+
|
| 435 |
+
// ββ Flush triggers: tab hide / unload βββββββββββββββββββββββββββββββββ
|
| 436 |
+
document.addEventListener('visibilitychange', function () {
|
| 437 |
+
if (document.visibilityState === 'hidden') flush(true);
|
| 438 |
+
});
|
| 439 |
+
window.addEventListener('pagehide', function () {
|
| 440 |
+
try { enqueue('funnel_exit', { route: currentRoute() }); } catch (e) {}
|
| 441 |
+
flush(true);
|
| 442 |
+
});
|
| 443 |
+
})();
|
|
@@ -112,7 +112,7 @@
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
-
<link rel="stylesheet" href="/static/app.css?v=20260727-
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
@@ -248,7 +248,7 @@
|
|
| 248 |
-->
|
| 249 |
<div class="nav-loop">
|
| 250 |
<p class="nav-loop-lbl">The loop</p>
|
| 251 |
-
<a class="nav-item nav-step" href="#design" title="Directed Evolution">
|
| 252 |
<span class="nav-icon" aria-hidden="true">
|
| 253 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 254 |
<path d="M7 3c0 4.5 10 6 10 9s-10 4.5-10 9"/>
|
|
@@ -259,7 +259,7 @@
|
|
| 259 |
<span class="nav-step-nm">Directed Evolution</span>
|
| 260 |
<span class="nav-step-ph">Design</span>
|
| 261 |
</a>
|
| 262 |
-
<a class="nav-item nav-step" href="#plasmid" title="Plasmid Editor">
|
| 263 |
<span class="nav-icon" aria-hidden="true">
|
| 264 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 265 |
<circle cx="12" cy="12" r="8"/>
|
|
@@ -269,7 +269,7 @@
|
|
| 269 |
<span class="nav-step-nm">Plasmid Editor</span>
|
| 270 |
<span class="nav-step-ph">Build</span>
|
| 271 |
</a>
|
| 272 |
-
<a class="nav-item nav-step" href="#crispr" title="CRISPR">
|
| 273 |
<span class="nav-icon" aria-hidden="true">
|
| 274 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 275 |
<circle cx="6" cy="6" r="2.6"/>
|
|
@@ -280,7 +280,7 @@
|
|
| 280 |
<span class="nav-step-nm">CRISPR</span>
|
| 281 |
<span class="nav-step-ph">Edit</span>
|
| 282 |
</a>
|
| 283 |
-
<a class="nav-item nav-step" href="#primers" title="Primer Analysis">
|
| 284 |
<span class="nav-icon" aria-hidden="true">
|
| 285 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 286 |
<circle cx="10.5" cy="10.5" r="6.5"/>
|
|
@@ -356,6 +356,15 @@
|
|
| 356 |
The account menu opens upward; "Sign out" asks the wrapper to drop the
|
| 357 |
Supabase session, then lands on /signin.
|
| 358 |
-->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
<a class="sidebar-signin" id="sidebarSignin" href="https://turingdna.com/signin/?from=app" target="_top">
|
| 360 |
<span class="acct-avatar acct-avatar--ghost" aria-hidden="true">
|
| 361 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -417,18 +426,9 @@
|
|
| 417 |
<h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
|
| 418 |
<span class="topbar-sub" id="topbarSub">Map, annotate & clone your construct</span>
|
| 419 |
</div>
|
| 420 |
-
<!--
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle light and dark theme" aria-pressed="false" title="Toggle light / dark">
|
| 424 |
-
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 425 |
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 426 |
-
</svg>
|
| 427 |
-
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 428 |
-
<circle cx="12" cy="12" r="4.5"/>
|
| 429 |
-
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
| 430 |
-
</svg>
|
| 431 |
-
</button>
|
| 432 |
</header>
|
| 433 |
|
| 434 |
<!-- =================================================================
|
|
@@ -482,19 +482,12 @@
|
|
| 482 |
|
| 483 |
<div class="bench-canvashead" id="benchCanvasHead" hidden>
|
| 484 |
<nav class="bench-tabs" id="benchTabs" aria-label="Construct artifacts">
|
| 485 |
-
<button class="bench-tab" data-route="structure" type="button">Structure</button>
|
| 486 |
-
<button class="bench-tab" data-route="design" type="button">Library</button>
|
| 487 |
-
<button class="bench-tab" data-route="plasmid" type="button">Map</button>
|
| 488 |
-
<button class="bench-tab" data-route="crispr" type="button">Guides</button>
|
| 489 |
-
<button class="bench-tab" data-route="primers" type="button">Primers</button>
|
| 490 |
</nav>
|
| 491 |
-
<div class="bench-verbs" aria-label="Do more with this construct">
|
| 492 |
-
<span class="bench-verbs-lbl">Do more →</span>
|
| 493 |
-
<button class="bench-verb" data-verb="design" type="button">Evolve</button>
|
| 494 |
-
<button class="bench-verb" data-verb="plasmid" type="button">Build plasmid</button>
|
| 495 |
-
<button class="bench-verb" data-verb="crispr" type="button">Design guides</button>
|
| 496 |
-
<button class="bench-verb" data-verb="primers" type="button">Check primers</button>
|
| 497 |
-
</div>
|
| 498 |
</div>
|
| 499 |
|
| 500 |
<div class="workspace">
|
|
@@ -590,7 +583,10 @@
|
|
| 590 |
<h2 id="structTitle">Predicted structure</h2>
|
| 591 |
<p class="card-sub" id="structSub">Turing loads the wild-type model as soon as you name a protein.</p>
|
| 592 |
</div>
|
| 593 |
-
<
|
|
|
|
|
|
|
|
|
|
| 594 |
</header>
|
| 595 |
<div class="struct-viewer" id="structViewer">
|
| 596 |
<div class="struct-empty" id="structEmpty">
|
|
@@ -641,7 +637,7 @@
|
|
| 641 |
<h2>Provide a wild-type</h2>
|
| 642 |
<p class="card-sub">FASTA, SnapGene, GenBank, raw DNA, or raw protein. Auto-detected.</p>
|
| 643 |
</div>
|
| 644 |
-
<button class="ghost" type="button" id="deExampleBtn"
|
| 645 |
title="Loads GFP β a classic directed-evolution target">Try an example</button>
|
| 646 |
</header>
|
| 647 |
|
|
@@ -742,9 +738,11 @@
|
|
| 742 |
|
| 743 |
<section class="card" id="settingsCard">
|
| 744 |
<header class="card-header">
|
| 745 |
-
<
|
| 746 |
-
|
| 747 |
-
|
|
|
|
|
|
|
| 748 |
</header>
|
| 749 |
|
| 750 |
<div class="setting-grid">
|
|
@@ -790,12 +788,14 @@
|
|
| 790 |
|
| 791 |
<section class="card run-card" id="runCard">
|
| 792 |
<header class="card-header">
|
| 793 |
-
<
|
| 794 |
-
|
| 795 |
-
|
|
|
|
|
|
|
| 796 |
</header>
|
| 797 |
|
| 798 |
-
<button id="runBtn" type="button" class="primary primary-lg" disabled>
|
| 799 |
<span class="primary-icon" aria-hidden="true">
|
| 800 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 801 |
<polygon points="6 3 20 12 6 21 6 3" fill="currentColor"/>
|
|
@@ -854,13 +854,13 @@
|
|
| 854 |
<span class="caret">βΎ</span>
|
| 855 |
</button>
|
| 856 |
<div class="download-menu-items" id="downloadMenuItems" hidden>
|
| 857 |
-
<a class="download-item" data-format="csv">CSV</a>
|
| 858 |
-
<a class="download-item" data-format="xlsx">Excel</a>
|
| 859 |
-
<a class="download-item" data-format="fasta">FASTA · protein</a>
|
| 860 |
-
<a class="download-item" data-format="fasta-dna">FASTA · DNA</a>
|
| 861 |
-
<a class="download-item" data-format="gb">GenBank</a>
|
| 862 |
-
<a class="download-item" data-format="json">JSON</a>
|
| 863 |
-
<a class="download-item" data-format="tsv">TSV</a>
|
| 864 |
</div>
|
| 865 |
</div>
|
| 866 |
</div>
|
|
@@ -1833,6 +1833,7 @@
|
|
| 1833 |
<label class="design-opt">Overlap (bp)<input type="number" id="coOverlap" min="0" max="120" value="25" /></label>
|
| 1834 |
<label class="design-opt">Result
|
| 1835 |
<select id="coResult" class="design-opt-select">
|
|
|
|
| 1836 |
<option value="1">Assembled</option>
|
| 1837 |
<option value="0.5">Partial</option>
|
| 1838 |
<option value="0">Failed</option>
|
|
@@ -2355,13 +2356,19 @@
|
|
| 2355 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2356 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2357 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2358 |
-
<script src="/static/app.js?v=20260727-
|
| 2359 |
<!-- THE COCKPIT β the persistent orchestrator rail. Loads after app.js so
|
| 2360 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 2361 |
render something. This is the only conversation surface in the app. -->
|
| 2362 |
-
<script src="/static/cockpit.js?v=20260727-
|
| 2363 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2364 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2365 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2366 |
</body>
|
| 2367 |
</html>
|
|
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
+
<link rel="stylesheet" href="/static/app.css?v=20260727-unified" />
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
|
|
| 248 |
-->
|
| 249 |
<div class="nav-loop">
|
| 250 |
<p class="nav-loop-lbl">The loop</p>
|
| 251 |
+
<a class="nav-item nav-step" href="#design" data-analytics="nav-design" title="Directed Evolution">
|
| 252 |
<span class="nav-icon" aria-hidden="true">
|
| 253 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 254 |
<path d="M7 3c0 4.5 10 6 10 9s-10 4.5-10 9"/>
|
|
|
|
| 259 |
<span class="nav-step-nm">Directed Evolution</span>
|
| 260 |
<span class="nav-step-ph">Design</span>
|
| 261 |
</a>
|
| 262 |
+
<a class="nav-item nav-step" href="#plasmid" data-analytics="nav-plasmid" title="Plasmid Editor">
|
| 263 |
<span class="nav-icon" aria-hidden="true">
|
| 264 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 265 |
<circle cx="12" cy="12" r="8"/>
|
|
|
|
| 269 |
<span class="nav-step-nm">Plasmid Editor</span>
|
| 270 |
<span class="nav-step-ph">Build</span>
|
| 271 |
</a>
|
| 272 |
+
<a class="nav-item nav-step" href="#crispr" data-analytics="nav-crispr" title="CRISPR">
|
| 273 |
<span class="nav-icon" aria-hidden="true">
|
| 274 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 275 |
<circle cx="6" cy="6" r="2.6"/>
|
|
|
|
| 280 |
<span class="nav-step-nm">CRISPR</span>
|
| 281 |
<span class="nav-step-ph">Edit</span>
|
| 282 |
</a>
|
| 283 |
+
<a class="nav-item nav-step" href="#primers" data-analytics="nav-primers" title="Primer Analysis">
|
| 284 |
<span class="nav-icon" aria-hidden="true">
|
| 285 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
| 286 |
<circle cx="10.5" cy="10.5" r="6.5"/>
|
|
|
|
| 356 |
The account menu opens upward; "Sign out" asks the wrapper to drop the
|
| 357 |
Supabase session, then lands on /signin.
|
| 358 |
-->
|
| 359 |
+
<button class="theme-toggle sidebar-theme" id="themeToggle" type="button" aria-label="Toggle light and dark theme" aria-pressed="false" title="Toggle light / dark">
|
| 360 |
+
<svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 361 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 362 |
+
</svg>
|
| 363 |
+
<svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 364 |
+
<circle cx="12" cy="12" r="4.5"/>
|
| 365 |
+
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
| 366 |
+
</svg>
|
| 367 |
+
</button>
|
| 368 |
<a class="sidebar-signin" id="sidebarSignin" href="https://turingdna.com/signin/?from=app" target="_top">
|
| 369 |
<span class="acct-avatar acct-avatar--ghost" aria-hidden="true">
|
| 370 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
| 426 |
<h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
|
| 427 |
<span class="topbar-sub" id="topbarSub">Map, annotate & clone your construct</span>
|
| 428 |
</div>
|
| 429 |
+
<!-- Theme toggle relocated to the sidebar footer, by the account chip
|
| 430 |
+
(see #themeToggle in the aside) β it now stays reachable in the bench
|
| 431 |
+
view, where this topbar is hidden. -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
</header>
|
| 433 |
|
| 434 |
<!-- =================================================================
|
|
|
|
| 482 |
|
| 483 |
<div class="bench-canvashead" id="benchCanvasHead" hidden>
|
| 484 |
<nav class="bench-tabs" id="benchTabs" aria-label="Construct artifacts">
|
| 485 |
+
<button class="bench-tab" data-route="structure" data-analytics="bench-tab-structure" type="button">Structure</button>
|
| 486 |
+
<button class="bench-tab" data-route="design" data-analytics="bench-tab-design" type="button">Library</button>
|
| 487 |
+
<button class="bench-tab" data-route="plasmid" data-analytics="bench-tab-plasmid" type="button">Map</button>
|
| 488 |
+
<button class="bench-tab" data-route="crispr" data-analytics="bench-tab-crispr" type="button">Guides</button>
|
| 489 |
+
<button class="bench-tab" data-route="primers" data-analytics="bench-tab-primers" type="button">Primers</button>
|
| 490 |
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
</div>
|
| 492 |
|
| 493 |
<div class="workspace">
|
|
|
|
| 583 |
<h2 id="structTitle">Predicted structure</h2>
|
| 584 |
<p class="card-sub" id="structSub">Turing loads the wild-type model as soon as you name a protein.</p>
|
| 585 |
</div>
|
| 586 |
+
<div class="struct-head-actions">
|
| 587 |
+
<a class="ghost" id="structEntryLink" target="_blank" rel="noopener" hidden>Open AlphaFold entry</a>
|
| 588 |
+
<button class="ghost" id="structFullBtn" type="button" aria-pressed="false">Fullscreen</button>
|
| 589 |
+
</div>
|
| 590 |
</header>
|
| 591 |
<div class="struct-viewer" id="structViewer">
|
| 592 |
<div class="struct-empty" id="structEmpty">
|
|
|
|
| 637 |
<h2>Provide a wild-type</h2>
|
| 638 |
<p class="card-sub">FASTA, SnapGene, GenBank, raw DNA, or raw protein. Auto-detected.</p>
|
| 639 |
</div>
|
| 640 |
+
<button class="ghost" type="button" id="deExampleBtn" data-analytics="de-example"
|
| 641 |
title="Loads GFP β a classic directed-evolution target">Try an example</button>
|
| 642 |
</header>
|
| 643 |
|
|
|
|
| 738 |
|
| 739 |
<section class="card" id="settingsCard">
|
| 740 |
<header class="card-header">
|
| 741 |
+
<div class="card-header-left">
|
| 742 |
+
<p class="card-kicker">§ 2 · Search</p>
|
| 743 |
+
<h2>Tune the parameters</h2>
|
| 744 |
+
<p class="card-sub">Sensible defaults β change if you know what you're after.</p>
|
| 745 |
+
</div>
|
| 746 |
</header>
|
| 747 |
|
| 748 |
<div class="setting-grid">
|
|
|
|
| 788 |
|
| 789 |
<section class="card run-card" id="runCard">
|
| 790 |
<header class="card-header">
|
| 791 |
+
<div class="card-header-left">
|
| 792 |
+
<p class="card-kicker">§ 3 · Generate</p>
|
| 793 |
+
<h2>Run the engine</h2>
|
| 794 |
+
<p class="card-sub">Scoring runs on our GPU. Your individual sequences are never shared, exposed, or reproduced; only anonymous, aggregated signals improve our models. <a href="https://turingdna.com/privacy/" target="_blank" rel="noopener">Privacy</a>.</p>
|
| 795 |
+
</div>
|
| 796 |
</header>
|
| 797 |
|
| 798 |
+
<button id="runBtn" type="button" class="primary primary-lg" data-analytics="de-run" disabled>
|
| 799 |
<span class="primary-icon" aria-hidden="true">
|
| 800 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 801 |
<polygon points="6 3 20 12 6 21 6 3" fill="currentColor"/>
|
|
|
|
| 854 |
<span class="caret">βΎ</span>
|
| 855 |
</button>
|
| 856 |
<div class="download-menu-items" id="downloadMenuItems" hidden>
|
| 857 |
+
<a class="download-item" data-format="csv" data-analytics="export-csv">CSV</a>
|
| 858 |
+
<a class="download-item" data-format="xlsx" data-analytics="export-xlsx">Excel</a>
|
| 859 |
+
<a class="download-item" data-format="fasta" data-analytics="export-fasta">FASTA · protein</a>
|
| 860 |
+
<a class="download-item" data-format="fasta-dna" data-analytics="export-fasta-dna">FASTA · DNA</a>
|
| 861 |
+
<a class="download-item" data-format="gb" data-analytics="export-gb">GenBank</a>
|
| 862 |
+
<a class="download-item" data-format="json" data-analytics="export-json">JSON</a>
|
| 863 |
+
<a class="download-item" data-format="tsv" data-analytics="export-tsv">TSV</a>
|
| 864 |
</div>
|
| 865 |
</div>
|
| 866 |
</div>
|
|
|
|
| 1833 |
<label class="design-opt">Overlap (bp)<input type="number" id="coOverlap" min="0" max="120" value="25" /></label>
|
| 1834 |
<label class="design-opt">Result
|
| 1835 |
<select id="coResult" class="design-opt-select">
|
| 1836 |
+
<option value="" selected>Select outcomeβ¦</option>
|
| 1837 |
<option value="1">Assembled</option>
|
| 1838 |
<option value="0.5">Partial</option>
|
| 1839 |
<option value="0">Failed</option>
|
|
|
|
| 2356 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2357 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2358 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2359 |
+
<script src="/static/app.js?v=20260727-unified" defer></script>
|
| 2360 |
<!-- THE COCKPIT β the persistent orchestrator rail. Loads after app.js so
|
| 2361 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 2362 |
render something. This is the only conversation surface in the app. -->
|
| 2363 |
+
<script src="/static/cockpit.js?v=20260727-unified" defer></script>
|
| 2364 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2365 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2366 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
| 2367 |
+
<!-- First-party UX-behaviour collector (dead/rage clicks, scroll depth,
|
| 2368 |
+
funnel steps, feature clicks, client errors). Structure-only, never
|
| 2369 |
+
reads sequence content; shares telemetry.js's persisted td_session_id
|
| 2370 |
+
and POSTs batches to /api/collect via sendBeacon. First-party /static
|
| 2371 |
+
file so it satisfies the strict script-src CSP. -->
|
| 2372 |
+
<script src="/static/collector.js?v=20260727-unified" defer></script>
|
| 2373 |
</body>
|
| 2374 |
</html>
|
|
@@ -35,7 +35,22 @@
|
|
| 35 |
});
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
var visibleMs = 0; // accumulated foreground time from FINISHED segments
|
| 40 |
var segStart = (document.visibilityState === 'visible') ? Date.now() : 0;
|
| 41 |
|
|
|
|
| 35 |
});
|
| 36 |
}
|
| 37 |
|
| 38 |
+
// ONE persisted session id, shared with collector.js via localStorage
|
| 39 |
+
// (key 'td_session_id') using identical logic in both files β whichever
|
| 40 |
+
// script runs first mints it, the other reads it. This is what joins the
|
| 41 |
+
// collector's client_events to THIS session's app_sessions row (which
|
| 42 |
+
// carries the user_id captured by the authenticated beats below). Falls
|
| 43 |
+
// back to a per-load in-memory id if localStorage is unavailable.
|
| 44 |
+
function persistedSessionId() {
|
| 45 |
+
try {
|
| 46 |
+
var k = 'td_session_id';
|
| 47 |
+
var v = localStorage.getItem(k);
|
| 48 |
+
if (!v) { v = uuid(); localStorage.setItem(k, v); }
|
| 49 |
+
return v;
|
| 50 |
+
} catch (e) { return uuid(); }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
var sessionId = persistedSessionId();
|
| 54 |
var visibleMs = 0; // accumulated foreground time from FINISHED segments
|
| 55 |
var segStart = (document.visibilityState === 'visible') ? Date.now() : 0;
|
| 56 |
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 0017_client_events.sql
|
| 2 |
+
-- First-party UX-behaviour telemetry: client_events.
|
| 3 |
+
--
|
| 4 |
+
-- Why: 0011_analytics.sql captures engagement (events) and dwell (app_sessions),
|
| 5 |
+
-- but nothing about HOW the UI is actually used β where clicks fall dead, where
|
| 6 |
+
-- users rage-click, how far a view is read, how they move between tools, and
|
| 7 |
+
-- what breaks in the browser. This table stores those client-side signals so
|
| 8 |
+
-- friction and funnels become queryable. Written by the Flask backend via the
|
| 9 |
+
-- service role (auth.log_client_events_async), fed by collector.js β /api/collect.
|
| 10 |
+
--
|
| 11 |
+
-- Privacy: mirrors public.events / public.runs β NO sequence content and NO
|
| 12 |
+
-- free-text values. The client sends only element STRUCTURE (an id, a
|
| 13 |
+
-- data-analytics label, or a tag[role]:nth-of-type path) in `selector`, bounded
|
| 14 |
+
-- numeric/label `meta`, and an opaque session id. Identity is NOT sent by the
|
| 15 |
+
-- client: the server stamps user_id (if signed in) or a salted /24-IP+UA
|
| 16 |
+
-- `anon_fingerprint` (never a raw IP). session_id joins public.app_sessions.
|
| 17 |
+
-- Retention is bounded by prune_old_client_events() (companion to 0011's
|
| 18 |
+
-- prune_old_analytics and 0010's prune_old_runs).
|
| 19 |
+
--
|
| 20 |
+
-- Run once in the Supabase SQL editor. Re-runnable: table/function use
|
| 21 |
+
-- IF NOT EXISTS / OR REPLACE, and policies are dropped before (re)create.
|
| 22 |
+
|
| 23 |
+
create extension if not exists "pgcrypto";
|
| 24 |
+
|
| 25 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
-- CLIENT_EVENTS β one row per client-side UX signal
|
| 27 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
-- kind β {dead_click, rage_click, scroll_depth, funnel_step, funnel_exit,
|
| 29 |
+
-- feature_click, client_error} (whitelisted server-side).
|
| 30 |
+
-- selector = SAFE structural descriptor only (never content). value_num carries
|
| 31 |
+
-- a scalar where it makes sense (scroll bucket 25/50/75/100, rage click count).
|
| 32 |
+
-- meta is a small bag of bounded primitives (coords, from/to route, error class).
|
| 33 |
+
create table if not exists public.client_events (
|
| 34 |
+
id uuid primary key default gen_random_uuid(),
|
| 35 |
+
session_id text,
|
| 36 |
+
user_id uuid references auth.users(id) on delete cascade,
|
| 37 |
+
anon_fingerprint text,
|
| 38 |
+
kind text not null,
|
| 39 |
+
route text,
|
| 40 |
+
selector text, -- element STRUCTURE only, never content
|
| 41 |
+
value_num numeric,
|
| 42 |
+
meta jsonb not null default '{}'::jsonb,
|
| 43 |
+
client_ts timestamptz,
|
| 44 |
+
created_at timestamptz not null default now()
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
comment on table public.client_events is
|
| 48 |
+
'First-party UX-behaviour signals (dead/rage clicks, scroll depth, funnel '
|
| 49 |
+
'steps, feature clicks, client errors). Structure-only β no sequence '
|
| 50 |
+
'content, no free-text values. session_id joins public.app_sessions.';
|
| 51 |
+
comment on column public.client_events.selector is
|
| 52 |
+
'Safe structural descriptor (id / data-analytics label / tag[role]:nth-of-type). '
|
| 53 |
+
'Never element value or textContent.';
|
| 54 |
+
|
| 55 |
+
create index if not exists client_events_kind_idx on public.client_events(kind, created_at desc);
|
| 56 |
+
create index if not exists client_events_session_idx on public.client_events(session_id);
|
| 57 |
+
create index if not exists client_events_route_idx on public.client_events(route, kind);
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
-- ROW-LEVEL SECURITY β deny by default; a user may read their OWN rows;
|
| 62 |
+
-- writes go through the service role. We ALSO add an explicit service-role
|
| 63 |
+
-- insert policy (belt-and-suspenders β service_role already bypasses RLS).
|
| 64 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 65 |
+
alter table public.client_events enable row level security;
|
| 66 |
+
|
| 67 |
+
drop policy if exists "client_events_select_own" on public.client_events;
|
| 68 |
+
drop policy if exists "client_events_insert_service" on public.client_events;
|
| 69 |
+
|
| 70 |
+
create policy "client_events_select_own"
|
| 71 |
+
on public.client_events for select using (auth.uid() = user_id);
|
| 72 |
+
create policy "client_events_insert_service"
|
| 73 |
+
on public.client_events for insert to service_role with check (true);
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
-- RETENTION SWEEP β companion to 0011's prune_old_analytics. Free tier has no
|
| 78 |
+
-- pg_cron, so this is a callable maintenance routine; safe to run repeatedly
|
| 79 |
+
-- and only ever deletes OLD rows.
|
| 80 |
+
--
|
| 81 |
+
-- Usage (run anytime in the SQL editor):
|
| 82 |
+
-- select public.prune_old_client_events(90); -- delete rows older than 90 days
|
| 83 |
+
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
create or replace function public.prune_old_client_events(retain_days integer default 90)
|
| 85 |
+
returns integer
|
| 86 |
+
language plpgsql
|
| 87 |
+
security definer
|
| 88 |
+
set search_path = public
|
| 89 |
+
as $$
|
| 90 |
+
declare
|
| 91 |
+
deleted integer;
|
| 92 |
+
begin
|
| 93 |
+
delete from public.client_events
|
| 94 |
+
where created_at < now() - make_interval(days => greatest(retain_days, 1));
|
| 95 |
+
get diagnostics deleted = row_count;
|
| 96 |
+
return deleted;
|
| 97 |
+
end;
|
| 98 |
+
$$;
|
| 99 |
+
|
| 100 |
+
revoke all on function public.prune_old_client_events(integer) from public;
|
| 101 |
+
revoke all on function public.prune_old_client_events(integer) from anon;
|
| 102 |
+
revoke all on function public.prune_old_client_events(integer) from authenticated;
|
| 103 |
+
|
| 104 |
+
comment on function public.prune_old_client_events(integer) is
|
| 105 |
+
'Maintenance: delete public.client_events rows older than retain_days '
|
| 106 |
+
'(default 90). Run manually or via pg_cron. Added in 0017_client_events.sql.';
|