github-actions[bot] commited on
Commit
415fa07
Β·
1 Parent(s): ba81ab3

Deploy 8279a26

Browse files

A named cut site answers a click

Source: https://github.com/WINTER4000/turingDNA/commit/8279a26cf5026404a3528af335d9946bfa2187fd

dee/static/app.css CHANGED
@@ -8345,6 +8345,21 @@ body[data-pe-full="1"] .pe-fullbar { display: flex; }
8345
  .plasmid-map-wrap[data-panning="1"] { user-select: none; }
8346
  .pm-feat:hover, .pm-feat-rect:hover { filter: brightness(1.25); }
8347
  .pm-feat-lbl:hover { text-decoration: underline; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8348
 
8349
  @media (max-width: 900px) {
8350
  /* Stack, and let each pane keep its own scroll. Side-by-side below ~900px
 
8345
  .plasmid-map-wrap[data-panning="1"] { user-select: none; }
8346
  .pm-feat:hover, .pm-feat-rect:hover { filter: brightness(1.25); }
8347
  .pm-feat-lbl:hover { text-decoration: underline; }
8348
+ /* Cut marks answer a click (showCutSite), so they have to say so. The tick is
8349
+ 1px wide β€” far too thin to aim at β€” and `stroke-width` would make it look
8350
+ heavier, so the hit area is widened invisibly instead. */
8351
+ .pm-cut, .pm-cut-lbl { cursor: pointer; }
8352
+ .pm-cut { stroke-linecap: round; }
8353
+ .pm-cut:hover, .pm-cut-lbl:hover { stroke: var(--ink); color: var(--ink); }
8354
+ .pm-cut-lbl:hover { text-decoration: underline; }
8355
+ .pm-cut { pointer-events: stroke; stroke-width: 1; }
8356
+ .plasmid-map-wrap .pm-cut { paint-order: stroke; }
8357
+ .seqcut { cursor: pointer; }
8358
+ .seqcut:hover b { color: var(--ink); text-decoration: underline; }
8359
+ .seqcut:hover .seqcut-tick { background: var(--ink); }
8360
+ /* The positions printed under the pane are jumps to each remaining site. */
8361
+ .seq-cut-jump { color: var(--brand); text-decoration: none; cursor: pointer; }
8362
+ .seq-cut-jump:hover { text-decoration: underline; }
8363
 
8364
  @media (max-width: 900px) {
8365
  /* Stack, and let each pane keep its own scroll. Side-by-side below ~900px
dee/static/app.js CHANGED
@@ -8734,7 +8734,7 @@ function runOracle(opts){
8734
  // Tick marks first, under every label.
8735
  cutInfo.shown.forEach((c) => {
8736
  const [x1, y1] = ptAt(c.pos, len, R - 12, cx, cy), [x2, y2] = ptAt(c.pos, len, R + 20, cx, cy);
8737
- svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-cut"/>`;
8738
  });
8739
  items.forEach((p) => {
8740
  const anchor = p.right ? 'start' : 'end';
@@ -8748,6 +8748,7 @@ function runOracle(opts){
8748
  + `dominant-baseline="middle">${esc(p.f.name)}</text>`;
8749
  } else {
8750
  svg += `<text x="${tx.toFixed(1)}" y="${p.ly.toFixed(1)}" class="pm-cut-lbl" `
 
8751
  + `text-anchor="${anchor}" dominant-baseline="middle">${esc(p.c.name)}`
8752
  + `<title>${esc(p.c.name)} Β· cuts at ${p.c.pos.toLocaleString()}`
8753
  + ` Β· ${p.c.cuts === 1 ? 'unique cutter' : p.c.cuts + ' sites'}</title></text>`;
@@ -9009,12 +9010,51 @@ function runOracle(opts){
9009
  window.addEventListener('blur', end);
9010
  }
9011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9012
  function wireFeatureClicks() {
9013
  wireFeatureDrag();
9014
  wireMapPan();
9015
  if (mapWrap.dataset.featWired === '1') return;
9016
  mapWrap.dataset.featWired = '1';
9017
  mapWrap.addEventListener('click', (e) => {
 
 
 
 
 
 
 
9018
  const el = e.target.closest && e.target.closest('[data-feat]');
9019
  if (!el || !plasmid) return;
9020
  // By INDEX when the element carries one. Resolving by name picked
@@ -9036,11 +9076,18 @@ function runOracle(opts){
9036
  /* Clicking bare backbone drops a caret at that coordinate. This lived
9037
  on the deleted companion map; without it the real map would have
9038
  become click-to-select-features only, which is a capability LOSS
9039
- dressed up as a rewrite. Feature clicks are handled above and stop
9040
- here, so the two never fight over one click. */
 
 
 
 
 
 
9041
  mapWrap.addEventListener('click', (e) => {
9042
  if (!plasmid) return;
9043
  if (e.target.closest && e.target.closest('[data-feat]')) return;
 
9044
  const svg = mapWrap.querySelector('svg');
9045
  if (!svg || !svg.contains(e.target)) return;
9046
  const r = svg.getBoundingClientRect();
@@ -9160,12 +9207,12 @@ function runOracle(opts){
9160
  let lastCutX = -1e9;
9161
  linCuts.shown.slice().sort((a, b) => a.pos - b.pos).forEach((c) => {
9162
  const xx = x(c.pos);
9163
- svg += `<line x1="${xx.toFixed(1)}" y1="20" x2="${xx.toFixed(1)}" y2="${trackY + 30}" class="pm-cut"/>`;
9164
  // One baseline, so two names closer than ~46px would overlap.
9165
  // Alternate rows rather than drop the second one.
9166
  const row = (xx - lastCutX) < 46 ? 1 : 0;
9167
  if (row === 0) lastCutX = xx;
9168
- svg += `<text x="${xx.toFixed(1)}" y="${row ? 6 : 15}" class="pm-cut-lbl" text-anchor="middle">${esc(c.name)}<title>${esc(c.name)} Β· cuts at ${c.pos.toLocaleString()} Β· ${c.cuts === 1 ? 'unique cutter' : c.cuts + ' sites'}</title></text>`;
9169
  });
9170
  svg += `</svg>`;
9171
  mapWrap.innerHTML = `<div class="plasmid-linear-scroll">${svg}</div>`;
@@ -10514,6 +10561,19 @@ function runOracle(opts){
10514
  else showSeqOut('Clipboard not available here.');
10515
  }
10516
  function showSeqOut(html) { const o = $('plasmidSeqOutput'); if (o) { o.hidden = false; o.innerHTML = html; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
10517
 
10518
  function _featColorAt(i, feats) { for (const f of feats) if (i >= f.start && i < f.end) return f.color || '#9AA0AA'; return ''; }
10519
  function _inFind(i) { for (const h of findHits) if (i >= h && i < h + findLen) return true; return false; }
@@ -10586,7 +10646,9 @@ function runOracle(opts){
10586
  out += '<div class="seqed-line seqed-cut"><span class="seqed-pos"></span>'
10587
  + `<span class="seqed-bases" style="--w:${w}">`;
10588
  for (const c of inLine) {
10589
- out += `<span class="seqcut" style="left:${c.pos - ls}ch">`
 
 
10590
  + `<i class="seqcut-tick"></i><b>${esc(c.name)}</b></span>`;
10591
  }
10592
  out += '</span></div>';
@@ -10816,6 +10878,14 @@ function runOracle(opts){
10816
  // repaint and a full render is the only way to show the selection.
10817
  if (seqSpans) paintSel(from - 1, to);
10818
  else renderSequence();
 
 
 
 
 
 
 
 
10819
  updateSelArc();
10820
  renderStatus();
10821
  announceSelection();
@@ -10993,7 +11063,17 @@ function runOracle(opts){
10993
  else { const p = caret == null ? len : caret; caret = p + ins.length; replaceRange(p, p, ins, true); }
10994
  sel = null; selAnchor = null; caretVisible(); focusEditor();
10995
  }
 
 
 
 
 
 
 
 
 
10996
  seqWrap.addEventListener('mousedown', (e) => {
 
10997
  if (!isEditable()) return;
10998
  const i = idxFromEvent(e); if (i == null) return;
10999
  e.preventDefault();
 
8734
  // Tick marks first, under every label.
8735
  cutInfo.shown.forEach((c) => {
8736
  const [x1, y1] = ptAt(c.pos, len, R - 12, cx, cy), [x2, y2] = ptAt(c.pos, len, R + 20, cx, cy);
8737
+ svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-cut" data-cut="${c.pos}" data-enz="${esc(c.name)}"/>`;
8738
  });
8739
  items.forEach((p) => {
8740
  const anchor = p.right ? 'start' : 'end';
 
8748
  + `dominant-baseline="middle">${esc(p.f.name)}</text>`;
8749
  } else {
8750
  svg += `<text x="${tx.toFixed(1)}" y="${p.ly.toFixed(1)}" class="pm-cut-lbl" `
8751
+ + `data-cut="${p.c.pos}" data-enz="${esc(p.c.name)}" `
8752
  + `text-anchor="${anchor}" dominant-baseline="middle">${esc(p.c.name)}`
8753
  + `<title>${esc(p.c.name)} Β· cuts at ${p.c.pos.toLocaleString()}`
8754
  + ` Β· ${p.c.cuts === 1 ? 'unique cutter' : p.c.cuts + ' sites'}</title></text>`;
 
9010
  window.addEventListener('blur', end);
9011
  }
9012
 
9013
+ /* A named cut site is a thing you can ACT on, so it answers a click.
9014
+
9015
+ It was decoration: the ring and the sequence both drew the tick and the
9016
+ enzyme's name, and neither did anything when you pressed it β€” you read
9017
+ "BsrGI" off the map and then went hunting for the coordinate by hand.
9018
+
9019
+ Clicking one puts the caret on that exact cut and scrolls the sequence
9020
+ to it, then lists EVERY site that enzyme has. That second part is the
9021
+ point: an enzyme cutting five times is a different decision from one
9022
+ cutting once, and the map only ever draws a subset (see cutSitesForMap).
9023
+ The positions come from the analysis, never from the drawing, so the
9024
+ list is complete even when the ring is showing nine of a hundred. */
9025
+ function showCutSite(pos, enz) {
9026
+ if (!plasmid || !restriction) return;
9027
+ const p = parseInt(pos, 10);
9028
+ if (!Number.isInteger(p) || p < 1 || p > plasmid.length) return;
9029
+ setSel(p, p); // the cut itself β€” 1 bp, a caret on it
9030
+ scrollSelIntoView();
9031
+ const e = (restriction.enzymes || []).find((x) => x.name === enz);
9032
+ const sites = (e && e.sites) || [];
9033
+ if (!sites.length) { showSeqOut(`<b>${esc(String(enz))}</b> cuts at ${p.toLocaleString()}.`); return; }
9034
+ const idx = sites.indexOf(p);
9035
+ const list = sites.map((s) => (s === p
9036
+ ? `<b>${s.toLocaleString()}</b>`
9037
+ : `<a href="#" class="seq-cut-jump" data-cut="${s}" data-enz="${esc(String(enz))}">${s.toLocaleString()}</a>`
9038
+ )).join(', ');
9039
+ showSeqOut(`<b>${esc(String(enz))}</b> β€” ${sites.length} site${sites.length === 1 ? '' : 's'}`
9040
+ + (idx >= 0 ? ` (this is #${idx + 1})` : '')
9041
+ + (e.overhang ? ` Β· ${esc(e.overhang)}` : '')
9042
+ + `<br>cuts at ${list}`);
9043
+ }
9044
+
9045
  function wireFeatureClicks() {
9046
  wireFeatureDrag();
9047
  wireMapPan();
9048
  if (mapWrap.dataset.featWired === '1') return;
9049
  mapWrap.dataset.featWired = '1';
9050
  mapWrap.addEventListener('click', (e) => {
9051
+ // A cut mark sits ON a feature arc, so it has to be checked first
9052
+ // or the feature underneath swallows every click.
9053
+ const cut = e.target.closest && e.target.closest('[data-cut]');
9054
+ if (cut) {
9055
+ showCutSite(cut.getAttribute('data-cut'), cut.getAttribute('data-enz'));
9056
+ return;
9057
+ }
9058
  const el = e.target.closest && e.target.closest('[data-feat]');
9059
  if (!el || !plasmid) return;
9060
  // By INDEX when the element carries one. Resolving by name picked
 
9076
  /* Clicking bare backbone drops a caret at that coordinate. This lived
9077
  on the deleted companion map; without it the real map would have
9078
  become click-to-select-features only, which is a capability LOSS
9079
+ dressed up as a rewrite. Feature AND cut-site clicks are handled
9080
+ above and stop here, so they never fight over one click.
9081
+ Both exclusions are load-bearing: this is a SEPARATE listener, so
9082
+ returning early in the handler above does not stop it. A cut site
9083
+ sits on a leader line pointing away from the ring, so without the
9084
+ `[data-cut]` guard a click on the enzyme's name resolved to the
9085
+ angle of the LABEL rather than the cut, and the caret landed
9086
+ somewhere else entirely. */
9087
  mapWrap.addEventListener('click', (e) => {
9088
  if (!plasmid) return;
9089
  if (e.target.closest && e.target.closest('[data-feat]')) return;
9090
+ if (e.target.closest && e.target.closest('[data-cut]')) return;
9091
  const svg = mapWrap.querySelector('svg');
9092
  if (!svg || !svg.contains(e.target)) return;
9093
  const r = svg.getBoundingClientRect();
 
9207
  let lastCutX = -1e9;
9208
  linCuts.shown.slice().sort((a, b) => a.pos - b.pos).forEach((c) => {
9209
  const xx = x(c.pos);
9210
+ svg += `<line x1="${xx.toFixed(1)}" y1="20" x2="${xx.toFixed(1)}" y2="${trackY + 30}" class="pm-cut" data-cut="${c.pos}" data-enz="${esc(c.name)}"/>`;
9211
  // One baseline, so two names closer than ~46px would overlap.
9212
  // Alternate rows rather than drop the second one.
9213
  const row = (xx - lastCutX) < 46 ? 1 : 0;
9214
  if (row === 0) lastCutX = xx;
9215
+ svg += `<text x="${xx.toFixed(1)}" y="${row ? 6 : 15}" class="pm-cut-lbl" data-cut="${c.pos}" data-enz="${esc(c.name)}" text-anchor="middle">${esc(c.name)}<title>${esc(c.name)} Β· cuts at ${c.pos.toLocaleString()} Β· ${c.cuts === 1 ? 'unique cutter' : c.cuts + ' sites'}</title></text>`;
9216
  });
9217
  svg += `</svg>`;
9218
  mapWrap.innerHTML = `<div class="plasmid-linear-scroll">${svg}</div>`;
 
10561
  else showSeqOut('Clipboard not available here.');
10562
  }
10563
  function showSeqOut(html) { const o = $('plasmidSeqOutput'); if (o) { o.hidden = false; o.innerHTML = html; } }
10564
+ /* Every position showCutSite prints is itself a jump β€” bound once, here,
10565
+ rather than re-bound on each render. */
10566
+ (function wireCutJumps() {
10567
+ const o = $('plasmidSeqOutput');
10568
+ if (!o || o.dataset.cutJumpWired === '1') return;
10569
+ o.dataset.cutJumpWired = '1';
10570
+ o.addEventListener('click', (e) => {
10571
+ const a = e.target.closest && e.target.closest('.seq-cut-jump');
10572
+ if (!a) return;
10573
+ e.preventDefault();
10574
+ showCutSite(a.getAttribute('data-cut'), a.getAttribute('data-enz'));
10575
+ });
10576
+ }());
10577
 
10578
  function _featColorAt(i, feats) { for (const f of feats) if (i >= f.start && i < f.end) return f.color || '#9AA0AA'; return ''; }
10579
  function _inFind(i) { for (const h of findHits) if (i >= h && i < h + findLen) return true; return false; }
 
10646
  out += '<div class="seqed-line seqed-cut"><span class="seqed-pos"></span>'
10647
  + `<span class="seqed-bases" style="--w:${w}">`;
10648
  for (const c of inLine) {
10649
+ out += `<span class="seqcut" style="left:${c.pos - ls}ch" `
10650
+ + `data-cut="${c.pos}" data-enz="${esc(c.name)}" `
10651
+ + `title="${esc(c.name)} cuts at ${c.pos.toLocaleString()} β€” click for every site">`
10652
  + `<i class="seqcut-tick"></i><b>${esc(c.name)}</b></span>`;
10653
  }
10654
  out += '</span></div>';
 
10878
  // repaint and a full render is the only way to show the selection.
10879
  if (seqSpans) paintSel(from - 1, to);
10880
  else renderSequence();
10881
+ // The From/To/length readout is part of the selection, not part of the
10882
+ // render. It used to come along for free because setSel called
10883
+ // renderSequence, which syncs it on the way past β€” so routing the fast
10884
+ // path through paintSel left the boxes showing whatever was there
10885
+ // before, on EVERY map click, feature or cut site. Cheap and
10886
+ // idempotent, so it runs on both branches rather than only the fast
10887
+ // one.
10888
+ syncSelInputs();
10889
  updateSelArc();
10890
  renderStatus();
10891
  announceSelection();
 
11063
  else { const p = caret == null ? len : caret; caret = p + ins.length; replaceRange(p, p, ins, true); }
11064
  sel = null; selAnchor = null; caretVisible(); focusEditor();
11065
  }
11066
+ /* A cut mark is a control, not a base. Without this the editor's
11067
+ drag-select starts on mousedown, calls preventDefault, and the click on
11068
+ the enzyme's name never arrives. */
11069
+ seqWrap.addEventListener('click', (e) => {
11070
+ const c = e.target.closest && e.target.closest('[data-cut]');
11071
+ if (!c) return;
11072
+ e.preventDefault();
11073
+ showCutSite(c.getAttribute('data-cut'), c.getAttribute('data-enz'));
11074
+ });
11075
  seqWrap.addEventListener('mousedown', (e) => {
11076
+ if (e.target.closest && e.target.closest('[data-cut]')) return;
11077
  if (!isEditable()) return;
11078
  const i = idxFromEvent(e); if (i == null) return;
11079
  e.preventDefault();
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=20260824-cuts3" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
@@ -3132,9 +3132,9 @@
3132
  <!-- Cloning reference data must load before app.js so the Designer
3133
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
3134
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
3135
- <script src="/static/context.js?v=20260824-cuts3" defer></script>
3136
- <script src="/static/lineage.js?v=20260824-cuts3" defer></script>
3137
- <script src="/static/app.js?v=20260824-cuts3" defer></script>
3138
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
3139
  on the very first event, and both are `defer`, so document order is
3140
  load order. Loading it after would drop the opening events of a
@@ -3143,7 +3143,7 @@
3143
  <!-- THE COCKPIT β€” the persistent orchestrator rail. Loads after app.js so
3144
  TDBench/TDStructure exist when a tool result asks the workspace to
3145
  render something. This is the only conversation surface in the app. -->
3146
- <script src="/static/cockpit.js?v=20260824-cuts3" defer></script>
3147
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
3148
  soon as it paints. Both are defer, so document order is load order. -->
3149
  <script src="/static/structcard.js?v=20260811-buildC" defer></script>
 
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=20260824-cutclick3" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
 
3132
  <!-- Cloning reference data must load before app.js so the Designer
3133
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
3134
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
3135
+ <script src="/static/context.js?v=20260824-cutclick3" defer></script>
3136
+ <script src="/static/lineage.js?v=20260824-cutclick3" defer></script>
3137
+ <script src="/static/app.js?v=20260824-cutclick3" defer></script>
3138
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
3139
  on the very first event, and both are `defer`, so document order is
3140
  load order. Loading it after would drop the opening events of a
 
3143
  <!-- THE COCKPIT β€” the persistent orchestrator rail. Loads after app.js so
3144
  TDBench/TDStructure exist when a tool result asks the workspace to
3145
  render something. This is the only conversation surface in the app. -->
3146
+ <script src="/static/cockpit.js?v=20260824-cutclick3" defer></script>
3147
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
3148
  soon as it paints. Both are defer, so document order is load order. -->
3149
  <script src="/static/structcard.js?v=20260811-buildC" defer></script>
tests/test_plasmid_editor_ui.py CHANGED
@@ -546,3 +546,64 @@ def test_a_cut_row_is_tall_enough_to_paint_its_own_enzyme_name():
546
  ".seqed-cut .seqed-bases must be vertical-align: bottom, or the label "
547
  "floats up by however much descender space the font happens to have"
548
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  ".seqed-cut .seqed-bases must be vertical-align: bottom, or the label "
547
  "floats up by however much descender space the font happens to have"
548
  )
549
+
550
+
551
+ # --- clicking a named cut site -------------------------------------------
552
+ def test_every_drawn_cut_site_carries_the_data_a_click_needs():
553
+ """A named cut site answers a click (showCutSite), so every place one is
554
+ DRAWN has to tag it with the position and the enzyme β€” the circular tick
555
+ and label, the linear tick and label, and the sequence pane. Miss one and
556
+ that surface silently goes back to being decoration.
557
+
558
+ `_code` matters here: the comments around this feature say "data-cut"
559
+ repeatedly, and a guard that greps its own explanation passes for free.
560
+ """
561
+ js = _code(_read(_APP_JS))
562
+ emitters = re.findall(r'data-cut="\$\{[^"]+\}"', js)
563
+ assert len(emitters) >= 5, (
564
+ f"only {len(emitters)} render path(s) tag a cut site; expected the "
565
+ f"circular tick + label, the linear tick + label, and the sequence pane"
566
+ )
567
+ # position AND enzyme β€” the site list is per-enzyme, so a bare position
568
+ # cannot answer "what else does this cut?"
569
+ assert js.count("data-enz=") >= 5, "cut marks must name their enzyme too"
570
+ assert "function showCutSite" in js, "the click has no handler"
571
+
572
+
573
+ def test_the_backbone_caret_does_not_swallow_a_cut_site_click():
574
+ """The map has TWO click listeners: one selects features and cut sites, a
575
+ second drops a caret wherever you click bare backbone. They are separate
576
+ listeners, so returning early in the first does not stop the second.
577
+
578
+ Without a `[data-cut]` guard in the second, clicking an enzyme's name
579
+ resolved to the angle of the LABEL β€” which sits out on a leader line β€” and
580
+ the caret landed somewhere else entirely, overwriting the correct
581
+ selection a few microseconds after it was made. Measured before the fix:
582
+ clicking BsaI at 794 selected 1153.
583
+ """
584
+ js = _code(_read(_APP_JS))
585
+ backbone = re.search(
586
+ r"mapWrap\.addEventListener\('click',\s*\(e\)\s*=>\s*\{\s*"
587
+ r"if \(!plasmid\) return;(.*?)\n\s*const svg", js, re.S)
588
+ assert backbone, "the bare-backbone caret handler moved or was renamed"
589
+ guards = backbone.group(1)
590
+ assert "data-feat" in guards, "backbone handler lost its feature guard"
591
+ assert "data-cut" in guards, (
592
+ "backbone handler must skip cut marks, or it overwrites the selection "
593
+ "showCutSite just made"
594
+ )
595
+
596
+
597
+ def test_setting_a_selection_updates_the_from_to_readout():
598
+ """`setSel` paints the selection directly (paintSel) instead of rebuilding
599
+ the pane, which is what made map clicks fast. But the From/To/length boxes
600
+ were synced by renderSequence on the way past, so the fast path left them
601
+ showing the PREVIOUS selection β€” on every map click, feature or cut site.
602
+ """
603
+ js = _code(_read(_APP_JS))
604
+ body = re.search(r"function setSel\(from, to\)\s*\{(.*?)\n \}", js, re.S)
605
+ assert body, "setSel moved or was renamed"
606
+ assert "syncSelInputs()" in body.group(1), (
607
+ "setSel must sync the From/To readout β€” paintSel does not, and "
608
+ "renderSequence is no longer on the common path"
609
+ )