screengraft 0.13.1 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/index.html CHANGED
@@ -277,8 +277,24 @@
277
277
  /* 304px wrapped "desktop" onto a second line. The rail is sized to the
278
278
  content it must hold — four device chips in one row — rather than to a
279
279
  round number. */
280
- .stage{display:grid;grid-template-columns:minmax(0,1fr) 344px;min-height:0;overflow:hidden}
281
- .stage.compare{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px}
280
+ /* Side by side is the layout, not a mode. Judging a corner means seeing the
281
+ same corner in the fit and in the composite at the same zoom, so a version
282
+ of this page where that is optional is a version where the check is
283
+ optional. Two equal panes plus the rail. */
284
+ .stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px;
285
+ min-height:0;overflow:hidden}
286
+ /* Below ~1100px two panes and a 344px rail no longer fit, and the page starts
287
+ scrolling sideways — which the UI audit catches as a hard failure. Stack the
288
+ panes instead of dropping one: losing the comparison is the one thing this
289
+ layout exists to prevent, so the axis gives way before the pairing does.
290
+ (Found by the audit after compare stopped being optional, at 742px.) */
291
+ @media (max-width:1100px){
292
+ .stage{grid-template-columns:minmax(0,1fr) 344px;
293
+ grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
294
+ .stage > .pane:first-of-type{grid-column:1;grid-row:1}
295
+ .stage > #outPane{grid-column:1;grid-row:2;border-top:1px solid var(--line)}
296
+ .stage > .rail{grid-column:2;grid-row:1 / span 2}
297
+ }
282
298
  #types{flex-wrap:nowrap}
283
299
  .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
284
300
  position:relative; /* the frame the corner bar is anchored to */
@@ -327,13 +343,19 @@
327
343
  photo is chosen but the screenshot is not, offering corners to fit
328
344
  nothing. */
329
345
  .cvbar[hidden]{display:none}
346
+ /* Render spinner. A determinate percentage was the first version and it read
347
+ as busier than the work felt; a single moving thing says "still going"
348
+ without asking to be read. prefers-reduced-motion gets a static ring —
349
+ the button's label already carries the meaning. */
350
+ .spin{display:inline-block;width:12px;height:12px;margin-left:8px;
351
+ vertical-align:-1px;border:2px solid currentColor;border-radius:50%;
352
+ border-right-color:transparent;opacity:.9;
353
+ animation:spin .7s linear infinite}
354
+ @keyframes spin{to{transform:rotate(360deg)}}
355
+ @media (prefers-reduced-motion:reduce){ .spin{animation:none} }
330
356
 
331
357
  /* Preview popover: the composite at whatever size fits, on the sunk surface
332
358
  so the image edge is unambiguous against the panel. */
333
- #previewWrap{margin-top:10px;background:var(--well);border-radius:var(--r-md);
334
- box-shadow:inset 0 1px 3px rgba(0,0,0,.5);display:grid;
335
- place-items:center;min-height:200px;max-height:70vh;overflow:auto}
336
- #previewImg{display:block;max-width:100%;height:auto}
337
359
 
338
360
  /* Switch — the full 8-variant matrix from Figma 17:61
339
361
  (State Rest/Hover/Pressed/Disabled x Selected No/Yes).
@@ -403,8 +425,7 @@
403
425
  button.switch:focus-visible{outline:none}
404
426
  button.switch:focus-visible .sw-track{box-shadow:0 0 0 3px var(--acc-soft);border-color:var(--acc)}
405
427
 
406
- #outPane{display:none}
407
- .stage.compare #outPane{display:grid}
428
+ #outPane{display:grid}
408
429
  #outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
409
430
  #outImg{display:block;margin:auto}
410
431
  .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
@@ -674,7 +695,6 @@
674
695
  </div>
675
696
  <span class="spacer"></span>
676
697
  <div class="tb-actions">
677
- <button id="preview" hidden>Preview</button>
678
698
  <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
679
699
  <button id="save" class="accent" disabled>Preview first</button>
680
700
  </div>
@@ -714,6 +734,7 @@
714
734
  <span class="cvbar-actions">
715
735
  <span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
716
736
  <button class="sm" id="redetect">Re-detect</button>
737
+ <button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
717
738
  </span>
718
739
  </div>
719
740
  </section>
@@ -726,7 +747,22 @@
726
747
  <span class="spacer"></span>
727
748
  <span class="sm" style="color:var(--mute)">shares zoom &amp; pan</span>
728
749
  </div>
729
- <div id="outWrap"><span class="empty" id="outEmpty">Press Preview — or turn on Compare view and this updates as you drag.</span><img id="outImg" alt="" hidden></div>
750
+ <div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden></div>
751
+ <!-- Video controls. Hidden entirely for a still source: a scrubber over a
752
+ screenshot is a control that can only confuse. The fit itself is
753
+ unchanged — you match the edges on one frame and every frame gets the
754
+ same geometry, because the photo does not move. -->
755
+ <div class="cvbar" id="vidbar" hidden>
756
+ <span class="sm" style="color:var(--mute)">Fit on frame</span>
757
+ <span class="cvbar-actions">
758
+ <input type="range" id="vframe" min="0" max="0" value="0" step="1"
759
+ aria-label="Which frame of the clip to match the edges on">
760
+ <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
761
+ <span class="stepper" role="group" aria-label="Output format">
762
+ <button class="sm sel" data-preset="web" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
763
+ </span>
764
+ </span>
765
+ </div>
730
766
  </section>
731
767
 
732
768
  <aside class="rail">
@@ -778,17 +814,6 @@
778
814
  </button>
779
815
  </div>
780
816
  </div>
781
- <div class="sect" id="secCompare" data-on="1">
782
- <div class="sect-top">
783
- <div class="sect-left">
784
- <h4>Compare view</h4>
785
- <button class="info" type="button" aria-label="About compare view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Shows the fit and the composite side by side, sharing zoom and pan, so a corner can be judged against the same corner.</span></button>
786
- </div>
787
- <button class="switch" id="cmpBtn" role="switch" aria-checked="true" aria-label="Compare view">
788
- <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
789
- </button>
790
- </div>
791
- </div>
792
817
  <div class="sect" id="secKeys" data-on="1">
793
818
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
794
819
  <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
@@ -824,12 +849,6 @@
824
849
  <div class="scrim" id="scrim"></div>
825
850
 
826
851
 
827
- <div class="pop" id="previewPop" style="width:min(900px,calc(100vw - 24px))">
828
- <h3>Preview</h3>
829
- <p class="sub" id="previewSt">Rendering…</p>
830
- <div id="previewWrap"><img id="previewImg" alt="" hidden></div>
831
- </div>
832
-
833
852
  <div class="pop" id="pop1">
834
853
  <h3>Photo of the device</h3>
835
854
  <p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
@@ -852,7 +871,7 @@
852
871
  <div class="pv" id="pv2"><span class="empty">Select a recent image</span></div>
853
872
  </div>
854
873
  <div class="drop" id="drop2">Drop a screenshot here or click to browse</div>
855
- <input type="file" id="file2" accept="image/*" hidden>
874
+ <input type="file" id="file2" accept="image/*,video/mp4,video/quicktime,video/webm,.mp4,.mov,.m4v,.webm" hidden>
856
875
  </div>
857
876
 
858
877
  <script>
@@ -1104,26 +1123,28 @@ async function loadRecent(){
1104
1123
  }
1105
1124
  }
1106
1125
  async function choose(role, path, el){
1107
- try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el); }
1126
+ try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
1108
1127
  catch(e){ alert(e.message); }
1109
1128
  }
1110
- function setChosen(role, path, size, el){
1129
+ function setChosen(role, path, size, el, meta){
1111
1130
  const n = role==='photo'?1:2;
1112
1131
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1113
1132
  if (el) el.classList.add('sel');
1114
1133
  $('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
1115
1134
  const chip = $('#chip'+n);
1116
1135
  chip.classList.remove('is-empty');
1117
- chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`;
1136
+ chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1137
+ + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1118
1138
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
1119
- if (role==='photo'){ st.photo = path; st.corners = null; } else st.shot = path;
1139
+ if (role==='photo'){ st.photo = path; st.corners = null; }
1140
+ else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1120
1141
  closePop();
1121
1142
  maybeStart();
1122
1143
  }
1123
1144
  async function upload(role, file){
1124
1145
  const buf = await file.arrayBuffer();
1125
1146
  const r = await api('/api/upload', null, {headers:{'X-Filename':encodeURIComponent(file.name),'X-Role':role}, body:buf});
1126
- setChosen(role, r.path, r.size, null);
1147
+ setChosen(role, r.path, r.size, null, r);
1127
1148
  }
1128
1149
  for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
1129
1150
  const drop = $('#drop'+n), file = $('#file'+n);
@@ -1235,7 +1256,7 @@ $('#figmaBtn').onclick = async () => {
1235
1256
  try {
1236
1257
  const r = await api('/api/job/adopt', {});
1237
1258
  stop('ok', 'Exported');
1238
- setChosen('screenshot', r.path, r.size, null);
1259
+ setChosen('screenshot', r.path, r.size, null, r);
1239
1260
  } catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
1240
1261
  },
1241
1262
  onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
@@ -1336,16 +1357,37 @@ async function detect(){
1336
1357
  if (r.corner_radius && r.corner_radius.confident){ setFrac(r.corner_radius.frac_of_width, 'measured from the photo'); }
1337
1358
  else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
1338
1359
  } else {
1339
- const w = img.naturalWidth, h = img.naturalHeight;
1340
- st.corners = [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
1341
- s.className='status warn'; s.textContent = 'No screen found — place the edges by hand';
1360
+ st.corners = defaultQuad();
1361
+ s.className='status warn';
1362
+ s.textContent = r.abstained ? 'Detector abstained — place the edges by hand'
1363
+ : 'No screen found — place the edges by hand';
1364
+ // The full reason is long and belongs on hover, not in a 40-character chip.
1365
+ s.title = r.message || '';
1342
1366
  if (!st.type) setType('phone'); applyPreset();
1343
1367
  }
1344
1368
  pick = {kind:'edge', i:0};
1345
1369
  draw(); drawStrip();
1346
1370
  autoPreview(); // if the compare pane is open, fill it straight away
1347
1371
  }
1372
+ // One definition of "a sane starting position", used by the abstention path and
1373
+ // by Reset. Deliberately a plain centred rectangle: it claims nothing about
1374
+ // where the screen is, and every handle is on the picture where it can be
1375
+ // grabbed — which is the property the detector's off-canvas quad lacked.
1376
+ function defaultQuad(){
1377
+ const w = img.naturalWidth, h = img.naturalHeight;
1378
+ return [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
1379
+ }
1348
1380
  $('#redetect').onclick = detect;
1381
+ $('#resetquad').onclick = () => {
1382
+ if (!img.naturalWidth) return;
1383
+ st.corners = defaultQuad();
1384
+ const s = $('#detSt'); s.className='status warn';
1385
+ s.textContent = 'Edges reset — place them on the screen';
1386
+ s.title = '';
1387
+ pick = {kind:'edge', i:0};
1388
+ setCanvasZoom(scale);
1389
+ draw(); drawStrip(); autoPreview();
1390
+ };
1349
1391
 
1350
1392
  function draw(){
1351
1393
  ctx.clearRect(0,0,cv.width,cv.height);
@@ -1724,33 +1766,12 @@ function syncOut(){
1724
1766
  }
1725
1767
  }
1726
1768
  $('#scroller').addEventListener('scroll', () => {
1727
- if ($('#stage').classList.contains('compare')) syncOut();
1769
+ syncOut();
1728
1770
  });
1729
- /* Compare is a switch now, not a chip. Its truth lives in aria-checked so the
1730
- control is a real switch to assistive tech, and the label text follows it —
1731
- a switch that says OFF while being on is worse than no label. */
1732
- function setCompareUI(on){
1733
- setSwitch($('#cmpBtn'), on);
1734
- // No text label in the component (17:61) — aria-checked carries the state.
1735
- // With compare on the result pane renders continuously, so a Preview button
1736
- // would offer something already on screen. It belongs to the off state.
1737
- $('#preview').hidden = on;
1738
- }
1739
- $('#cmpBtn').onclick = () => {
1740
- const on = $('#stage').classList.toggle('compare');
1741
- setCompareUI(on);
1742
- remember('compare', on ? '1' : '0');
1743
- if (img.naturalWidth){
1744
- const wasFit = Math.abs(scale-fitScale)<1e-6;
1745
- computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
1746
- }
1747
- if (on && $('#outImg').hidden) renderPreview(); else if (on) syncOut();
1748
- };
1749
1771
  function autoPreview(){
1750
1772
  // Judge while you fit: with the compare pane open the composite re-renders
1751
1773
  // after each correction (compose() measures 0.06s server-side), so Preview
1752
1774
  // stops being a round trip.
1753
- if (!$('#stage').classList.contains('compare')) return;
1754
1775
  clearTimeout(autoTimer);
1755
1776
  autoTimer = setTimeout(renderPreview, 350);
1756
1777
  }
@@ -1763,30 +1784,13 @@ async function renderPreview(){
1763
1784
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1764
1785
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1765
1786
  s.textContent = `radius ${r.radius_px}px on the screenshot`;
1766
- const b = $('#save'); b.disabled = false; b.textContent = 'Save';
1787
+ const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
1767
1788
  b.classList.add('primary'); // Save is now the next action
1768
1789
  if (!previewHinted){ previewHinted = true;
1769
1790
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
1770
1791
  }
1771
1792
  }catch(e){ s.textContent = e.message; }
1772
1793
  }
1773
- /* With compare off there is no result pane to draw into, so the composite
1774
- opens in a popup over the canvas — the same surface the pickers use, so it
1775
- is one modal idiom rather than two. */
1776
- async function openPreviewPop(){
1777
- const pop = $('#previewPop');
1778
- const img = $('#previewImg'), st_ = $('#previewSt');
1779
- openPopAt(pop, $('#preview').getBoundingClientRect());
1780
- st_.textContent = 'Rendering…'; img.hidden = true;
1781
- try{
1782
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1783
- img.onload = () => { img.hidden = false; };
1784
- img.src = `${fileURL(r.path)}&t=${Date.now()}`;
1785
- st_.textContent = `radius ${r.radius_px}px on the screenshot`;
1786
- const b = $('#save'); b.disabled = false; b.textContent = 'Save'; b.classList.add('primary');
1787
- }catch(e){ st_.textContent = e.message; }
1788
- }
1789
- $('#preview').onclick = openPreviewPop;
1790
1794
  function prettyDir(p){
1791
1795
  // ~ for home, and show the last two segments of a long path: the interesting
1792
1796
  // part of a long output path is the end, not the volume.
@@ -1796,7 +1800,85 @@ function prettyDir(p){
1796
1800
  return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
1797
1801
  }
1798
1802
 
1803
+ let preset = 'web';
1804
+ document.querySelectorAll('[data-preset]').forEach(btn => {
1805
+ btn.onclick = () => {
1806
+ preset = btn.dataset.preset;
1807
+ document.querySelectorAll('[data-preset]').forEach(o => o.classList.toggle('sel', o === btn));
1808
+ };
1809
+ });
1810
+
1811
+ /* A video source changes exactly two things in this page: which frame the fit
1812
+ is shown on, and what the primary button does. Everything else — the edge
1813
+ matching, the loupe, the compare view, the realism rail — is untouched,
1814
+ because the geometry does not care whether the pixels move. */
1815
+ function syncVideoUI(){
1816
+ const v = st.video;
1817
+ $('#vidbar').hidden = !v;
1818
+ const b = $('#save');
1819
+ if (v && v.ffmpeg === false){
1820
+ // Say it now, not after the fit. The fitting still works — it is only the
1821
+ // encode that cannot run — so the page stays usable and the message names
1822
+ // the one thing to do.
1823
+ toast('err', 'Video needs <b>ffmpeg</b>, which is not installed yet. '
1824
+ + 'Ask Claude to install it (about 25 MB, into screengraft\'s own '
1825
+ + 'environment — nothing system-wide), then reload this page.');
1826
+ }
1827
+ if (v){
1828
+ const sl = $('#vframe');
1829
+ sl.max = Math.max(0, (v.frames || 1) - 1);
1830
+ sl.value = 0;
1831
+ $('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
1832
+ if (b.textContent === 'Save') b.textContent = 'Render';
1833
+ }
1834
+ }
1835
+ $('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
1836
+ $('#vframe').onchange = async () => {
1837
+ // Swapping the poster frame re-runs preview through the same path a still
1838
+ // uses, so what you match the edges against is the frame you chose.
1839
+ try{
1840
+ const r = await api('/api/frame', {index: +$('#vframe').value});
1841
+ $('#pv2').innerHTML = `<img src="${fileURL(r.path)}" alt="">`;
1842
+ $('#sw2').style.background = `center/cover no-repeat url("${fileURL(r.path)}")`;
1843
+ autoPreview();
1844
+ }catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
1845
+ };
1846
+
1847
+ async function renderVideo(){
1848
+ const b = $('#save');
1849
+ b.disabled = true;
1850
+ b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
1851
+ try{
1852
+ await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1853
+ grade: gradeValue(), preset, fit_frame: +$('#vframe').value});
1854
+ }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent='Render'; return; }
1855
+ // Poll rather than hold a request open: a clip is hundreds of frames and a
1856
+ // browser would time the request out long before the render finished.
1857
+ const tick = setInterval(async () => {
1858
+ let d; try { d = await api('/api/render_status', null, {method:'GET'}); }
1859
+ catch(e){ return; }
1860
+ if (d.state === 'running'){
1861
+ // The button says what it is doing; the frame counter beside the
1862
+ // scrubber carries the numbers, so the primary control stays calm.
1863
+ b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
1864
+ if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
1865
+ } else if (d.state === 'done'){
1866
+ clearInterval(tick);
1867
+ b.textContent = 'Render'; b.disabled = false; b.classList.remove('primary');
1868
+ $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1869
+ toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
1870
+ const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
1871
+ } else if (d.state === 'error'){
1872
+ clearInterval(tick);
1873
+ b.textContent = 'Render'; b.disabled = false;
1874
+ $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1875
+ toast('err', 'Render failed: ' + (d.message || 'unknown'));
1876
+ }
1877
+ }, 900);
1878
+ }
1879
+
1799
1880
  $('#save').onclick = async () => {
1881
+ if (st.video) return renderVideo();
1800
1882
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1801
1883
  try{
1802
1884
  const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
@@ -1864,8 +1946,8 @@ $('#imp').onclick = async () => {
1864
1946
  // Compare is the DEFAULT view: judging a fit means comparing it with the
1865
1947
  // photo, so the side-by-side is the normal state and turning it off is the
1866
1948
  // deliberate act. recall() only overrides it if the user chose otherwise.
1867
- if (recall('compare','1') === '1'){ $('#stage').classList.add('compare'); setCompareUI(true); }
1868
- else setCompareUI(false);
1949
+ // Nothing to restore: the side-by-side is the layout now, so there is no
1950
+ // remembered preference and no state where the check is unavailable.
1869
1951
  await loadRecent();
1870
1952
  // A session restored after a reload may already have a saved composite, in
1871
1953
  // which case Import is still meaningful — leaving it disabled would strand
@@ -1879,7 +1961,7 @@ $('#imp').onclick = async () => {
1879
1961
  if (s.device) st.type = s.device;
1880
1962
  for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
1881
1963
  if (!path) continue;
1882
- try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null); }
1964
+ try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null, r); }
1883
1965
  catch(e){ /* file moved since: leave that slot empty */ }
1884
1966
  }
1885
1967
  // Populate the radius dropdown on the restore path too. detect() only
@@ -1888,7 +1970,7 @@ $('#imp').onclick = async () => {
1888
1970
  if (st.type) setType(st.type);
1889
1971
  if (s.radius_frac) setFrac(s.radius_frac, 'restored from this session');
1890
1972
  else applyPreset();
1891
- if ($('#stage').classList.contains('compare') && st.corners) renderPreview();
1973
+ if (st.corners) renderPreview();
1892
1974
  }
1893
1975
  if (!st.photo) openPop(1);
1894
1976
  window.addEventListener('resize', () => {