screengraft 0.64.2 → 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.64.2",
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.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.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
@@ -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,13 +149,18 @@
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;
143
160
  /* 120ms originally, per the visual-language notes. asked for a bit
144
161
  slower on 5 Sep — one token, so retuning is a one-line change. */
145
- --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);
146
164
  --t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
147
165
  /* Every control animates the SAME four properties on the same curve. Kept as
148
166
  one token because the failure mode here is a control that transitions its
@@ -177,11 +195,25 @@
177
195
  the part that should give; the actions are fixed-size and must not. */
178
196
  .topbar > *{min-width:0}
179
197
  .tb-chips{overflow:hidden}
180
- .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)}
181
203
  .tb-left{display:flex;align-items:center;gap:var(--s9)}
182
204
  .tb-chips{display:flex;align-items:center;gap:var(--s2)}
183
205
  .tb-arrow{color:var(--mute)}
184
- .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}
185
217
  /* Mona Sans is the brand face in Figma; it is not installed on a stock Mac,
186
218
  so it degrades to the system UI face rather than to a serif. */
187
219
  /* The wordmark ships WITH the plugin. Mona Sans is the brand face in Figma and
@@ -222,7 +254,7 @@
222
254
  where every variant carries the same radius. Md had been 8 since 11 Sep. */
223
255
  button{font:inherit;color:var(--ink);background:var(--raise);
224
256
  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);
257
+ padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;
226
258
  transition:var(--t-all), transform var(--t)}
227
259
  button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
228
260
  button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
@@ -260,7 +292,7 @@
260
292
  as an ordinary neutral control. */
261
293
  button.accent:not(:disabled):not(.primary){
262
294
  background:var(--raise); color:var(--ink); border-color:var(--edge);
263
- font-weight:400; box-shadow:var(--hi)}
295
+ font-weight:400}
264
296
  /* Radius is deliberately NOT restated in these three rules any more. It is the
265
297
  base button's, so .sm keeps winning on size — an accent .sm button used to
266
298
  be handed --r-md by specificity alone. */
@@ -331,7 +363,7 @@
331
363
  for the next action. The old accent ring is gone for that reason.
332
364
  ------------------------------------------------------------------ */
333
365
  .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)}
366
+ background:var(--raise);border-color:var(--edge)}
335
367
  /* Chip state matrix, ported from the Chip component set. Every state now
336
368
  steps in the direction it should: hover UP from rest, pressed DOWN from it,
337
369
  and selected-hover up again from selected-rest. Before the new tokens
@@ -340,7 +372,7 @@
340
372
  .chip:hover{background:var(--raise-mid);border-color:var(--edge-mid)}
341
373
  .chip:active{background:var(--raise);border-color:var(--edge-mid)}
342
374
  .chip.on{background:var(--raise-hi);border-color:var(--edge-hi);
343
- color:var(--ink);font-weight:600;box-shadow:var(--hi)}
375
+ color:var(--ink);font-weight:600}
344
376
  .chip.on:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
345
377
  .chip.on:active{background:var(--raise-mid);border-color:var(--edge-hi)}
346
378
  .chip[disabled]{opacity:.5;color:var(--mute);background:var(--raise);border-color:var(--edge)}
@@ -355,13 +387,6 @@
355
387
  column and read like a segmented *bar*. justify-self:start is what
356
388
  actually holds it to its content width. */
357
389
 
358
- /* A stepper should look joined. */
359
- /* Stepper (Figma 28:816). NOT a bespoke control: it is ordinary Default
360
- buttons butted together, so every state — hover, pressed, disabled — is
361
- the button's own. The previous version was borderless, transparent and
362
- 24px tall, which is exactly why +/- read as a different species from the
363
- buttons beside them. Only the corner radii and the shared middle border
364
- are special. */
365
390
  /* ------------------------------------------------------------------
366
391
  `hidden` must actually hide. THREE components have now needed this
367
392
  patched one at a time — .cvbar, #outImg/#outVid, and the format
@@ -377,16 +402,6 @@
377
402
  ------------------------------------------------------------------ */
378
403
  [hidden]{display:none !important}
379
404
 
380
- .stepper{display:inline-flex;align-items:center}
381
- .stepper button{border-radius:0}
382
- .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
383
- .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
384
- /* One border between neighbours rather than two stacked. */
385
- .stepper button + button{margin-left:calc(-1 * var(--bw))}
386
- .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
387
- /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
388
- .dock-step button{padding:0 var(--s3)}
389
-
390
405
  /* ------------------------------------------------------------------
391
406
  Segmented control (Figma 84:177). NOT a stepper: a stepper is ordinary
392
407
  buttons butted together, which is right for -/+ but wrong for a choice —
@@ -414,7 +429,11 @@
414
429
  .seg:active{border-color:var(--edge-mid)}
415
430
  /* The track carries the border and the elevation, so a segment is a label
416
431
  until it is selected: no border, no shadow, no lift on press. */
417
- .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);
418
437
  background:transparent;border:0;box-shadow:none;color:var(--mute);
419
438
  font-weight:400;display:inline-flex;flex-direction:column;
420
439
  align-items:center;justify-content:center}
@@ -434,11 +453,14 @@
434
453
  .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
435
454
  .seg button.sel:hover{background:var(--raise-hi)}
436
455
  .seg button.sel:active{background:var(--raise-mid);transform:none}
437
- /* A toggle in the strip head. On = the raised surface every selected thing
438
- on this page has (the segmented thumb, the device chip), off = a label. */
439
- .striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
440
- .striphead button[aria-pressed]:hover,#dofBoth[aria-pressed]:hover{color:var(--ink)}
441
- .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}
442
464
  /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
443
465
  than inherited from the base button rule, so the filled state is explicit and
444
466
  the .is-empty override below has something definite to override.
@@ -452,7 +474,7 @@
452
474
  chip: accent stroke and label on the empty one, accent fill on the filled. */
453
475
  .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
454
476
  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)}
477
+ background:var(--raise-hi);border:var(--bw) solid var(--edge-hi)}
456
478
  .inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
457
479
  .inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
458
480
  .inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
@@ -534,18 +556,47 @@
534
556
  of this page where that is optional is a version where the check is
535
557
  optional. Two equal panes plus the rail. */
536
558
  .stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px;
537
- 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)}
538
567
  /* Below ~1100px two panes and a 344px rail no longer fit, and the page starts
539
568
  scrolling sideways — which the UI audit catches as a hard failure. Stack the
540
569
  panes instead of dropping one: losing the comparison is the one thing this
541
570
  layout exists to prevent, so the axis gives way before the pairing does.
542
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)}
543
587
  @media (max-width:1100px){
544
588
  .stage{grid-template-columns:minmax(0,1fr) 344px;
545
589
  grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
546
590
  .stage > .pane:first-of-type{grid-column:1;grid-row:1}
547
591
  .stage > #outPane{grid-column:1;grid-row:2;border-top:1px solid var(--line)}
548
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}
549
600
  }
550
601
  #types{flex-wrap:nowrap}
551
602
  /* The same omission one level down: rows constrained, column implicit.
@@ -616,19 +667,56 @@
616
667
  inset:calc(-1 * var(--group-bw));border:var(--group-bw) solid transparent;
617
668
  border-radius:calc(var(--r-sm) + var(--group-bw))}
618
669
  .noblend .pillwrap > .ring.lift{mix-blend-mode:normal;border-color:transparent}
619
- .bgroup.row{flex-direction:row}
670
+ /* flex-wrap:nowrap and align-items:stretch are declared here, not just on
671
+ .bgroup, because a generic .row utility (line ~908, unrelated -- it wraps
672
+ chip lists in the sidebar) happens to share this class name and sets
673
+ flex-wrap:wrap/align-items:center at the same specificity as .bgroup's
674
+ own base rule. .bgroup.row's two classes outrank the generic one-class
675
+ .row, so declaring them here wins outright rather than depending on
676
+ which rule the cascade happens to read last. Latent since the class was
677
+ first reused for orientation (11 Sep) -- every existing horizontal group
678
+ happened to fit its container without wrapping, so nothing ever forced
679
+ it to matter until the video bar's Button Group (16 Sep, 7:38) hit a
680
+ narrower parent and visibly split into two rows. */
681
+ /* gap:0 for the same reason as the two properties above: the generic .row
682
+ utility sets gap:8px (line ~908), and .bgroup never declared its own, so
683
+ it leaked in here too -- found 16 Sep when Play and Preview turned out to
684
+ have an 8px gap between them that the file doesn't show (they're meant
685
+ to touch; the divider is the only separation). */
686
+ .bgroup.row{flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:0}
620
687
  .bgroup > .segbtn:first-of-type{border-top-left-radius:var(--r-sm);border-top-right-radius:var(--r-sm)}
621
688
  .bgroup > .segbtn:last-of-type{border-bottom-left-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
622
689
  .bgroup.row > .segbtn:first-of-type{border-radius:var(--r-sm) 0 0 var(--r-sm)}
623
690
  .bgroup.row > .segbtn:last-of-type{border-radius:0 var(--r-sm) var(--r-sm) 0}
624
691
  .bgroup > .segbtn:only-of-type{border-radius:var(--r-sm)}
625
692
  .segbtn,.segbtn:hover,.segbtn:active{border:0;box-shadow:none;transform:none}
626
- .segbtn{border-radius:0}
693
+ .segbtn{border-radius:0;position:relative}
694
+ /* The divider is an absolutely-positioned 1px slice, not a border -- a
695
+ border would add a device pixel to the segment's own measured width
696
+ (this file keeps those to the pixel against the Figma frame). Only
697
+ .row (horizontal) groups get it, matching the file: Vertical's variants
698
+ carry no divider prop at all. :last-of-type is the "off where needed" --
699
+ the last segment has nothing to its right to divide from. */
700
+ /* Height corrected 16 Sep: the file's own Divider node (226:5252) measures
701
+ 1x20 at y=6 inside a 32-tall button -- a 6px inset top and bottom, not
702
+ the button's full height. First pass used top:0/bottom:0 (32px) without
703
+ checking the node's own geometry. */
704
+ .bgroup.row > .segbtn:not(:last-of-type)::after{content:"";position:absolute;
705
+ top:6px;bottom:6px;right:0;width:1px;background:var(--seg-divider);pointer-events:none}
627
706
  /* Segment Button re-read 14 Sep 2026: 32px tall, 32px minimum, 8px side
628
707
  padding; a text label sits in its own 6px-padded box, so a text button
629
708
  pads 14px. Was 28 / 6. */
709
+ /* flex-shrink:0 -- min-width:32px above is a floor for an ICON button's
710
+ square, not a licence to crush a text button's label below readable
711
+ width. A .segbtn's min-width:auto default (content-based) would already
712
+ have protected text buttons in a flex row; setting an explicit min-width
713
+ removes that protection, so it has to come back explicitly. Surfaced by
714
+ the video bar's Button Group (16 Sep, 7:38): "Preview" shrank to a single
715
+ "P" in a narrow pane before this. The slider's own input keeps its
716
+ min-width:140 floor and is what gives way first (see #vframe below). */
630
717
  .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}
718
+ background:var(--raise);display:inline-flex;align-items:center;justify-content:center;gap:4px;
719
+ flex-shrink:0}
632
720
  .segbtn.txt{padding:0 14px}
633
721
  .segbtn:hover{background:var(--raise-mid)}
634
722
  .segbtn:active{background:var(--raise-low)}
@@ -666,7 +754,6 @@
666
754
  max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
667
755
  display:inline-flex;line-height:20px}
668
756
  #detSt:empty,#outSt:empty{display:none}
669
- .lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
670
757
  /* overscroll-behavior:none on both wells (14 Sep 2026): a trackpad pinch is a
671
758
  wheel event we zoom on, but macOS still owns the gesture's elastic bounce.
672
759
  While the fingers were moving the browser let the well rubber-band past
@@ -688,21 +775,56 @@
688
775
  keeps it where the eye expects while the picture moves under it. */
689
776
  /* max-width so a narrow pane (compare view on a small window) can never
690
777
  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}
778
+ /* Positioning only -- the look (background, the four-blend-ring stroke,
779
+ radius, shadow) now comes from the .bgroup.row inside it, same split as
780
+ .brail/.bgroup on the fit pane. Centred with left:0/right:0 + justify-
781
+ content:center, NOT left:50%+transform: a transform (like the z-index
782
+ this replaced) opens its own stacking context, and mix-blend-mode cannot
783
+ cross a stacking-context boundary -- the group's four rings would blend
784
+ against .cvbar's own (empty) background instead of the video/photo
785
+ behind it. Exactly the gotcha .brail was already written to avoid;
786
+ .cvbar just hadn't been, since it predates the group being inside it.
787
+ pointer-events follows .brail's pattern for the same reason: the bar
788
+ now spans the full width, so clicks on the canvas either side of the
789
+ group must still pass through. */
790
+ /* overflow-x:auto is gone -- CSS forces the OTHER axis to compute as
791
+ 'auto' too whenever one of overflow-x/overflow-y is non-visible (you
792
+ cannot actually get overflow-x:auto with a truly visible overflow-y),
793
+ so it was clipping .bgroup's own drop shadow and the top/bottom of its
794
+ blend rings to exactly the bar's 32px height -- invisible instead of a
795
+ shadow, and the ring only partly there. Found by comparing this rail to
796
+ .brail (fit pane), which has never set any overflow and shows both
797
+ fully. .brail also never needed to scroll: it doesn't set overflow at
798
+ all and simply lets its content overflow visibly, so a narrow pane's
799
+ residual clipping (rare -- a normal window is comfortably wider than
800
+ either rail) now happens at #outWrap's own overflow:auto instead of a
801
+ dedicated scrollbar on the bar itself. Trading a rare, already-degraded
802
+ edge case for a shadow and stroke that are always correct. */
803
+ .cvbar{position:absolute;left:0;right:0;bottom:16px;display:flex;justify-content:center;
804
+ padding:0 var(--s4);pointer-events:none;white-space:nowrap}
805
+ .cvbar .bgroup{pointer-events:auto}
806
+ /* The slider cell: label, track, frame counter, in the Segment Button's own
807
+ 32px height and 12px side padding (227:5326). Same background as the
808
+ group it sits in, so it needs no radius of its own to look seamless at
809
+ rest -- given one anyway, for the corner .bgroup itself doesn't clip. */
810
+ /* Right padding 8px, not the symmetric 12 the Figma frame's own Slider
811
+ wrapper carries: with .bgroup.row's gap now 0, the space from the frame
812
+ count to the play icon is this padding PLUS .segbtn's own 8px left
813
+ padding, and 12+8=20 read too wide against the group's own -- 8+8=16
814
+ matches --s4 and the file's row of Play/Preview. Left padding (before
815
+ "Fit on frame") is unaffected. */
816
+ .cvslider{display:flex;align-items:center;gap:var(--s2);height:32px;box-sizing:border-box;
817
+ padding:0 8px 0 12px;background:var(--raise);border-radius:var(--r-sm) 0 0 var(--r-sm)}
818
+ /* .bgroup.row > .segbtn:first-of-type rounds the first BUTTON's left
819
+ corners -- wrong here, since the slider cell (a <div>, not a .segbtn) is
820
+ what actually sits leftmost. Cancelled by ID specificity. */
821
+ #vidbar .bgroup > .segbtn:first-of-type{border-radius:0}
699
822
  /* The scrubber needs a floor, or the bar "fits" by crushing it. Moving the
700
823
  format control out stopped the bar overflowing at 742px — and the space it
701
824
  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
825
+ measured. The bar may scroll (.cvbar keeps overflow-x:auto); the one
703
826
  control that is useless when small must not be the one that gives way. */
704
827
  #vframe{min-width:140px;flex:1 1 180px}
705
- .cvbar{white-space:nowrap}
706
828
  /* `hidden` is a UA display:none, which any explicit display beats — and
707
829
  .cvbar sets display:flex. Without this the bar stays visible while the
708
830
  photo is chosen but the screenshot is not, offering corners to fit
@@ -863,15 +985,9 @@
863
985
  reason a true-black UI stops reading as a hole. It is NOT the same maths as
864
986
  compose()'s emissive, and the page says so. */
865
987
  #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}
988
+ /* .sm.icon (the square icon-only small button) is gone -- #playlive is a
989
+ .segbtn now, part of the video bar's Button Group (7:38, 16 Sep), and
990
+ nothing else used the class. */
875
991
  .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
876
992
 
877
993
  .rail{min-width:0;overflow:auto;padding:0;display:grid;align-content:start;background:var(--card)}
@@ -914,7 +1030,11 @@
914
1030
  tooltip hides the only copy of the explanation from anyone not using a
915
1031
  mouse. */
916
1032
  .info{flex:none;width:16px;height:16px;padding:0;border:none;
917
- 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}
918
1038
  .info svg{display:block;width:16px;height:16px}
919
1039
  .info:hover, .info:focus-visible{color:var(--mute)}
920
1040
  /* The tooltip is anchored to the section HEADER, not to the 16px icon, and
@@ -936,7 +1056,7 @@
936
1056
  .tip{position:fixed;width:264px;max-width:calc(100vw - 32px);
937
1057
  padding:8px 10px;border-radius:var(--r-md);
938
1058
  background:var(--float);border:1px solid var(--edge);
939
- box-shadow:var(--shadow), var(--hi);
1059
+ box-shadow:var(--shadow);
940
1060
  color:var(--ink);font-size:12px;font-weight:400;line-height:1.45;
941
1061
  letter-spacing:normal;text-transform:none;text-align:left;white-space:normal;
942
1062
  z-index:70;opacity:0;visibility:hidden;pointer-events:none;
@@ -975,15 +1095,61 @@
975
1095
  land, since the thumb travels inside the track's content box. */
976
1096
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:20px;
977
1097
  margin:0;padding-inline:2px;background:transparent;box-sizing:border-box}
1098
+ /* Ported from Figma 216:470 (16 Sep). Rail is surface/float, not --sunk --
1099
+ the two read identically at a glance but are different tokens (the old
1100
+ port had borrowed --sunk). Thumb is a squircle-radius square (r 3px),
1101
+ not a pill -- the one visible shape change from the previous slider. */
978
1102
  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%);
1103
+ background:linear-gradient(to right, var(--acc) 0 calc(var(--p,0) * 100%), var(--float) calc(var(--p,0) * 100%) 100%);
980
1104
  box-shadow:inset 0 0 0 1px var(--line)}
1105
+ /* slider/track/pressed is a darker accent (--acc-press), not the rest
1106
+ colour dimmed -- the fill itself recedes while the thumb comes forward. */
1107
+ input[type=range]:active::-webkit-slider-runnable-track{
1108
+ background:linear-gradient(to right, var(--acc-press) 0 calc(var(--p,0) * 100%), var(--float) calc(var(--p,0) * 100%) 100%)}
1109
+ /* All five states in the file carry a crisp 1px --card ring around the
1110
+ handle, sampled at (22,22,24) on every one of them (default, disabled,
1111
+ hover, pressed) -- not just where the glow needed it. It is the casing
1112
+ that separates the handle from whatever is under the rail, same idea as
1113
+ the corner-loupe / quad-overlay casing elsewhere in this file. State
1114
+ changes use their own 150ms transition rather than --t-all's 180ms --
1115
+ the handle is dragged continuously, so it reads better a touch snappier
1116
+ than a click target does. */
981
1117
  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)}
1118
+ margin-top:-5px;border-radius:3px;background:var(--raise);border:1px solid var(--edge-hi);
1119
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1px var(--card);
1120
+ transition:background-color .15s cubic-bezier(.2,0,0,1), border-color .15s cubic-bezier(.2,0,0,1),
1121
+ box-shadow .15s cubic-bezier(.2,0,0,1)}
1122
+ /* Hover/Pressed in the file are rasterised because the ring carries a soft
1123
+ outer glow (blur), which Figma exports as an image rather than a
1124
+ translatable box-shadow. The glow's own blur radius is judged against
1125
+ the two PNGs' bounding boxes (44px/52px tall on a 26px track --
1126
+ symmetric ~9px/~13px halos) rather than measured to the pixel. */
1127
+ input[type=range]:hover::-webkit-slider-thumb{background:var(--raise-mid);border-color:var(--acc-stroke);
1128
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1px var(--card), 0 0 6px 1px var(--acc-glow)}
1129
+ input[type=range]:active::-webkit-slider-thumb{background:var(--acc);border-color:var(--acc-stroke);
1130
+ 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)}
1131
+ /* Focus keeps the default handle colours and adds a second, wider ring
1132
+ (the same --card gap, at 1.5px so it clears the thicker glow) plus the
1133
+ accent glow, same language as the switch's focus ring (line ~792) --
1134
+ not a browser outline. */
985
1135
  input[type=range]:focus-visible{outline:none}
986
- input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--acc);outline-offset:2px}
1136
+ input[type=range]:focus-visible::-webkit-slider-thumb{
1137
+ box-shadow:0 1px 3px rgba(0,0,0,.4), 0 0 0 1.5px var(--card), 0 0 0 3px var(--acc-glow)}
1138
+ /* Disabled reads as the handle's own colours at roughly 50% opacity over
1139
+ --card in the file (sampled fill ~(33,33,36), which is --raise blended
1140
+ ~52% over --card, and the track's ~(105,33,12) is --acc-dis at the same
1141
+ ~50%) -- Figma's own Disabled variants are usually built as a group
1142
+ opacity rather than a recolour. Kept as solid tokens instead: the rest
1143
+ of this system deliberately does NOT fade disabled controls (see the
1144
+ accent-disabled note above --acc-dis) so it can stay readable, and a
1145
+ lone opacity-faded slider would be the one control that fades. The ring
1146
+ stays -- it is the one thing every state agrees on. */
1147
+ input[type=range][disabled]{cursor:not-allowed}
1148
+ input[type=range][disabled]::-webkit-slider-runnable-track{
1149
+ background:linear-gradient(to right, var(--acc-dis) 0 calc(var(--p,0) * 100%), var(--raise-low) calc(var(--p,0) * 100%) 100%);
1150
+ box-shadow:inset 0 0 0 1px var(--line)}
1151
+ input[type=range][disabled]::-webkit-slider-thumb{background:var(--raise);border-color:var(--edge-low);
1152
+ box-shadow:0 0 0 1px var(--card)}
987
1153
  /* Captions are text/faint in Figma. The port had quietly used --mute
988
1154
  instead; now they follow the token as designed. At the brightened
989
1155
  #8f939ab2 that measures 3.48:1 on card — up from 2.42:1, still under the
@@ -991,13 +1157,20 @@
991
1157
  silently re-deviated. */
992
1158
  .hint{color:var(--faint);font-size:12px;margin:8px 0 0;line-height:1.5}
993
1159
 
994
- /* Figma 8:28: 198px tall — 16 padding, 28 header, 8 gap, 130 strip, 16
995
- padding. It is a fixed band and must never absorb leftover height. */
996
- .dock{background:var(--card);padding:var(--s4);display:grid;gap:var(--s2);
997
- 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;
998
1171
  /* The rule is an inset shadow, not a border, so it does not add a
999
- pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
1000
- 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)}
1001
1174
  .dock.hidden{display:none}
1002
1175
  /* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
1003
1176
  screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
@@ -1027,14 +1200,29 @@
1027
1200
  #pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
1028
1201
  #pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
1029
1202
  .scroller.pointing canvas#c{cursor:crosshair}
1030
- /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
1031
- shrink to hug them. */
1032
- .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}
1033
1221
  canvas#strip{display:block;width:100%;height:130px;border-radius:var(--r-md);
1034
1222
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
1035
1223
  /* Floating mode: the loupe follows the edge instead of taking a permanent band. */
1036
1224
  .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)}
1225
+ background:var(--float);border:1px solid var(--edge);box-shadow:var(--shadow)}
1038
1226
  .floatloupe.on{display:block}
1039
1227
  .floatloupe canvas{display:block;width:340px;height:104px;border-radius:var(--r-sm);
1040
1228
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line)}
@@ -1063,7 +1251,7 @@
1063
1251
  .toast{position:absolute;left:0;top:0;width:100%;box-sizing:border-box;
1064
1252
  display:flex;align-items:flex-start;gap:8px;padding:10px 12px;
1065
1253
  border-radius:var(--r-md);background:var(--float);
1066
- border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi);
1254
+ border:1px solid var(--edge);box-shadow:var(--shadow);
1067
1255
  font-size:13px;line-height:1.45;color:var(--ink);
1068
1256
  pointer-events:auto;touch-action:none;transform-origin:50% 0;
1069
1257
  will-change:transform,opacity;
@@ -1127,7 +1315,7 @@
1127
1315
  .pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
1128
1316
  max-height:78vh;overflow:auto;background:var(--float-glass);border:var(--bw) solid var(--edge);
1129
1317
  backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);
1130
- border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
1318
+ border-radius:var(--r-lg);box-shadow:var(--shadow);padding:16px;
1131
1319
  opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
1132
1320
  transform-origin:50% 0;
1133
1321
  transition:opacity var(--t-pop), transform var(--t-pop),
@@ -1220,8 +1408,11 @@
1220
1408
  <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
1221
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>
1222
1410
  </span>
1223
- <button id="save" class="accent" disabled>Preview first</button>
1224
- <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>
1225
1416
  </div>
1226
1417
  </header>
1227
1418
 
@@ -1291,32 +1482,41 @@
1291
1482
  unchanged — you match the edges on one frame and every frame gets the
1292
1483
  same geometry, because the photo does not move. -->
1293
1484
  <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>
1485
+ <!-- Ported from Figma 7:38 (16 Sep): the bar is now one Button Group
1486
+ (solid --raise, the same four-blend-ring stroke every other rail
1487
+ uses), not a frosted floating pill — the slider and the two
1488
+ actions read as one joined control instead of a popover sitting
1489
+ over the video. .cvbar itself keeps only the positioning
1490
+ (centred, 16px off the bottom, scrolls if it doesn't fit) — same
1491
+ split as .brail/.bgroup on the fit pane. -->
1492
+ <div class="bgroup row"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1493
+ <!-- What is left in this bar is only what the RESULT pane is for:
1494
+ which moment of the clip you are looking at, and the two ways of
1495
+ looking at it. The output format left for the top bar, beside
1496
+ Render, because deciding how a file is written is not judging a
1497
+ fit — and as the fifth control here it was squeezing the scrubber
1498
+ to nothing at 742px. -->
1499
+ <div class="cvslider">
1500
+ <span class="sm" style="color:var(--mute)">Fit on frame</span>
1501
+ <input type="range" id="vframe" min="0" max="0" value="0" step="1"
1502
+ aria-label="Which frame of the clip to match the edges on">
1503
+ <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
1504
+ </div>
1305
1505
  <!-- Viewing controls sit with the scrubber, which is the other viewing
1306
1506
  control; the format stepper is about the OUTPUT and stays at the
1307
1507
  end. Icon-only, so each carries an aria-label — a title alone
1308
1508
  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"
1509
+ <button class="segbtn" id="playlive" aria-label="Play the clip on the photo"
1310
1510
  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>
1511
+ <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
1512
  </button>
1313
- <button class="sm" id="playclip"
1513
+ <button class="segbtn txt" id="playclip"
1314
1514
  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>
1515
+ </div>
1316
1516
  </div>
1317
1517
  </section>
1318
1518
 
1319
- <aside class="rail">
1519
+ <aside class="rail" id="rail">
1320
1520
  <div class="sect" id="secDevice" data-on="1">
1321
1521
  <div class="sect-top"><div class="sect-left"><h4>Device</h4></div></div>
1322
1522
  <div class="chips" id="types"></div>
@@ -1425,15 +1625,24 @@
1425
1625
  </div>
1426
1626
 
1427
1627
  <footer class="dock" id="dock">
1428
- <div class="striphead">
1429
- <span class="lbl">Edge view</span>
1430
- <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>
1431
- <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
1432
- <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>
1433
- <span class="spacer"></span>
1434
- <span class="sm" id="stripSt" style="color:var(--mute)"></span>
1435
- </div>
1436
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>
1437
1646
  </footer>
1438
1647
  </div>
1439
1648
 
@@ -2646,7 +2855,12 @@ function paintRingsFallback(){
2646
2855
  if (!IS_WEBKIT || _ringsQueued) return;
2647
2856
  _ringsQueued = true;
2648
2857
  requestAnimationFrame(() => { _ringsQueued = false;
2649
- 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')]];
2650
2864
  for (const [src, pane] of panes){
2651
2865
  if (!src || !pane) continue;
2652
2866
  const ok = src.tagName === 'CANVAS' ? src.width > 0 : (!src.hidden && src.naturalWidth > 0);
@@ -3284,7 +3498,7 @@ function setStripHC(on){
3284
3498
  $('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
3285
3499
  }
3286
3500
  $('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
3287
- function paintStrip(c, W, H, i, mode){
3501
+ function paintStrip(c, W, H, i, mode, padLeft = 9){
3288
3502
  c.setTransform(1,0,0,1,0,0);
3289
3503
  c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
3290
3504
  const A = st.corners[i], B = st.corners[(i+1)%4];
@@ -3324,8 +3538,8 @@ function paintStrip(c, W, H, i, mode){
3324
3538
  const centre = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
3325
3539
  const inwardIsDown = (n[0]*(centre[0]-A[0]) + n[1]*(centre[1]-A[1])) > 0;
3326
3540
  c.font = '600 11px system-ui'; c.textBaseline = 'middle';
3327
- casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
3328
- 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)');
3329
3543
  }
3330
3544
  function swingText(mode){
3331
3545
  if (mode === 'rotA') return 'the right end swings, pivot on the left';
@@ -3372,11 +3586,31 @@ function paintCorner(c, W, H, i){
3372
3586
  casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
3373
3587
  }
3374
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
+ }
3375
3610
  function drawStrip(){
3376
3611
  const a = drag || hover || pick;
3377
3612
  const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
3378
3613
  const corner = has && a.kind === 'corner';
3379
- $('#stripZ').textContent = '±' + stripHalf + 'px';
3380
3614
  if (loupeMode === 'dock'){
3381
3615
  $('#floatLoupe').classList.remove('on');
3382
3616
  const W = strip.width, H = strip.height;
@@ -3389,7 +3623,7 @@ function drawStrip(){
3389
3623
  paintCorner(sctx, W, H, a.i);
3390
3624
  $('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
3391
3625
  } else {
3392
- paintStrip(sctx, W, H, a.i, a.mode);
3626
+ paintStrip(sctx, W, H, a.i, a.mode, stripLabelInset(W));
3393
3627
  $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
3394
3628
  }
3395
3629
  } else {
@@ -4062,6 +4296,51 @@ $('#imp').onclick = async () => {
4062
4296
  computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
4063
4297
  });
4064
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
+ })();
4065
4344
  </script>
4066
4345
  </body>
4067
4346
  </html>