screengraft 0.63.10 → 0.64.1

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.63.10",
3
+ "version": "0.64.1",
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",
@@ -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.63):** 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** (the images you have used before, 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.64):** 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** (the images you have used before, 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
@@ -813,12 +813,25 @@
813
813
  /* Same rules as the fit overlay (v0.54.3): thin core, a light casing
814
814
  underneath at 35% black, dashes 6/5. The casing is what survives a
815
815
  photograph; the thinness is the request. */
816
- #dofGizmo .case{stroke:rgba(0,0,0,.35);fill:none;vector-effect:non-scaling-stroke}
817
- #dofGizmo .focus{stroke:#f5623d;stroke-width:1;fill:none;vector-effect:non-scaling-stroke}
818
- #dofGizmo .far{stroke:#f5623d;stroke-width:0.9;fill:none;vector-effect:non-scaling-stroke}
816
+ /* The casing colour is one token so the canvas overlay and the SVG gizmo
817
+ agree. `html.lightcase` is an EXPERIMENT (14 Sep 2026): a white casing
818
+ instead of black, to be judged on real photographs. Flip it with
819
+ ?case=light in the URL or the toggle in Overlay; nothing else changes. */
820
+ :root{--overlay-case:rgba(0,0,0,.35)}
821
+ html.lightcase{--overlay-case:rgba(255,255,255,.55)}
822
+ /* Every gizmo width is a SCREEN px times --gk (photo px per screen px, set
823
+ by paintGizmo on each zoom) -- not vector-effect:non-scaling-stroke. WebKit
824
+ applies non-scaling-stroke to the dash pattern as well as the width, so
825
+ dashes set in photo px came out scaled twice and grew with every zoom out
826
+ (reported 14 Sep 2026); Chrome scaled only the width. Doing the maths
827
+ ourselves makes the two agree, and matches the canvas overlay, which has
828
+ always drawn in screen px. Lines are 1px, casings +2px, as on the canvas. */
829
+ #dofGizmo{--gk:1}
830
+ #dofGizmo .case{stroke:var(--overlay-case);fill:none;stroke-width:calc(3px * var(--gk))}
831
+ #dofGizmo .focus{stroke:#f5623d;stroke-width:calc(1px * var(--gk));fill:none}
832
+ #dofGizmo .far{stroke:#f5623d;stroke-width:calc(1px * var(--gk));fill:none}
819
833
  #dofGizmo .far.out{opacity:.75}
820
- /* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
821
- are viewBox units; non-scaling-stroke does not cover them), so they read
834
+ /* Dash lengths are set inline in photo px scaled by the zoom, so they read
822
835
  as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
823
836
  /* pointer-events:ALL, not auto: the rings are hollow (v0.54.7), and an
824
837
  unfilled SVG shape under `auto` is hit only on its stroke — the centre of
@@ -826,17 +839,17 @@
826
839
  could not be grabbed at all on a steep fit (reported 14 Sep 2026). `all`
827
840
  hits the interior whatever the fill. Each handle also carries a .hit disc,
828
841
  a 10px-radius invisible target, because a 4px ring is not a grab area. */
829
- #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:all;cursor:move}
842
+ #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:calc(2px * var(--gk));pointer-events:all;cursor:move}
830
843
  #dofGizmo .hit{fill:transparent;stroke:none;pointer-events:all;cursor:move}
831
844
  #dofGizmo .hit.pipHit{cursor:inherit}
832
845
  #dofGizmo .hit[data-h="str"]{cursor:ew-resize}
833
- #dofGizmo .hcase{fill:none;stroke:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
846
+ #dofGizmo .hcase{fill:none;stroke:var(--overlay-case);stroke-width:calc(4px * var(--gk));pointer-events:none}
834
847
  #dofGizmo .h.pip{cursor:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round'/%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 11, crosshair}
835
848
  #dofGizmo .h.thumb{cursor:ew-resize}
836
- #dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:1.2}
837
- #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
849
+ #dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:calc(1.2px * var(--gk))}
850
+ #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:calc(2px * var(--gk))}
838
851
  #dofGizmo .h:active{cursor:grabbing}
839
- #dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
852
+ #dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:calc(3px * var(--gk));stroke-linejoin:round;pointer-events:none}
840
853
  #outImg,#outVid{display:block;margin:auto}
841
854
  /* The live layer stacks the clip on the photo. `position:relative` on the
842
855
  wrapper and `transform-origin:0 0` on the video are what make the matrix3d
@@ -1705,7 +1718,15 @@ const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v
1705
1718
  The dark casing underneath is NOT optional and stays: flat accent measures
1706
1719
  ~1.06:1 against mid-tone photography, which is the failure the casing was
1707
1720
  introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
1708
- const CASE_A = 'rgba(0,0,0,.35)', CORE_IDLE = 'rgba(255,255,255,.92)';
1721
+ let CASE_A = 'rgba(0,0,0,.35)'; const CORE_IDLE = 'rgba(255,255,255,.92)';
1722
+ // Casing follows the --overlay-case token (see the CSS), so the light-casing
1723
+ // experiment reaches the canvas overlay too. Read once per toggle, not per draw.
1724
+ function setLightCase(on){
1725
+ document.documentElement.classList.toggle('lightcase', !!on);
1726
+ CASE_A = getComputedStyle(document.documentElement).getPropertyValue('--overlay-case').trim() || CASE_A;
1727
+ try { if (img.naturalWidth) draw(); } catch (e) {} // before the image exists there is nothing to redraw
1728
+ }
1729
+ addEventListener('DOMContentLoaded', () => { if (new URLSearchParams(location.search).get('case') === 'light') setLightCase(true); });
1709
1730
  const CORE_ACTIVE = '#f5623d';
1710
1731
  const CORE_QUAD = '#f23b0d';
1711
1732
  // Line weights for the fit overlay, in one place. Thinned 13 Sep 2026 on
@@ -1726,7 +1747,10 @@ const GUIDE_DASH = [6, 5];
1726
1747
  function cased(c, path, coreW, coreColor, dash){
1727
1748
  c.save();
1728
1749
  if (dash) c.setLineDash(dash);
1729
- c.lineCap = 'round'; c.lineJoin = 'round';
1750
+ // Butt caps on a dashed stroke: a round cap grows every dash by the line
1751
+ // width at both ends, so the 6/5 guide read as ~9/2 under its 2.9px casing
1752
+ // while the gizmo (SVG, butt caps) showed a true 6/5 (14 Sep 2026).
1753
+ c.lineCap = dash ? 'butt' : 'round'; c.lineJoin = 'round';
1730
1754
  c.strokeStyle = CASE_A; c.lineWidth = coreW + CASE_PAD; path(); c.stroke();
1731
1755
  c.strokeStyle = coreColor; c.lineWidth = coreW; path(); c.stroke();
1732
1756
  c.restore();
@@ -2127,6 +2151,7 @@ $('#figmaBtn').onclick = async () => {
2127
2151
  with the two UNCHANGED adjacent lines. Corner handles stay for edges that
2128
2152
  run off-frame or sit behind an occluder. */
2129
2153
  const cv = $('#c'), ctx = cv.getContext('2d');
2154
+ let cvW = 0, cvH = 0, cvDpr = 1; // the canvas's CSS size and the device-pixel factor of its store
2130
2155
  const strip = $('#strip'), sctx = strip.getContext('2d');
2131
2156
  const stripF = $('#stripF'), fctx = stripF.getContext('2d');
2132
2157
  const img = new Image();
@@ -2202,8 +2227,17 @@ function computeFit(){
2202
2227
  function setCanvasZoom(z, focus){
2203
2228
  const prev = scale;
2204
2229
  scale = Math.max(0.05, Math.min(4, z));
2205
- cv.width = Math.round(img.naturalWidth * scale);
2206
- cv.height = Math.round(img.naturalHeight * scale);
2230
+ // The backing store is sized in DEVICE pixels and the element in CSS pixels,
2231
+ // so on a Retina display the photo and the overlay are drawn at 2x and the
2232
+ // overlay's 1px lines are as sharp as the SVG gizmo's in the result pane.
2233
+ // Until 14 Sep 2026 the store was CSS-sized and the browser upsampled it --
2234
+ // every line in the fit pane was softer than the same line one pane over.
2235
+ // draw() undoes the factor with setTransform, so everything below still
2236
+ // thinks in CSS px: cvW/cvH are the CSS size, cv.width/height the store.
2237
+ cvW = Math.round(img.naturalWidth * scale); cvH = Math.round(img.naturalHeight * scale);
2238
+ cvDpr = Math.max(1, window.devicePixelRatio || 1);
2239
+ cv.width = Math.round(cvW * cvDpr); cv.height = Math.round(cvH * cvDpr);
2240
+ cv.style.width = cvW + 'px'; cv.style.height = cvH + 'px';
2207
2241
  $('#czSt').textContent = Math.round(scale*100) + '%';
2208
2242
  draw();
2209
2243
  const sc = $('#scroller');
@@ -2243,8 +2277,8 @@ function zoomAt(z, clientX, clientY){
2243
2277
  const before = pos({clientX, clientY});
2244
2278
  setCanvasZoom(z);
2245
2279
  const sc = $('#scroller'), r = cv.getBoundingClientRect();
2246
- sc.scrollLeft += r.left + before[0] * scale * (r.width / cv.width) - clientX;
2247
- sc.scrollTop += r.top + before[1] * scale * (r.height / cv.height) - clientY;
2280
+ sc.scrollLeft += r.left + before[0] * scale * (r.width / cvW) - clientX;
2281
+ sc.scrollTop += r.top + before[1] * scale * (r.height / cvH) - clientY;
2248
2282
  }
2249
2283
 
2250
2284
  /* A trackpad pinch arrives as a wheel event with ctrlKey synthesised true, and
@@ -2640,9 +2674,10 @@ function draw(){
2640
2674
  // fit, so leaving it on the old corners would make the two panes disagree
2641
2675
  // mid-drag — which is the one thing the side-by-side exists to prevent.
2642
2676
  if (!$('#liveWrap').hidden) placeLiveVideo();
2643
- ctx.clearRect(0,0,cv.width,cv.height);
2677
+ ctx.setTransform(cvDpr, 0, 0, cvDpr, 0, 0); // CSS px from here on; the store is cvDpr times larger
2678
+ ctx.clearRect(0,0,cvW,cvH);
2644
2679
  if (!img.naturalWidth) return;
2645
- ctx.drawImage(img, 0,0, cv.width, cv.height);
2680
+ ctx.drawImage(img, 0,0, cvW, cvH);
2646
2681
  if (!st.corners) return;
2647
2682
  const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
2648
2683
  const active = drag || hover || pick;
@@ -2721,7 +2756,7 @@ function draw(){
2721
2756
 
2722
2757
  function pos(e){
2723
2758
  const r = cv.getBoundingClientRect();
2724
- return [(e.clientX-r.left)*cv.width/r.width/scale, (e.clientY-r.top)*cv.height/r.height/scale];
2759
+ return [(e.clientX-r.left)*cvW/r.width/scale, (e.clientY-r.top)*cvH/r.height/scale];
2725
2760
  }
2726
2761
  function hitTest(p){
2727
2762
  if (!st.corners) return null;
@@ -3093,6 +3128,7 @@ function paintGizmo(){
3093
3128
  gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
3094
3129
  gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
3095
3130
  const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
3131
+ gz.style.setProperty('--gk', k); // every stroke width in the CSS is screen px * this
3096
3132
  const g = gizmoGeom(); if (!g.H || !g.Hi) return;
3097
3133
  const P = (f, q) => applyH(g.H, gzPt(g, f, q));
3098
3134
  const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, dofK);
@@ -3119,9 +3155,9 @@ function paintGizmo(){
3119
3155
  const split = (p0, p1, h) => { const v = unit(sub(p1, p0)); return [[p0, sub(h, mul(v, brk))], [add(h, mul(v, brk)), p1]]; };
3120
3156
  const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
3121
3157
  gz.innerHTML =
3122
- LL(a0, a1, ac, 'case', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
3123
- LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
3124
- (two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
3158
+ LL(a0, a1, ac, 'case') + LL(a0, a1, ac, 'focus') +
3159
+ LL(b0, b1, bc, 'case', dash(beyond)) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
3160
+ (two ? LL(c0, c1, cc, 'case', dash(beyond2)) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
3125
3161
  hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
3126
3162
  hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
3127
3163
  ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +