screengraft 0.65.0 → 0.66.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.65.0",
3
+ "version": "0.66.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",
@@ -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.65):** 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** (the images you have used before, 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.66):** 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** (the images you have used before, 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
@@ -159,7 +159,8 @@
159
159
  --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s9:48px;
160
160
  /* 120ms originally, per the visual-language notes. asked for a bit
161
161
  slower on 5 Sep — one token, so retuning is a one-line change. */
162
- --t:180ms cubic-bezier(.2,0,0,1);
162
+ --t-dur:180ms; /* the duration alone, for delays */
163
+ --t:var(--t-dur) cubic-bezier(.2,0,0,1);
163
164
  --t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
164
165
  /* Every control animates the SAME four properties on the same curve. Kept as
165
166
  one token because the failure mode here is a control that transitions its
@@ -194,11 +195,25 @@
194
195
  the part that should give; the actions are fixed-size and must not. */
195
196
  .topbar > *{min-width:0}
196
197
  .tb-chips{overflow:hidden}
197
- .tb-actions{flex:none}
198
+ /* Figma 15:108: three groups at 16px — the format control, the save PAIR,
199
+ and the rail toggle — with 8px inside the pair. Save and Send to Claude
200
+ belong together (272:1517 frames them); the toggle does not. */
201
+ .tb-actions{flex:none;display:flex;align-items:center;gap:var(--s4)}
202
+ .tb-save{display:flex;align-items:center;gap:var(--s2)}
198
203
  .tb-left{display:flex;align-items:center;gap:var(--s9)}
199
204
  .tb-chips{display:flex;align-items:center;gap:var(--s2)}
200
205
  .tb-arrow{color:var(--mute)}
201
- .tb-actions{display:flex;align-items:center;gap:var(--s2)}
206
+ /* Icon-only, but NOT squeezed: Figma 272:1494 is a secondary button keeping
207
+ the base 12px side padding around a 16px icon, so 40x28. An earlier version
208
+ of this forced it to 28 square and read as a different species from the
209
+ buttons beside it. */
210
+ .iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center}
211
+ .iconbtn svg{display:block;width:16px;height:16px}
212
+ /* Two glyphs, one button (icons / sidebar-opened 272:1489, sidebar-closed
213
+ 272:1463). Each shows the rail's CURRENT state in the width of its two
214
+ panels, while its chevron points the way the next click will move it. */
215
+ #railBtn .i-closed,.app.railoff #railBtn .i-open{display:none}
216
+ .app.railoff #railBtn .i-closed{display:block}
202
217
  /* Mona Sans is the brand face in Figma; it is not installed on a stock Mac,
203
218
  so it degrades to the system UI face rather than to a serif. */
204
219
  /* The wordmark ships WITH the plugin. Mona Sans is the brand face in Figma and
@@ -372,13 +387,6 @@
372
387
  column and read like a segmented *bar*. justify-self:start is what
373
388
  actually holds it to its content width. */
374
389
 
375
- /* A stepper should look joined. */
376
- /* Stepper (Figma 28:816). NOT a bespoke control: it is ordinary Default
377
- buttons butted together, so every state — hover, pressed, disabled — is
378
- the button's own. The previous version was borderless, transparent and
379
- 24px tall, which is exactly why +/- read as a different species from the
380
- buttons beside them. Only the corner radii and the shared middle border
381
- are special. */
382
390
  /* ------------------------------------------------------------------
383
391
  `hidden` must actually hide. THREE components have now needed this
384
392
  patched one at a time — .cvbar, #outImg/#outVid, and the format
@@ -394,16 +402,6 @@
394
402
  ------------------------------------------------------------------ */
395
403
  [hidden]{display:none !important}
396
404
 
397
- .stepper{display:inline-flex;align-items:center}
398
- .stepper button{border-radius:0}
399
- .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
400
- .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
401
- /* One border between neighbours rather than two stacked. */
402
- .stepper button + button{margin-left:calc(-1 * var(--bw))}
403
- .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
404
- /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
405
- .dock-step button{padding:0 var(--s3)}
406
-
407
405
  /* ------------------------------------------------------------------
408
406
  Segmented control (Figma 84:177). NOT a stepper: a stepper is ordinary
409
407
  buttons butted together, which is right for -/+ but wrong for a choice —
@@ -431,7 +429,11 @@
431
429
  .seg:active{border-color:var(--edge-mid)}
432
430
  /* The track carries the border and the elevation, so a segment is a label
433
431
  until it is selected: no border, no shadow, no lift on press. */
434
- .seg button{height:24px;padding:0 11px;font-size:12px;border-radius:var(--r-xs);
432
+ /* 70px each (Figma 84:161, so 2+70+2+70+2 = 146): a two-way choice reads as
433
+ one control rather than two adjacent buttons, and equal halves are most of
434
+ what sells that. min-width rather than width so the ghost below still
435
+ governs if a label ever outgrows 70. */
436
+ .seg button{height:24px;min-width:70px;padding:0 11px;font-size:12px;border-radius:var(--r-xs);
435
437
  background:transparent;border:0;box-shadow:none;color:var(--mute);
436
438
  font-weight:400;display:inline-flex;flex-direction:column;
437
439
  align-items:center;justify-content:center}
@@ -451,11 +453,14 @@
451
453
  .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
452
454
  .seg button.sel:hover{background:var(--raise-hi)}
453
455
  .seg button.sel:active{background:var(--raise-mid);transform:none}
454
- /* A toggle in the strip head. On = the raised surface every selected thing
455
- on this page has (the segmented thumb, the device chip), off = a label. */
456
- .striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
457
- .striphead button[aria-pressed]:hover,#dofBoth[aria-pressed]:hover{color:var(--ink)}
458
- .striphead button[aria-pressed="true"],#dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
456
+ /* A text toggle that reads as a label until it is on. On = the raised
457
+ surface every selected thing on this page has (the segmented thumb, the
458
+ device chip). The edge view's Contrast toggle used to share this rule; it
459
+ is a Segment Button inside the strip's own zoom group now (Figma 8:28) and
460
+ takes .segbtn[aria-pressed="true"] instead. */
461
+ #dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
462
+ #dofBoth[aria-pressed]:hover{color:var(--ink)}
463
+ #dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
459
464
  /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
460
465
  than inherited from the base button rule, so the filled state is explicit and
461
466
  the .is-empty override below has something definite to override.
@@ -551,18 +556,47 @@
551
556
  of this page where that is optional is a version where the check is
552
557
  optional. Two equal panes plus the rail. */
553
558
  .stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px;
554
- min-height:0;overflow:hidden}
559
+ min-height:0;overflow:hidden;
560
+ /* The COLUMN animates, not the rail's contents: the panes have to grow
561
+ into the space at exactly the rate the rail gives it up, and one
562
+ interpolating track does that for free. Engines that will not
563
+ interpolate grid-template-columns simply jump, which is the
564
+ behaviour this replaces. The global prefers-reduced-motion rule
565
+ near the top of this sheet switches it off. */
566
+ transition:grid-template-columns var(--t)}
555
567
  /* Below ~1100px two panes and a 344px rail no longer fit, and the page starts
556
568
  scrolling sideways — which the UI audit catches as a hard failure. Stack the
557
569
  panes instead of dropping one: losing the comparison is the one thing this
558
570
  layout exists to prevent, so the axis gives way before the pairing does.
559
571
  (Found by the audit after compare stopped being optional, at 742px.) */
572
+ /* Collapsed (the top bar's last button). The rail is 344px of window the two
573
+ panes are not getting, and nothing in it is needed while you are placing a
574
+ corner — so it folds away and they take the room. The state is remembered.
575
+ It also buys back the side-by-side comparison on a narrower window: the
576
+ rule below stacks the panes under 1100px because two of them PLUS a 344px
577
+ rail do not fit, and without the rail they do. */
578
+ /* A 0px THIRD track, not two tracks: the rail is still a grid item, and with
579
+ only two tracks declared it would be auto-placed into an implicit third
580
+ column and reappear at full width. */
581
+ .app.railoff .stage{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 0px}
582
+ /* display:none cannot animate, and a 0-wide rail with overflow:auto still
583
+ holds focusable buttons — so visibility does the hiding, delayed to the end
584
+ of the slide on the way out and immediate on the way in. */
585
+ .rail{transition:visibility 0s}
586
+ .app.railoff .rail{visibility:hidden;transition:visibility 0s linear var(--t-dur)}
560
587
  @media (max-width:1100px){
561
588
  .stage{grid-template-columns:minmax(0,1fr) 344px;
562
589
  grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
563
590
  .stage > .pane:first-of-type{grid-column:1;grid-row:1}
564
591
  .stage > #outPane{grid-column:1;grid-row:2;border-top:1px solid var(--line)}
565
592
  .stage > .rail{grid-column:2;grid-row:1 / span 2}
593
+ /* Collapsed at this width the panes are stacked and each one is now the
594
+ FULL width rather than sharing it with the rail, which is the better
595
+ trade here: side by side they would be ~370px each, narrower than the
596
+ stacked pair, and the comparison is what the layout exists for. */
597
+ /* Two tracks again rather than one, for the same reason as above: this
598
+ media query places the rail in column 2 explicitly. */
599
+ .app.railoff .stage{grid-template-columns:minmax(0,1fr) 0px}
566
600
  }
567
601
  #types{flex-wrap:nowrap}
568
602
  /* The same omission one level down: rows constrained, column implicit.
@@ -720,7 +754,6 @@
720
754
  max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
721
755
  display:inline-flex;line-height:20px}
722
756
  #detSt:empty,#outSt:empty{display:none}
723
- .lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
724
757
  /* overscroll-behavior:none on both wells (14 Sep 2026): a trackpad pinch is a
725
758
  wheel event we zoom on, but macOS still owns the gesture's elastic bounce.
726
759
  While the fingers were moving the browser let the well rubber-band past
@@ -997,7 +1030,11 @@
997
1030
  tooltip hides the only copy of the explanation from anyone not using a
998
1031
  mouse. */
999
1032
  .info{flex:none;width:16px;height:16px;padding:0;border:none;
1000
- background:none;box-shadow:none;color:var(--edge-hi);cursor:help;line-height:0}
1033
+ /* The file draws it at #3a3a40 = --raise-hi (64:375), well below the
1034
+ --edge-hi it used to carry. It is an affordance, not information:
1035
+ the sentence it holds is reachable by hover AND focus, and the
1036
+ rule below lifts it to --mute the moment anyone goes looking. */
1037
+ background:none;box-shadow:none;color:var(--raise-hi);cursor:help;line-height:0}
1001
1038
  .info svg{display:block;width:16px;height:16px}
1002
1039
  .info:hover, .info:focus-visible{color:var(--mute)}
1003
1040
  /* The tooltip is anchored to the section HEADER, not to the 16px icon, and
@@ -1120,13 +1157,20 @@
1120
1157
  silently re-deviated. */
1121
1158
  .hint{color:var(--faint);font-size:12px;margin:8px 0 0;line-height:1.5}
1122
1159
 
1123
- /* Figma 8:28: 198px tall — 16 padding, 28 header, 8 gap, 130 strip, 16
1124
- padding. It is a fixed band and must never absorb leftover height. */
1125
- .dock{background:var(--card);padding:var(--s4);display:grid;gap:var(--s2);
1126
- grid-template-rows:28px 130px;flex:none;
1160
+ /* Figma 8:28, re-read 17 Sep: 146px tall — 8 padding, 130 strip, 8 padding.
1161
+ It is a fixed band and must never absorb leftover height. The 28px header
1162
+ that held the zoom stepper and the status line is gone; both float over
1163
+ the strip now, and the 52px it cost is given back to the picture. */
1164
+ .dock{background:var(--card);padding:var(--s2);display:grid;
1165
+ grid-template-rows:130px;flex:none;
1166
+ /* Anchors the two floating children. It must stay z-index:auto — a
1167
+ stacking context here would cut their blend rings off from the strip
1168
+ behind them, which is exactly what cost the video bar its stroke in
1169
+ v0.65.0. position:relative alone creates no context. */
1170
+ position:relative;
1127
1171
  /* The rule is an inset shadow, not a border, so it does not add a
1128
- pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
1129
- box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
1172
+ pixel to the band's height: 8+130+8 = 146 exactly. */
1173
+ box-shadow:inset 0 1px 0 var(--line)}
1130
1174
  .dock.hidden{display:none}
1131
1175
  /* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
1132
1176
  screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
@@ -1156,9 +1200,24 @@
1156
1200
  #pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
1157
1201
  #pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
1158
1202
  .scroller.pointing canvas#c{cursor:crosshair}
1159
- /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
1160
- shrink to hug them. */
1161
- .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
1203
+ /* Both of these float OVER the strip (Figma 8:28). Their offsets in the file
1204
+ are from the STRIP's own top-left, and the strip starts at the dock's 8px
1205
+ padding, so each one carries that 8 too: the group at 8+8 / 8+17, the pill
1206
+ at 8+8 from the top and the right. An absolutely positioned child is laid
1207
+ out against its ancestor's PADDING box, so these are measured from the
1208
+ dock's own edges and the arithmetic is done here rather than by the box.
1209
+ .stripzoom is a WRAPPER, not a group (Figma's Frame 271): two Button
1210
+ Groups stacked with 8px between them, 32 wide and 104 tall in total. The
1211
+ zoom pair is one group and Contrast is a group of its own — the same
1212
+ separation the fit pane's left rail makes between its zoom group and Fit,
1213
+ and the reason for it is the same: a group reads as one family of actions,
1214
+ and magnification and tonal treatment are not one family. The file's 1x is
1215
+ not ported; an edge view has no "actual size" to return to. */
1216
+ .stripzoom{position:absolute;left:16px;top:25px;
1217
+ display:flex;flex-direction:column;gap:var(--s2);align-items:flex-start}
1218
+ /* left:auto undoes .pillwrap's own centring, which is right for the two pane
1219
+ pills and wrong for this one. */
1220
+ .pillwrap.stripstat{top:16px;left:auto;right:16px;margin:0}
1162
1221
  canvas#strip{display:block;width:100%;height:130px;border-radius:var(--r-md);
1163
1222
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
1164
1223
  /* Floating mode: the loupe follows the edge instead of taking a permanent band. */
@@ -1349,8 +1408,11 @@
1349
1408
  <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
1350
1409
  <button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
1351
1410
  </span>
1352
- <button id="save" class="accent" disabled>Preview first</button>
1353
- <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1411
+ <span class="tb-save">
1412
+ <button id="save" class="accent" disabled>Preview first</button>
1413
+ <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1414
+ </span>
1415
+ <button id="railBtn" class="iconbtn" aria-expanded="true" aria-controls="rail" aria-label="Settings rail"><svg class="i-open" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M4.76855 1C4.50954 1.2888 4.30589 1.62677 4.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H4.17383C4.30589 14.3732 4.50954 14.7112 4.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H4.76855Z"/><path fill="var(--icon)" d="M7 15C5.89543 15 5 14.1046 5 13V3C5 1.96435 5.78722 1.113 6.7959 1.01074L7 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H7ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H7C6.44771 2 6 2.44771 6 3V13C6 13.5523 6.44772 14 7 14H13ZM7.14648 8.64648L7.79297 8L7.14648 7.35352L7.85352 6.64648L9.20703 8L7.85352 9.35352L7.14648 8.64648Z"/></svg><svg class="i-closed" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M8.76855 1C8.50954 1.2888 8.30589 1.62677 8.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H8.17383C8.30589 14.3732 8.50954 14.7112 8.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H8.76855Z"/><path fill="var(--icon)" d="M11 15C9.89543 15 9 14.1046 9 13V3C9 1.96435 9.78722 1.113 10.7959 1.01074L11 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H11ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H11C10.4477 2 10 2.44771 10 3V13C10 13.5523 10.4477 14 11 14H13ZM10.793 8L12.1465 6.64648L12.8535 7.35352L12.207 8L12.8535 8.64648L12.1465 9.35352L10.793 8Z"/></svg></button>
1354
1416
  </div>
1355
1417
  </header>
1356
1418
 
@@ -1454,7 +1516,7 @@
1454
1516
  </div>
1455
1517
  </section>
1456
1518
 
1457
- <aside class="rail">
1519
+ <aside class="rail" id="rail">
1458
1520
  <div class="sect" id="secDevice" data-on="1">
1459
1521
  <div class="sect-top"><div class="sect-left"><h4>Device</h4></div></div>
1460
1522
  <div class="chips" id="types"></div>
@@ -1563,15 +1625,24 @@
1563
1625
  </div>
1564
1626
 
1565
1627
  <footer class="dock" id="dock">
1566
- <div class="striphead">
1567
- <span class="lbl">Edge view</span>
1568
- <span class="stepper dock-step"><button id="stripOut" aria-label="Zoom the edge view out">&minus;</button><button id="stripIn" aria-label="Zoom the edge view in">+</button></span>
1569
- <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
1570
- <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>
1571
- <span class="spacer"></span>
1572
- <span class="sm" id="stripSt" style="color:var(--mute)"></span>
1573
- </div>
1574
1628
  <canvas id="strip" width="1280" height="130"></canvas>
1629
+ <!-- Both float over the strip and come AFTER it in the DOM, so they paint
1630
+ on top of it without a z-index — the same reason #outSt sits after
1631
+ #outWrap. A z-index here would cost them their blend rings. -->
1632
+ <div class="stripzoom">
1633
+ <div class="bgroup" role="group" aria-label="Edge view magnification"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1634
+ <button class="segbtn" id="stripIn" aria-label="Zoom the edge view in" title="Zoom the edge view in — a narrower band across the boundary"><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>
1635
+ <button class="segbtn" id="stripOut" aria-label="Zoom the edge view out" title="Zoom the edge view out — a wider band across the boundary"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--icon)" d="M12.5 8.5H3.5V7.5H12.5V8.5Z"/></svg></button>
1636
+ </div>
1637
+ <!-- Its own group (Figma 271:1337), a single segment, so it takes the
1638
+ radius on all four corners from .bgroup > .segbtn:only-of-type.
1639
+ Glyph is icons / contrast (272:1421), which replaced the half-disc
1640
+ drawn here while the file still had the Fit icon on this button. -->
1641
+ <div class="bgroup"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1642
+ <button class="segbtn" id="stripHC" aria-pressed="false" aria-label="High contrast" 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."><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M7 2.08398C4.16237 2.56013 2 5.02706 2 8C2 10.9729 4.16245 13.4388 7 13.915V14.9268C3.60787 14.4414 1 11.5264 1 8C1 4.4736 3.6078 1.55758 7 1.07227V2.08398Z"/><path fill="var(--icon)" d="M8 1C9.90691 1 11.635 1.76326 12.8975 3H13V3.10156C14.237 4.36408 15 6.09284 15 8C15 9.90691 14.2367 11.635 13 12.8975V13H12.8975C11.635 14.2367 9.90691 15 8 15V1ZM9 13.915C9.34419 13.8573 9.67834 13.7709 10 13.6572V2.3418C9.67838 2.22811 9.34414 2.14173 9 2.08398V13.915ZM11 13.1953C11.3584 12.9879 11.6934 12.7452 12 12.4707V3.52832C11.6935 3.25392 11.3584 3.01105 11 2.80371V13.1953ZM13 11.3164C13.6315 10.3663 14 9.22632 14 8C14 6.77339 13.6317 5.63287 13 4.68262V11.3164Z"/></svg></button>
1643
+ </div>
1644
+ </div>
1645
+ <span class="pillwrap stripstat"><i class="ring sat" aria-hidden="true"></i><i class="ring lum" aria-hidden="true"></i><i class="ring hue" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i><span class="status" id="stripSt"></span></span>
1575
1646
  </footer>
1576
1647
  </div>
1577
1648
 
@@ -2784,7 +2855,12 @@ function paintRingsFallback(){
2784
2855
  if (!IS_WEBKIT || _ringsQueued) return;
2785
2856
  _ringsQueued = true;
2786
2857
  requestAnimationFrame(() => { _ringsQueued = false;
2787
- const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')]];
2858
+ // The edge-view dock is a third surface carrying groups (Figma 8:28, 17
2859
+ // Sep), and its backdrop is the strip canvas rather than either pane's
2860
+ // picture. Without this pair the dock's zoom group and status pill get no
2861
+ // ring colour at all in Safari: they sit outside both panes, so the loop
2862
+ // never reached them and .noblend leaves the ring transparent.
2863
+ const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')], [strip, $('#dock')]];
2788
2864
  for (const [src, pane] of panes){
2789
2865
  if (!src || !pane) continue;
2790
2866
  const ok = src.tagName === 'CANVAS' ? src.width > 0 : (!src.hidden && src.naturalWidth > 0);
@@ -3422,7 +3498,7 @@ function setStripHC(on){
3422
3498
  $('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
3423
3499
  }
3424
3500
  $('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
3425
- function paintStrip(c, W, H, i, mode){
3501
+ function paintStrip(c, W, H, i, mode, padLeft = 9){
3426
3502
  c.setTransform(1,0,0,1,0,0);
3427
3503
  c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
3428
3504
  const A = st.corners[i], B = st.corners[(i+1)%4];
@@ -3462,8 +3538,8 @@ function paintStrip(c, W, H, i, mode){
3462
3538
  const centre = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
3463
3539
  const inwardIsDown = (n[0]*(centre[0]-A[0]) + n[1]*(centre[1]-A[1])) > 0;
3464
3540
  c.font = '600 11px system-ui'; c.textBaseline = 'middle';
3465
- casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
3466
- casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
3541
+ casedText(c, 'screen', padLeft, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
3542
+ casedText(c, 'outside', padLeft, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
3467
3543
  }
3468
3544
  function swingText(mode){
3469
3545
  if (mode === 'rotA') return 'the right end swings, pivot on the left';
@@ -3510,11 +3586,31 @@ function paintCorner(c, W, H, i){
3510
3586
  casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
3511
3587
  }
3512
3588
  const HANDLE_GAP_S = 4 + 1 + CASE_PAD/2 + 1; // the same break the canvas leaves around a ring
3589
+ /* Figma 8:28 puts the strip's labels at x=59, which is exactly where the
3590
+ floating zoom group ends (8 left + 32 wide) plus 19 of air. Two things stop
3591
+ that 59 from being a literal here.
3592
+ The canvas is a fixed 1280 wide stretched to whatever the band measures, so
3593
+ a number in CSS pixels and a number in canvas pixels only agree at one window
3594
+ width — hence the conversion.
3595
+ And the gap is measured off the GROUP rather than assumed: the labels are
3596
+ drawn from JS while the group is laid out by CSS, so anything that changes
3597
+ the group's width (a longer icon set, a different radius, a zoom level) would
3598
+ silently slide it under the text. Reading its real right edge means the air
3599
+ between them is the design's 19px whatever the group does, and the design's
3600
+ 59 is the floor for the case where the group has not been laid out yet —
3601
+ width 0 would otherwise put the labels at the strip's edge, UNDER the group.
3602
+ The floating loupe has no group over it and keeps the default. */
3603
+ function stripLabelInset(W){
3604
+ const r = strip.getBoundingClientRect();
3605
+ if (!(r.width > 0)) return Math.round(59 * W / 1280);
3606
+ const g = document.querySelector('.stripzoom');
3607
+ const gRight = g ? g.getBoundingClientRect().right - r.left : 40;
3608
+ return Math.round(Math.max(59, gRight + 19) * W / r.width);
3609
+ }
3513
3610
  function drawStrip(){
3514
3611
  const a = drag || hover || pick;
3515
3612
  const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
3516
3613
  const corner = has && a.kind === 'corner';
3517
- $('#stripZ').textContent = '±' + stripHalf + 'px';
3518
3614
  if (loupeMode === 'dock'){
3519
3615
  $('#floatLoupe').classList.remove('on');
3520
3616
  const W = strip.width, H = strip.height;
@@ -3527,7 +3623,7 @@ function drawStrip(){
3527
3623
  paintCorner(sctx, W, H, a.i);
3528
3624
  $('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
3529
3625
  } else {
3530
- paintStrip(sctx, W, H, a.i, a.mode);
3626
+ paintStrip(sctx, W, H, a.i, a.mode, stripLabelInset(W));
3531
3627
  $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
3532
3628
  }
3533
3629
  } else {
@@ -4200,6 +4296,51 @@ $('#imp').onclick = async () => {
4200
4296
  computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
4201
4297
  });
4202
4298
  })();
4299
+
4300
+ /* ---- the settings rail collapses -----------------------------------------
4301
+ Last button in the top bar, after Send to Claude. Nothing in the rail is
4302
+ needed while a corner is being placed, and it is 344px the two panes are not
4303
+ getting — so it folds away and they take the room. Remembered across
4304
+ sessions, like the loupe mode and the strip's contrast.
4305
+ This block sits at the very end of the script deliberately: it calls into
4306
+ drawStrip/syncOut on toggle, and those close over consts declared throughout
4307
+ the file, so it must not run while any of them is still in its temporal dead
4308
+ zone. */
4309
+ (() => {
4310
+ const app = document.querySelector('.app'), btn = $('#railBtn'), stage = $('#stage');
4311
+ let railWait;
4312
+ const apply = (open, relayout) => {
4313
+ app.classList.toggle('railoff', !open);
4314
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
4315
+ btn.title = open ? 'Hide the settings rail' : 'Show the settings rail';
4316
+ if (!relayout) return;
4317
+ // The panes just changed width, which is a resize in everything but name —
4318
+ // so do what the resize handler does, then repaint the things that are
4319
+ // MEASURED rather than laid out and have all just moved: the strip's
4320
+ // labels are positioned off the zoom group's box, and the blend rings'
4321
+ // WebKit fallback samples pixels at coordinates taken from the panes.
4322
+ // AFTER the slide, not during: measuring mid-transition fits the canvas to
4323
+ // a width the pane is still leaving. The wait is read off the transition
4324
+ // itself rather than written as a number, so it cannot drift from --t, and
4325
+ // it collapses to ~nothing under prefers-reduced-motion, where the rule at
4326
+ // the top of the sheet has already set the duration to zero.
4327
+ clearTimeout(railWait);
4328
+ const ms = (parseFloat(getComputedStyle(stage).transitionDuration) || 0) * 1000;
4329
+ railWait = setTimeout(() => {
4330
+ if (img.naturalWidth){
4331
+ const wasFit = Math.abs(scale - fitScale) < 1e-6;
4332
+ computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
4333
+ }
4334
+ drawStrip(); syncOut(); refreshRings();
4335
+ }, ms + 20);
4336
+ };
4337
+ btn.onclick = () => {
4338
+ const open = app.classList.contains('railoff'); // toggling TO open
4339
+ remember('rail', open ? '1' : '0');
4340
+ apply(open, true);
4341
+ };
4342
+ apply(recall('rail', '1') === '1', false);
4343
+ })();
4203
4344
  </script>
4204
4345
  </body>
4205
4346
  </html>