screengraft 0.57.0 → 0.59.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.57.0",
3
+ "version": "0.59.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/dof.py CHANGED
@@ -107,7 +107,10 @@ def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: flo
107
107
  q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
108
108
  proj = q @ d
109
109
  lo, hi = float(proj.min()), float(proj.max())
110
- start = float(np.clip(start, 0.0, 0.95))
110
+ # start may be negative: the plane of focus in FRONT of the screen, so the
111
+ # near edge is already part-way up the ramp. Same reach past the screen as
112
+ # `end` has on the far side.
113
+ start = float(np.clip(start, -0.5, 0.95))
111
114
  end = float(np.clip(end, start + 0.05, 1.5))
112
115
  s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
113
116
  xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
package/scripts/ui.py CHANGED
@@ -711,7 +711,8 @@ def _dof_args(b):
711
711
  except (TypeError, ValueError):
712
712
  strength, angle, start, end = 0.0, 0.0, 0.0, 1.0
713
713
  space = "screen" if b.get("dof_space") == "screen" else "photo"
714
- start = float(min(max(start, 0.0), 0.95))
714
+ # -0.5 lets the plane of focus sit in front of the glass (14 Sep 2026).
715
+ start = float(min(max(start, -0.5), 0.95))
715
716
  # The near limit is optional: absent or null means one-sided.
716
717
  end2 = b.get("dof_end2")
717
718
  try:
@@ -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.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.
8
+ **What ships (v0.59):** 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
@@ -527,29 +527,58 @@
527
527
  min-width:0 lets the PANE shrink; it does nothing for the auto track
528
528
  inside it, which was still sizing to the header's min-content — 798px
529
529
  wide in a 398px column. */
530
- .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
530
+ /* One row: neither pane has a header any more (Figma 7:12 / 7:38, 13 Sep
531
+ 2026). Everything that used to live in the header floats over the well as
532
+ a rail, so the picture gets the whole pane and the two wells line up. */
533
+ .pane{min-width:0;min-height:0;display:grid;grid-template-rows:minmax(0,1fr);
531
534
  grid-template-columns:minmax(0,1fr);
532
- position:relative; /* the frame the corner bar is anchored to */
535
+ position:relative; /* the frame the rails are anchored to */
533
536
  border-right:1px solid var(--line)}
534
537
  .panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
535
538
  box-shadow:inset 0 -1px 0 var(--line);font-size:12px;flex-wrap:wrap;min-height:48px}
536
- /* The Fit header pads 12px vertically around 28px controls (Figma 7:13),
537
- which makes it 52px — deliberately taller than the Result header's 48. */
538
- /* nowrap matches the design, but a long detection message must not be able
539
- to widen the grid column — that is the v0.3.1 blowout, where an
540
- unconstrained child forced the fixed rail off-screen. The pill truncates
541
- instead; min-width:0 is what actually lets it. */
542
- .fit-head{justify-content:space-between;flex-wrap:nowrap}
543
- /* Both pane headers are the same height: they sit side by side, and 4px of
544
- difference reads as a misalignment rather than as a hierarchy. */
545
539
  .panehead{padding:var(--s3) var(--s4);min-height:52px}
546
- .fit-head > .status{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
547
- display:inline-block;line-height:20px}
548
- .fit-head > .status::before{margin-right:6px}
549
- .ph-left{display:flex;align-items:center;gap:var(--s6);min-width:0}
550
- .ph-left-1{display:flex;align-items:center;gap:var(--s4)}
551
- .ph-left-2{display:flex;align-items:center;gap:var(--s2)}
552
- .zoomval{color:var(--mute);font-size:12px;font-variant-numeric:tabular-nums;min-width:38px}
540
+ /* The zoom readout has no place on the icon rail; it is kept for assistive
541
+ tech, announced on change. */
542
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
543
+ clip:rect(0,0,0,0);white-space:nowrap;border:0}
544
+
545
+ /* ------------------------------------------------------------------
546
+ Rails over the well (Figma 7:12, Button Group 145:372, Segment Button
547
+ 138:255). A rail is a Button Group: a 28px-wide column of 28px segment
548
+ buttons on the raised surface, one 0.5px translucent stroke and one
549
+ shadow around the whole group — the stroke is the only translucent one
550
+ in the system, because the group sits over a photograph of any tone.
551
+ Three groups on the left (zoom, fit, corners), three on the bottom, and
552
+ the status pill top-centre; the well underneath keeps every pixel.
553
+ ------------------------------------------------------------------ */
554
+ .bgroup{display:inline-flex;flex-direction:column;align-items:stretch;flex:none;
555
+ background:var(--raise);border:var(--bw) solid var(--group-edge);
556
+ border-radius:var(--r-xs);box-shadow:var(--group-shadow);overflow:hidden}
557
+ .segbtn,.segbtn:hover,.segbtn:active{border:0;border-radius:0;box-shadow:none;transform:none}
558
+ .segbtn{height:28px;min-width:28px;padding:0 6px;font-size:12px;color:var(--ink);
559
+ background:var(--raise);display:inline-flex;align-items:center;justify-content:center;gap:4px}
560
+ .segbtn:hover{background:var(--raise-mid)}
561
+ .segbtn:active{background:var(--raise-low)}
562
+ .segbtn.sel,.segbtn[aria-pressed="true"]{background:var(--raise-hi)}
563
+ .segbtn[disabled],.segbtn[disabled]:hover{background:var(--raise-low);color:var(--mute)}
564
+ .segbtn:focus-visible{outline-offset:-2px}
565
+ .segbtn svg{width:16px;height:16px;display:block;flex:none}
566
+ .segbtn svg text{font:600 8px/1 -apple-system,BlinkMacSystemFont,"SF Pro",system-ui,sans-serif;fill:var(--ink)}
567
+ .lrail{position:absolute;left:24px;top:48px;z-index:3;display:flex;flex-direction:column;gap:var(--s2)}
568
+ .lrail .corners{margin-top:var(--s3)} /* 8 + 12 = the design's 20px */
569
+ .brail{position:absolute;left:0;right:0;bottom:12px;z-index:3;display:flex;justify-content:center;
570
+ align-items:center;gap:4px;pointer-events:none;flex-wrap:wrap;padding:0 var(--s4)}
571
+ .brail .bgroup{pointer-events:auto}
572
+ /* The status pill (Status pill 5:7 / 5:10): floats top-centre, its own
573
+ recessed surface and the group stroke, hidden while it has nothing to say.
574
+ Glyph + weight + luminance encoding unchanged (see .status.ok/.warn/.err). */
575
+ /* Keyed on the ids, not a class: the JS rewrites className on both pills. */
576
+ #detSt,#outSt{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:3;
577
+ height:22px;padding:0 10px;border-radius:999px;background:var(--inchip-empty);
578
+ border:1px solid var(--group-edge);box-shadow:var(--pill-shadow);
579
+ max-width:calc(100% - 96px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
580
+ display:inline-flex;line-height:20px}
581
+ #detSt:empty,#outSt:empty{display:none}
553
582
  .lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
554
583
  .scroller{overflow:auto;background:var(--well);display:grid;
555
584
  min-height:0;min-width:0;
@@ -686,7 +715,16 @@
686
715
  /* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
687
716
  are viewBox units; non-scaling-stroke does not cover them), so they read
688
717
  as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
689
- #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
718
+ /* pointer-events:ALL, not auto: the rings are hollow (v0.54.7), and an
719
+ unfilled SVG shape under `auto` is hit only on its stroke — the centre of
720
+ every handle passed the pointer through to the image, so the sharp line
721
+ could not be grabbed at all on a steep fit (reported 14 Sep 2026). `all`
722
+ hits the interior whatever the fill. Each handle also carries a .hit disc,
723
+ a 10px-radius invisible target, because a 4px ring is not a grab area. */
724
+ #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:all;cursor:move}
725
+ #dofGizmo .hit{fill:transparent;stroke:none;pointer-events:all;cursor:move}
726
+ #dofGizmo .hit.pipHit{cursor:inherit}
727
+ #dofGizmo .hit[data-h="str"]{cursor:ew-resize}
690
728
  #dofGizmo .hcase{fill:none;stroke:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
691
729
  #dofGizmo .h.pip{cursor:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round'/%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 11, crosshair}
692
730
  #dofGizmo .h.thumb{cursor:ew-resize}
@@ -1049,49 +1087,52 @@
1049
1087
  100%), with the status pill pushed to the far end by
1050
1088
  justify-between. Buttons are Md — the same size as every other
1051
1089
  button on screen, which is the point of the change. -->
1052
- <div class="panehead fit-head">
1053
- <div class="ph-left">
1054
- <div class="ph-left-1">
1055
- <span class="lbl">Fit</span>
1056
- <span class="stepper"><button id="czOut">&minus;</button><button id="czIn">+</button></span>
1057
- <span id="czSt" class="zoomval"></span>
1058
- <button class="info" type="button" aria-label="About canvas navigation"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Hold ⌘ and scroll to zoom to the pointer — the pixel under the cursor stays under the cursor, which is how you keep a corner in view while you magnify it. Hold space and drag to pan. Scrolling on its own pans too; the Result pane follows either way.</span></button>
1059
- </div>
1060
- <div class="ph-left-2">
1061
- <button id="czFit">Fit</button>
1062
- <button id="cz100">100%</button>
1063
- </div>
1064
- </div>
1065
- <span class="status" id="detSt"></span>
1066
- </div>
1067
1090
  <div class="scroller" id="scroller">
1068
1091
  <canvas id="c" tabindex="-1"></canvas>
1069
1092
  </div>
1093
+ <span class="status" id="detSt"></span>
1094
+ <span id="czSt" class="sr-only" aria-live="polite"></span>
1095
+ <!-- Left rail (Figma 137:4025): the zoom group, the fit button, then the
1096
+ corner rail. Icon-only, so every button carries an aria-label. The
1097
+ corner glyphs light the corner they name; the one currently picked
1098
+ on the canvas is also raised (kept in step from draw()). -->
1099
+ <div class="lrail" id="lrail">
1100
+ <div class="bgroup" role="group" aria-label="Zoom">
1101
+ <button class="segbtn" id="czIn" aria-label="Zoom in" title="Zoom in (⌘ + scroll zooms to the pointer)"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--icon)" d="M8.5 7.5H12.5V8.5H8.5V12.5H7.5V8.5H3.5V7.5H7.5V3.5H8.5V7.5Z"/></svg></button>
1102
+ <button class="segbtn" id="cz100" aria-label="Actual size, 100%" title="Actual size — one photo pixel per screen pixel"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M14.1025 1.00488C14.6067 1.05621 15 1.48232 15 2V14L14.9951 14.1025C14.9472 14.573 14.573 14.9472 14.1025 14.9951L14 15H2C1.48232 15 1.05621 14.6067 1.00488 14.1025L1 14V2C1 1.44772 1.44772 1 2 1H14L14.1025 1.00488ZM2 14H14V2H2V14Z"/><text x="8" y="10.8" text-anchor="middle">1x</text></svg></button>
1103
+ <button class="segbtn" id="czOut" aria-label="Zoom out" title="Zoom out"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--icon)" d="M12.5 8.5H3.5V7.5H12.5V8.5Z"/></svg></button>
1104
+ </div>
1105
+ <div class="bgroup">
1106
+ <button class="segbtn" id="czFit" aria-label="Fit the photo to the pane" title="Fit the whole photo in the pane"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="var(--mute)" d="M14.1025 1.00488C14.6067 1.05621 15 1.48232 15 2V14L14.9951 14.1025C14.9472 14.573 14.573 14.9472 14.1025 14.9951L14 15H2C1.48232 15 1.05621 14.6067 1.00488 14.1025L1 14V2C1 1.44772 1.44772 1 2 1H14L14.1025 1.00488ZM2 14H14V2H2V14Z"/><path stroke="var(--icon)" d="M7 11.5H4.5V9M4.5 11.5L7.5 8.5"/><path stroke="var(--icon)" d="M9 4.5H11.5V7M11.5 4.5L8.5 7.5"/></svg></button>
1107
+ </div>
1108
+ <div class="bgroup corners" role="group" aria-label="Select corner">
1109
+ <button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2Z"/></svg></button>
1110
+ <button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M10 2H13C13.5523 2 14 2.44772 14 3V6H15V3C15 1.89543 14.1046 1 13 1H10V2Z"/></svg></button>
1111
+ <button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M6 14H3C2.44772 14 2 13.5523 2 13V10H1V13C1 14.1046 1.89543 15 3 15H6V14Z"/></svg></button>
1112
+ <button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M10 14H13C13.5523 14 14 13.5523 14 13V10H15V13C15 14.1046 14.1046 15 13 15H10V14Z"/></svg></button>
1113
+ </div>
1114
+ </div>
1070
1115
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
1071
1116
  positioned inside a scroll container scrolls with the content, so
1072
1117
  when it lived over the canvas it slid out of view the moment you
1073
1118
  zoomed in. Anchored to the pane it stays put while the picture moves
1074
1119
  underneath, which is what a floating control should do. -->
1075
- <div class="cvbar" id="cvbar">
1076
- <span class="sm" style="color:var(--mute)">Select corner</span>
1077
- <span class="cvbar-actions">
1078
- <span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
1079
- <button class="sm" id="redetect">Re-detect</button>
1080
- <button class="sm" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button>
1081
- <button class="sm" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button>
1082
- <button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
1083
- </span>
1120
+ <!-- Bottom rail (Figma 113:2851): the actions on the FIT as a whole, each
1121
+ its own one-button group, centred. Corner selection moved to the left
1122
+ rail — it is navigation, not an action on the fit. Rotate is not in
1123
+ the design frame and is kept until it is ruled on. -->
1124
+ <div class="brail" id="cvbar">
1125
+ <div class="bgroup"><button class="segbtn" id="redetect">Re-detect</button></div>
1126
+ <div class="bgroup"><button class="segbtn" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button></div>
1127
+ <div class="bgroup"><button class="segbtn" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button></div>
1128
+ <div class="bgroup"><button class="segbtn" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button></div>
1084
1129
  </div>
1085
1130
  </section>
1086
1131
 
1087
1132
  <section class="pane" id="outPane">
1088
- <div class="panehead">
1089
- <span class="lbl">Result</span>
1090
- <span class="gap-3"></span>
1091
- <span class="sm" id="outSt" style="color:var(--mute)">Press Preview</span>
1092
- <span class="spacer"></span>
1093
- <span class="sm" style="color:var(--mute)">shares zoom &amp; pan</span>
1094
- </div>
1133
+ <!-- No header (Figma 7:38): the well fills the pane so it lines up with
1134
+ the fit well beside it, and the render state floats as a pill. -->
1135
+ <span class="status" id="outSt">Press Preview</span>
1095
1136
  <div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><svg id="dofGizmo" hidden aria-label="Depth of field: drag the solid line to place focus, its ends to turn it, the dashed line for strength"></svg><!--
1096
1137
  The live layer: the PHOTOGRAPH with the source clip warped onto the
1097
1138
  fitted quad by the browser. Not the composite — the composite already
@@ -2339,7 +2380,16 @@ $('#resetquad').onclick = () => {
2339
2380
  draw(); drawStrip(); autoPreview();
2340
2381
  };
2341
2382
 
2383
+ function syncCornerRail(){
2384
+ const i = pick && pick.kind === 'corner' ? pick.i : -1;
2385
+ document.querySelectorAll('#lrail [data-jump]').forEach(b => {
2386
+ const on = +b.dataset.jump === i;
2387
+ b.classList.toggle('sel', on);
2388
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2389
+ });
2390
+ }
2342
2391
  function draw(){
2392
+ syncCornerRail();
2343
2393
  // The clip follows the quad while it is being dragged: the live layer IS the
2344
2394
  // fit, so leaving it on the old corners would make the two panes disagree
2345
2395
  // mid-drag — which is the one thing the side-by-side exists to prevent.
@@ -2656,7 +2706,7 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
2656
2706
  let dofOn = recall('dof','0') === '1';
2657
2707
  let dofAng = parseFloat(recall('dofAng','90')) || 0;
2658
2708
  let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
2659
- let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= 0)) dofStart = 0;
2709
+ let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= -0.5)) dofStart = 0;
2660
2710
  function dofAngle(){ return dofOn ? dofAng : 0; }
2661
2711
  function dofStrength(){ return dofOn ? dofStr : 0; }
2662
2712
  function dofStartV(){ return dofOn ? dofStart : 0; }
@@ -2802,7 +2852,9 @@ function paintGizmo(){
2802
2852
  const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
2803
2853
  const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
2804
2854
  const r = 4 * k, rp = 3.5 * k, fs11 = 11 * k, off = 14 * k;
2805
- const ring = (p, h, cls='') => `<circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="hcase"/><circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="h ${cls}" data-h="${h}"/>`;
2855
+ const rh = 10 * k; // hit radius, 10 screen px
2856
+ const hit = (p, h, cls='') => `<circle cx="${p[0]}" cy="${p[1]}" r="${rh}" class="hit ${cls}" data-h="${h}"/>`;
2857
+ const ring = (p, h, cls='') => hit(p, h) + `<circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="hcase"/><circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="h ${cls}" data-h="${h}"/>`;
2806
2858
  const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
2807
2859
  const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
2808
2860
  // GUIDE_DASH in screen px, expressed in photo px; a line past the screen gets a sparser dash
@@ -2810,6 +2862,7 @@ function paintGizmo(){
2810
2862
  const away = unit(sub(bc, ac)); // label offsets: away from the other line
2811
2863
  const beyond = dofEnd > 1.0001;
2812
2864
  const beyond2 = two && dofEnd2 < -0.0001;
2865
+ const before = dofStart < -0.0001; // focus in front of the glass
2813
2866
  // Each line breaks around its ring handle: the ring is hollow, so a line
2814
2867
  // running through it would show inside. Break = ring outer edge + 1px.
2815
2868
  const brk = (HANDLE_BREAK) * k;
@@ -2819,11 +2872,11 @@ function paintGizmo(){
2819
2872
  LL(a0, a1, ac, 'case', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
2820
2873
  LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
2821
2874
  (two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
2822
- `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2823
- `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2875
+ hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2876
+ hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2824
2877
  ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
2825
- `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
2826
- T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
2878
+ hit(th, 'str') + `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
2879
+ T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp' + (before ? ' · in front of the screen' : ''), 4*k) +
2827
2880
  T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
2828
2881
  (two ? T([cc[0] - away[0]*off, cc[1] - away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond2 ? ' · past the screen' : ''}`, 4*k) : '') +
2829
2882
  T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
@@ -2860,7 +2913,10 @@ gz.addEventListener('pointermove', e => {
2860
2913
  const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
2861
2914
  const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
2862
2915
  if (gzDrag.h === 'start'){
2863
- dofStart = Math.max(dofEnd2 === null ? 0 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
2916
+ // -0.5 .. 0.95, the same reach past the screen the far line has: on a
2917
+ // steep, flat phone the plane of focus is on the table in front of the
2918
+ // glass, not on it (reported 14 Sep 2026 — "cannot place it on the photo").
2919
+ dofStart = Math.max(dofEnd2 === null ? -0.5 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
2864
2920
  } else if (gzDrag.h === 'end'){
2865
2921
  dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
2866
2922
  } else if (gzDrag.h === 'end2'){
@@ -2886,7 +2942,7 @@ gz.addEventListener('pointermove', e => {
2886
2942
  // Re-express the lines against the new extent so nothing moves but the angle.
2887
2943
  const g2 = gizmoGeom();
2888
2944
  const f0 = (c[0]*g2.d[0] + c[1]*g2.d[1] - g2.lo) / (g2.hi - g2.lo);
2889
- dofStart = Math.max(0, Math.min(0.95, f0));
2945
+ dofStart = Math.max(-0.5, Math.min(0.95, f0));
2890
2946
  dofK = Math.max(0.05, Math.min(0.95, (c[0]*g2.u[0] + c[1]*g2.u[1] - g2.ulo) / (g2.uhi - g2.ulo)));
2891
2947
  dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, dofStart + gzDrag.dFar / (g2.hi - g2.lo)));
2892
2948
  if (dofEnd2 !== null) dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, dofStart - gzDrag.dNear / (g2.hi - g2.lo)));