screengraft 0.61.0 → 0.62.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 +1 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +56 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.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.
|
|
8
|
+
**What ships (v0.62):** 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
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
/* surface/float is a real token (#26262b, the chip hover surface). The
|
|
40
40
|
canvas overlay is a separate thing and gets its own name. */
|
|
41
41
|
--float:#26262b;
|
|
42
|
+
/* --float at 86%: the popover's glass, over a background blur. */
|
|
43
|
+
--float-glass:rgba(38,38,43,.86);
|
|
42
44
|
--overlay:rgba(17,17,18,.98);
|
|
43
45
|
--ink:#e9ecf1; --mute:#8f939a; --faint:#8f939ab2;
|
|
44
46
|
--line:#1d1e20; --edge:#3a3b41; --edge-mid:#494a50; --edge-hi:#6d6f79;
|
|
@@ -459,7 +461,12 @@
|
|
|
459
461
|
make it visible", 12 Sep 2026). Its own keyboard stop with its own name
|
|
460
462
|
("Remove photo"), distinct from the fit bar's Reset -- that resets the
|
|
461
463
|
QUAD; this un-chooses a SOURCE. Only present while the chip is filled. */
|
|
462
|
-
|
|
464
|
+
/* Above the scrim (z 50) and beside the popover (z 51): the chip whose
|
|
465
|
+
popover is open stays visible and lit, so it is clear which source is
|
|
466
|
+
being chosen; the other chip stays clickable to switch (14 Sep 2026). The
|
|
467
|
+
top bar creates no stacking context of its own, so this z-index reaches
|
|
468
|
+
the root. */
|
|
469
|
+
.chipwrap{position:relative;display:inline-flex;min-width:0;z-index:52}
|
|
463
470
|
.chipwrap .inputchip:not(.is-empty){padding-right:30px}
|
|
464
471
|
.chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
|
465
472
|
width:22px;height:22px;padding:0;border-radius:999px;flex:none;
|
|
@@ -1006,8 +1013,13 @@
|
|
|
1006
1013
|
opacity:0;visibility:hidden;
|
|
1007
1014
|
transition:opacity var(--t-pop), visibility 0s linear var(--t-pop)}
|
|
1008
1015
|
.scrim.on{opacity:1;visibility:visible;transition:opacity var(--t-pop), visibility 0s}
|
|
1016
|
+
/* surface/float at 86% over a background blur. Figma's blur radius is
|
|
1017
|
+
about twice CSS's -- a Figma background blur of 10 is blur(5px) here
|
|
1018
|
+
(14 Sep 2026, on request). Enough shows through to read as glass; the
|
|
1019
|
+
stroke stays a hairline. */
|
|
1009
1020
|
.pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
|
|
1010
|
-
max-height:78vh;overflow:auto;background:var(--float);border:
|
|
1021
|
+
max-height:78vh;overflow:auto;background:var(--float-glass);border:var(--bw) solid var(--edge);
|
|
1022
|
+
backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
|
|
1011
1023
|
border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
|
|
1012
1024
|
opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
|
|
1013
1025
|
transform-origin:50% 0;
|
|
@@ -1047,11 +1059,23 @@
|
|
|
1047
1059
|
.pv{border-radius:var(--r-md);background:var(--sunk);min-height:0;
|
|
1048
1060
|
display:grid;place-items:center;overflow:hidden}
|
|
1049
1061
|
.pv img{max-width:100%;max-height:300px;object-fit:contain;display:block}
|
|
1050
|
-
|
|
1062
|
+
/* The drop zone's dashed rule is drawn, not bordered: CSS `dashed` fixes the
|
|
1063
|
+
dash at ~3× the stroke and cannot be tuned. A masked pseudo-element paints
|
|
1064
|
+
a token colour through an SVG rounded rect with a 5px dash / 4px gap
|
|
1065
|
+
(2 longer / 1 longer than the browser's, on request, 14 Sep 2026) and a
|
|
1066
|
+
1px stroke centred on the box edge, so half a pixel shows inside -- the
|
|
1067
|
+
same hairline weight as every other stroke -- in the lighter edge tone. */
|
|
1068
|
+
.drop{position:relative;border:0;border-radius:var(--r-md);padding:30px 14px;text-align:center;
|
|
1051
1069
|
color:var(--mute);cursor:pointer;margin-top:12px;font-size:12px;
|
|
1052
1070
|
transition:var(--t-all)}
|
|
1053
|
-
.drop:
|
|
1054
|
-
|
|
1071
|
+
.drop::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
|
|
1072
|
+
background:var(--edge-mid);transition:background-color var(--t);
|
|
1073
|
+
-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' rx='8' fill='none' stroke='%23000' stroke-width='1' stroke-dasharray='5 4'/%3E%3C/svg%3E") 0 0/100% 100% no-repeat;
|
|
1074
|
+
mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' rx='8' fill='none' stroke='%23000' stroke-width='1' stroke-dasharray='5 4'/%3E%3C/svg%3E") 0 0/100% 100% no-repeat}
|
|
1075
|
+
.drop:hover{color:var(--ink)}
|
|
1076
|
+
.drop:hover::before{background:var(--edge-hi)}
|
|
1077
|
+
.drop.over{color:var(--ink);background:var(--acc-soft)}
|
|
1078
|
+
.drop.over::before{background:var(--acc-stroke)}
|
|
1055
1079
|
.sm{font-size:12px}
|
|
1056
1080
|
code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
|
|
1057
1081
|
border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
|
|
@@ -1850,8 +1874,11 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
|
1850
1874
|
// popover. Same upload path as the popover's drop zone.
|
|
1851
1875
|
const chip = $('#chip'+n);
|
|
1852
1876
|
chip.ondragover = e => { if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) { e.preventDefault(); chip.classList.add('over'); } };
|
|
1853
|
-
chip
|
|
1854
|
-
|
|
1877
|
+
// Leaving with a drag only un-lights the chip if its popover is not the
|
|
1878
|
+
// reason it is lit.
|
|
1879
|
+
const popOpen = () => $('#pop'+n).classList.contains('on');
|
|
1880
|
+
chip.ondragleave = () => { if (!popOpen()) chip.classList.remove('over'); };
|
|
1881
|
+
chip.ondrop = e => { e.preventDefault(); if (!popOpen()) chip.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
|
|
1855
1882
|
}
|
|
1856
1883
|
/* One popover mechanic for every popover: anchor it under whatever opened it,
|
|
1857
1884
|
clamped into the viewport. The pickers and the preview both use it. */
|
|
@@ -1862,10 +1889,16 @@ function openPopAt(pop, anchor){
|
|
|
1862
1889
|
pop.style.left = Math.max(12, Math.min(window.innerWidth - w - 12, anchor.left)) + 'px';
|
|
1863
1890
|
pop.style.top = (anchor.bottom + 8) + 'px';
|
|
1864
1891
|
}
|
|
1865
|
-
|
|
1892
|
+
// The chip whose popover is open wears the Input chip's ACTIVE state (the
|
|
1893
|
+
// same one a file dragged over it shows): the popover is about that chip.
|
|
1894
|
+
function openPop(n){
|
|
1895
|
+
openPopAt($('#pop'+n), $('#chip'+n).getBoundingClientRect());
|
|
1896
|
+
$('#chip'+n).classList.add('over');
|
|
1897
|
+
}
|
|
1866
1898
|
function closePop(){
|
|
1867
1899
|
document.querySelectorAll('.pop').forEach(p=>p.classList.remove('on'));
|
|
1868
1900
|
$('#scrim').classList.remove('on');
|
|
1901
|
+
document.querySelectorAll('.inputchip.over').forEach(c => c.classList.remove('over'));
|
|
1869
1902
|
}
|
|
1870
1903
|
$('#scrim').onclick = closePop;
|
|
1871
1904
|
|
|
@@ -3078,10 +3111,22 @@ function swingText(mode){
|
|
|
3078
3111
|
the strip (H / 2·stripHalf, so the ± buttons work here too), centred on the
|
|
3079
3112
|
corner, unrotated, with both edges drawn through it and the ring at the
|
|
3080
3113
|
centre. Requested 13 Sep 2026; edges keep the strip as it is. */
|
|
3114
|
+
// The corner view's own scale. A corner is an ARC, not a point: it starts a
|
|
3115
|
+
// radius away from where the two edges would meet, on each edge. At the edge
|
|
3116
|
+
// strip's magnification (±14px) a rounded corner showed only the bezel's
|
|
3117
|
+
// blank curve and neither edge, so there was nothing to judge (14 Sep 2026).
|
|
3118
|
+
// The window covers 2.5 radii on each side -- the whole arc plus enough straight
|
|
3119
|
+
// edge to see it land -- never tighter than the strip. The radius in photo px
|
|
3120
|
+
// is the radius fraction (of the screenshot's width) times the quad's top edge.
|
|
3121
|
+
function cornerHalf(){
|
|
3122
|
+
const C = st.corners; if (!C) return stripHalf;
|
|
3123
|
+
const rPhoto = (typeof radiusValue === 'function' ? radiusValue() : 0) * vlen(sub(C[1], C[0]));
|
|
3124
|
+
return Math.max(stripHalf, 1.25 * rPhoto, 24);
|
|
3125
|
+
}
|
|
3081
3126
|
function paintCorner(c, W, H, i){
|
|
3082
3127
|
c.setTransform(1,0,0,1,0,0);
|
|
3083
3128
|
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
3084
|
-
const P = st.corners[i], M = H / (2*
|
|
3129
|
+
const P = st.corners[i], M = H / (2*cornerHalf());
|
|
3085
3130
|
c.save();
|
|
3086
3131
|
c.setTransform(M, 0, 0, M, W/2 - P[0]*M, H/2 - P[1]*M);
|
|
3087
3132
|
c.imageSmoothingEnabled = true;
|
|
@@ -3115,7 +3160,7 @@ function drawStrip(){
|
|
|
3115
3160
|
}
|
|
3116
3161
|
if (corner){
|
|
3117
3162
|
paintCorner(sctx, W, H, a.i);
|
|
3118
|
-
$('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*
|
|
3163
|
+
$('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
|
|
3119
3164
|
} else {
|
|
3120
3165
|
paintStrip(sctx, W, H, a.i, a.mode);
|
|
3121
3166
|
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
|
|
@@ -3125,7 +3170,7 @@ function drawStrip(){
|
|
|
3125
3170
|
if (!has){ fl.classList.remove('on'); return; }
|
|
3126
3171
|
if (corner){
|
|
3127
3172
|
paintCorner(fctx, stripF.width, stripF.height, a.i);
|
|
3128
|
-
$('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*
|
|
3173
|
+
$('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
|
|
3129
3174
|
} else {
|
|
3130
3175
|
paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
|
|
3131
3176
|
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
|