screengraft 0.48.0 → 0.50.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/README.md CHANGED
@@ -232,6 +232,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
232
232
  run in CI, and a change to the compositing engine needs a measurement, not an
233
233
  opinion.
234
234
 
235
+
236
+ **Testing a change without reinstalling.** The desktop app snapshots an installed plugin per session and only refreshes it after *Check for updates*, so release → check → new session is a slow loop. Instead, run `scripts/dev-root.sh` once in your checkout: it writes `~/.screengraft/dev-root`, and from then on every session's `launch.sh` — including the installed copy's — runs the UI from your tree. The badge reads `dev <sha>+` while it does. `scripts/dev-root.sh off` restores the installed copy. Only `SKILL.md` and `mcp/server.py` changes still need a real update, because the app loads those itself.
237
+
235
238
  ## Licence
236
239
 
237
240
  MIT. The bundled Mona Sans subset is SIL OFL — see `ui/fonts/OFL.txt`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.48.0",
3
+ "version": "0.50.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",
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env zsh
2
+ # Point the installed plugin at a checkout, or stop doing so.
3
+ #
4
+ # scripts/dev-root.sh -> use THIS checkout (the one the script is in)
5
+ # scripts/dev-root.sh /some/tree -> use that one
6
+ # scripts/dev-root.sh off -> back to the installed copy
7
+ # scripts/dev-root.sh status -> say which
8
+ #
9
+ # Writes ~/.screengraft/dev-root, which launch.sh reads. See launch.sh for why.
10
+ set -euo pipefail
11
+ FILE="$HOME/.screengraft/dev-root"
12
+ case "${1:-}" in
13
+ off) rm -f "$FILE"; echo "dev root off — launch.sh uses the installed copy" ;;
14
+ status) if [ -s "$FILE" ]; then echo "dev root: $(head -n1 "$FILE")"; else echo "dev root off"; fi ;;
15
+ *)
16
+ TREE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
17
+ TREE="$(cd "$TREE" && pwd)"
18
+ [ -f "$TREE/scripts/ui.py" ] || { echo "error: $TREE has no scripts/ui.py" >&2; exit 2; }
19
+ mkdir -p "$(dirname "$FILE")"
20
+ printf '%s\n' "$TREE" > "$FILE"
21
+ echo "dev root: $TREE — every session's launch.sh now runs this tree; the badge will read 'dev <sha>'"
22
+ ;;
23
+ esac
package/scripts/launch.sh CHANGED
@@ -18,6 +18,26 @@
18
18
  # the same reason a Terminal window did, and there is no window to clean up.
19
19
  set -euo pipefail
20
20
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
21
+ # Dev root: run the UI from a checkout instead of the installed copy.
22
+ # `~/.screengraft/dev-root` holds one line, the path to a screengraft tree.
23
+ # The desktop app snapshots every installed plugin per session and only
24
+ # refreshes that snapshot after "Check for updates", so testing a change used
25
+ # to mean release -> check -> new session. With the file in place any session,
26
+ # including one already open, runs whatever is in the tree right now; the
27
+ # badge reads "dev <sha>+" so it can never pass for a release. Opt-in by the
28
+ # file's existence; a path that is not a tree is ignored with a warning, so
29
+ # a stale file cannot silently break a launch. `scripts/dev-root.sh` writes it.
30
+ DEV_ROOT_FILE="$HOME/.screengraft/dev-root"
31
+ if [ -s "$DEV_ROOT_FILE" ]; then
32
+ DEV_ROOT="$(head -n1 "$DEV_ROOT_FILE")"
33
+ DEV_ROOT="${DEV_ROOT/#\~/$HOME}"
34
+ if [ -f "$DEV_ROOT/scripts/ui.py" ]; then
35
+ ROOT="$DEV_ROOT"
36
+ echo "dev root: running from $ROOT (remove $DEV_ROOT_FILE to use the installed copy)" >&2
37
+ else
38
+ echo "warning: $DEV_ROOT_FILE points at $DEV_ROOT, which has no scripts/ui.py — using the installed copy" >&2
39
+ fi
40
+ fi
21
41
  PORT=0
22
42
  OUT_DIR=""
23
43
 
@@ -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.48):** 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.50):** 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
 
@@ -54,6 +54,8 @@ scripts/launch.sh --out-dir "<the user's current project folder>/mockups"
54
54
 
55
55
  **Do not launch `ui.py` directly with `nohup ... &` from your own shell.** That was tried on 3 Sep 2026 and the server died silently between turns, twice, losing whatever the user had already entered — the shell session that launches it can be torn down out from under a merely-backgrounded child. `launch.sh` starts it with `--daemon`, which double-forks and calls `setsid`, putting the server in its own session with no controlling terminal; it survives the launching shell and opens no window. It self-checks (waits for startup output, then confirms the server responds) before returning.
56
56
 
57
+ **If `launch.sh` prints `dev root: running from <path>`**, the UI is running from a source checkout, not from this installed copy — the developer has opted in with `~/.screengraft/dev-root` (see `scripts/dev-root.sh`). Say so in one line, and treat the badge's `dev <sha>+` as expected. Anything the user reports is then about that tree, not about the release.
58
+
57
59
  A Terminal.app window was the previous fix for the same problem. It worked but left a dead "[Process completed]" window behind after every session, and closing those from AppleScript proved unreliable — Terminal reports stale ttys for dead windows and leaves zero-tab husks that `close` reports success on. The daemon removes the window entirely, so there is nothing to clean up.
58
60
 
59
61
  It prints one JSON line — `url`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
@@ -140,6 +142,7 @@ The server is a detached daemon with no window. **Stop it with `scripts/stop.sh`
140
142
  | `scripts/preflight.py` | Dependency report; `--install` builds the venv |
141
143
  | `scripts/launch.sh` | Starts `ui.py` as a detached daemon (no window) and verifies it's alive — use this, not `ui.py` directly |
142
144
  | `scripts/stop.sh` | Stops the UI with SIGTERM so the session pointer is cleared — use this instead of `pkill` |
145
+ | `scripts/dev-root.sh` | Developer opt-in: point every session's `launch.sh` at a checkout (`on` by default, `off`, `status`). Testing a change then needs no release and no plugin update. |
143
146
  | `scripts/ui.py` | Local server + browser UI; calls the two below |
144
147
  | `scripts/detect.py` | Advisory screen-quad + corner-radius detection (no ML) |
145
148
  | `scripts/warp.py` | The engine: `compose()` and a CLI for scripted use |
package/ui/index.html CHANGED
@@ -384,6 +384,11 @@
384
384
  .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
385
385
  .seg button.sel:hover{background:var(--raise-mid)}
386
386
  .seg button.sel:active{background:var(--raise-low);transform:none}
387
+ /* A toggle in the strip head. On = the raised surface every selected thing
388
+ on this page has (the segmented thumb, the device chip), off = a label. */
389
+ .striphead button[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
390
+ .striphead button[aria-pressed]:hover{color:var(--ink)}
391
+ .striphead button[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
387
392
  /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
388
393
  than inherited from the base button rule, so the filled state is explicit and
389
394
  the .is-empty override below has something definite to override.
@@ -1136,6 +1141,7 @@
1136
1141
  <span class="lbl">Edge view</span>
1137
1142
  <span class="stepper dock-step"><button class="sm" id="stripOut">&minus;</button><button class="sm" id="stripIn">+</button></span>
1138
1143
  <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
1144
+ <button class="sm" id="stripHC" aria-pressed="false" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched.">Contrast</button>
1139
1145
  <span class="spacer"></span>
1140
1146
  <span class="sm" id="stripSt" style="color:var(--mute)"></span>
1141
1147
  </div>
@@ -2463,7 +2469,38 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
2463
2469
 
2464
2470
  $('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
2465
2471
 
2466
- function paintStrip(c, W, H, i){
2472
+ /* Contrast for the strip only. A dark screen on a dark frame puts the
2473
+ boundary a few levels apart — 8 against 20 — which is invisible at any
2474
+ zoom, and no amount of magnification adds contrast. This takes the strip's
2475
+ own 1st–99th percentile of luminance and stretches it to 0–255, the same
2476
+ gain on every channel so hue is kept. It is a viewing aid: the composite,
2477
+ the fit and the saved file never see it. Per strip, per frame — 1280×130
2478
+ is 166k pixels and this runs on every hover. */
2479
+ let stripHC = recall('stripHC','0') === '1';
2480
+ function stretchContrast(c, W, H){
2481
+ const im = c.getImageData(0, 0, W, H), d = im.data, n = W*H;
2482
+ const hist = new Uint32Array(256);
2483
+ for (let k = 0; k < n; k++){
2484
+ const o = k*4;
2485
+ hist[(d[o]*77 + d[o+1]*151 + d[o+2]*28) >> 8]++;
2486
+ }
2487
+ let lo = 0, hi = 255, acc = 0;
2488
+ for (let v = 0; v < 256; v++){ acc += hist[v]; if (acc > n*0.01){ lo = v; break; } }
2489
+ acc = 0;
2490
+ for (let v = 255; v >= 0; v--){ acc += hist[v]; if (acc > n*0.01){ hi = v; break; } }
2491
+ if (hi - lo < 2) return;
2492
+ const g = 255 / (hi - lo);
2493
+ const lut = new Uint8ClampedArray(256);
2494
+ for (let v = 0; v < 256; v++) lut[v] = Math.max(0, Math.min(255, (v - lo) * g));
2495
+ for (let o = 0; o < n*4; o += 4){ d[o] = lut[d[o]]; d[o+1] = lut[d[o+1]]; d[o+2] = lut[d[o+2]]; }
2496
+ c.putImageData(im, 0, 0);
2497
+ }
2498
+ function setStripHC(on){
2499
+ stripHC = on; remember('stripHC', on ? '1' : '0');
2500
+ $('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
2501
+ }
2502
+ $('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
2503
+ function paintStrip(c, W, H, i, mode){
2467
2504
  c.setTransform(1,0,0,1,0,0);
2468
2505
  c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
2469
2506
  const A = st.corners[i], B = st.corners[(i+1)%4];
@@ -2478,7 +2515,25 @@ function paintStrip(c, W, H, i){
2478
2515
  c.imageSmoothingEnabled = true;
2479
2516
  c.drawImage(img, 0, 0);
2480
2517
  c.restore();
2518
+ if (stripHC) stretchContrast(c, W, H);
2481
2519
  cased(c, () => { c.beginPath(); c.moveTo(0, H/2); c.lineTo(W, H/2); }, 1.25, CORE_ACTIVE);
2520
+ // Which end is about to swing. The canvas shows this with the pivot pips,
2521
+ // but the eye is on the strip while an edge is being placed, and "rotA" /
2522
+ // "rotB" are meaningless there without a mark. A grab near one end pivots
2523
+ // on the OTHER: the pivot end gets the same filled pip the canvas uses, the
2524
+ // swinging end a double arrow across the rule. Translate marks nothing —
2525
+ // both ends move. Left in the strip is A, the edge's first corner.
2526
+ if (mode === 'rotA' || mode === 'rotB'){
2527
+ // At 0.16 / 0.84 of the edge: where the canvas draws its pips, and clear
2528
+ // of the screen/outside labels at the left edge.
2529
+ const pivotX = mode === 'rotA' ? W*0.16 : W*0.84, swingX = mode === 'rotA' ? W*0.84 : W*0.16;
2530
+ casedDisc(c, pivotX, H/2, 3.5, CORE_ACTIVE);
2531
+ cased(c, () => { c.beginPath();
2532
+ c.moveTo(swingX, H/2 - 22); c.lineTo(swingX, H/2 + 22);
2533
+ c.moveTo(swingX - 5, H/2 - 16); c.lineTo(swingX, H/2 - 22); c.lineTo(swingX + 5, H/2 - 16);
2534
+ c.moveTo(swingX - 5, H/2 + 16); c.lineTo(swingX, H/2 + 22); c.lineTo(swingX + 5, H/2 + 16); },
2535
+ 1.5, CORE_ACTIVE);
2536
+ }
2482
2537
  cased(c, () => { c.beginPath();
2483
2538
  for (const f of [0.25,0.5,0.75]){ c.moveTo(W*f, H/2-9); c.lineTo(W*f, H/2+9); } },
2484
2539
  1, 'rgba(255,255,255,.7)');
@@ -2488,6 +2543,11 @@ function paintStrip(c, W, H, i){
2488
2543
  casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
2489
2544
  casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
2490
2545
  }
2546
+ function swingText(mode){
2547
+ if (mode === 'rotA') return 'the right end swings, pivot on the left';
2548
+ if (mode === 'rotB') return 'the left end swings, pivot on the right';
2549
+ return 'flat on the rule means aligned';
2550
+ }
2491
2551
  function drawStrip(){
2492
2552
  const a = drag || hover || pick;
2493
2553
  const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
@@ -2500,13 +2560,13 @@ function drawStrip(){
2500
2560
  $('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
2501
2561
  return;
2502
2562
  }
2503
- paintStrip(sctx, W, H, a.i);
2504
- $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — flat on the rule means aligned`;
2563
+ paintStrip(sctx, W, H, a.i, a.mode);
2564
+ $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
2505
2565
  } else {
2506
2566
  const fl = $('#floatLoupe');
2507
2567
  if (!has){ fl.classList.remove('on'); return; }
2508
- paintStrip(fctx, stripF.width, stripF.height, a.i);
2509
- $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — flat on the rule means aligned`;
2568
+ paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
2569
+ $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
2510
2570
  fl.classList.add('on');
2511
2571
  const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
2512
2572
  let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
@@ -3110,6 +3170,7 @@ $('#imp').onclick = async () => {
3110
3170
  b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
3111
3171
  }
3112
3172
  setLoupeMode(loupeMode);
3173
+ setStripHC(stripHC);
3113
3174
  setGrade(gradeOn, gradeAmt);
3114
3175
  setEmis(emisOn, emisAmt);
3115
3176
  setRadiusOn(radiusOn);