screengraft 0.46.0 → 0.48.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.46.0",
3
+ "version": "0.48.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
@@ -900,7 +900,10 @@ class Handler(BaseHTTPRequestHandler):
900
900
  with open(dest, "wb") as f:
901
901
  f.write(self._body())
902
902
  # A video is only ever a screen source; a photo must be a still.
903
- return self._json(_adopt(role, dest))
903
+ # The chip shows the file's OWN name: the session copy is
904
+ # "<role>-<epoch>-<name>", and a chip reading "photo-1789…" told
905
+ # the person nothing about which photo was loaded (12 Sep 2026).
906
+ return self._json({**_adopt(role, dest), "name": name})
904
907
 
905
908
  b = self._jbody()
906
909
 
@@ -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.46):** 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.48):** 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
@@ -366,7 +366,16 @@
366
366
  until it is selected: no border, no shadow, no lift on press. */
367
367
  .seg button{height:22px;padding:0 11px;font-size:12px;border-radius:var(--r-sm);
368
368
  background:transparent;border:0;box-shadow:none;color:var(--mute);
369
- font-weight:400}
369
+ font-weight:400;display:inline-flex;flex-direction:column;
370
+ align-items:center;justify-content:center}
371
+ /* Each segment is as wide as its label at the SELECTED weight, whichever
372
+ weight it is showing. Without this the control reflowed on every switch:
373
+ 600 is wider than 400, so selecting "ProRes" grew that segment, and the
374
+ whole control — left edge included — moved (reported 12 Sep 2026).
375
+ A zero-height ghost of the label at 600 sits under the visible one and
376
+ sets the width; `data-label` carries the text. Not padding. */
377
+ .seg button::after{content:attr(data-label);font-weight:600;height:0;
378
+ overflow:hidden;visibility:hidden;pointer-events:none}
370
379
  .seg button:hover{background:transparent;color:var(--ink)}
371
380
  .seg button:active{background:transparent;transform:none}
372
381
  /* The thumb. Its three surfaces are the Default button's own, which is the
@@ -381,7 +390,7 @@
381
390
  NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
382
391
  centred placeholder-paragraph class (padding:24px), and the chip was
383
392
  silently inheriting it and rendering 50px tall instead of 32. */
384
- .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:300px;
393
+ .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
385
394
  height:32px;padding:0 12px 0 6px;border-radius:999px;
386
395
  background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
387
396
  .inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
@@ -393,17 +402,30 @@
393
402
  .inputchip.is-empty{background:var(--card);border-style:dashed;
394
403
  color:var(--mute);box-shadow:none}
395
404
  .inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
396
- /* The clear control is a SIBLING of the chip, not a child: the chip is one
397
- <button>, and a button inside a button is invalid HTML that browsers
398
- repair unpredictably. Beside it, it is its own keyboard stop with its own
399
- name ("Remove photo"), which is also what makes it distinct from the fit
400
- bar's Reset -- that resets the QUAD; this un-chooses a SOURCE. A small
401
- round Sm button, so it reads as belonging to the chip without competing
402
- with it. Only present while the chip is filled. */
403
- .chipclear{width:24px;height:24px;padding:0;border-radius:999px;flex:none;
404
- margin-left:calc(-1 * var(--s1));font-size:15px;line-height:1;
405
- color:var(--mute)}
406
- .chipclear:hover{color:var(--ink)}
405
+ /* The clear control is a SIBLING of the chip in the markup, not a child:
406
+ the chip is one <button>, and a button inside a button is invalid HTML
407
+ that browsers repair unpredictably. It is drawn INSIDE the pill though --
408
+ .chipwrap positions it over the chip's right end and the filled chip
409
+ reserves room for it -- because beside the chip it read as a stray
410
+ character and nobody found it ("place clear buttons inside of chip and
411
+ make it visible", 12 Sep 2026). Its own keyboard stop with its own name
412
+ ("Remove photo"), distinct from the fit bar's Reset -- that resets the
413
+ QUAD; this un-chooses a SOURCE. Only present while the chip is filled. */
414
+ .chipwrap{position:relative;display:inline-flex;min-width:0}
415
+ .chipwrap .inputchip:not(.is-empty){padding-right:30px}
416
+ .chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
417
+ width:22px;height:22px;padding:0;border-radius:999px;flex:none;
418
+ display:inline-flex;align-items:center;justify-content:center;
419
+ color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge)}
420
+ /* The cross is drawn, not typed. As the &times; character at 15px it sat
421
+ above centre in its disc -- a glyph carries its own ascent and side
422
+ bearings and no line-height setting centres it in a 22px circle ("X icon
423
+ is not fully centered", 12 Sep 2026). A 10px SVG in a flex
424
+ centre is centred by construction, and it is the same route the toast
425
+ glyphs took in v0.42.0. */
426
+ .chipclear svg{display:block;width:10px;height:10px}
427
+ .chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
428
+ .chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
407
429
 
408
430
  /* Status carries meaning in THREE channels: glyph, weight, luminance —
409
431
  never hue alone. */
@@ -922,11 +944,9 @@
922
944
  <div class="tb-left">
923
945
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
924
946
  <div class="tb-chips">
925
- <button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button>
926
- <button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden>&times;</button>
947
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
927
948
  <span class="tb-arrow">&rarr;</span>
928
- <button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button>
929
- <button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden>&times;</button>
949
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
930
950
  </div>
931
951
  </div>
932
952
  <span class="spacer"></span>
@@ -941,7 +961,7 @@
941
961
  the one-accent-on-screen rule is untouched. -->
942
962
  <div class="tb-actions">
943
963
  <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
944
- <button class="sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
964
+ <button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
945
965
  </span>
946
966
  <button id="save" class="accent" disabled>Preview first</button>
947
967
  <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
@@ -984,7 +1004,7 @@
984
1004
  <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>
985
1005
  <button class="sm" id="redetect">Re-detect</button>
986
1006
  <button class="sm" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button>
987
- <button class="sm" id="rotatequad" title="Turn the screenshot a quarter turn inside the same four edges. The edges stay where they are; only which one is the top changes. Use this when the phone lies on its side and the screenshot lands sideways.">Rotate 90°</button>
1007
+ <button class="sm" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button>
988
1008
  <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>
989
1009
  </span>
990
1010
  </div>
@@ -1512,12 +1532,12 @@ async function clearSource(role){
1512
1532
  const chip = $('#chip'+n);
1513
1533
  chip.classList.add('is-empty');
1514
1534
  chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
1535
+ chip.title = '';
1515
1536
  $('#sw'+n).style.background = '';
1516
1537
  $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
1517
1538
  $('#clear'+n).hidden = true;
1518
1539
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1519
- stopLive();
1520
- showResult('empty');
1540
+ dropResult();
1521
1541
  if (role==='photo'){
1522
1542
  st.photo = null; st.corners = null; st.remembered = null; st.quadFrom = null;
1523
1543
  st.measured = null;
@@ -1539,13 +1559,27 @@ async function clearSource(role){
1539
1559
  ? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
1540
1560
  : 'Screenshot removed.';
1541
1561
  }
1542
- markStale();
1543
1562
  // The control that had focus has just hidden itself; without this, focus
1544
1563
  // drops to <body> and a keyboard user is nowhere. The empty chip is the
1545
1564
  // natural next action.
1546
1565
  chip.focus();
1547
1566
  }
1548
1567
  document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
1568
+ /* A source has changed, so whatever the Result pane shows was made from
1569
+ something that is no longer chosen. One place for the three things that
1570
+ must happen together: stop the live clip (its <video> still holds the OLD
1571
+ file and would keep playing it), empty the pane, and un-light Save. Before
1572
+ this, swapping a clip for a screenshot — or one photo for another with a
1573
+ fit already in hand — left the previous composite on screen, and the live
1574
+ layer kept the previous clip running, because setChosen() only redrew the
1575
+ canvas and renderPreview() will not take the pane away from a playing clip
1576
+ (reported 12 Sep 2026: "the preview is not being updated"). */
1577
+ function dropResult(){
1578
+ stopLive();
1579
+ showResult('empty');
1580
+ $('#outSt').textContent = '';
1581
+ markStale();
1582
+ }
1549
1583
 
1550
1584
  async function choose(role, path, el){
1551
1585
  try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
@@ -1560,7 +1594,18 @@ function setChosen(role, path, size, el, meta){
1560
1594
  $('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
1561
1595
  const chip = $('#chip'+n);
1562
1596
  chip.classList.remove('is-empty');
1563
- chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1597
+ // The file's own name and nothing else. The size and, for a clip, the frame
1598
+ // count used to follow it in the same 12px, which pushed the one thing that
1599
+ // identifies the file into an ellipsis; they live on hover now, with the
1600
+ // full path. An upload's session copy is "<role>-<epoch>-<name>", so the
1601
+ // server sends the original name back as `name`.
1602
+ const shown = (meta && meta.name) || path.split('/').pop();
1603
+ chip.querySelector('.nm').textContent = shown;
1604
+ // A different file in either role: the composite on screen is of the old
1605
+ // one. Dropped here, before the state changes, so nothing downstream can
1606
+ // narrate over it; maybeStart() re-renders once the fit is ready.
1607
+ if ((role==='photo' ? st.photo : st.shot) !== path) dropResult();
1608
+ chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
1564
1609
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1565
1610
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1566
1611
  $('#clear'+n).hidden = false;
@@ -1748,7 +1793,7 @@ async function maybeStart(){
1748
1793
  }
1749
1794
  $('#cvbar').hidden = false;
1750
1795
  if (st.corners){
1751
- draw(); drawStrip();
1796
+ draw(); drawStrip(); autoPreview();
1752
1797
  // A reload arrives here with the session's corners already in hand, and
1753
1798
  // the only thing on the status line is the "now choose the screenshot"
1754
1799
  // text from the photo-only step above — a message about a step already
@@ -2049,10 +2094,22 @@ function turnQuad(){
2049
2094
  if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
2050
2095
  else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
2051
2096
  }
2097
+ // A quarter turn is only offered where the screenshot still FITS. A portrait
2098
+ // screenshot on a phone fits two ways, top on either short edge; turned onto a
2099
+ // long edge it is mapped anyway — 804px across a 890px edge and 1748px down a
2100
+ // 441px one — and the 4:1 squash also flattens the corner rounding into an
2101
+ // ellipse, which is what "the corners rotate too and no longer fit the frame"
2102
+ // looked like (reported 12 Sep 2026). So Rotate steps to the NEXT
2103
+ // orientation the screenshot fits: a half turn on a phone, a quarter turn on a
2104
+ // near-square screen or when the screenshot's aspect suits the other edges.
2052
2105
  function rotateQuad(){
2053
2106
  if (!st.corners) return;
2054
2107
  turnQuad();
2108
+ let turns = 1;
2109
+ if (!orientationFits()){ turnQuad(); turns = 2; }
2055
2110
  draw(); drawStrip(); autoPreview();
2111
+ toast('info', turns === 1 ? 'Turned the screenshot a quarter turn.'
2112
+ : 'Turned the screenshot a half turn — a portrait screenshot fits this screen only two ways.');
2056
2113
  }
2057
2114
  $('#rotatequad').onclick = rotateQuad;
2058
2115
  // The automatic half. Detection orders corners geometrically and cannot know
@@ -2066,16 +2123,24 @@ $('#rotatequad').onclick = rotateQuad;
2066
2123
  // this page has it. Near-square quads (under 1.25) are left alone — perspective
2067
2124
  // can make either kind of edge the longer one. Returns whether it turned.
2068
2125
  const ORIENT_ASPECT = 1.25;
2069
- function orientQuad(){
2070
- if (!st.corners || !st.shotSize) return false;
2126
+ // Does the screenshot fit the quad the way it is ordered now? True when the
2127
+ // quad is near-square (either kind of edge can be the top), when there is no
2128
+ // screenshot yet, or when the top edge is the kind the screenshot's aspect
2129
+ // wants — short for portrait, long for landscape. Shared by the automatic
2130
+ // orientation below and by the Rotate button.
2131
+ function orientationFits(){
2132
+ if (!st.corners || !st.shotSize) return true;
2071
2133
  const C = st.corners;
2072
2134
  const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
2073
2135
  const a = (len(0) + len(2)) / 2, b = (len(1) + len(3)) / 2; // edges 0/2 vs 1/3
2074
- if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return false;
2136
+ if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return true;
2075
2137
  const shotPortrait = st.shotSize[1] > st.shotSize[0];
2076
- const topShouldBeShort = shotPortrait;
2077
- const topIsShort = a < b;
2078
- if (topIsShort === topShouldBeShort) return false;
2138
+ return (a < b) === shotPortrait;
2139
+ }
2140
+ function orientQuad(){
2141
+ if (!st.corners || !st.shotSize) return false;
2142
+ if (orientationFits()) return false;
2143
+ const C = st.corners;
2079
2144
  // Two candidate top edges (1 and 3, the other pair); the higher one in the
2080
2145
  // image is the top. Turn once to make edge 1 the top, twice more for edge 3.
2081
2146
  const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;
@@ -2818,12 +2883,6 @@ function homePath(p){
2818
2883
  const home = st.home || '';
2819
2884
  return (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
2820
2885
  }
2821
- function prettyDir(p){
2822
- // The short form, for places that cannot wrap: the last two segments.
2823
- const seg = homePath(p).split('/');
2824
- return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : homePath(p);
2825
- }
2826
-
2827
2886
  let preset = 'web';
2828
2887
  document.querySelectorAll('[data-preset]').forEach(btn => {
2829
2888
  btn.onclick = () => {