screengraft 0.46.0 → 0.47.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 +1 -1
- package/scripts/ui.py +4 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +29 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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
|
-
|
|
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.
|
|
8
|
+
**What ships (v0.47):** 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
|
@@ -381,7 +381,7 @@
|
|
|
381
381
|
NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
|
|
382
382
|
centred placeholder-paragraph class (padding:24px), and the chip was
|
|
383
383
|
silently inheriting it and rendering 50px tall instead of 32. */
|
|
384
|
-
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:
|
|
384
|
+
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
|
|
385
385
|
height:32px;padding:0 12px 0 6px;border-radius:999px;
|
|
386
386
|
background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
|
|
387
387
|
.inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
|
|
@@ -393,17 +393,23 @@
|
|
|
393
393
|
.inputchip.is-empty{background:var(--card);border-style:dashed;
|
|
394
394
|
color:var(--mute);box-shadow:none}
|
|
395
395
|
.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:
|
|
397
|
-
<button>, and a button inside a button is invalid HTML
|
|
398
|
-
repair unpredictably.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
.
|
|
396
|
+
/* The clear control is a SIBLING of the chip in the markup, not a child:
|
|
397
|
+
the chip is one <button>, and a button inside a button is invalid HTML
|
|
398
|
+
that browsers repair unpredictably. It is drawn INSIDE the pill though --
|
|
399
|
+
.chipwrap positions it over the chip's right end and the filled chip
|
|
400
|
+
reserves room for it -- because beside the chip it read as a stray
|
|
401
|
+
character and nobody found it ("place clear buttons inside of chip and
|
|
402
|
+
make it visible", 12 Sep 2026). Its own keyboard stop with its own name
|
|
403
|
+
("Remove photo"), distinct from the fit bar's Reset -- that resets the
|
|
404
|
+
QUAD; this un-chooses a SOURCE. Only present while the chip is filled. */
|
|
405
|
+
.chipwrap{position:relative;display:inline-flex;min-width:0}
|
|
406
|
+
.chipwrap .inputchip:not(.is-empty){padding-right:30px}
|
|
407
|
+
.chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
|
408
|
+
width:22px;height:22px;padding:0;border-radius:999px;flex:none;
|
|
409
|
+
font-size:15px;line-height:1;color:var(--ink);
|
|
410
|
+
background:var(--raise-hi);border:1px solid var(--edge)}
|
|
411
|
+
.chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
412
|
+
.chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
|
|
407
413
|
|
|
408
414
|
/* Status carries meaning in THREE channels: glyph, weight, luminance —
|
|
409
415
|
never hue alone. */
|
|
@@ -922,11 +928,9 @@
|
|
|
922
928
|
<div class="tb-left">
|
|
923
929
|
<span class="brand">Screengraft</span><span id="sess" hidden></span>
|
|
924
930
|
<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>×</button>
|
|
931
|
+
<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>×</button></span>
|
|
927
932
|
<span class="tb-arrow">→</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>×</button>
|
|
933
|
+
<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>×</button></span>
|
|
930
934
|
</div>
|
|
931
935
|
</div>
|
|
932
936
|
<span class="spacer"></span>
|
|
@@ -1512,6 +1516,7 @@ async function clearSource(role){
|
|
|
1512
1516
|
const chip = $('#chip'+n);
|
|
1513
1517
|
chip.classList.add('is-empty');
|
|
1514
1518
|
chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
|
|
1519
|
+
chip.title = '';
|
|
1515
1520
|
$('#sw'+n).style.background = '';
|
|
1516
1521
|
$('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
|
|
1517
1522
|
$('#clear'+n).hidden = true;
|
|
@@ -1560,7 +1565,14 @@ function setChosen(role, path, size, el, meta){
|
|
|
1560
1565
|
$('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
|
|
1561
1566
|
const chip = $('#chip'+n);
|
|
1562
1567
|
chip.classList.remove('is-empty');
|
|
1563
|
-
|
|
1568
|
+
// The file's own name and nothing else. The size and, for a clip, the frame
|
|
1569
|
+
// count used to follow it in the same 12px, which pushed the one thing that
|
|
1570
|
+
// identifies the file into an ellipsis; they live on hover now, with the
|
|
1571
|
+
// full path. An upload's session copy is "<role>-<epoch>-<name>", so the
|
|
1572
|
+
// server sends the original name back as `name`.
|
|
1573
|
+
const shown = (meta && meta.name) || path.split('/').pop();
|
|
1574
|
+
chip.querySelector('.nm').textContent = shown;
|
|
1575
|
+
chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
|
|
1564
1576
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1565
1577
|
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
|
|
1566
1578
|
$('#clear'+n).hidden = false;
|
|
@@ -2818,12 +2830,6 @@ function homePath(p){
|
|
|
2818
2830
|
const home = st.home || '';
|
|
2819
2831
|
return (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
|
|
2820
2832
|
}
|
|
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
2833
|
let preset = 'web';
|
|
2828
2834
|
document.querySelectorAll('[data-preset]').forEach(btn => {
|
|
2829
2835
|
btn.onclick = () => {
|