screengraft 0.63.10 → 0.64.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.63.10",
3
+ "version": "0.64.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",
@@ -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
@@ -3093,6 +3114,7 @@ function paintGizmo(){
3093
3114
  gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
3094
3115
  gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
3095
3116
  const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
3117
+ gz.style.setProperty('--gk', k); // every stroke width in the CSS is screen px * this
3096
3118
  const g = gizmoGeom(); if (!g.H || !g.Hi) return;
3097
3119
  const P = (f, q) => applyH(g.H, gzPt(g, f, q));
3098
3120
  const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, dofK);
@@ -3119,9 +3141,9 @@ function paintGizmo(){
3119
3141
  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
3142
  const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
3121
3143
  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)) : '') +
3144
+ LL(a0, a1, ac, 'case') + LL(a0, a1, ac, 'focus') +
3145
+ LL(b0, b1, bc, 'case', dash(beyond)) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
3146
+ (two ? LL(c0, c1, cc, 'case', dash(beyond2)) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
3125
3147
  hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
3126
3148
  hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
3127
3149
  ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +