screengraft 0.64.1 → 0.65.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.64.1",
3
+ "version": "0.65.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.64):** 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.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.
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
@@ -9,10 +9,12 @@
9
9
  SURFACES STEP: well (recessed) < bg < sunk < card < raise < float. The
10
10
  previous build used one tone for the top bar, rail, dock, popovers AND
11
11
  buttons, so nothing read as a control sitting on a panel — it read as
12
- regions of one sheet divided by lines. Depth now comes from tone steps
13
- plus a 1px top inner highlight (--hi), which is what actually separates
14
- a raised control from a div in a dark UI; shadow is reserved for things
15
- that genuinely float.
12
+ regions of one sheet divided by lines. Depth comes from tone steps alone
13
+ now; the 1px top inner highlight (--hi) that visual-language.md documents
14
+ as load-bearing for reading a raised control apart from a div was
15
+ removed app-wide by request, 16 Sep 2026 — a deliberate departure
16
+ from that doc, not a regression. Shadow is reserved for things that
17
+ genuinely float.
16
18
  Status colours keep three distinct LUMINANCE steps, not three hues —
17
19
  measured, --ok and --warn once differed by 1.01:1, invisible in greyscale
18
20
  and to red-green colour blindness. --line divides; --edge bounds anything
@@ -57,6 +59,10 @@
57
59
  primary button is therefore the cursor and the press, as designed. */
58
60
  --acc:#f23b0d; --acc-btn-rest:#f23b0d; --acc-press:#bb2b00;
59
61
  --acc-stroke:#ff6833; --acc-ink:#fbf9f9; --acc-soft:rgba(242,59,13,.14);
62
+ /* slider/handle/ring/focus, read as a solid at 40% over the card in
63
+ Figma's Focus state (216:470) -- rgba(255,104,51,.4) is --acc-stroke's
64
+ own channels, not a separate pick. */
65
+ --acc-glow:rgba(255,104,51,.4);
60
66
  /* Neutral button hover/pressed are explicit surfaces in Figma, not a
61
67
  brightness filter on the rest state. Re-picked 11 Sep 2026 against the
62
68
  polished Button matrix (4:14): hover's border is border/edge-mid, and
@@ -107,6 +113,13 @@
107
113
  --group-edge-lift:rgba(255,255,255,.4);
108
114
  /* The Button Group's stroke weight (0.75, set 14 Sep); the pills keep 1px. */
109
115
  --group-bw:.75px;
116
+ /* Segment Button 138:255, re-read 16 Sep 2026: a horizontal group now
117
+ carries its own divider between segments (button/secondary/label/default
118
+ at 10% -- exactly --ink at 10%), a real element in the file, on by
119
+ default and turned off per-instance where it isn't wanted (the last
120
+ segment, mainly -- there's nothing after it to divide from). Vertical
121
+ groups still have no divider variant in the file; not added here. */
122
+ --seg-divider:rgba(233,236,241,.1);
110
123
  --group-shadow:0 2px 4px rgba(0,0,0,.08), 0 3px 10px rgba(0,0,0,.12), 0 4px 24px rgba(0,0,0,.25);
111
124
  --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);
112
125
 
@@ -136,7 +149,11 @@
136
149
  Don't "fix" either by moving --ok back; that reopens the pair that counts. */
137
150
  --ok:#4fbf8b; --err:#e5534b;
138
151
 
139
- --hi:inset 0 1px 0 rgba(255,255,255,.06);
152
+ /* --hi (inset 0 1px 0 rgba(255,255,255,.06), the top inner highlight on
153
+ every raised control) removed by request, 16 Sep 2026 -- was
154
+ documented in visual-language.md as deliberate ('most of the distance
155
+ between div and control'). Left here as the reason it is gone, not
156
+ just that it is. */
140
157
  --shadow:0 16px 40px rgba(0,0,0,.55), 0 3px 10px rgba(0,0,0,.4);
141
158
  --r-sm:6px; --r-md:8px; --r-lg:12px;
142
159
  --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s9:48px;
@@ -222,7 +239,7 @@
222
239
  where every variant carries the same radius. Md had been 8 since 11 Sep. */
223
240
  button{font:inherit;color:var(--ink);background:var(--raise);
224
241
  border:var(--bw) solid var(--edge);border-radius:var(--r-sm);
225
- padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;box-shadow:var(--hi);
242
+ padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;
226
243
  transition:var(--t-all), transform var(--t)}
227
244
  button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
228
245
  button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
@@ -260,7 +277,7 @@
260
277
  as an ordinary neutral control. */
261
278
  button.accent:not(:disabled):not(.primary){
262
279
  background:var(--raise); color:var(--ink); border-color:var(--edge);
263
- font-weight:400; box-shadow:var(--hi)}
280
+ font-weight:400}
264
281
  /* Radius is deliberately NOT restated in these three rules any more. It is the
265
282
  base button's, so .sm keeps winning on size — an accent .sm button used to
266
283
  be handed --r-md by specificity alone. */
@@ -331,7 +348,7 @@
331
348
  for the next action. The old accent ring is gone for that reason.
332
349
  ------------------------------------------------------------------ */
333
350
  .chip{border-radius:999px;padding:0 var(--s3);height:28px;font-size:12px;
334
- background:var(--raise);border-color:var(--edge);box-shadow:var(--hi)}
351
+ background:var(--raise);border-color:var(--edge)}
335
352
  /* Chip state matrix, ported from the Chip component set. Every state now
336
353
  steps in the direction it should: hover UP from rest, pressed DOWN from it,
337
354
  and selected-hover up again from selected-rest. Before the new tokens
@@ -340,7 +357,7 @@
340
357
  .chip:hover{background:var(--raise-mid);border-color:var(--edge-mid)}
341
358
  .chip:active{background:var(--raise);border-color:var(--edge-mid)}
342
359
  .chip.on{background:var(--raise-hi);border-color:var(--edge-hi);
343
- color:var(--ink);font-weight:600;box-shadow:var(--hi)}
360
+ color:var(--ink);font-weight:600}
344
361
  .chip.on:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
345
362
  .chip.on:active{background:var(--raise-mid);border-color:var(--edge-hi)}
346
363
  .chip[disabled]{opacity:.5;color:var(--mute);background:var(--raise);border-color:var(--edge)}
@@ -452,7 +469,7 @@
452
469
  chip: accent stroke and label on the empty one, accent fill on the filled. */
453
470
  .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
454
471
  height:32px;padding:0 12px 0 6px;border-radius:999px;color:var(--ink);
455
- background:var(--raise-hi);border:var(--bw) solid var(--edge-hi);box-shadow:var(--hi)}
472
+ background:var(--raise-hi);border:var(--bw) solid var(--edge-hi)}
456
473
  .inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
457
474
  .inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
458
475
  .inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
@@ -616,19 +633,56 @@
616
633
  inset:calc(-1 * var(--group-bw));border:var(--group-bw) solid transparent;
617
634
  border-radius:calc(var(--r-sm) + var(--group-bw))}
618
635
  .noblend .pillwrap > .ring.lift{mix-blend-mode:normal;border-color:transparent}
619
- .bgroup.row{flex-direction:row}
636
+ /* flex-wrap:nowrap and align-items:stretch are declared here, not just on
637
+ .bgroup, because a generic .row utility (line ~908, unrelated -- it wraps
638
+ chip lists in the sidebar) happens to share this class name and sets
639
+ flex-wrap:wrap/align-items:center at the same specificity as .bgroup's
640
+ own base rule. .bgroup.row's two classes outrank the generic one-class
641
+ .row, so declaring them here wins outright rather than depending on
642
+ which rule the cascade happens to read last. Latent since the class was
643
+ first reused for orientation (11 Sep) -- every existing horizontal group
644
+ happened to fit its container without wrapping, so nothing ever forced
645
+ it to matter until the video bar's Button Group (16 Sep, 7:38) hit a
646
+ narrower parent and visibly split into two rows. */
647
+ /* gap:0 for the same reason as the two properties above: the generic .row
648
+ utility sets gap:8px (line ~908), and .bgroup never declared its own, so
649
+ it leaked in here too -- found 16 Sep when Play and Preview turned out to
650
+ have an 8px gap between them that the file doesn't show (they're meant
651
+ to touch; the divider is the only separation). */
652
+ .bgroup.row{flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:0}
620
653
  .bgroup > .segbtn:first-of-type{border-top-left-radius:var(--r-sm);border-top-right-radius:var(--r-sm)}
621
654
  .bgroup > .segbtn:last-of-type{border-bottom-left-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
622
655
  .bgroup.row > .segbtn:first-of-type{border-radius:var(--r-sm) 0 0 var(--r-sm)}
623
656
  .bgroup.row > .segbtn:last-of-type{border-radius:0 var(--r-sm) var(--r-sm) 0}
624
657
  .bgroup > .segbtn:only-of-type{border-radius:var(--r-sm)}
625
658
  .segbtn,.segbtn:hover,.segbtn:active{border:0;box-shadow:none;transform:none}
626
- .segbtn{border-radius:0}
659
+ .segbtn{border-radius:0;position:relative}
660
+ /* The divider is an absolutely-positioned 1px slice, not a border -- a
661
+ border would add a device pixel to the segment's own measured width
662
+ (this file keeps those to the pixel against the Figma frame). Only
663
+ .row (horizontal) groups get it, matching the file: Vertical's variants
664
+ carry no divider prop at all. :last-of-type is the "off where needed" --
665
+ the last segment has nothing to its right to divide from. */
666
+ /* Height corrected 16 Sep: the file's own Divider node (226:5252) measures
667
+ 1x20 at y=6 inside a 32-tall button -- a 6px inset top and bottom, not
668
+ the button's full height. First pass used top:0/bottom:0 (32px) without
669
+ checking the node's own geometry. */
670
+ .bgroup.row > .segbtn:not(:last-of-type)::after{content:"";position:absolute;
671
+ top:6px;bottom:6px;right:0;width:1px;background:var(--seg-divider);pointer-events:none}
627
672
  /* Segment Button re-read 14 Sep 2026: 32px tall, 32px minimum, 8px side
628
673
  padding; a text label sits in its own 6px-padded box, so a text button
629
674
  pads 14px. Was 28 / 6. */
675
+ /* flex-shrink:0 -- min-width:32px above is a floor for an ICON button's
676
+ square, not a licence to crush a text button's label below readable
677
+ width. A .segbtn's min-width:auto default (content-based) would already
678
+ have protected text buttons in a flex row; setting an explicit min-width
679
+ removes that protection, so it has to come back explicitly. Surfaced by
680
+ the video bar's Button Group (16 Sep, 7:38): "Preview" shrank to a single
681
+ "P" in a narrow pane before this. The slider's own input keeps its
682
+ min-width:140 floor and is what gives way first (see #vframe below). */
630
683
  .segbtn{height:32px;min-width:32px;padding:0 8px;font-size:12px;color:var(--ink);
631
- background:var(--raise);display:inline-flex;align-items:center;justify-content:center;gap:4px}
684
+ background:var(--raise);display:inline-flex;align-items:center;justify-content:center;gap:4px;
685
+ flex-shrink:0}
632
686
  .segbtn.txt{padding:0 14px}
633
687
  .segbtn:hover{background:var(--raise-mid)}
634
688
  .segbtn:active{background:var(--raise-low)}
@@ -688,21 +742,56 @@
688
742
  keeps it where the eye expects while the picture moves under it. */
689
743
  /* max-width so a narrow pane (compare view on a small window) can never
690
744
  push the bar outside the frame it is anchored to. */
691
- .cvbar{position:absolute;left:50%;transform:translateX(-50%);bottom:16px;z-index:3;
692
- max-width:calc(100% - 32px);overflow-x:auto;
693
- display:flex;align-items:center;gap:var(--s6);
694
- padding:var(--s2) var(--s2) var(--s2) var(--s3);
695
- background:var(--overlay);backdrop-filter:blur(5px);
696
- -webkit-backdrop-filter:blur(5px);
697
- border-radius:var(--r-lg);box-shadow:var(--shadow)}
698
- .cvbar-actions{display:flex;align-items:center;gap:var(--s2);flex-wrap:nowrap}
745
+ /* Positioning only -- the look (background, the four-blend-ring stroke,
746
+ radius, shadow) now comes from the .bgroup.row inside it, same split as
747
+ .brail/.bgroup on the fit pane. Centred with left:0/right:0 + justify-
748
+ content:center, NOT left:50%+transform: a transform (like the z-index
749
+ this replaced) opens its own stacking context, and mix-blend-mode cannot
750
+ cross a stacking-context boundary -- the group's four rings would blend
751
+ against .cvbar's own (empty) background instead of the video/photo
752
+ behind it. Exactly the gotcha .brail was already written to avoid;
753
+ .cvbar just hadn't been, since it predates the group being inside it.
754
+ pointer-events follows .brail's pattern for the same reason: the bar
755
+ now spans the full width, so clicks on the canvas either side of the
756
+ group must still pass through. */
757
+ /* overflow-x:auto is gone -- CSS forces the OTHER axis to compute as
758
+ 'auto' too whenever one of overflow-x/overflow-y is non-visible (you
759
+ cannot actually get overflow-x:auto with a truly visible overflow-y),
760
+ so it was clipping .bgroup's own drop shadow and the top/bottom of its
761
+ blend rings to exactly the bar's 32px height -- invisible instead of a
762
+ shadow, and the ring only partly there. Found by comparing this rail to
763
+ .brail (fit pane), which has never set any overflow and shows both
764
+ fully. .brail also never needed to scroll: it doesn't set overflow at
765
+ all and simply lets its content overflow visibly, so a narrow pane's
766
+ residual clipping (rare -- a normal window is comfortably wider than
767
+ either rail) now happens at #outWrap's own overflow:auto instead of a
768
+ dedicated scrollbar on the bar itself. Trading a rare, already-degraded
769
+ edge case for a shadow and stroke that are always correct. */
770
+ .cvbar{position:absolute;left:0;right:0;bottom:16px;display:flex;justify-content:center;
771
+ padding:0 var(--s4);pointer-events:none;white-space:nowrap}
772
+ .cvbar .bgroup{pointer-events:auto}
773
+ /* The slider cell: label, track, frame counter, in the Segment Button's own
774
+ 32px height and 12px side padding (227:5326). Same background as the
775
+ group it sits in, so it needs no radius of its own to look seamless at
776
+ rest -- given one anyway, for the corner .bgroup itself doesn't clip. */
777
+ /* Right padding 8px, not the symmetric 12 the Figma frame's own Slider
778
+ wrapper carries: with .bgroup.row's gap now 0, the space from the frame
779
+ count to the play icon is this padding PLUS .segbtn's own 8px left
780
+ padding, and 12+8=20 read too wide against the group's own -- 8+8=16
781
+ matches --s4 and the file's row of Play/Preview. Left padding (before
782
+ "Fit on frame") is unaffected. */
783
+ .cvslider{display:flex;align-items:center;gap:var(--s2);height:32px;box-sizing:border-box;
784
+ padding:0 8px 0 12px;background:var(--raise);border-radius:var(--r-sm) 0 0 var(--r-sm)}
785
+ /* .bgroup.row > .segbtn:first-of-type rounds the first BUTTON's left
786
+ corners -- wrong here, since the slider cell (a <div>, not a .segbtn) is
787
+ what actually sits leftmost. Cancelled by ID specificity. */
788
+ #vidbar .bgroup > .segbtn:first-of-type{border-radius:0}
699
789
  /* The scrubber needs a floor, or the bar "fits" by crushing it. Moving the
700
790
  format control out stopped the bar overflowing at 742px — and the space it
701
791
  freed went to the other controls while the slider collapsed to FOUR pixels,
702
- measured. The bar may scroll (it has overflow-x:auto by design); the one
792
+ measured. The bar may scroll (.cvbar keeps overflow-x:auto); the one
703
793
  control that is useless when small must not be the one that gives way. */
704
794
  #vframe{min-width:140px;flex:1 1 180px}
705
- .cvbar{white-space:nowrap}
706
795
  /* `hidden` is a UA display:none, which any explicit display beats — and
707
796
  .cvbar sets display:flex. Without this the bar stays visible while the
708
797
  photo is chosen but the screenshot is not, offering corners to fit
@@ -863,15 +952,9 @@
863
952
  reason a true-black UI stops reading as a hole. It is NOT the same maths as
864
953
  compose()'s emissive, and the page says so. */
865
954
  #liveVid.emissive{mix-blend-mode:screen}
866
- /* An icon button is square and centres its glyph; everything else about it —
867
- height, border, the raise ladder — stays whatever .sm already is, so the bar
868
- keeps one button shape. */
869
- /* `flex:none` and a min-width, not just a width: the bar is a nowrap flex row
870
- that overflows at narrow widths, and a flex item with a width will happily
871
- shrink to its content. Measured at 742px before this: the button was 13px
872
- wide — the glyph and its border — which is a target nobody can hit. */
873
- .sm.icon{display:inline-flex;align-items:center;justify-content:center;
874
- flex:none;width:28px;min-width:28px;padding:0}
955
+ /* .sm.icon (the square icon-only small button) is gone -- #playlive is a
956
+ .segbtn now, part of the video bar's Button Group (7:38, 16 Sep), and
957
+ nothing else used the class. */
875
958
  .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
876
959
 
877
960
  .rail{min-width:0;overflow:auto;padding:0;display:grid;align-content:start;background:var(--card)}
@@ -936,7 +1019,7 @@
936
1019
  .tip{position:fixed;width:264px;max-width:calc(100vw - 32px);
937
1020
  padding:8px 10px;border-radius:var(--r-md);
938
1021
  background:var(--float);border:1px solid var(--edge);
939
- box-shadow:var(--shadow), var(--hi);
1022
+ box-shadow:var(--shadow);
940
1023
  color:var(--ink);font-size:12px;font-weight:400;line-height:1.45;
941
1024
  letter-spacing:normal;text-transform:none;text-align:left;white-space:normal;
942
1025
  z-index:70;opacity:0;visibility:hidden;pointer-events:none;
@@ -975,15 +1058,61 @@
975
1058
  land, since the thumb travels inside the track's content box. */
976
1059
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:20px;
977
1060
  margin:0;padding-inline:2px;background:transparent;box-sizing:border-box}
1061
+ /* Ported from Figma 216:470 (16 Sep). Rail is surface/float, not --sunk --
1062
+ the two read identically at a glance but are different tokens (the old
1063
+ port had borrowed --sunk). Thumb is a squircle-radius square (r 3px),
1064
+ not a pill -- the one visible shape change from the previous slider. */
978
1065
  input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:999px;transition:var(--t-all);
979
- background:linear-gradient(to right, var(--acc) 0 calc(var(--p,0) * 100%), var(--sunk) calc(var(--p,0) * 100%) 100%);
1066
+ background:linear-gradient(to right, var(--acc) 0 calc(var(--p,0) * 100%), var(--float) calc(var(--p,0) * 100%) 100%);
980
1067
  box-shadow:inset 0 0 0 1px var(--line)}
1068
+ /* slider/track/pressed is a darker accent (--acc-press), not the rest
1069
+ colour dimmed -- the fill itself recedes while the thumb comes forward. */
1070
+ input[type=range]:active::-webkit-slider-runnable-track{
1071
+ background:linear-gradient(to right, var(--acc-press) 0 calc(var(--p,0) * 100%), var(--float) calc(var(--p,0) * 100%) 100%)}
1072
+ /* All five states in the file carry a crisp 1px --card ring around the
1073
+ handle, sampled at (22,22,24) on every one of them (default, disabled,
1074
+ hover, pressed) -- not just where the glow needed it. It is the casing
1075
+ that separates the handle from whatever is under the rail, same idea as
1076
+ the corner-loupe / quad-overlay casing elsewhere in this file. State
1077
+ changes use their own 150ms transition rather than --t-all's 180ms --
1078
+ the handle is dragged continuously, so it reads better a touch snappier
1079
+ than a click target does. */
981
1080
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;
982
- margin-top:-5px;border-radius:999px;background:var(--raise);border:1px solid var(--edge-hi);
983
- box-shadow:var(--hi), 0 1px 3px rgba(0,0,0,.4);transition:var(--t-all)}
984
- input[type=range]:hover::-webkit-slider-thumb{border-color:var(--acc)}
1081
+ margin-top:-5px;border-radius:3px;background:var(--raise);border:1px solid var(--edge-hi);
1082
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1px var(--card);
1083
+ transition:background-color .15s cubic-bezier(.2,0,0,1), border-color .15s cubic-bezier(.2,0,0,1),
1084
+ box-shadow .15s cubic-bezier(.2,0,0,1)}
1085
+ /* Hover/Pressed in the file are rasterised because the ring carries a soft
1086
+ outer glow (blur), which Figma exports as an image rather than a
1087
+ translatable box-shadow. The glow's own blur radius is judged against
1088
+ the two PNGs' bounding boxes (44px/52px tall on a 26px track --
1089
+ symmetric ~9px/~13px halos) rather than measured to the pixel. */
1090
+ input[type=range]:hover::-webkit-slider-thumb{background:var(--raise-mid);border-color:var(--acc-stroke);
1091
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1px var(--card), 0 0 6px 1px var(--acc-glow)}
1092
+ input[type=range]:active::-webkit-slider-thumb{background:var(--acc);border-color:var(--acc-stroke);
1093
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1px var(--card), 0 0 9px 2px rgba(255,104,51,.6)}
1094
+ /* Focus keeps the default handle colours and adds a second, wider ring
1095
+ (the same --card gap, at 1.5px so it clears the thicker glow) plus the
1096
+ accent glow, same language as the switch's focus ring (line ~792) --
1097
+ not a browser outline. */
985
1098
  input[type=range]:focus-visible{outline:none}
986
- input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--acc);outline-offset:2px}
1099
+ input[type=range]:focus-visible::-webkit-slider-thumb{
1100
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1.5px var(--card), 0 0 0 3px var(--acc-glow)}
1101
+ /* Disabled reads as the handle's own colours at roughly 50% opacity over
1102
+ --card in the file (sampled fill ~(33,33,36), which is --raise blended
1103
+ ~52% over --card, and the track's ~(105,33,12) is --acc-dis at the same
1104
+ ~50%) -- Figma's own Disabled variants are usually built as a group
1105
+ opacity rather than a recolour. Kept as solid tokens instead: the rest
1106
+ of this system deliberately does NOT fade disabled controls (see the
1107
+ accent-disabled note above --acc-dis) so it can stay readable, and a
1108
+ lone opacity-faded slider would be the one control that fades. The ring
1109
+ stays -- it is the one thing every state agrees on. */
1110
+ input[type=range][disabled]{cursor:not-allowed}
1111
+ input[type=range][disabled]::-webkit-slider-runnable-track{
1112
+ background:linear-gradient(to right, var(--acc-dis) 0 calc(var(--p,0) * 100%), var(--raise-low) calc(var(--p,0) * 100%) 100%);
1113
+ box-shadow:inset 0 0 0 1px var(--line)}
1114
+ input[type=range][disabled]::-webkit-slider-thumb{background:var(--raise);border-color:var(--edge-low);
1115
+ box-shadow:0 0 0 1px var(--card)}
987
1116
  /* Captions are text/faint in Figma. The port had quietly used --mute
988
1117
  instead; now they follow the token as designed. At the brightened
989
1118
  #8f939ab2 that measures 3.48:1 on card — up from 2.42:1, still under the
@@ -1034,7 +1163,7 @@
1034
1163
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
1035
1164
  /* Floating mode: the loupe follows the edge instead of taking a permanent band. */
1036
1165
  .floatloupe{position:fixed;z-index:40;display:none;padding:8px;border-radius:var(--r-lg);
1037
- background:var(--float);border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi)}
1166
+ background:var(--float);border:1px solid var(--edge);box-shadow:var(--shadow)}
1038
1167
  .floatloupe.on{display:block}
1039
1168
  .floatloupe canvas{display:block;width:340px;height:104px;border-radius:var(--r-sm);
1040
1169
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line)}
@@ -1063,7 +1192,7 @@
1063
1192
  .toast{position:absolute;left:0;top:0;width:100%;box-sizing:border-box;
1064
1193
  display:flex;align-items:flex-start;gap:8px;padding:10px 12px;
1065
1194
  border-radius:var(--r-md);background:var(--float);
1066
- border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi);
1195
+ border:1px solid var(--edge);box-shadow:var(--shadow);
1067
1196
  font-size:13px;line-height:1.45;color:var(--ink);
1068
1197
  pointer-events:auto;touch-action:none;transform-origin:50% 0;
1069
1198
  will-change:transform,opacity;
@@ -1127,7 +1256,7 @@
1127
1256
  .pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
1128
1257
  max-height:78vh;overflow:auto;background:var(--float-glass);border:var(--bw) solid var(--edge);
1129
1258
  backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
1130
- border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
1259
+ border-radius:var(--r-lg);box-shadow:var(--shadow);padding:16px;
1131
1260
  opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
1132
1261
  transform-origin:50% 0;
1133
1262
  transition:opacity var(--t-pop), transform var(--t-pop),
@@ -1291,28 +1420,37 @@
1291
1420
  unchanged — you match the edges on one frame and every frame gets the
1292
1421
  same geometry, because the photo does not move. -->
1293
1422
  <div class="cvbar" id="vidbar" hidden>
1294
- <!-- What is left in this bar is only what the RESULT pane is for:
1295
- which moment of the clip you are looking at, and the two ways of
1296
- looking at it. The output format left for the top bar, beside
1297
- Render, because deciding how a file is written is not judging a
1298
- fit — and as the fifth control here it was squeezing the scrubber
1299
- to nothing at 742px. -->
1300
- <span class="sm" style="color:var(--mute)">Fit on frame</span>
1301
- <span class="cvbar-actions">
1302
- <input type="range" id="vframe" min="0" max="0" value="0" step="1"
1303
- aria-label="Which frame of the clip to match the edges on">
1304
- <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
1423
+ <!-- Ported from Figma 7:38 (16 Sep): the bar is now one Button Group
1424
+ (solid --raise, the same four-blend-ring stroke every other rail
1425
+ uses), not a frosted floating pill — the slider and the two
1426
+ actions read as one joined control instead of a popover sitting
1427
+ over the video. .cvbar itself keeps only the positioning
1428
+ (centred, 16px off the bottom, scrolls if it doesn't fit) — same
1429
+ split as .brail/.bgroup on the fit pane. -->
1430
+ <div class="bgroup row"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1431
+ <!-- What is left in this bar is only what the RESULT pane is for:
1432
+ which moment of the clip you are looking at, and the two ways of
1433
+ looking at it. The output format left for the top bar, beside
1434
+ Render, because deciding how a file is written is not judging a
1435
+ fit — and as the fifth control here it was squeezing the scrubber
1436
+ to nothing at 742px. -->
1437
+ <div class="cvslider">
1438
+ <span class="sm" style="color:var(--mute)">Fit on frame</span>
1439
+ <input type="range" id="vframe" min="0" max="0" value="0" step="1"
1440
+ aria-label="Which frame of the clip to match the edges on">
1441
+ <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
1442
+ </div>
1305
1443
  <!-- Viewing controls sit with the scrubber, which is the other viewing
1306
1444
  control; the format stepper is about the OUTPUT and stays at the
1307
1445
  end. Icon-only, so each carries an aria-label — a title alone
1308
1446
  names it for a mouse and not for a screen reader. -->
1309
- <button class="sm icon" id="playlive" aria-label="Play the clip on the photo"
1447
+ <button class="segbtn" id="playlive" aria-label="Play the clip on the photo"
1310
1448
  title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade, grain and depth of field are not in this view.">
1311
- <svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><path d="M3 1.5 10 6 3 10.5Z" fill="currentColor"/></svg>
1449
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M5 3.5 12.5 8 5 12.5Z" fill="currentColor"/></svg>
1312
1450
  </button>
1313
- <button class="sm" id="playclip"
1451
+ <button class="segbtn txt" id="playclip"
1314
1452
  title="Composite a few seconds from the fitted frame through the real pipeline and play that. Slower — it is a render — but it is the only view with the grade, the grain and the true emissive blend.">Preview</button>
1315
- </span>
1453
+ </div>
1316
1454
  </div>
1317
1455
  </section>
1318
1456
 
@@ -1742,7 +1880,7 @@ const ROTATE_CURSOR = 'url("data:image/svg+xml;utf8,' + encodeURIComponent(
1742
1880
  '<path 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"/>' +
1743
1881
  '<path d="M15.6 2.6l0.4 4.2-4.2 0.3" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
1744
1882
  '</svg>') + '") 11 11, crosshair';
1745
- const QUAD_W = 0.9, QUAD_ACTIVE_W = 1.5, GUIDE_W = 0.9, CASE_PAD = 2.0;
1883
+ const QUAD_W = 1, QUAD_ACTIVE_W = 1, GUIDE_W = 1, CASE_PAD = 2.0; // every line 1px, 14 Sep 2026 — the active edge no longer thickens
1746
1884
  const GUIDE_DASH = [6, 5];
1747
1885
  function cased(c, path, coreW, coreColor, dash){
1748
1886
  c.save();