Tengo Gzirishvili Claude commited on
Commit
c0cef42
Β·
1 Parent(s): c7ee003

Deploy: merged engine (Sam's UX overhaul + Turing driving the panels)

Browse files

Squashed 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 CHANGED
@@ -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
dee/server.py CHANGED
@@ -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
dee/static/app.css CHANGED
@@ -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: #FBFAF6; /* warm paper card */
50
- --gray-1: #F7F5F0; /* warm paper canvas */
51
  --gray-2: #EFECE5; /* paper soft */
52
- --gray-3: #E5E2DC; /* hairline-soft */
53
- --gray-4: #D6D3D1; /* hairline */
54
  --gray-5: #B8B4AE; /* hairline-strong */
55
- --gray-6: #8A857E; /* ink faint */
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: var(--gray-5);
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
- .alphafold-viewer .msp-plugin { background: #0E141B !important; }
 
 
 
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: #FFF8EC;
3557
- border-bottom: 1px solid #E8D8AC;
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 spine */
6794
- .mc-cmd { position: relative; display: flex; align-items: center; gap: 12px; border: 1px solid var(--ink-strong);
6795
- padding: 12px 12px 12px 18px; background: var(--bg-card); }
6796
- .mc-cmd-i { width: 16px; height: 16px; color: var(--ink-faint); flex: none; }
6797
- .mc-cmd input { flex: 1; border: 0; background: none; font-family: var(--font-ui); font-size: 15px;
6798
- color: var(--ink); outline: none; }
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--structure { padding: 28px 32px 40px; overflow: auto; }
7038
- .struct { max-width: 1080px; margin: 0 auto; }
7039
- .struct-head { display: flex; align-items: flex-end; justify-content: space-between;
 
 
 
 
 
 
 
7040
  gap: 20px; margin-bottom: 18px; }
7041
  .struct-head h2 { font-weight: 500; letter-spacing: -0.012em; margin: 2px 0 0; }
7042
- .struct-viewer { position: relative; width: 100%; height: min(62vh, 560px);
 
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: flex; align-items: center; gap: 22px; padding: 0 22px;
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: transparent; border-bottom: 1px solid var(--line);
7196
- box-shadow: none; font-size: 11px; color: var(--ink-faint);
7197
- opacity: .72; transition: opacity var(--t-fast);
7198
  }
7199
  body[data-ruo="accepted"] .research-strip:hover { opacity: 1; }
7200
- body[data-ruo="accepted"] .research-strip-dot { color: var(--ink-faint); }
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: 8px;
7473
- padding: 10px 12px 12px;
7474
- border-top: 1px solid var(--line);
7475
- background: var(--bg-card);
7476
  }
 
7477
  .cp-compose textarea {
7478
- flex: 1; resize: none; border: 1px solid var(--line-strong); border-radius: 8px;
7479
- padding: 9px 11px; font: inherit; font-size: 13px; line-height: 1.45;
7480
- background: var(--bg-app); color: var(--ink); max-height: 160px;
7481
  font-family: inherit;
7482
  }
7483
  .cp-compose textarea:focus {
7484
- outline: none; border-color: var(--ink-soft);
7485
- box-shadow: 0 0 0 3px var(--brand-ring);
7486
  }
7487
  .cp-send {
7488
- flex: 0 0 auto; width: 34px; height: 34px; border-radius: 8px; cursor: pointer;
7489
- border: 1px solid var(--ink-strong); background: var(--ink-strong);
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; }
dee/static/app.js CHANGED
@@ -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
- const viewer = await molstar.Viewer.create(host, _AF_VIEWER_OPTS);
 
 
 
 
 
 
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($('coResult').value), outcome: 'measured' }] };
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
- try { mountAlphaFoldViewer({ alphafold_url: url }, host); } catch (_) {}
 
 
 
 
 
 
 
 
 
 
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
- window.TDStructure = { show: show, open: open };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
dee/static/cockpit.js CHANGED
@@ -368,8 +368,6 @@
368
  'aria-expanded="false" aria-label="Past runs" title="Past runs">&#9662;</button>' +
369
  '<button type="button" class="cp-icon" id="cpExport" ' +
370
  'aria-label="Export this run" title="Export run as Markdown">&#8595;</button>' +
371
- '<button type="button" class="cp-min" id="cpMin" aria-label="Collapse Turing" ' +
372
- 'aria-expanded="true" title="Collapse">&minus;</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 (state.els) {
1187
- state.els.min.innerHTML = min ? "&minus;" : "&plus;";
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
- if (narrow && state.els) {
1552
- state.els.min.innerHTML = "&plus;";
1553
- state.els.min.setAttribute("aria-expanded", "false");
1554
- }
1555
  restore();
1556
  }
1557
  if (document.readyState !== "loading") boot();
 
368
  'aria-expanded="false" aria-label="Past runs" title="Past runs">&#9662;</button>' +
369
  '<button type="button" class="cp-icon" id="cpExport" ' +
370
  'aria-label="Export this run" title="Export run as Markdown">&#8595;</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();
dee/static/collector.js ADDED
@@ -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
+ })();
dee/static/index.html CHANGED
@@ -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-agentpaint2" />
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 &amp; clone your construct</span>
419
  </div>
420
- <!-- Light / dark theme toggle. State lives on <html data-theme>,
421
- persisted to localStorage by app.js. Sun shows in dark mode,
422
- moon in light mode (CSS-swapped by the data-theme attr). -->
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 &rarr;</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
- <a class="ghost" id="structEntryLink" target="_blank" rel="noopener" hidden>Open AlphaFold entry</a>
 
 
 
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
- <p class="card-kicker">&sect; 2 &middot; Search</p>
746
- <h2>Tune the parameters</h2>
747
- <p class="card-sub">Sensible defaults β€” change if you know what you're after.</p>
 
 
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
- <p class="card-kicker">&sect; 3 &middot; Generate</p>
794
- <h2>Run the engine</h2>
795
- <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>
 
 
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 &middot; protein</a>
860
- <a class="download-item" data-format="fasta-dna">FASTA &middot; 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-agentpaint2" defer></script>
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-agentpaint2" defer></script>
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 &amp; 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">&sect; 2 &middot; 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">&sect; 3 &middot; 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 &middot; protein</a>
860
+ <a class="download-item" data-format="fasta-dna" data-analytics="export-fasta-dna">FASTA &middot; 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>
dee/static/telemetry.js CHANGED
@@ -35,7 +35,22 @@
35
  });
36
  }
37
 
38
- var sessionId = uuid();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
supabase/migrations/0017_client_events.sql ADDED
@@ -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.';