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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.56.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 sidecar replayed through the API keeps its own
684
- value -- a sidecar from before the gain existed carries none and passes
685
- 1.0 explicitly at the replay site, never here.
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
- return bool(tok) and bool(TOKEN) and secrets.compare_digest(tok, TOKEN)
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
- n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
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
- n -= len(self.rfile.read(min(n, 1 << 20)) or b"\0")
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.56):** 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.
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
- --acc:#f23b0d; --acc-btn-rest:rgba(242,59,13,.85); --acc-press:#bb2b00;
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
- --acc-dis:#bb2b00b2; --acc-dis-edge:#ff6833b2; --acc-dis-ink:#fbf9f966;
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
- /* Md is radius/md, Sm is radius/sm — one rule, both variants. Md had been
174
- 6px here while Primary/Md was 8px, so the neutral and accent buttons beside
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:1px solid var(--edge);border-radius:var(--r-md);
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(--faint);cursor:default;font-weight:400;
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(--sunk);border:1px solid var(--edge);
362
- border-radius:var(--r-md);transition:var(--t-all)}
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:22px;padding:0 11px;font-size:12px;border-radius:var(--r-sm);
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-mid)}
386
- .seg button.sel:active{background:var(--raise-low);transform:none}
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:1px solid var(--edge);box-shadow:var(--hi)}
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(--card);border-style:dashed;
408
- color:var(--mute);box-shadow:none}
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:1px solid var(--edge);
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:1px;bottom:1px;left:1px;width:24px;border-radius:4px;
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:11px;width:4px;top:7px;bottom:7px;
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(--float)}
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:12px}
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:1px;background:var(--acc-ink)}
607
- button.switch[aria-checked="true"] .sw-grip{left:32px;width:2px;top:4px;bottom:4px;
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:31px;background:var(--acc-press)}
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. */