screengraft 0.41.0 → 0.42.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/ui.py CHANGED
@@ -702,6 +702,10 @@ def _adopt(role, path):
702
702
  return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
703
703
 
704
704
 
705
+ class RenderBusy(Exception):
706
+ """A request that must not run while a render is in flight."""
707
+
708
+
705
709
  def _clear(role):
706
710
  """Un-choose a source. The mirror of _adopt(), and the only way to start
707
711
  over: picking a different file was the one exit, and a reload restores the
@@ -719,6 +723,13 @@ def _clear(role):
719
723
  """
720
724
  if role not in ROLES:
721
725
  raise ValueError(f"role must be one of {', '.join(ROLES)}")
726
+ # A render in flight would publish its output and sidecar AFTER this
727
+ # cleared them, from a source that is no longer loaded -- the exact stale
728
+ # artefact this exists to prevent. Refused, the way the render route
729
+ # refuses a second render; the page shows the message.
730
+ with RENDER_LOCK:
731
+ if RENDER["state"] == "running":
732
+ raise RenderBusy("a render is running — wait for it, then remove the source")
722
733
  if role == "photo":
723
734
  SESSION.update(photo=None, corners=None, output=None)
724
735
  else:
@@ -897,7 +908,10 @@ class Handler(BaseHTTPRequestHandler):
897
908
  return self._json(_adopt(b["role"], b["path"]))
898
909
 
899
910
  if u.path == "/api/clear":
900
- return self._json(_clear(b.get("role")))
911
+ try:
912
+ return self._json(_clear(b.get("role")))
913
+ except RenderBusy as exc:
914
+ return self._json({"error": str(exc)}, 409)
901
915
 
902
916
  if u.path == "/api/figma":
903
917
  return self._json(SESSION.enqueue({
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.41):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.42):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
11
11
 
package/ui/index.html CHANGED
@@ -812,7 +812,7 @@
812
812
  display:flex;align-items:flex-start;gap:8px;padding:10px 12px;
813
813
  border-radius:var(--r-md);background:var(--float);
814
814
  border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi);
815
- font-size:12px;line-height:1.45;color:var(--ink);
815
+ font-size:13px;line-height:1.45;color:var(--ink);
816
816
  pointer-events:auto;touch-action:none;transform-origin:50% 0;
817
817
  will-change:transform,opacity;
818
818
  overflow:hidden;
@@ -826,21 +826,35 @@
826
826
  @media (prefers-reduced-motion:reduce){ .toast{transition:opacity 120ms} }
827
827
 
828
828
  /* Same three-channel encoding as .status — glyph, weight, luminance — so a
829
- toast and a status pill never disagree, and meaning survives without hue. */
830
- .toast .g{flex:none;font-weight:600;line-height:1.45}
831
- .toast.ok .g{color:var(--ok)} .toast.ok .g::before{content:"\2713"}
832
- .toast.warn .g{color:var(--warn)} .toast.warn .g::before{content:"!"}
833
- .toast.err .g{color:var(--err)} .toast.err .g::before{content:"\00d7"}
834
- .toast.info .g{color:var(--mute)} .toast.info .g::before{content:"\2022"}
829
+ toast and a status pill never disagree, and meaning survives without hue.
830
+ The glyphs are SHAPES now, not characters: the error glyph used to be the
831
+ × character, which is the same character as the Dismiss button beside it
832
+ (reported 11 Sep 2026). Each kind has a silhouette nothing else on the
833
+ page uses — circle-tick, triangle-bang, octagon-bang, circle-i — and the
834
+ one × left in a toast is the control that closes it. */
835
+ .toast .g{flex:none;width:16px;height:16px;margin-top:1px;line-height:0}
836
+ .toast .g svg{width:16px;height:16px;display:block}
837
+ .toast.ok .g{color:var(--ok)}
838
+ .toast.warn .g{color:var(--warn)}
839
+ .toast.err .g{color:var(--err)}
840
+ .toast.info .g{color:var(--mute)}
841
+ /* A path is part of the message, in the message's face — it was <code> at
842
+ the browser's monospace default, a second typeface for no reason. It gets
843
+ its own line, so the sentence ends and the path begins where the eye
844
+ expects, and it wraps on its own separators rather than mid-word. */
845
+ .toast code{font:inherit}
846
+ .toast .path{display:block;margin-top:6px;font:inherit;color:var(--ink);
847
+ overflow-wrap:anywhere}
848
+ .toast.err .path{color:var(--warn)}
835
849
  /* Error toast: the CONTAINER stays quiet — stroke close to its fill, the same
836
850
  relationship a standard button has — and the message carries the colour,
837
851
  rather than a tinted stroke and glyph shouting around neutral text. */
838
852
  .toast.err{border-color:var(--edge)}
839
853
  .toast.err .g, .toast.err .msg{color:var(--warn)}
840
854
  .toast .msg{min-width:0;word-break:break-word}
841
- .toast .x{flex:none;margin:-4px -4px 0 4px;width:22px;height:22px;padding:0;
855
+ .toast .x{flex:none;margin:-3px -4px 0 4px;width:22px;height:22px;padding:0;
842
856
  background:none;border:none;box-shadow:none;color:var(--mute);
843
- font-size:14px;line-height:22px;border-radius:var(--r-sm);opacity:0;
857
+ font-size:15px;line-height:22px;border-radius:var(--r-sm);opacity:0;
844
858
  transition:opacity var(--t), background-color var(--t), color var(--t)}
845
859
  /* Close is stack-level furniture: it shows when the stack is open, so a
846
860
  resting stack stays clean. Always available to keyboard users. */
@@ -1244,6 +1258,25 @@ function layoutToasts(expanded) {
1244
1258
  });
1245
1259
  }
1246
1260
 
1261
+ /* One silhouette per kind, none of them an ×. 16px, currentColor, drawn on a
1262
+ 16-unit grid so the stroke stays 1.5px at 1×. */
1263
+ const TOAST_GLYPH = {
1264
+ ok: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.2l2 2 4-4.4"/></svg>',
1265
+ warn: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2 14.3 13.2H1.7z"/><path d="M8 6.4v3.2"/><circle cx="8" cy="11.4" r=".55" fill="currentColor" stroke="none"/></svg>',
1266
+ err: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5.4 1.8h5.2l3.6 3.6v5.2l-3.6 3.6H5.4L1.8 10.6V5.4z"/><path d="M8 4.9v3.8"/><circle cx="8" cy="11.1" r=".55" fill="currentColor" stroke="none"/></svg>',
1267
+ info: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M8 7.2v4"/><circle cx="8" cy="4.9" r=".55" fill="currentColor" stroke="none"/></svg>',
1268
+ };
1269
+
1270
+ /* A file path for a toast: its own line, full and unshortened, in the message's
1271
+ own face. Escaped, because a path is user data. */
1272
+ function toastPath(p){
1273
+ const esc = String(p || '').replace(/&/g, '&amp;').replace(/</g, '&lt;');
1274
+ // Break opportunities after every separator, so a long path wraps at a
1275
+ // slash rather than in the middle of a filename. overflow-wrap:anywhere in
1276
+ // the CSS is the fallback for a single segment wider than the toast.
1277
+ return `<span class="path">${esc.replace(/\//g, '/<wbr>')}</span>`;
1278
+ }
1279
+
1247
1280
  function toast(kind, html, opts = {}) {
1248
1281
  const wrap = $('#toasts');
1249
1282
  if (!wrap) return null;
@@ -1251,7 +1284,7 @@ function toast(kind, html, opts = {}) {
1251
1284
  const el = document.createElement('div');
1252
1285
  el.className = `toast ${kind}`;
1253
1286
  if (kind === 'err') el.setAttribute('role', 'alert');
1254
- el.innerHTML = `<span class="g" aria-hidden="true"></span><div class="msg"></div>`;
1287
+ el.innerHTML = `<span class="g" aria-hidden="true">${TOAST_GLYPH[kind] || TOAST_GLYPH.info}</span><div class="msg"></div>`;
1255
1288
  el.querySelector('.msg').innerHTML = html;
1256
1289
 
1257
1290
  const life = opts.ms !== undefined ? opts.ms : TOASTS[kind];
@@ -1490,6 +1523,11 @@ async function clearSource(role){
1490
1523
  // Nothing to draw on: the canvas goes back to its empty state rather
1491
1524
  // than showing the last photograph with no way to act on it.
1492
1525
  const c = $('#c'); c.getContext('2d').clearRect(0, 0, c.width, c.height);
1526
+ // ... and the edge view, which would otherwise keep showing a strip of a
1527
+ // photograph that is no longer here.
1528
+ for (const id of ['strip', 'stripF']) {
1529
+ const k = $('#' + id); if (k) k.getContext('2d').clearRect(0, 0, k.width, k.height);
1530
+ }
1493
1531
  $('#cvbar').hidden = true;
1494
1532
  $('#detSt').className = 'status';
1495
1533
  $('#detSt').textContent = 'Photo removed. A fit you saved for it comes back when you pick it again.';
@@ -1501,6 +1539,10 @@ async function clearSource(role){
1501
1539
  : 'Screenshot removed.';
1502
1540
  }
1503
1541
  markStale();
1542
+ // The control that had focus has just hidden itself; without this, focus
1543
+ // drops to <body> and a keyboard user is nowhere. The empty chip is the
1544
+ // natural next action.
1545
+ chip.focus();
1504
1546
  }
1505
1547
  document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
1506
1548
 
@@ -2715,13 +2757,17 @@ async function renderPreview(){
2715
2757
  }
2716
2758
  }catch(e){ say(e.message); }
2717
2759
  }
2718
- function prettyDir(p){
2719
- // ~ for home, and show the last two segments of a long path: the interesting
2720
- // part of a long output path is the end, not the volume.
2760
+ function homePath(p){
2761
+ // ~ for home, nothing else touched. Now that a path has its own line and
2762
+ // wraps, it can afford to be complete — an elided "…/mockups/file.png" told
2763
+ // you the file and not where it was, which is the half people go looking for.
2721
2764
  const home = st.home || '';
2722
- let d = (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
2723
- const seg = d.split('/');
2724
- return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
2765
+ return (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
2766
+ }
2767
+ function prettyDir(p){
2768
+ // The short form, for places that cannot wrap: the last two segments.
2769
+ const seg = homePath(p).split('/');
2770
+ return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : homePath(p);
2725
2771
  }
2726
2772
 
2727
2773
  let preset = 'web';
@@ -2859,7 +2905,7 @@ async function renderVideo(){
2859
2905
  setRenderProgress(null);
2860
2906
  b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
2861
2907
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
2862
- toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
2908
+ toast('ok', `Rendered ${d.done} frames` + toastPath(homePath(d.output || '')));
2863
2909
  const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
2864
2910
  } else if (d.state === 'error'){
2865
2911
  clearInterval(tick);
@@ -2883,8 +2929,8 @@ $('#save').onclick = async () => {
2883
2929
  // Naming the fit file here is the whole reason it is discoverable: it is
2884
2930
  // written silently beside the mockup, and a file nobody knows about is a
2885
2931
  // file nobody drags back in.
2886
- toast('ok', `Saved <code>${r.output.split('/').pop()}</code> to <code>${prettyDir(st.outDir || '')}</code>`
2887
- + (r.fit_file ? ` · the fit is beside it as <code>${r.fit_file.split('/').pop()}</code> — drop that back on this page to reuse these corners` : ''),
2932
+ toast('ok', 'Saved' + toastPath(homePath(r.output))
2933
+ + (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : ''),
2888
2934
  { ms: 9000, dismissible: true });
2889
2935
  const imp = $('#imp');
2890
2936
  imp.disabled = false; imp.textContent = 'Send to Claude';
@@ -2943,7 +2989,7 @@ $('#imp').onclick = async () => {
2943
2989
  }
2944
2990
  // Say up front where Save will put things. With --out-dir this is usually the
2945
2991
  // project folder, and a designer should not have to press Save to find out.
2946
- toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
2992
+ toast('info', 'Fit the four edges, then Preview. Saves go to' + toastPath(homePath(s.out_dir || '')),
2947
2993
  { ms: 9000, dismissible: true });
2948
2994
  const types = $('#types');
2949
2995
  for (const t of ['phone','tablet','laptop','desktop']){