WINTER4000 commited on
Commit
4e240b0
·
verified ·
1 Parent(s): b1e8353

Cloning customizer + mobile-responsive DEE app

Browse files
Files changed (2) hide show
  1. dee/static/app.css +102 -0
  2. dee/static/app.js +254 -5
dee/static/app.css CHANGED
@@ -1621,6 +1621,108 @@ input:focus, textarea:focus, select:focus {
1621
  .copy-btn:hover { border-color: var(--brand); color: var(--brand-deep); }
1622
  .copy-btn.copied { background: var(--brand-50); border-color: var(--brand); color: var(--brand-deep); }
1623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1624
  /* ====================================== SYNTH ROW (per-row vendor buttons) */
1625
 
1626
  .synth-row {
 
1621
  .copy-btn:hover { border-color: var(--brand); color: var(--brand-deep); }
1622
  .copy-btn.copied { background: var(--brand-50); border-color: var(--brand); color: var(--brand-deep); }
1623
 
1624
+ /* =================================================== CLONING CUSTOMIZER */
1625
+
1626
+ .cloning-section { gap: 14px; }
1627
+ .cloning-hint {
1628
+ font-size: 12px;
1629
+ color: var(--ink-soft);
1630
+ line-height: 1.55;
1631
+ margin: 4px 0 8px;
1632
+ max-width: 64ch;
1633
+ }
1634
+
1635
+ .cloning-controls { display: flex; flex-wrap: wrap; gap: 12px; }
1636
+ .cloning-field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 240px; }
1637
+ .cloning-field-label {
1638
+ font-size: 11px;
1639
+ text-transform: uppercase;
1640
+ letter-spacing: 0.08em;
1641
+ color: var(--ink-faint);
1642
+ font-weight: 600;
1643
+ }
1644
+ .cloning-field select { width: 100%; }
1645
+
1646
+ .cloning-preview {
1647
+ margin-top: 4px;
1648
+ border: 1px solid var(--line);
1649
+ border-radius: var(--r-2);
1650
+ background: var(--gray-0);
1651
+ overflow: hidden;
1652
+ }
1653
+ .cloning-preview:empty { display: none; }
1654
+
1655
+ .cloning-meta {
1656
+ display: flex;
1657
+ flex-wrap: wrap;
1658
+ gap: 14px;
1659
+ align-items: center;
1660
+ padding: 10px 14px;
1661
+ background: var(--gray-1);
1662
+ border-bottom: 1px solid var(--line);
1663
+ font-size: 12px;
1664
+ color: var(--ink-soft);
1665
+ font-variant-numeric: tabular-nums;
1666
+ }
1667
+ .cloning-meta strong { color: var(--ink-strong); font-weight: 700; margin-right: 2px; }
1668
+ .cloning-legend {
1669
+ display: inline-flex;
1670
+ align-items: center;
1671
+ gap: 10px;
1672
+ margin-left: auto;
1673
+ font-size: 11px;
1674
+ color: var(--ink-faint);
1675
+ }
1676
+ .legend-swatch {
1677
+ display: inline-block;
1678
+ width: 10px;
1679
+ height: 10px;
1680
+ border-radius: 2px;
1681
+ margin-right: 3px;
1682
+ vertical-align: middle;
1683
+ }
1684
+ .legend-flank { background: #FEF3C7; border: 1px solid #FDE68A; }
1685
+ .legend-cds { background: var(--brand-50); border: 1px solid var(--brand-100); }
1686
+
1687
+ .cloning-preview-block {
1688
+ font-family: var(--font-mono);
1689
+ font-size: 12px;
1690
+ color: var(--ink-strong);
1691
+ background: var(--gray-0);
1692
+ padding: 14px 16px;
1693
+ line-height: 1.65;
1694
+ white-space: pre;
1695
+ overflow: auto;
1696
+ max-height: 240px;
1697
+ letter-spacing: 0.04em;
1698
+ }
1699
+ .cloning-preview-block .sec-flank {
1700
+ background: #FEF3C7;
1701
+ color: #92400E;
1702
+ border-radius: 1px;
1703
+ }
1704
+ .cloning-preview-block .sec-cds {
1705
+ background: var(--brand-50);
1706
+ color: var(--brand-deep);
1707
+ border-radius: 1px;
1708
+ }
1709
+
1710
+ .cloning-notes, .cloning-sublabel {
1711
+ padding: 0 14px 12px;
1712
+ font-size: 12px;
1713
+ color: var(--ink-soft);
1714
+ line-height: 1.6;
1715
+ }
1716
+ .cloning-notes strong { color: var(--ink-strong); font-weight: 700; }
1717
+ .cloning-sublabel { color: var(--ink-faint); }
1718
+
1719
+ @media (max-width: 959px) {
1720
+ .cloning-meta { padding: 9px 12px; font-size: 11px; gap: 10px; }
1721
+ .cloning-legend { width: 100%; margin-left: 0; }
1722
+ .cloning-preview-block { font-size: 11px; padding: 12px 12px; }
1723
+ .cloning-notes, .cloning-sublabel { padding: 0 12px 12px; font-size: 11px; }
1724
+ }
1725
+
1726
  /* ====================================== SYNTH ROW (per-row vendor buttons) */
1727
 
1728
  .synth-row {
dee/static/app.js CHANGED
@@ -908,6 +908,7 @@ function toggleDetailRow(idx, headRow, detailRow, v) {
908
  if (detailRow.dataset.populated !== '1') {
909
  detailRow.querySelector('td').innerHTML = renderDetailPanel(v);
910
  wireCopyButtons(detailRow);
 
911
  detailRow.dataset.populated = '1';
912
  }
913
  detailRow.hidden = false;
@@ -940,6 +941,194 @@ const VENDORS = {
940
  },
941
  };
942
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
943
  async function synthesizeDna(dnaSeq, variantId, vendorKey) {
944
  // Twist gets the in-app gateway flow: live quote + order placement
945
  // against http://127.0.0.1:8765. GenScript + IDT remain external
@@ -1976,11 +2165,38 @@ function renderDetailPanel(v) {
1976
  <div class="detail-section">
1977
  <h4>Optimized DNA sequence (${v.Length_bp} bp) · mutated codons highlighted</h4>
1978
  <div class="sequence-block">${formatDna(v.Optimized_DNA_Seq, mutPositionList)}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1979
  <div class="synth-row">
1980
  <span class="synth-row-label">Synthesize via</span>
1981
  ${Object.entries(VENDORS).map(([key, vendor]) => `
1982
  <button class="copy-btn synth-btn"
1983
- data-synth-dna="${escapeHtml(v.Optimized_DNA_Seq)}"
1984
  data-synth-id="${escapeHtml(v.Variant_ID)}"
1985
  data-synth-vendor="${escapeHtml(key)}">
1986
  <span>${escapeHtml(vendor.name)}</span>
@@ -1991,7 +2207,8 @@ function renderDetailPanel(v) {
1991
  `).join('')}
1992
  </div>
1993
  <div class="primer-actions" style="margin-top:10px">
1994
- <button class="copy-btn" data-copy="${escapeHtml(v.Optimized_DNA_Seq)}">Copy DNA</button>
 
1995
  <button class="copy-btn" data-copy="${escapeHtml(v.Mutant_AA_Seq)}">Copy protein</button>
1996
  </div>
1997
  </div>
@@ -2082,15 +2299,47 @@ function wireCopyButtons(scope) {
2082
  });
2083
  });
2084
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2085
  // Synthesize-via-X buttons — one per vendor, each carrying its vendor
2086
- // key in data-synth-vendor. Click copies sequence, shows toast, then
2087
- // opens that specific vendor's site in a new tab.
 
2088
  scope.querySelectorAll('.synth-btn').forEach((el) => {
2089
  el.addEventListener('click', (e) => {
2090
  e.stopPropagation();
2091
- const dna = el.dataset.synthDna;
2092
  const variantId = el.dataset.synthId;
2093
  const vendorKey = el.dataset.synthVendor;
 
 
 
 
 
 
 
 
 
2094
  synthesizeDna(dna, variantId, vendorKey);
2095
  const original = el.innerHTML;
2096
  el.classList.add('copied');
 
908
  if (detailRow.dataset.populated !== '1') {
909
  detailRow.querySelector('td').innerHTML = renderDetailPanel(v);
910
  wireCopyButtons(detailRow);
911
+ wireCloningControls(detailRow);
912
  detailRow.dataset.populated = '1';
913
  }
914
  detailRow.hidden = false;
 
941
  },
942
  };
943
 
944
+ // =============================================================== CLONING
945
+ // Minimal "prepare for synthesis" toolkit. Wraps the bare CDS with the
946
+ // appropriate 5'/3' flanking for common cloning workflows. Pure
947
+ // client-side computation — no backend involvement, no server roundtrip
948
+ // per dropdown change. Add new presets here; each one is a pure function
949
+ // from bare CDS string → formatted DNA string, plus metadata.
950
+ //
951
+ // Section coloring in the preview:
952
+ // - five_prime → amber (added 5' flanking)
953
+ // - cds → ink (original codon-optimized CDS)
954
+ // - three_prime → amber (added 3' flanking)
955
+ // Mutations highlighting is preserved from the bare CDS view above.
956
+
957
+ const STOP_CODONS = new Set(['TAA', 'TAG', 'TGA']);
958
+
959
+ function stripLeadingAtg(cds) {
960
+ return cds.startsWith('ATG') ? cds.slice(3) : cds;
961
+ }
962
+ function stripTrailingStop(cds) {
963
+ return STOP_CODONS.has(cds.slice(-3).toUpperCase()) ? cds.slice(0, -3) : cds;
964
+ }
965
+
966
+ const CLONING_PRESETS = {
967
+ bare: {
968
+ name: 'Bare CDS · no flanking',
969
+ sublabel: 'Recommended when you order through a vendor cloning service (Twist Clonal Gene, IDT Genes, GenScript clone-into-vector). The vendor adds the overhangs internally — you just specify the destination vector at checkout.',
970
+ format(cds) {
971
+ return { five: '', cds, three: '' };
972
+ },
973
+ notes: '',
974
+ },
975
+
976
+ pet28a_ndei_xhoi: {
977
+ name: 'pET28a · NdeI + XhoI restriction sites',
978
+ sublabel: 'For traditional restriction cloning into pET28a. Digest the synthesized DNA with NdeI + XhoI, ligate into pET28a opened with the same enzymes. T7 promoter, IPTG induction, N-terminal His6 + thrombin site provided by the vector.',
979
+ format(cds) {
980
+ // NdeI = CATATG (the embedded ATG IS the start codon — strip the CDS's own).
981
+ // XhoI = CTCGAG, in pET28a is followed by C-term His6 + stop, so we strip
982
+ // the CDS's stop codon (the vector provides it).
983
+ const inner = stripTrailingStop(stripLeadingAtg(cds));
984
+ return {
985
+ five: 'AAAA' + 'CATATG',
986
+ cds: inner,
987
+ three: 'CTCGAG' + 'AAAA',
988
+ };
989
+ },
990
+ notes: 'Strips the CDS\'s own ATG (NdeI provides the start codon) and stop codon (pET28a\'s C-term His6 + stop follow XhoI). 4 nt AAAA buffers ensure efficient enzyme cleavage at the ends.',
991
+ },
992
+
993
+ gibson_pet28a: {
994
+ name: 'Gibson · pET28a (NdeI / XhoI linearized)',
995
+ sublabel: 'For Gibson Assembly Master Mix into pET28a linearized by sequential NdeI + XhoI digest. 20+ bp homology arms; no restriction digest of your insert required.',
996
+ format(cds) {
997
+ // Real pET28a flanking sequences. Upstream of NdeI: T7 RBS through ATG.
998
+ // Downstream of XhoI: pET28a's encoded C-term His6 (which now follows in frame).
999
+ const inner = stripTrailingStop(stripLeadingAtg(cds));
1000
+ return {
1001
+ five: 'AAGGAGATATACATATG', // pET28a RBS + NdeI
1002
+ cds: inner,
1003
+ three: 'CTCGAGCACCACCACCACCACCACTGA', // XhoI + His6 + stop
1004
+ };
1005
+ },
1006
+ notes: 'The 5\' arm is pET28a\'s ribosome binding site through the NdeI ATG (which becomes your start codon). The 3\' arm continues into pET28a\'s built-in C-terminal His6 tag + stop codon, so the purified protein gets a C-term His6 for IMAC.',
1007
+ },
1008
+
1009
+ goldengate_moclo: {
1010
+ name: 'Golden Gate · BsaI · MoClo-compatible',
1011
+ sublabel: 'BsaI sites flanking standard MoClo CDS overhangs (5\' AATG, 3\' GCTT). After digestion, the insert drops into any MoClo Level-1 destination vector accepting a CDS module.',
1012
+ format(cds) {
1013
+ // BsaI cuts GGTCTC(N1)|(N4)... → leaves a 4 nt 5' overhang we control.
1014
+ // MoClo CDS module convention: 5' AATG (which encodes Met start) and 3' GCTT.
1015
+ // So we put GGTCTC, 1 nt spacer (any nucleotide), then AATG / [CDS without ATG] / [no stop] / GCTT / 1 nt / GAGACC (BsaI on opposite strand).
1016
+ const inner = stripTrailingStop(stripLeadingAtg(cds));
1017
+ return {
1018
+ five: 'AAA' + 'GGTCTC' + 'A' + 'AATG',
1019
+ cds: inner,
1020
+ three: 'GCTT' + 'A' + 'GAGACC' + 'AAA',
1021
+ };
1022
+ },
1023
+ notes: 'After BsaI cleavage, the insert is bounded by AATG (5\') and GCTT (3\') overhangs — the standard MoClo CDS module ends. The A before AATG becomes the methionine start codon. Compatible with MoClo / GoldenBraid / Loop Assembly toolkits.',
1024
+ },
1025
+ };
1026
+
1027
+ // Per-variant state for the live preview. Keyed by Variant_ID so two
1028
+ // expanded rows can have different formats picked simultaneously.
1029
+ const cloningState = new Map(); // variantId -> { presetKey, bareCds, mutationPositions }
1030
+
1031
+ function applyCloningPreset(bareCds, presetKey) {
1032
+ const preset = CLONING_PRESETS[presetKey] || CLONING_PRESETS.bare;
1033
+ const parts = preset.format(bareCds);
1034
+ return {
1035
+ ...parts,
1036
+ full: parts.five + parts.cds + parts.three,
1037
+ preset,
1038
+ };
1039
+ }
1040
+
1041
+ function formattedDnaFor(variantId) {
1042
+ const state = cloningState.get(variantId);
1043
+ if (!state) return null;
1044
+ return applyCloningPreset(state.bareCds, state.presetKey);
1045
+ }
1046
+
1047
+ function renderCloningPreview(variantId) {
1048
+ const host = document.querySelector(`.cloning-preview[data-variant="${cssEscape(variantId)}"]`);
1049
+ if (!host) return;
1050
+ const state = cloningState.get(variantId);
1051
+ if (!state) { host.innerHTML = ''; return; }
1052
+ const { five, cds, three, full, preset } = applyCloningPreset(state.bareCds, state.presetKey);
1053
+ const gc = ((full.toUpperCase().match(/[GC]/g) || []).length / Math.max(1, full.length) * 100).toFixed(1);
1054
+
1055
+ // Build the codon-grouped, section-colored preview. Same wrap as formatDna
1056
+ // (60 nt per line, 10-nt blocks) but with three colored ranges.
1057
+ const html = renderSectionedSequence(five, cds, three);
1058
+
1059
+ host.innerHTML = `
1060
+ <div class="cloning-meta">
1061
+ <span><strong>${full.length}</strong> bp</span>
1062
+ <span><strong>${gc}%</strong> GC</span>
1063
+ ${state.presetKey === 'bare' ? '' : `
1064
+ <span class="cloning-legend">
1065
+ <span class="legend-swatch legend-flank"></span> added flanking
1066
+ <span class="legend-swatch legend-cds"></span> codon-optimized CDS
1067
+ </span>
1068
+ `}
1069
+ </div>
1070
+ <div class="cloning-preview-block">${html}</div>
1071
+ ${preset.notes ? `<p class="cloning-notes"><strong>How it ligates:</strong> ${escapeHtml(preset.notes)}</p>` : ''}
1072
+ ${preset.sublabel ? `<p class="cloning-sublabel">${escapeHtml(preset.sublabel)}</p>` : ''}
1073
+ `;
1074
+ }
1075
+
1076
+ // Render a length-3-grouped, line-wrapped DNA sequence with colored sections.
1077
+ function renderSectionedSequence(five, cds, three) {
1078
+ const lower = (five + cds + three).toLowerCase();
1079
+ const fiveEnd = five.length;
1080
+ const threeStart = five.length + cds.length;
1081
+ const padWidth = String(lower.length).length;
1082
+ const charsPerLine = 60;
1083
+ const blockSize = 10;
1084
+ const out = [];
1085
+ for (let i = 0; i < lower.length; i += charsPerLine) {
1086
+ const num = String(i + 1).padStart(padWidth, ' ');
1087
+ let lineHtml = '';
1088
+ for (let j = 0; j < charsPerLine && i + j < lower.length; j++) {
1089
+ if (j > 0 && j % blockSize === 0) lineHtml += ' ';
1090
+ const idx = i + j;
1091
+ const ch = lower[idx];
1092
+ const sectionClass = idx < fiveEnd ? 'sec-flank'
1093
+ : idx >= threeStart ? 'sec-flank'
1094
+ : 'sec-cds';
1095
+ lineHtml += `<span class="${sectionClass}">${escapeHtml(ch)}</span>`;
1096
+ }
1097
+ out.push(`<span class="seq-num">${escapeHtml(num)}</span> ${lineHtml}`);
1098
+ }
1099
+ return out.join('\n');
1100
+ }
1101
+
1102
+ // CSS.escape polyfill — needed for querySelector with arbitrary user-supplied
1103
+ // variant IDs (V0001 etc. are safe, but defensive).
1104
+ function cssEscape(s) {
1105
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
1106
+ }
1107
+
1108
+ function wireCloningControls(scope) {
1109
+ scope.querySelectorAll('.cloning-format').forEach((sel) => {
1110
+ const variantId = sel.dataset.variant;
1111
+ // Seed state for this variant on first render.
1112
+ if (!cloningState.has(variantId)) {
1113
+ const variant = (tableState.variants || []).find(v => v.Variant_ID === variantId);
1114
+ if (variant) {
1115
+ cloningState.set(variantId, {
1116
+ presetKey: sel.value,
1117
+ bareCds: variant.Optimized_DNA_Seq || '',
1118
+ });
1119
+ }
1120
+ }
1121
+ renderCloningPreview(variantId);
1122
+
1123
+ sel.addEventListener('change', () => {
1124
+ const state = cloningState.get(variantId);
1125
+ if (!state) return;
1126
+ state.presetKey = sel.value;
1127
+ renderCloningPreview(variantId);
1128
+ });
1129
+ });
1130
+ }
1131
+
1132
  async function synthesizeDna(dnaSeq, variantId, vendorKey) {
1133
  // Twist gets the in-app gateway flow: live quote + order placement
1134
  // against http://127.0.0.1:8765. GenScript + IDT remain external
 
2165
  <div class="detail-section">
2166
  <h4>Optimized DNA sequence (${v.Length_bp} bp) · mutated codons highlighted</h4>
2167
  <div class="sequence-block">${formatDna(v.Optimized_DNA_Seq, mutPositionList)}</div>
2168
+ </div>
2169
+
2170
+ <div class="detail-section cloning-section" data-variant-id="${escapeHtml(v.Variant_ID)}">
2171
+ <h4>Prepare for synthesis</h4>
2172
+ <p class="cloning-hint">
2173
+ Bare CDS won't drop into a vector — synthesis vendors price by length
2174
+ but you'll still need restriction sites, Gibson overhangs, or a
2175
+ vendor cloning service for it to be useful. Pick a format below
2176
+ to wrap the CDS with the right ends.
2177
+ </p>
2178
+
2179
+ <div class="cloning-controls">
2180
+ <label class="cloning-field">
2181
+ <span class="cloning-field-label">Format</span>
2182
+ <select class="cloning-format" data-variant="${escapeHtml(v.Variant_ID)}">
2183
+ ${Object.entries(CLONING_PRESETS).map(([key, p]) => `
2184
+ <option value="${escapeHtml(key)}"${key === 'bare' ? ' selected' : ''}>${escapeHtml(p.name)}</option>
2185
+ `).join('')}
2186
+ </select>
2187
+ </label>
2188
+ </div>
2189
+
2190
+ <!-- Live preview of the formatted DNA. Sections are color-coded:
2191
+ yellow = added 5'/3' flanking, brand = original CDS, magenta = embedded tag. -->
2192
+ <div class="cloning-preview" data-variant="${escapeHtml(v.Variant_ID)}">
2193
+ <!-- Filled in by renderCloningPreview() on render and on format change. -->
2194
+ </div>
2195
+
2196
  <div class="synth-row">
2197
  <span class="synth-row-label">Synthesize via</span>
2198
  ${Object.entries(VENDORS).map(([key, vendor]) => `
2199
  <button class="copy-btn synth-btn"
 
2200
  data-synth-id="${escapeHtml(v.Variant_ID)}"
2201
  data-synth-vendor="${escapeHtml(key)}">
2202
  <span>${escapeHtml(vendor.name)}</span>
 
2207
  `).join('')}
2208
  </div>
2209
  <div class="primer-actions" style="margin-top:10px">
2210
+ <button class="copy-btn" data-cloning-copy="formatted" data-variant="${escapeHtml(v.Variant_ID)}">Copy formatted DNA</button>
2211
+ <button class="copy-btn" data-copy="${escapeHtml(v.Optimized_DNA_Seq)}">Copy bare CDS</button>
2212
  <button class="copy-btn" data-copy="${escapeHtml(v.Mutant_AA_Seq)}">Copy protein</button>
2213
  </div>
2214
  </div>
 
2299
  });
2300
  });
2301
 
2302
+ // "Copy formatted DNA" — reads the live cloning-state for this variant
2303
+ // so the user gets the currently-previewed format (not stale data baked
2304
+ // into a data-copy attribute at render time).
2305
+ scope.querySelectorAll('[data-cloning-copy]').forEach((el) => {
2306
+ if (!el.classList.contains('copy-btn')) return;
2307
+ el.addEventListener('click', async (e) => {
2308
+ e.stopPropagation();
2309
+ const variantId = el.dataset.variant;
2310
+ const formatted = formattedDnaFor(variantId);
2311
+ if (!formatted) return;
2312
+ try {
2313
+ await navigator.clipboard.writeText(formatted.full);
2314
+ const original = el.innerHTML;
2315
+ el.classList.add('copied');
2316
+ el.textContent = `Copied ✓ (${formatted.full.length} bp)`;
2317
+ setTimeout(() => {
2318
+ el.classList.remove('copied');
2319
+ el.innerHTML = original;
2320
+ }, 1600);
2321
+ } catch (_) { /* clipboard denied */ }
2322
+ });
2323
+ });
2324
+
2325
  // Synthesize-via-X buttons — one per vendor, each carrying its vendor
2326
+ // key in data-synth-vendor. Sends the *currently-selected* formatted
2327
+ // DNA (per the cloning section's dropdown), not the bare CDS, so the
2328
+ // vendor receives an order with proper cloning ends already attached.
2329
  scope.querySelectorAll('.synth-btn').forEach((el) => {
2330
  el.addEventListener('click', (e) => {
2331
  e.stopPropagation();
 
2332
  const variantId = el.dataset.synthId;
2333
  const vendorKey = el.dataset.synthVendor;
2334
+ const formatted = formattedDnaFor(variantId);
2335
+ // Fall back to the bare CDS in the unlikely event the cloning
2336
+ // state wasn't seeded (e.g. user clicked the synth button
2337
+ // before the dropdown wired up).
2338
+ let dna = formatted ? formatted.full : '';
2339
+ if (!dna) {
2340
+ const v = (tableState.variants || []).find(x => x.Variant_ID === variantId);
2341
+ dna = v?.Optimized_DNA_Seq || '';
2342
+ }
2343
  synthesizeDna(dna, variantId, vendorKey);
2344
  const original = el.innerHTML;
2345
  el.classList.add('copied');