screengraft 0.56.0 → 0.57.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 +17 -6
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +75 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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
|
@@ -680,9 +680,10 @@ def _grain_gain(b) -> float:
|
|
|
680
680
|
"""How much of the surround's noise floor the screen carries.
|
|
681
681
|
|
|
682
682
|
The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
|
|
683
|
-
is read from the body so a
|
|
684
|
-
|
|
685
|
-
|
|
683
|
+
is read from the body so a caller that does send one (a future control, a
|
|
684
|
+
test) is honoured. Absent means the shipped gain, NOT 1.0: the API is for
|
|
685
|
+
new renders. Replaying an old sidecar is done through compose() directly,
|
|
686
|
+
where the parameter's own default (1.0) reproduces the old bytes.
|
|
686
687
|
"""
|
|
687
688
|
try:
|
|
688
689
|
g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
|
|
@@ -845,7 +846,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
845
846
|
if site and site not in ("same-origin", "none"):
|
|
846
847
|
return False
|
|
847
848
|
tok = self.headers.get("X-Screengraft-Token") or (q.get("t") or [""])[0]
|
|
848
|
-
|
|
849
|
+
# compare_digest wants ASCII str on both sides; a token is url-safe
|
|
850
|
+
# base64, so anything else is wrong before it is compared.
|
|
851
|
+
if not (tok and TOKEN and tok.isascii()):
|
|
852
|
+
return False
|
|
853
|
+
return secrets.compare_digest(tok, TOKEN)
|
|
849
854
|
|
|
850
855
|
def _refuse(self, u):
|
|
851
856
|
if u.path == "/":
|
|
@@ -1003,9 +1008,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1003
1008
|
# Read the body off the wire first (bounded): refusing while the
|
|
1004
1009
|
# client is still sending turns a clean 403 into a broken pipe on
|
|
1005
1010
|
# its side, which reads as "the server died", not "you were refused".
|
|
1006
|
-
|
|
1011
|
+
try:
|
|
1012
|
+
n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
|
|
1013
|
+
except ValueError:
|
|
1014
|
+
n = 0
|
|
1007
1015
|
while n > 0:
|
|
1008
|
-
|
|
1016
|
+
chunk = self.rfile.read(min(n, 1 << 20))
|
|
1017
|
+
if not chunk: # client gone; nothing left to drain
|
|
1018
|
+
break
|
|
1019
|
+
n -= len(chunk)
|
|
1009
1020
|
return self._refuse(u)
|
|
1010
1021
|
try:
|
|
1011
1022
|
if u.path == "/api/upload":
|
|
@@ -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.57):** 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
|
@@ -49,7 +49,11 @@
|
|
|
49
49
|
accent at 85%, not solid — solid is the hover state. */
|
|
50
50
|
/* accent/acc + accent/acc_btn_rest re-picked in Figma 5 Sep:
|
|
51
51
|
#ec4c13 -> #f23b0d. --acc-soft is derived from the accent, so it follows. */
|
|
52
|
-
|
|
52
|
+
/* Primary at REST is the solid accent — re-read from Button 4:14 on
|
|
53
|
+
13 Sep 2026: container/default and container/hover are both #f23b0d, so
|
|
54
|
+
the 85% rest that used to sit under hover is gone. Hover feedback on the
|
|
55
|
+
primary button is therefore the cursor and the press, as designed. */
|
|
56
|
+
--acc:#f23b0d; --acc-btn-rest:#f23b0d; --acc-press:#bb2b00;
|
|
53
57
|
--acc-stroke:#ff6833; --acc-ink:#fbf9f9; --acc-soft:rgba(242,59,13,.14);
|
|
54
58
|
/* Neutral button hover/pressed are explicit surfaces in Figma, not a
|
|
55
59
|
brightness filter on the rest state. Re-picked 11 Sep 2026 against the
|
|
@@ -60,7 +64,31 @@
|
|
|
60
64
|
--btn-press:#1c1c20; --btn-press-edge:#3a3b41;
|
|
61
65
|
/* Accent disabled keeps the label READABLE rather than fading the whole
|
|
62
66
|
button — the Button component in Figma says so explicitly. */
|
|
63
|
-
|
|
67
|
+
/* Re-read 13 Sep 2026: primary/container/disabled is the SOLID press
|
|
68
|
+
colour with the solid stroke and the solid label — the design fades
|
|
69
|
+
nothing here, it recesses. Was three alpha variants. */
|
|
70
|
+
--acc-dis:#bb2b00; --acc-dis-edge:#ff6833; --acc-dis-ink:#fbf9f9;
|
|
71
|
+
/* Strokes are 0.5px throughout the component sets (button, chip, input
|
|
72
|
+
chip, segmented, switch, button group). A half pixel snaps to one device
|
|
73
|
+
pixel on any screen, so it never vanishes; on a Retina Mac it is the
|
|
74
|
+
hairline the design shows. Colour contrast of the stroke is unchanged. */
|
|
75
|
+
--bw:.5px;
|
|
76
|
+
/* radius/xs — the segment button and the segmented thumb. */
|
|
77
|
+
--r-xs:4px;
|
|
78
|
+
/* color/foreground/icon-construction — the 16px icon glyph in a segment
|
|
79
|
+
button; quieter than --ink on purpose. */
|
|
80
|
+
--icon:#d9d9d9;
|
|
81
|
+
/* input-chip/unselected/label — the empty chip's label sits between
|
|
82
|
+
--ink and --mute. input-chip/unselected/container/default shares
|
|
83
|
+
neutral/cool/650 with border/line; it gets its own name here so a line
|
|
84
|
+
colour change cannot silently repaint a surface. */
|
|
85
|
+
--inchip-ink:#c2c3cb; --inchip-empty:#1d1e20;
|
|
86
|
+
/* button-group/default/border — a 20% cool grey, the ONLY translucent
|
|
87
|
+
stroke in the system; it sits over the photograph, so it has to read on
|
|
88
|
+
any tone. The two shadows are the Button Group's and the Status pill's. */
|
|
89
|
+
--group-edge:rgba(194,195,203,.2);
|
|
90
|
+
--group-shadow:0 2px 4px rgba(0,0,0,.03), 0 3px 10px rgba(0,0,0,.08), 0 4px 24px rgba(0,0,0,.25);
|
|
91
|
+
--pill-shadow:0 2px 2px rgba(0,0,0,.03), 0 3px 5px rgba(0,0,0,.08), 0 4px 12px rgba(0,0,0,.25);
|
|
64
92
|
|
|
65
93
|
--warn:#f0c375;
|
|
66
94
|
/* --ok is status/ok, re-picked in the design file (#8fe3c0 -> #4fbf8b).
|
|
@@ -170,12 +198,10 @@
|
|
|
170
198
|
filter over the rest state — a filter shifts hue and cannot be measured
|
|
171
199
|
against a token.
|
|
172
200
|
------------------------------------------------------------------ */
|
|
173
|
-
/*
|
|
174
|
-
|
|
175
|
-
each other in the top bar were not the same shape. Settled in Figma
|
|
176
|
-
11 Sep 2026: Md = 8, Sm = 6. */
|
|
201
|
+
/* Both sizes are radius/sm (6px) — re-read from Button 4:14 on 13 Sep 2026,
|
|
202
|
+
where every variant carries the same radius. Md had been 8 since 11 Sep. */
|
|
177
203
|
button{font:inherit;color:var(--ink);background:var(--raise);
|
|
178
|
-
border:
|
|
204
|
+
border:var(--bw) solid var(--edge);border-radius:var(--r-sm);
|
|
179
205
|
padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;box-shadow:var(--hi);
|
|
180
206
|
transition:var(--t-all), transform var(--t)}
|
|
181
207
|
button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
|
|
@@ -198,7 +224,7 @@
|
|
|
198
224
|
dimmest text in the tool. The description wins until that is
|
|
199
225
|
settled in the file. */
|
|
200
226
|
button[disabled]{background:var(--raise-low);border-color:var(--edge-low);
|
|
201
|
-
color:var(--
|
|
227
|
+
color:var(--mute);cursor:default;font-weight:400;
|
|
202
228
|
box-shadow:none;transform:none}
|
|
203
229
|
button[disabled]:hover{background:var(--raise-low);border-color:var(--edge-low)}
|
|
204
230
|
button.sm{height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
|
|
@@ -357,14 +383,18 @@
|
|
|
357
383
|
the stepper was 24px against 28px neighbours), so the thumb is 22px here so the track lands on 28px exactly. Everything else
|
|
358
384
|
— padding, gap, border, both radii — is as drawn.
|
|
359
385
|
------------------------------------------------------------------ */
|
|
386
|
+
/* Segmented control 84:177, re-read 13 Sep 2026: the track is
|
|
387
|
+
surface/raise-low (not --sunk) inside a 6px radius with a 0.5px stroke;
|
|
388
|
+
the thumb is 24px tall at radius/xs. Hover lifts the thumb to raise-hi
|
|
389
|
+
and the track's stroke to edge-mid; pressed drops the thumb to raise-mid. */
|
|
360
390
|
.seg{display:inline-flex;align-items:center;gap:2px;padding:2px;
|
|
361
|
-
background:var(--
|
|
362
|
-
border-radius:var(--r-
|
|
391
|
+
background:var(--raise-low);border:var(--bw) solid var(--edge);
|
|
392
|
+
border-radius:var(--r-sm);transition:var(--t-all)}
|
|
363
393
|
.seg:hover{border-color:var(--edge-mid)}
|
|
364
394
|
.seg:active{border-color:var(--edge-mid)}
|
|
365
395
|
/* The track carries the border and the elevation, so a segment is a label
|
|
366
396
|
until it is selected: no border, no shadow, no lift on press. */
|
|
367
|
-
.seg button{height:
|
|
397
|
+
.seg button{height:24px;padding:0 11px;font-size:12px;border-radius:var(--r-xs);
|
|
368
398
|
background:transparent;border:0;box-shadow:none;color:var(--mute);
|
|
369
399
|
font-weight:400;display:inline-flex;flex-direction:column;
|
|
370
400
|
align-items:center;justify-content:center}
|
|
@@ -382,8 +412,8 @@
|
|
|
382
412
|
point of the component: one raised thing, behaving like every other raised
|
|
383
413
|
thing on the page. */
|
|
384
414
|
.seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
|
|
385
|
-
.seg button.sel:hover{background:var(--raise-
|
|
386
|
-
.seg button.sel:active{background:var(--raise-
|
|
415
|
+
.seg button.sel:hover{background:var(--raise-hi)}
|
|
416
|
+
.seg button.sel:active{background:var(--raise-mid);transform:none}
|
|
387
417
|
/* A toggle in the strip head. On = the raised surface every selected thing
|
|
388
418
|
on this page has (the segmented thumb, the device chip), off = a label. */
|
|
389
419
|
.striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
@@ -395,17 +425,30 @@
|
|
|
395
425
|
NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
|
|
396
426
|
centred placeholder-paragraph class (padding:24px), and the chip was
|
|
397
427
|
silently inheriting it and rendering 50px tall instead of 32. */
|
|
428
|
+
/* Input chip 159:4117, re-read 13 Sep 2026. Filled = the SELECTED chip's
|
|
429
|
+
surfaces (raise-hi / edge-hi, hover raise-highest, pressed raise-mid): a
|
|
430
|
+
chosen source reads as a selection. Empty = its own recessed, dashed
|
|
431
|
+
surface with a mid-grey label. Active = a file is being dragged OVER the
|
|
432
|
+
chip: accent stroke and label on the empty one, accent fill on the filled. */
|
|
398
433
|
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
|
|
399
|
-
height:32px;padding:0 12px 0 6px;border-radius:999px;
|
|
400
|
-
background:var(--raise);border:
|
|
434
|
+
height:32px;padding:0 12px 0 6px;border-radius:999px;color:var(--ink);
|
|
435
|
+
background:var(--raise-hi);border:var(--bw) solid var(--edge-hi);box-shadow:var(--hi)}
|
|
436
|
+
.inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
437
|
+
.inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
|
|
438
|
+
.inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
|
|
439
|
+
color:var(--acc-ink)}
|
|
401
440
|
.inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
|
|
402
441
|
background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}
|
|
403
442
|
.inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
404
443
|
/* Empty is the SAME box as filled — 32px tall, same padding, same 22px
|
|
405
444
|
avatar. Only the surface, border style and label colour change. It was
|
|
406
445
|
reading oversized because it had no surface to sit in. */
|
|
407
|
-
.inputchip.is-empty{background:var(--
|
|
408
|
-
color:var(--
|
|
446
|
+
.inputchip.is-empty{background:var(--inchip-empty);border-style:dashed;
|
|
447
|
+
border-color:var(--edge);color:var(--inchip-ink);box-shadow:none}
|
|
448
|
+
.inputchip.is-empty:hover{background:var(--float);border-color:var(--edge-mid)}
|
|
449
|
+
.inputchip.is-empty:active{background:var(--raise-low);border-color:var(--edge-mid)}
|
|
450
|
+
.inputchip.is-empty.over,.inputchip.is-empty.over:hover{background:var(--float);
|
|
451
|
+
border-color:var(--acc-stroke);color:var(--acc-stroke)}
|
|
409
452
|
.inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
|
|
410
453
|
/* The clear control is a SIBLING of the chip in the markup, not a child:
|
|
411
454
|
the chip is one <button>, and a button inside a button is invalid HTML
|
|
@@ -587,30 +630,30 @@
|
|
|
587
630
|
/* Track 48×20 inside a 48×28 hit area (Figma 17:61): the button keeps the
|
|
588
631
|
full 28px for the pointer, the drawn track is 20px. Was 22px. */
|
|
589
632
|
.sw-track{position:relative;display:block;width:48px;height:20px;border-radius:6px;
|
|
590
|
-
background:var(--bg);border:
|
|
633
|
+
background:var(--bg);border:var(--bw) solid var(--edge);
|
|
591
634
|
transition:var(--t-all), box-shadow var(--t)}
|
|
592
|
-
.sw-handle{position:absolute;top:
|
|
635
|
+
.sw-handle{position:absolute;top:1.5px;bottom:1.5px;left:1.5px;width:24px;border-radius:4px;
|
|
593
636
|
background:var(--raise-hi);
|
|
594
637
|
transition:left var(--t), right var(--t), width var(--t), background-color var(--t), opacity var(--t)}
|
|
595
638
|
.sw-grip{position:absolute;border-radius:1px;background:var(--sunk);
|
|
596
|
-
left:
|
|
639
|
+
left:11.5px;width:4px;top:8.5px;bottom:8.5px;
|
|
597
640
|
transition:left var(--t), width var(--t), top var(--t), bottom var(--t), background-color var(--t)}
|
|
598
641
|
|
|
599
642
|
/* --- OFF ------------------------------------------------------------- */
|
|
600
|
-
button.switch:hover .sw-handle{background:var(--
|
|
643
|
+
button.switch:hover .sw-handle{background:var(--raise-mid)}
|
|
601
644
|
button.switch:active .sw-handle{width:26px;background:var(--raise-hi)}
|
|
602
|
-
button.switch:active .sw-grip{left:
|
|
645
|
+
button.switch:active .sw-grip{left:12.5px}
|
|
603
646
|
|
|
604
647
|
/* --- ON -------------------------------------------------------------- */
|
|
605
648
|
button.switch[aria-checked="true"] .sw-track{background:var(--acc-btn-rest);border-color:var(--acc-stroke)}
|
|
606
|
-
button.switch[aria-checked="true"] .sw-handle{left:auto;right:
|
|
607
|
-
button.switch[aria-checked="true"] .sw-grip{left:
|
|
649
|
+
button.switch[aria-checked="true"] .sw-handle{left:auto;right:1.5px;background:var(--acc-ink)}
|
|
650
|
+
button.switch[aria-checked="true"] .sw-grip{left:32.5px;width:2px;top:5.5px;bottom:5.5px;
|
|
608
651
|
background:var(--acc-btn-rest)}
|
|
609
652
|
button.switch[aria-checked="true"]:hover .sw-track{background:var(--acc);border-color:var(--acc-stroke)}
|
|
610
653
|
button.switch[aria-checked="true"]:hover .sw-grip{background:var(--acc)}
|
|
611
654
|
button.switch[aria-checked="true"]:active .sw-track{background:var(--acc-press);border-color:var(--acc-stroke)}
|
|
612
655
|
button.switch[aria-checked="true"]:active .sw-handle{width:26px;opacity:.8}
|
|
613
|
-
button.switch[aria-checked="true"]:active .sw-grip{left:
|
|
656
|
+
button.switch[aria-checked="true"]:active .sw-grip{left:31.5px;background:var(--acc-press)}
|
|
614
657
|
|
|
615
658
|
/* --- disabled --------------------------------------------------------
|
|
616
659
|
The component uses opacity here, against the project's usual rule. Kept as
|
|
@@ -1735,6 +1778,13 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
|
1735
1778
|
drop.ondragleave = () => drop.classList.remove('over');
|
|
1736
1779
|
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
|
|
1737
1780
|
$('#chip'+n).onclick = () => openPop(n);
|
|
1781
|
+
// The chip itself takes a drop (Input chip's Active state): drag a file
|
|
1782
|
+
// onto "Choose a photo" and it lands in that role without opening the
|
|
1783
|
+
// popover. Same upload path as the popover's drop zone.
|
|
1784
|
+
const chip = $('#chip'+n);
|
|
1785
|
+
chip.ondragover = e => { if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) { e.preventDefault(); chip.classList.add('over'); } };
|
|
1786
|
+
chip.ondragleave = () => chip.classList.remove('over');
|
|
1787
|
+
chip.ondrop = e => { e.preventDefault(); chip.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
|
|
1738
1788
|
}
|
|
1739
1789
|
/* One popover mechanic for every popover: anchor it under whatever opened it,
|
|
1740
1790
|
clamped into the viewport. The pickers and the preview both use it. */
|