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 +1 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +390 -111
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
|
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
|
|
|
5
5
|
|
|
6
6
|
# Inject a screenshot onto a photographed device
|
|
7
7
|
|
|
8
|
-
**What ships (v0.
|
|
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
|
|
13
|
-
|
|
14
|
-
a raised control from a div
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
438
|
-
on this page has (the segmented thumb, the
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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 (
|
|
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
|
-
/*
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
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)
|
|
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(--
|
|
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:
|
|
983
|
-
box-shadow:
|
|
984
|
-
|
|
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{
|
|
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:
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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:
|
|
1000
|
-
box-shadow:inset 0 1px 0 var(--line)
|
|
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
|
-
/*
|
|
1031
|
-
|
|
1032
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
<
|
|
1224
|
-
|
|
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
|
-
<!--
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
<
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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="
|
|
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
|
|
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="
|
|
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
|
-
</
|
|
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">−</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
|
-
|
|
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',
|
|
3328
|
-
casedText(c, 'outside',
|
|
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>
|