screengraft 0.66.0 → 0.67.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/scripts/ui.py +29 -0
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +492 -83
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
package/scripts/ui.py
CHANGED
|
@@ -1077,6 +1077,35 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1077
1077
|
except RenderBusy as exc:
|
|
1078
1078
|
return self._json({"error": str(exc)}, 409)
|
|
1079
1079
|
|
|
1080
|
+
if u.path == "/api/reveal":
|
|
1081
|
+
# Show a saved file in the desktop's file manager. The PAGE
|
|
1082
|
+
# cannot do this -- a browser will not open a Finder window
|
|
1083
|
+
# from an http origin -- and this server is the half of
|
|
1084
|
+
# screengraft that runs on the user's machine, so it is the
|
|
1085
|
+
# only thing that can.
|
|
1086
|
+
#
|
|
1087
|
+
# _safe_local_path is the whole security story and it already
|
|
1088
|
+
# guards /file: it expands ~, resolves symlinks, refuses any
|
|
1089
|
+
# path outside HOME, and requires a file that exists. The
|
|
1090
|
+
# command is an argument LIST handed to the OS, never a string
|
|
1091
|
+
# through a shell, so a path cannot become an argument or a
|
|
1092
|
+
# second command however it is spelled.
|
|
1093
|
+
try:
|
|
1094
|
+
p = _safe_local_path(b.get("path") or "")
|
|
1095
|
+
except (PermissionError, FileNotFoundError, TypeError) as exc:
|
|
1096
|
+
return self._json({"error": str(exc)}, 400)
|
|
1097
|
+
if sys.platform == "darwin":
|
|
1098
|
+
cmd = ["open", "-R", p] # reveals AND selects it
|
|
1099
|
+
elif os.name == "nt":
|
|
1100
|
+
cmd = ["explorer", "/select,", p] # exits 1 even on success
|
|
1101
|
+
else:
|
|
1102
|
+
cmd = ["xdg-open", os.path.dirname(p)]
|
|
1103
|
+
try:
|
|
1104
|
+
subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
1105
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1106
|
+
return self._json({"error": f"could not open the folder: {exc}"}, 500)
|
|
1107
|
+
return self._json({"ok": True})
|
|
1108
|
+
|
|
1080
1109
|
if u.path == "/api/figma":
|
|
1081
1110
|
return self._json(SESSION.enqueue({
|
|
1082
1111
|
"type": "figma_export", "url": b["url"],
|
|
@@ -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.67):** 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
|
@@ -68,14 +68,32 @@
|
|
|
68
68
|
polished Button matrix (4:14): hover's border is border/edge-mid, and
|
|
69
69
|
pressed keeps border/edge rather than dimming to edge-low — pressing a
|
|
70
70
|
control must not make it look unavailable. Both had drifted. */
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
/* Re-read 20 Sep 2026 and the family is now COMPLETE: rest had no token
|
|
72
|
+
of its own, so the button borrowed --raise/--edge and had quietly
|
|
73
|
+
diverged from button/secondary/container|border/default. Every value
|
|
74
|
+
below is read straight off 4:14. --btn-rest-edge (#46464e) is the border
|
|
75
|
+
every raised control wears — the Default button at rest AND pressed, and
|
|
76
|
+
the segmented track — which is why it is one token used in three places
|
|
77
|
+
rather than a new step in the --edge ladder.
|
|
78
|
+
NOTE: hover's surface (#2b2b30) is now a hair DARKER than rest
|
|
79
|
+
(#2b2c31), so hover is carried almost entirely by the border stepping to
|
|
80
|
+
edge-mid. That is what the file says; it looks like the hover variant
|
|
81
|
+
kept the old rest value when rest moved. Flagged for the design file. */
|
|
82
|
+
--btn-rest:#2b2c31; --btn-rest-edge:#46464e;
|
|
83
|
+
--btn-hover:#2b2b30; --btn-hover-edge:#494a50;
|
|
84
|
+
--btn-press:#1c1c20; --btn-press-edge:#46464e;
|
|
73
85
|
/* Accent disabled keeps the label READABLE rather than fading the whole
|
|
74
86
|
button — the Button component in Figma says so explicitly. */
|
|
75
87
|
/* Re-read 13 Sep 2026: primary/container/disabled is the SOLID press
|
|
76
88
|
colour with the solid stroke and the solid label — the design fades
|
|
77
89
|
nothing here, it recesses. Was three alpha variants. */
|
|
78
|
-
|
|
90
|
+
/* Re-read 20 Sep 2026: primary/container/disabled stepped down again,
|
|
91
|
+
#bb2b00 -> #992800, and its border is now the accent itself rather than
|
|
92
|
+
the lighter stroke. #bb2b00 is primary/container/PRESSED only, which is
|
|
93
|
+
--acc-press — the two had been sharing one value and the pressed state
|
|
94
|
+
was borrowing --acc-dis-edge for its border, so it now has its own. */
|
|
95
|
+
--acc-dis:#992800; --acc-dis-edge:#f23b0d; --acc-dis-ink:#fbf9f9;
|
|
96
|
+
--acc-press-edge:#f23b0d;
|
|
79
97
|
/* Strokes are 0.5px throughout the component sets (button, chip, input
|
|
80
98
|
chip, segmented, switch, button group). A half pixel snaps to one device
|
|
81
99
|
pixel on any screen, so it never vanishes; on a Retina Mac it is the
|
|
@@ -160,6 +178,13 @@
|
|
|
160
178
|
/* 120ms originally, per the visual-language notes. asked for a bit
|
|
161
179
|
slower on 5 Sep — one token, so retuning is a one-line change. */
|
|
162
180
|
--t-dur:180ms; /* the duration alone, for delays */
|
|
181
|
+
/* The source chip's grow/collapse runs slower than the app's general
|
|
182
|
+
motion on purpose: it is a shape change across ~100px, not a state
|
|
183
|
+
flip, and at 180ms it read as a snap rather than a movement. The cross
|
|
184
|
+
that follows it is quicker, and waits out this duration first. Tune
|
|
185
|
+
these two and the whole chip sequence moves with them. */
|
|
186
|
+
--t-chip:260ms;
|
|
187
|
+
--t-chip-cross:150ms;
|
|
163
188
|
--t:var(--t-dur) cubic-bezier(.2,0,0,1);
|
|
164
189
|
--t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
|
|
165
190
|
/* Every control animates the SAME four properties on the same curve. Kept as
|
|
@@ -186,14 +211,29 @@
|
|
|
186
211
|
grid-template-columns:minmax(0,1fr);min-height:0;
|
|
187
212
|
padding-bottom:32px;background:var(--bg)}
|
|
188
213
|
|
|
214
|
+
/* The bottom rule is an INSET SHADOW, not a border, for the same reason the
|
|
215
|
+
dock's is — and here it is not only about height, it is about which device
|
|
216
|
+
pixel things land on. A 1px border under box-sizing:border-box leaves a
|
|
217
|
+
55px content box; centring the 32px chip row in an ODD height puts it at
|
|
218
|
+
y=11.5. The chips carry a 0.5px stroke (--bw), which only survives because
|
|
219
|
+
a half CSS pixel snaps to one whole device pixel — and that assumption
|
|
220
|
+
holds only when the element's EDGES sit on whole device pixels. At 11.5 the
|
|
221
|
+
bottom edge straddles the boundary, renders faint or vanishes at dpr 2, and
|
|
222
|
+
the chip reads as pressed. Reported 20 Sep with a screenshot of exactly
|
|
223
|
+
that. As an inset shadow the content box is the full 56, the row centres on
|
|
224
|
+
y=12, and the rule is drawn in the same place it was before. */
|
|
189
225
|
.topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
|
|
190
|
-
background:var(--card);
|
|
191
|
-
box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0
|
|
226
|
+
background:var(--card);
|
|
227
|
+
box-shadow:inset 0 1px 0 rgba(255,255,255,.04), inset 0 -1px 0 var(--line),
|
|
228
|
+
0 1px 0 rgba(0,0,0,.25)}
|
|
192
229
|
/* A nowrap flex row whose children keep min-width:auto cannot shrink
|
|
193
230
|
below their content, so at 742px Save and Send to Claude sat at x=750..885
|
|
194
231
|
— outside the viewport and unclickable. The chips carry a filename and are
|
|
195
232
|
the part that should give; the actions are fixed-size and must not. */
|
|
196
233
|
.topbar > *{min-width:0}
|
|
234
|
+
/* overflow:hidden clips the horizontal only in spirit — CSS couples the
|
|
235
|
+
axes — but nothing needs vertical room any more now that pressed does not
|
|
236
|
+
move. A long filename is still the part that gives. */
|
|
197
237
|
.tb-chips{overflow:hidden}
|
|
198
238
|
/* Figma 15:108: three groups at 16px — the format control, the save PAIR,
|
|
199
239
|
and the rail toggle — with 8px inside the pair. Save and Send to Claude
|
|
@@ -252,13 +292,20 @@
|
|
|
252
292
|
------------------------------------------------------------------ */
|
|
253
293
|
/* Both sizes are radius/sm (6px) — re-read from Button 4:14 on 13 Sep 2026,
|
|
254
294
|
where every variant carries the same radius. Md had been 8 since 11 Sep. */
|
|
255
|
-
button{font:inherit;color:var(--ink);background:var(--
|
|
256
|
-
border:var(--bw) solid var(--edge);border-radius:var(--r-sm);
|
|
295
|
+
button{font:inherit;color:var(--ink);background:var(--btn-rest);
|
|
296
|
+
border:var(--bw) solid var(--btn-rest-edge);border-radius:var(--r-sm);
|
|
257
297
|
padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;
|
|
258
298
|
transition:var(--t-all), transform var(--t)}
|
|
259
299
|
button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
|
|
300
|
+
/* Pressed does NOT move. It used to carry translateY(.5px) — half a CSS
|
|
301
|
+
pixel, so one device pixel at 2x — and that read as the button jumping on
|
|
302
|
+
every click rather than depressing. It also put the button's own 0.5px
|
|
303
|
+
stroke on a half-pixel boundary, and inside any clipping parent it pushed
|
|
304
|
+
the bottom edge out of the box: the top-bar chips visibly lost their
|
|
305
|
+
bottom stroke while held (20 Sep). The darker surface and the darker
|
|
306
|
+
border already say pressed, and they say it without motion. */
|
|
260
307
|
button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
|
|
261
|
-
box-shadow:none
|
|
308
|
+
box-shadow:none}
|
|
262
309
|
button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
|
|
263
310
|
/* Default/Disabled RECESSES: surface/raise-low with border/edge-low, label at
|
|
264
311
|
text/faint x 50%. It used to keep the raised surface and dim only the label.
|
|
@@ -283,22 +330,25 @@
|
|
|
283
330
|
|
|
284
331
|
/* Primary. The accent means exactly one thing: the next action, so only one
|
|
285
332
|
of these is lit at a time (see the accent hand-off in the JS). */
|
|
333
|
+
/* Weight 400, not 600: every one of the sixteen Button variants in 4:14 is
|
|
334
|
+
SF Pro Regular. The bold primary was a carry-over from before the matrix
|
|
335
|
+
existed, and it made the accent shout twice. */
|
|
286
336
|
button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
|
|
287
|
-
border-color:var(--acc-stroke);font-weight:
|
|
337
|
+
border-color:var(--acc-stroke);font-weight:400;box-shadow:none}
|
|
288
338
|
button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
|
|
289
|
-
button.primary:active{background:var(--acc-press);border-color:var(--acc-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
button.
|
|
339
|
+
button.primary:active{background:var(--acc-press);border-color:var(--acc-press-edge)}
|
|
340
|
+
/* The `.accent` class is gone (20 Sep 2026). Save and Send to Claude wore it
|
|
341
|
+
so that they could be accent-coloured while DISABLED — which is exactly
|
|
342
|
+
backwards: before anything is loaded the top bar showed two lit orange
|
|
343
|
+
buttons offering actions that could not be taken. They are ordinary
|
|
344
|
+
Default buttons now and gain `.primary` only when they genuinely are the
|
|
345
|
+
next action, which is the rule the Button component states.
|
|
346
|
+
Radius is deliberately NOT restated below. It is the base button's, so .sm
|
|
347
|
+
keeps winning on size — an accent .sm button used to be handed --r-md by
|
|
348
|
+
specificity alone. */
|
|
349
|
+
button.primary:disabled{
|
|
300
350
|
background:var(--acc-dis); border-color:var(--acc-dis-edge);
|
|
301
|
-
color:var(--acc-dis-ink); font-weight:
|
|
351
|
+
color:var(--acc-dis-ink); font-weight:400; box-shadow:none; opacity:1}
|
|
302
352
|
|
|
303
353
|
/* ------------------------------------------------------------------
|
|
304
354
|
Render, while it is rendering: the button IS the progress bar.
|
|
@@ -423,7 +473,7 @@
|
|
|
423
473
|
the thumb is 24px tall at radius/xs. Hover lifts the thumb to raise-hi
|
|
424
474
|
and the track's stroke to edge-mid; pressed drops the thumb to raise-mid. */
|
|
425
475
|
.seg{display:inline-flex;align-items:center;gap:2px;padding:2px;
|
|
426
|
-
background:var(--raise-low);border:var(--bw) solid var(--edge);
|
|
476
|
+
background:var(--raise-low);border:var(--bw) solid var(--btn-rest-edge);
|
|
427
477
|
border-radius:var(--r-sm);transition:var(--t-all)}
|
|
428
478
|
.seg:hover{border-color:var(--edge-mid)}
|
|
429
479
|
.seg:active{border-color:var(--edge-mid)}
|
|
@@ -437,22 +487,23 @@
|
|
|
437
487
|
background:transparent;border:0;box-shadow:none;color:var(--mute);
|
|
438
488
|
font-weight:400;display:inline-flex;flex-direction:column;
|
|
439
489
|
align-items:center;justify-content:center}
|
|
440
|
-
/*
|
|
441
|
-
|
|
442
|
-
600 is wider than 400, so selecting "ProRes"
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
490
|
+
/* There used to be a zero-height ghost of the label at weight 600 here,
|
|
491
|
+
sizing each segment to its SELECTED width so the control did not reflow
|
|
492
|
+
when the selection moved (600 is wider than 400, so selecting "ProRes"
|
|
493
|
+
grew that segment and the whole control shifted — reported 12 Sep 2026).
|
|
494
|
+
The ghost is gone because its cause is: 84:177 has BOTH segments at
|
|
495
|
+
regular, selected and not, so no width changes on selection and the
|
|
496
|
+
`data-label` attributes in the markup are now inert. Selection is carried
|
|
497
|
+
by the thumb surface and the label colour, which is the stronger signal
|
|
498
|
+
anyway. Put the ghost back if a weight difference ever returns. */
|
|
448
499
|
.seg button:hover{background:transparent;color:var(--ink)}
|
|
449
500
|
.seg button:active{background:transparent;transform:none}
|
|
450
501
|
/* The thumb. Its three surfaces are the Default button's own, which is the
|
|
451
502
|
point of the component: one raised thing, behaving like every other raised
|
|
452
503
|
thing on the page. */
|
|
453
|
-
.seg button.sel{background:var(--
|
|
504
|
+
.seg button.sel{background:var(--btn-rest);color:var(--ink);font-weight:400}
|
|
454
505
|
.seg button.sel:hover{background:var(--raise-hi)}
|
|
455
|
-
.seg button.sel:active{background:var(--
|
|
506
|
+
.seg button.sel:active{background:var(--btn-hover);transform:none}
|
|
456
507
|
/* A text toggle that reads as a label until it is on. On = the raised
|
|
457
508
|
surface every selected thing on this page has (the segmented thumb, the
|
|
458
509
|
device chip). The edge view's Contrast toggle used to share this rule; it
|
|
@@ -472,15 +523,30 @@
|
|
|
472
523
|
chosen source reads as a selection. Empty = its own recessed, dashed
|
|
473
524
|
surface with a mid-grey label. Active = a file is being dragged OVER the
|
|
474
525
|
chip: accent stroke and label on the empty one, accent fill on the filled. */
|
|
475
|
-
|
|
526
|
+
/* The gap lives on the THUMB as a margin, not here as a flex gap: a gap
|
|
527
|
+
survives its neighbour collapsing to zero width, so an empty chip would
|
|
528
|
+
keep 8px of nothing between a thumbnail that is not there and its label.
|
|
529
|
+
As a margin it collapses with the thumb. The chip's own width is auto, so
|
|
530
|
+
it follows the thumb frame by frame — the only thing it animates itself is
|
|
531
|
+
the padding it gives back when the clear button leaves. */
|
|
532
|
+
.inputchip{display:inline-flex;align-items:center;max-width:220px;
|
|
476
533
|
height:32px;padding:0 12px 0 6px;border-radius:999px;color:var(--ink);
|
|
477
|
-
background:var(--raise-hi);border:var(--bw) solid var(--edge-hi)
|
|
534
|
+
background:var(--raise-hi);border:var(--bw) solid var(--edge-hi);
|
|
535
|
+
transition:var(--t-all), padding var(--t-chip) cubic-bezier(.2,0,0,1),
|
|
536
|
+
width var(--t-chip) cubic-bezier(.2,0,0,1)}
|
|
478
537
|
.inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
479
538
|
.inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
|
|
480
539
|
.inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
|
|
481
540
|
color:var(--acc-ink)}
|
|
482
|
-
.inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
|
|
483
|
-
background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)
|
|
541
|
+
.inputchip .sw{width:22px;height:22px;margin-right:var(--s2);border-radius:999px;flex:none;
|
|
542
|
+
background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);
|
|
543
|
+
/* Scale from the left edge so the disc grows INTO the chip and
|
|
544
|
+
the label is pushed, rather than both sliding apart. */
|
|
545
|
+
transform-origin:left center;
|
|
546
|
+
transition:width var(--t-chip) cubic-bezier(.2,0,0,1),
|
|
547
|
+
margin-right var(--t-chip) cubic-bezier(.2,0,0,1),
|
|
548
|
+
transform var(--t-chip) cubic-bezier(.2,0,0,1),
|
|
549
|
+
opacity var(--t-chip) cubic-bezier(.2,0,0,1)}
|
|
484
550
|
.inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
485
551
|
/* Empty is the SAME box as filled — 32px tall, same padding, same 22px
|
|
486
552
|
avatar. Only the surface, border style and label colour change. It was
|
|
@@ -489,9 +555,27 @@
|
|
|
489
555
|
border-color:var(--edge);color:var(--inchip-ink);box-shadow:none}
|
|
490
556
|
.inputchip.is-empty:hover{background:var(--float);border-color:var(--edge-mid)}
|
|
491
557
|
.inputchip.is-empty:active{background:var(--raise-low);border-color:var(--edge-mid)}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
558
|
+
/* Active on the UNSELECTED chip -- popover open, or a file dragged over it.
|
|
559
|
+
Re-read from Input chip 159:4117 on 20 Sep 2026: the stroke is grey
|
|
560
|
+
(input-chip/unselected/border/pressed, #494a50) and the surface is
|
|
561
|
+
raise-low, not the accent. The accent stroke shipped here was the one
|
|
562
|
+
place a control that is merely READY wore the colour reserved for "this
|
|
563
|
+
is the next action" -- and with an empty chip nothing has been chosen
|
|
564
|
+
yet, so it was promising an outcome the drop had not produced. The label
|
|
565
|
+
stays accent: that is what the file says, and one accent-coloured word
|
|
566
|
+
inside a grey pill reads as a target rather than a result.
|
|
567
|
+
The FILLED chip's Active state is unchanged and still solid accent
|
|
568
|
+
(input-chip/filled/container|border/active), which the file confirms. */
|
|
569
|
+
.inputchip.is-empty.over,.inputchip.is-empty.over:hover{background:var(--raise-low);
|
|
570
|
+
border-color:var(--edge-mid);color:var(--acc-stroke)}
|
|
571
|
+
/* Empty shows NO thumbnail — there is nothing to show, and a placeholder
|
|
572
|
+
disc read as a broken image. It collapses to zero width AND zero scale:
|
|
573
|
+
the width is what moves the label and grows the chip, the scale is what
|
|
574
|
+
makes it look like the picture arrives rather than the box stretching. */
|
|
575
|
+
.inputchip.is-empty .sw{width:0;margin-right:0;transform:scale(0);opacity:0;
|
|
576
|
+
background:none;box-shadow:none}
|
|
577
|
+
/* No thumb to hug, so the label gets the same 12px both sides. */
|
|
578
|
+
.inputchip.is-empty{padding-left:12px}
|
|
495
579
|
/* The clear control is a SIBLING of the chip in the markup, not a child:
|
|
496
580
|
the chip is one <button>, and a button inside a button is invalid HTML
|
|
497
581
|
that browsers repair unpredictably. It is drawn INSIDE the pill though --
|
|
@@ -508,10 +592,29 @@
|
|
|
508
592
|
the root. */
|
|
509
593
|
.chipwrap{position:relative;display:inline-flex;min-width:0;z-index:52}
|
|
510
594
|
.chipwrap .inputchip:not(.is-empty){padding-right:30px}
|
|
511
|
-
.
|
|
595
|
+
/* It waits for its space. The chip spends --t-dur growing and opening the
|
|
596
|
+
30px of right padding this sits in; only then does the cross fade and
|
|
597
|
+
scale in, so it never appears on top of a label still sliding underneath
|
|
598
|
+
it. Leaving is the reverse and immediate — it goes first, then the chip
|
|
599
|
+
closes up behind it.
|
|
600
|
+
visibility, not [hidden]: the global [hidden]{display:none!important} rule
|
|
601
|
+
cannot animate, and visibility still takes the button out of the tab order
|
|
602
|
+
and the accessibility tree while it is away. */
|
|
603
|
+
.chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%) scale(.6);
|
|
512
604
|
width:22px;height:22px;padding:0;border-radius:999px;flex:none;
|
|
513
605
|
display:inline-flex;align-items:center;justify-content:center;
|
|
514
|
-
|
|
606
|
+
opacity:0;visibility:hidden;pointer-events:none;
|
|
607
|
+
color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge);
|
|
608
|
+
transition:opacity var(--t-chip-cross) cubic-bezier(.2,0,0,1),
|
|
609
|
+
transform var(--t-chip-cross) cubic-bezier(.2,0,0,1),
|
|
610
|
+
background-color var(--t), border-color var(--t),
|
|
611
|
+
visibility 0s linear var(--t-chip-cross)}
|
|
612
|
+
.chipclear.on{opacity:1;visibility:visible;pointer-events:auto;
|
|
613
|
+
transform:translateY(-50%) scale(1);
|
|
614
|
+
transition:opacity var(--t-chip-cross) cubic-bezier(.2,0,0,1) var(--t-chip),
|
|
615
|
+
transform var(--t-chip-cross) cubic-bezier(.2,0,0,1) var(--t-chip),
|
|
616
|
+
background-color var(--t), border-color var(--t),
|
|
617
|
+
visibility 0s}
|
|
515
618
|
/* The cross is drawn, not typed. As the × character at 15px it sat
|
|
516
619
|
above centre in its disc -- a glyph carries its own ascent and side
|
|
517
620
|
bearings and no line-height setting centres it in a 22px circle ("X icon
|
|
@@ -520,7 +623,7 @@
|
|
|
520
623
|
glyphs took in v0.42.0. */
|
|
521
624
|
.chipclear svg{display:block;width:10px;height:10px}
|
|
522
625
|
.chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
523
|
-
.chipclear:active{background:var(--raise-mid)
|
|
626
|
+
.chipclear:active{background:var(--raise-mid)}
|
|
524
627
|
|
|
525
628
|
/* Status carries meaning in THREE channels: glyph, weight, luminance —
|
|
526
629
|
never hue alone. */
|
|
@@ -531,14 +634,33 @@
|
|
|
531
634
|
height:22px;padding:0 10px;border-radius:999px;background:var(--sunk);
|
|
532
635
|
border:1px solid var(--line);box-shadow:none;transition:var(--t-all)}
|
|
533
636
|
.status::before{font-size:11px}
|
|
637
|
+
/* The depth-of-field advanced row: two buttons and a whole sentence sharing
|
|
638
|
+
a ~296px rail. As a NOWRAP flex row every item shrank below its own label,
|
|
639
|
+
the labels wrapped inside boxes with a fixed height (button.sm is 24px,
|
|
640
|
+
.status is 22px), and .sect-body{overflow:hidden} sliced whatever stuck
|
|
641
|
+
out — "Both sides" rendered as "Both ·˙·" and the measured note stopped
|
|
642
|
+
mid-word (found 20 Sep by a UI test pass).
|
|
643
|
+
The fix is the ROW, not the container: the buttons keep their natural
|
|
644
|
+
width, and the note takes a line of its own and grows to fit. A fixed
|
|
645
|
+
height is right for a one-line pill and wrong for a sentence. */
|
|
646
|
+
.dof-adv{flex-wrap:wrap}
|
|
647
|
+
.dof-adv > button{flex:none}
|
|
648
|
+
.dof-adv > .status{flex:1 1 100%;height:auto;min-height:22px;
|
|
649
|
+
padding-top:3px;padding-bottom:3px;align-items:flex-start}
|
|
534
650
|
.status.ok{color:var(--ok);font-weight:400} .status.ok::before{content:"\2713";font-weight:600}
|
|
535
651
|
.status.warn{color:var(--warn);font-weight:600} .status.warn::before{content:"!";font-weight:400}
|
|
536
652
|
.status.err{color:var(--err);font-weight:600} .status.err::before{content:"\00d7"}
|
|
537
653
|
/* A real spinner, not a static glyph. The old "\22ef" ellipsis never moved,
|
|
538
654
|
so a wait that genuinely was in progress looked identical to a dead UI —
|
|
539
655
|
which is exactly what it felt like while the agent wasn't running. */
|
|
656
|
+
/* --acc, not --accent. It said `var(--accent)` from the day it was written
|
|
657
|
+
and there is no such token in this file, so the declaration was invalid at
|
|
658
|
+
computed-value time and border-top-color fell back to currentColor. The
|
|
659
|
+
ring still turned -- the lit arc was just the label's own grey instead of
|
|
660
|
+
the accent, which is why nobody caught it by looking. Second one of these
|
|
661
|
+
today; see the kbd font shorthand. */
|
|
540
662
|
.status.busy::before{content:"";width:9px;height:9px;border-radius:50%;
|
|
541
|
-
border:1.5px solid var(--line);border-top-color:var(--
|
|
663
|
+
border:1.5px solid var(--line);border-top-color:var(--acc);
|
|
542
664
|
animation:sg-spin .7s linear infinite}
|
|
543
665
|
@keyframes sg-spin{to{transform:rotate(360deg)}}
|
|
544
666
|
@media (prefers-reduced-motion:reduce){
|
|
@@ -1292,6 +1414,11 @@
|
|
|
1292
1414
|
.toast.err{border-color:var(--edge)}
|
|
1293
1415
|
.toast.err .g, .toast.err .msg{color:var(--warn)}
|
|
1294
1416
|
.toast .msg{min-width:0;word-break:break-word}
|
|
1417
|
+
/* The one action a toast may carry. Sm height and the neutral surface: it is
|
|
1418
|
+
an offer beside the message, not the next step, so it takes no accent —
|
|
1419
|
+
that means "the next action" and Send to Claude already has it. */
|
|
1420
|
+
.toast .act{display:inline-flex;align-items:center;margin-top:8px;
|
|
1421
|
+
height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
|
|
1295
1422
|
.toast .x{flex:none;margin:-3px -4px 0 4px;width:22px;height:22px;padding:0;
|
|
1296
1423
|
background:none;border:none;box-shadow:none;color:var(--mute);
|
|
1297
1424
|
font-size:15px;line-height:22px;border-radius:var(--r-sm);opacity:0;
|
|
@@ -1374,8 +1501,36 @@
|
|
|
1374
1501
|
.sm{font-size:12px}
|
|
1375
1502
|
code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
|
|
1376
1503
|
border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
|
|
1504
|
+
/* LONGHAND, deliberately. This was `font:11px/1.6 inherit`, which is an
|
|
1505
|
+
INVALID shorthand -- `inherit` is not a permitted font-family value there,
|
|
1506
|
+
so the browser dropped the whole declaration and every key cap silently
|
|
1507
|
+
rendered at .hint's inherited 12px/1.5. The 11px never once shipped.
|
|
1508
|
+
Caught 20 Sep 2026 while measuring cap heights, because the computed
|
|
1509
|
+
line-height came back 18px and 19.5px when it had just been set to 14.
|
|
1510
|
+
13px, up a step by request: a key cap is a label you hunt for mid-fit, not
|
|
1511
|
+
body copy. `kbd.sym` goes one further because a bare glyph -- an arrow, the
|
|
1512
|
+
cmd or shift sign -- fills a fraction of its em box, so at a shared size it
|
|
1513
|
+
reads smaller and lighter than a cap holding a word like Tab.
|
|
1514
|
+
The line box is pinned in px rather than a ratio so both sizes yield caps
|
|
1515
|
+
of the SAME height; on a ratio they came out 20 and 19, and a row of caps
|
|
1516
|
+
disagreeing by a pixel reads as a wobble rather than a set. */
|
|
1377
1517
|
kbd{border:1px solid var(--edge);border-bottom-width:2px;border-radius:var(--r-sm);
|
|
1378
|
-
padding:1px 5px;font:
|
|
1518
|
+
padding:1px 5px;font-family:inherit;font-size:13px;line-height:15px;
|
|
1519
|
+
/* inline-block, and this is the load-bearing part: on a plain inline box
|
|
1520
|
+
the painted height comes from the FONT's content area, not line-height,
|
|
1521
|
+
so pinning the line box did nothing and the 14px caps still measured
|
|
1522
|
+
21.5 against the 13px caps' 20. An inline-block takes its height from
|
|
1523
|
+
its line box, so both land on 20. */
|
|
1524
|
+
display:inline-block;
|
|
1525
|
+
color:var(--mute);background:var(--sunk)}
|
|
1526
|
+
kbd.sym{font-size:14px}
|
|
1527
|
+
/* The Keys legend. max-content on the first column sizes it to the widest
|
|
1528
|
+
cap, so every description starts on the same x and the caps form a single
|
|
1529
|
+
column the eye can run down. */
|
|
1530
|
+
dl.keys{display:grid;grid-template-columns:max-content 1fr;gap:6px 10px;
|
|
1531
|
+
margin:8px 0 0;align-items:baseline}
|
|
1532
|
+
dl.keys dt{color:var(--faint);font-size:12px;white-space:nowrap}
|
|
1533
|
+
dl.keys dd{margin:0;color:var(--faint);font-size:12px;line-height:1.5}
|
|
1379
1534
|
</style>
|
|
1380
1535
|
</head>
|
|
1381
1536
|
<body>
|
|
@@ -1389,9 +1544,9 @@
|
|
|
1389
1544
|
<div class="tb-left">
|
|
1390
1545
|
<span class="brand">Screengraft</span><span id="sess" hidden></span>
|
|
1391
1546
|
<div class="tb-chips">
|
|
1392
|
-
<span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again."
|
|
1547
|
+
<span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
|
|
1393
1548
|
<span class="tb-arrow">→</span>
|
|
1394
|
-
<span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept."
|
|
1549
|
+
<span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
|
|
1395
1550
|
</div>
|
|
1396
1551
|
</div>
|
|
1397
1552
|
<span class="spacer"></span>
|
|
@@ -1409,8 +1564,8 @@
|
|
|
1409
1564
|
<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>
|
|
1410
1565
|
</span>
|
|
1411
1566
|
<span class="tb-save">
|
|
1412
|
-
<button id="save"
|
|
1413
|
-
<button id="imp"
|
|
1567
|
+
<button id="save" disabled>Save</button>
|
|
1568
|
+
<button id="imp" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
|
|
1414
1569
|
</span>
|
|
1415
1570
|
<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>
|
|
1416
1571
|
</div>
|
|
@@ -1442,10 +1597,10 @@
|
|
|
1442
1597
|
<button class="segbtn" id="czFit" aria-label="Fit the photo to the pane" title="Fit the whole photo in the pane"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="var(--mute)" d="M14.1025 1.00488C14.6067 1.05621 15 1.48232 15 2V14L14.9951 14.1025C14.9472 14.573 14.573 14.9472 14.1025 14.9951L14 15H2C1.48232 15 1.05621 14.6067 1.00488 14.1025L1 14V2C1 1.44772 1.44772 1 2 1H14L14.1025 1.00488ZM2 14H14V2H2V14Z"/><path stroke="var(--icon)" d="M7 11.5H4.5V9M4.5 11.5L7.5 8.5"/><path stroke="var(--icon)" d="M9 4.5H11.5V7M11.5 4.5L8.5 7.5"/></svg></button>
|
|
1443
1598
|
</div>
|
|
1444
1599
|
<div class="bgroup corners" role="group" aria-label="Select corner"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
|
|
1445
|
-
<button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--
|
|
1446
|
-
<button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--
|
|
1447
|
-
<button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--
|
|
1448
|
-
<button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--
|
|
1600
|
+
<button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M5 2.5H3C2.72386 2.5 2.5 2.72386 2.5 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2.5Z"/></svg></button>
|
|
1601
|
+
<button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M11 2.5H13C13.2761 2.5 13.5 2.72386 13.5 3V5H15V3C15 1.89543 14.1046 1 13 1H11V2.5Z"/></svg></button>
|
|
1602
|
+
<button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M5 13.5H3C2.72386 13.5 2.5 13.2761 2.5 13V11H1V13C1 14.1046 1.89543 15 3 15H5V13.5Z"/></svg></button>
|
|
1603
|
+
<button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M11 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V11H15V13C15 14.1046 14.1046 15 13 15H11V13.5Z"/></svg></button>
|
|
1449
1604
|
</div>
|
|
1450
1605
|
</div>
|
|
1451
1606
|
<!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
|
|
@@ -1476,7 +1631,13 @@
|
|
|
1476
1631
|
fitted quad by the browser. Not the composite — the composite already
|
|
1477
1632
|
has a frame burned into it, so playing over that would show two screens.
|
|
1478
1633
|
--><div id="liveWrap" hidden><img id="liveBg" alt=""><video id="liveVid" loop muted playsinline></video></div></div>
|
|
1479
|
-
<span class="pillwrap"><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
|
|
1634
|
+
<span class="pillwrap"><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><!-- Empty on purpose. It read "Press Preview" until 20 Sep 2026, which named
|
|
1635
|
+
a button that does not exist: the compare pane renders on its own after
|
|
1636
|
+
every correction, so there is nothing to press. The pane already says
|
|
1637
|
+
what it is waiting for in its own body text, and .pillwrap:has(>
|
|
1638
|
+
.status:empty) hides the pill until there is something true to put in
|
|
1639
|
+
it. -->
|
|
1640
|
+
<span class="status" id="outSt"></span></span>
|
|
1480
1641
|
<!-- Video controls. Hidden entirely for a still source: a scrubber over a
|
|
1481
1642
|
screenshot is a control that can only confuse. The fit itself is
|
|
1482
1643
|
unchanged — you match the edges on one frame and every frame gets the
|
|
@@ -1584,7 +1745,7 @@
|
|
|
1584
1745
|
<div class="sect-body"><div class="sect-body-in">
|
|
1585
1746
|
<div style="display:grid;gap:var(--s2)">
|
|
1586
1747
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
|
|
1587
|
-
<span class="sm" style="color:var(--mute)">Blur grows toward
|
|
1748
|
+
<span class="sm" style="color:var(--mute)">Blur grows toward</span>
|
|
1588
1749
|
<span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1589
1750
|
</div>
|
|
1590
1751
|
<input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
|
|
@@ -1593,7 +1754,7 @@
|
|
|
1593
1754
|
<span class="sm" id="dofStrVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1594
1755
|
</div>
|
|
1595
1756
|
<input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
|
|
1596
|
-
<div style="display:flex;align-items:center;gap:var(--s2)">
|
|
1757
|
+
<div class="dof-adv" style="display:flex;align-items:center;gap:var(--s2)">
|
|
1597
1758
|
<button class="sm" id="dofBoth" aria-pressed="false" title="Blur on both sides of the focus line — the depth of field's near and far limits, for a phone whose middle is sharp and both ends soft. Off, the plane of focus is at the screen's edge and blur grows one way.">Both sides</button>
|
|
1598
1759
|
<button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
|
|
1599
1760
|
<span class="status sm" id="dofSt" style="min-width:0"></span>
|
|
@@ -1614,12 +1775,38 @@
|
|
|
1614
1775
|
</div>
|
|
1615
1776
|
<div class="sect" id="secKeys" data-on="1">
|
|
1616
1777
|
<div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
|
|
1617
|
-
<!--
|
|
1618
|
-
the
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1778
|
+
<!-- One row per key, in the order you meet them: place the quad, move
|
|
1779
|
+
between its parts, then move the view you are placing it in.
|
|
1780
|
+
|
|
1781
|
+
A <dl>, because these ARE term/description pairs -- saying so in the
|
|
1782
|
+
markup is also what lets a screen reader read a key and its meaning
|
|
1783
|
+
as one unit. It ran as prose lines until 20 Sep 2026, which read
|
|
1784
|
+
fine in a wide rail and badly in a narrow one: the caps ended up
|
|
1785
|
+
mid-sentence and mid-wrap, so finding one meant reading the line
|
|
1786
|
+
rather than scanning a column.
|
|
1787
|
+
|
|
1788
|
+
Checked line by line against the handlers the same day and it held,
|
|
1789
|
+
with two gaps, both fixed here. (a) The arrows do nothing until an
|
|
1790
|
+
edge or corner is SELECTED (`if (!pick) return`) -- "nudge 1px"
|
|
1791
|
+
implied they always work. (b) Escape was undocumented, and it is the
|
|
1792
|
+
way out of the canvas's Tab cycle, so the one line that told you how
|
|
1793
|
+
to get IN had no counterpart telling you how to get out. Also named
|
|
1794
|
+
the trackpad pinch: the wheel handler takes ctrlKey as well as
|
|
1795
|
+
metaKey, so a pinch has always zoomed, unmentioned, on the gesture
|
|
1796
|
+
most people reach for first on this hardware.
|
|
1797
|
+
|
|
1798
|
+
Those two conditions sit in the note BELOW the list rather than in
|
|
1799
|
+
parentheses on a row, because each qualifies several rows. -->
|
|
1800
|
+
<dl class="keys">
|
|
1801
|
+
<dt><kbd class="sym">←↑→↓</kbd></dt><dd>nudge 1px</dd>
|
|
1802
|
+
<dt><kbd class="sym">⇧</kbd>+arrow</dt><dd>nudge 10px</dd>
|
|
1803
|
+
<dt><kbd>Tab</kbd></dt><dd>next edge</dd>
|
|
1804
|
+
<dt><kbd>Esc</kbd></dt><dd>release the photo</dd>
|
|
1805
|
+
<dt><kbd class="sym">⌘</kbd>+scroll or pinch</dt><dd>zoom to the pointer</dd>
|
|
1806
|
+
<dt><kbd>Space</kbd>+drag</dt><dd>pan</dd>
|
|
1807
|
+
<dt><kbd>scroll</kbd></dt><dd>pan</dd>
|
|
1808
|
+
</dl>
|
|
1809
|
+
<p class="hint">Arrows act on the selected edge or corner. <kbd>Tab</kbd> and <kbd>Esc</kbd> need a click on the photo first.</p>
|
|
1623
1810
|
</div>
|
|
1624
1811
|
</aside>
|
|
1625
1812
|
</div>
|
|
@@ -1801,6 +1988,20 @@ function toastPath(p){
|
|
|
1801
1988
|
return `<span class="path">${esc.replace(/\//g, '/<wbr>')}</span>`;
|
|
1802
1989
|
}
|
|
1803
1990
|
|
|
1991
|
+
/* "Open folder" on the save toast. A browser cannot open a file manager from
|
|
1992
|
+
an http page, so the page asks the server, which runs on the machine.
|
|
1993
|
+
Delegated from the container rather than wired per toast: a toast is created
|
|
1994
|
+
and destroyed constantly, and one listener outlives all of them. It fails
|
|
1995
|
+
soft on purpose — the page has a second host with no filesystem at all, and
|
|
1996
|
+
there the route simply is not there. */
|
|
1997
|
+
$('#toasts')?.addEventListener('click', async (e) => {
|
|
1998
|
+
const b = e.target.closest('[data-reveal]');
|
|
1999
|
+
if (!b) return;
|
|
2000
|
+
b.disabled = true;
|
|
2001
|
+
try { await api('/api/reveal', { path: decodeURIComponent(b.dataset.reveal) }); }
|
|
2002
|
+
catch (err) { b.disabled = false; toast('err', 'Could not open the folder: ' + err.message); }
|
|
2003
|
+
});
|
|
2004
|
+
|
|
1804
2005
|
function toast(kind, html, opts = {}) {
|
|
1805
2006
|
const wrap = $('#toasts');
|
|
1806
2007
|
if (!wrap) return null;
|
|
@@ -1837,7 +2038,11 @@ function toast(kind, html, opts = {}) {
|
|
|
1837
2038
|
there is nowhere down for it to go. */
|
|
1838
2039
|
let startX = 0, startY = 0, dragging = false;
|
|
1839
2040
|
el.addEventListener('pointerdown', e => {
|
|
1840
|
-
|
|
2041
|
+
// Anything clickable in a toast must bail out BEFORE the capture below:
|
|
2042
|
+
// setPointerCapture retargets the resulting click to the toast, so a
|
|
2043
|
+
// button inside it never sees one. The dismiss × has always needed this;
|
|
2044
|
+
// .act (Open folder) needs it for exactly the same reason.
|
|
2045
|
+
if (e.target.closest('.x, .act')) return;
|
|
1841
2046
|
dragging = true; startX = e.clientX; startY = e.clientY;
|
|
1842
2047
|
el.dataset.dragging = 'true';
|
|
1843
2048
|
try { el.setPointerCapture(e.pointerId); } catch (err) {}
|
|
@@ -2011,12 +2216,66 @@ function casedText(c, text, x, y, color){
|
|
|
2011
2216
|
The page reads the bytes and the SERVER judges them: whether this is the
|
|
2012
2217
|
photograph the fit was made for is a question only the side holding the
|
|
2013
2218
|
pixels can answer. */
|
|
2219
|
+
/* One busy state for every operation on this page that can take a while.
|
|
2220
|
+
|
|
2221
|
+
Measured 20 Sep 2026, because the report was "the 4K image takes time to
|
|
2222
|
+
load" and the guess attached to it was detection. The guess was right and
|
|
2223
|
+
the description was not: a cold 4000x4000 photo fetches and decodes in about
|
|
2224
|
+
150ms, while `scripts/detect.py` on the same file takes **11.2s** -- it runs
|
|
2225
|
+
at full resolution, it does not downscale. So the wait is real, it is
|
|
2226
|
+
detection, and it was already being narrated -- just not well enough to be
|
|
2227
|
+
believed, because a static "Detecting..." for eleven seconds is
|
|
2228
|
+
indistinguishable from a page that has stopped.
|
|
2229
|
+
|
|
2230
|
+
Two things fix that, and neither is a bigger spinner. A counter that keeps
|
|
2231
|
+
moving is the difference between slow and stuck, which the Figma import has
|
|
2232
|
+
understood since it was written -- this is that pattern, lifted out of it so
|
|
2233
|
+
three more callers can have it. And the 250ms delay means a fast operation
|
|
2234
|
+
never flashes a spinner it did not need, which is why this can be used on
|
|
2235
|
+
everything without making the page feel busier than it is.
|
|
2236
|
+
|
|
2237
|
+
`settle(restore)` ends it. Pass true when the caller does NOT write its own
|
|
2238
|
+
status afterwards and the line held something worth putting back; pass false
|
|
2239
|
+
when the caller is about to overwrite it anyway. */
|
|
2240
|
+
function busy(el, label, opts){
|
|
2241
|
+
const o = opts || {};
|
|
2242
|
+
const delay = o.delay == null ? 250 : o.delay;
|
|
2243
|
+
const countFrom = o.countFrom == null ? 2 : o.countFrom;
|
|
2244
|
+
const t0 = Date.now();
|
|
2245
|
+
const prev = {cls: el.className, html: el.innerHTML, title: el.title};
|
|
2246
|
+
// Keep a size modifier the caller had set; only the state word changes.
|
|
2247
|
+
const cls = 'status ' + (/\bsm\b/.test(prev.cls) ? 'sm ' : '') + 'busy';
|
|
2248
|
+
let armed = false, tick = null;
|
|
2249
|
+
const paint = () => {
|
|
2250
|
+
const n = Math.round((Date.now() - t0) / 1000);
|
|
2251
|
+
el.className = cls;
|
|
2252
|
+
el.innerHTML = n >= countFrom ? `${label} <span class="el">${n}s</span>` : `${label}…`;
|
|
2253
|
+
};
|
|
2254
|
+
const arm = setTimeout(() => { armed = true; paint(); tick = setInterval(paint, 1000); }, delay);
|
|
2255
|
+
// A timer is throttled to about once a minute in a hidden tab, and this is
|
|
2256
|
+
// exactly the wait somebody switches away from. Repaint on return so the
|
|
2257
|
+
// number is right the moment they look at it again.
|
|
2258
|
+
document.addEventListener('visibilitychange', paint);
|
|
2259
|
+
return function settle(restore){
|
|
2260
|
+
clearTimeout(arm);
|
|
2261
|
+
if (tick){ clearInterval(tick); tick = null; }
|
|
2262
|
+
document.removeEventListener('visibilitychange', paint);
|
|
2263
|
+
if (armed && restore){ el.className = prev.cls; el.innerHTML = prev.html; el.title = prev.title; }
|
|
2264
|
+
armed = false;
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2014
2267
|
async function loadFitFile(file){
|
|
2015
2268
|
const s = $('#detSt');
|
|
2269
|
+
// Declared out here so the catch can end the counter too.
|
|
2270
|
+
let settle = () => {};
|
|
2016
2271
|
try {
|
|
2017
2272
|
const text = await file.text();
|
|
2018
|
-
|
|
2273
|
+
// /api/fit hashes the photograph's decoded pixels to decide whether this
|
|
2274
|
+
// fit belongs to it, so it costs more on a big photo, for the same reason
|
|
2275
|
+
// detection does.
|
|
2276
|
+
settle = busy(s, 'Reading that fit');
|
|
2019
2277
|
const r = await api('/api/fit', {fit: JSON.parse(text)});
|
|
2278
|
+
settle(false);
|
|
2020
2279
|
st.corners = r.corners.map(c => c.slice());
|
|
2021
2280
|
st.measured = null;
|
|
2022
2281
|
st.quadFrom = 'file';
|
|
@@ -2039,6 +2298,7 @@ async function loadFitFile(file){
|
|
|
2039
2298
|
pick = {kind:'edge', i:0};
|
|
2040
2299
|
draw(); drawStrip(); autoPreview();
|
|
2041
2300
|
} catch (e) {
|
|
2301
|
+
settle(false);
|
|
2042
2302
|
s.className = 'status warn'; s.textContent = 'That fit could not be used';
|
|
2043
2303
|
s.title = e.message || '';
|
|
2044
2304
|
toast('err', 'Could not load that fit: ' + (e.message || e));
|
|
@@ -2087,17 +2347,59 @@ async function loadRecent(){
|
|
|
2087
2347
|
the corners, the screenshot owns the video state and the fitted frame,
|
|
2088
2348
|
either owns the result. The server does the same to the session
|
|
2089
2349
|
(/api/clear), so a reload agrees with what is on screen. */
|
|
2350
|
+
/* The chip is sized by its content, and content changes in ONE frame: the
|
|
2351
|
+
label swaps between "Choose a photo…" and a filename, and the box arrives at
|
|
2352
|
+
its new width before the thumbnail has moved at all. That is the jump — the
|
|
2353
|
+
thumb and the padding were animating politely while the width had already
|
|
2354
|
+
finished. `auto` cannot be transitioned, so the width is measured on both
|
|
2355
|
+
sides of the change and animated explicitly between the two.
|
|
2356
|
+
Released back to auto once it lands, because a fixed width would stop a
|
|
2357
|
+
later filename or a window resize from reflowing the chip normally.
|
|
2358
|
+
Found 20 Sep by reading a screen recording frame by frame. */
|
|
2359
|
+
const chipMs = () => parseFloat(getComputedStyle(document.documentElement)
|
|
2360
|
+
.getPropertyValue('--t-chip')) || 260;
|
|
2361
|
+
function reshapeChip(chip, apply){
|
|
2362
|
+
const from = chip.getBoundingClientRect().width;
|
|
2363
|
+
// Pin BEFORE the content changes, so the swap cannot paint at its new
|
|
2364
|
+
// natural width even for a frame — and so the measurement below, which
|
|
2365
|
+
// forces a layout, cannot commit that width as the transition's start.
|
|
2366
|
+
chip.style.width = from + 'px';
|
|
2367
|
+
apply();
|
|
2368
|
+
// Measure the target on a GHOST, not on the live chip. The live thumbnail is
|
|
2369
|
+
// mid-transition at this instant and still reports width 0, so measuring
|
|
2370
|
+
// here lands ~30px short and the chip finishes with a second, untimed step
|
|
2371
|
+
// when the pin is released. The ghost carries the same classes and content
|
|
2372
|
+
// with transitions off, so it is already at rest.
|
|
2373
|
+
const ghost = chip.cloneNode(true);
|
|
2374
|
+
ghost.removeAttribute('id');
|
|
2375
|
+
ghost.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
|
|
2376
|
+
ghost.style.cssText = 'position:absolute;left:-9999px;top:0;width:auto;' +
|
|
2377
|
+
'visibility:hidden;pointer-events:none;transition:none';
|
|
2378
|
+
chip.parentElement.appendChild(ghost);
|
|
2379
|
+
const to = ghost.getBoundingClientRect().width;
|
|
2380
|
+
ghost.remove();
|
|
2381
|
+
if (Math.abs(to - from) < 0.5){ chip.style.width = ''; return; }
|
|
2382
|
+
void chip.offsetWidth; // commit the start
|
|
2383
|
+
chip.style.width = to + 'px';
|
|
2384
|
+
// Back to auto once it lands: a fixed width would stop a later filename or a
|
|
2385
|
+
// window resize from reflowing the chip normally.
|
|
2386
|
+
clearTimeout(chip._reshape);
|
|
2387
|
+
chip._reshape = setTimeout(() => { chip.style.width = ''; }, chipMs() + 40);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2090
2390
|
async function clearSource(role){
|
|
2091
2391
|
try { await api('/api/clear', {role}); }
|
|
2092
2392
|
catch(e){ alert(e.message); return; }
|
|
2093
2393
|
const n = role==='photo'?1:2;
|
|
2094
2394
|
const chip = $('#chip'+n);
|
|
2095
|
-
chip
|
|
2096
|
-
|
|
2395
|
+
reshapeChip(chip, () => {
|
|
2396
|
+
chip.classList.add('is-empty');
|
|
2397
|
+
chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
|
|
2398
|
+
});
|
|
2097
2399
|
chip.title = '';
|
|
2098
2400
|
$('#sw'+n).style.background = '';
|
|
2099
2401
|
$('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
|
|
2100
|
-
$('#clear'+n).
|
|
2402
|
+
$('#clear'+n).classList.remove('on');
|
|
2101
2403
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
2102
2404
|
dropResult();
|
|
2103
2405
|
if (role==='photo'){
|
|
@@ -2106,6 +2408,10 @@ async function clearSource(role){
|
|
|
2106
2408
|
// Nothing to draw on: the canvas goes back to its empty state rather
|
|
2107
2409
|
// than showing the last photograph with no way to act on it.
|
|
2108
2410
|
const c = $('#c'); c.getContext('2d').clearRect(0, 0, c.width, c.height);
|
|
2411
|
+
// Belt and braces, and worth it on its own: a 4000x4000 decode is ~64MB of
|
|
2412
|
+
// pixels held for a photograph the user has just removed. Dropping it makes
|
|
2413
|
+
// the stale-repaint above impossible rather than merely unpainted.
|
|
2414
|
+
img.removeAttribute('src');
|
|
2109
2415
|
// ... and the edge view, which would otherwise keep showing a strip of a
|
|
2110
2416
|
// photograph that is no longer here.
|
|
2111
2417
|
for (const id of ['strip', 'stripF']) {
|
|
@@ -2156,14 +2462,16 @@ function setChosen(role, path, size, el, meta){
|
|
|
2156
2462
|
const ver = (meta && meta.mtime) || Date.now();
|
|
2157
2463
|
$('#pv'+n).innerHTML = `<img src="${srcURL(thumb, ver)}" alt="">`;
|
|
2158
2464
|
const chip = $('#chip'+n);
|
|
2159
|
-
chip.classList.remove('is-empty');
|
|
2160
2465
|
// The file's own name and nothing else. The size and, for a clip, the frame
|
|
2161
2466
|
// count used to follow it in the same 12px, which pushed the one thing that
|
|
2162
2467
|
// identifies the file into an ellipsis; they live on hover now, with the
|
|
2163
2468
|
// full path. An upload's session copy is "<role>-<epoch>-<name>", so the
|
|
2164
2469
|
// server sends the original name back as `name`.
|
|
2165
2470
|
const shown = (meta && meta.name) || path.split('/').pop();
|
|
2166
|
-
chip
|
|
2471
|
+
reshapeChip(chip, () => {
|
|
2472
|
+
chip.classList.remove('is-empty');
|
|
2473
|
+
chip.querySelector('.nm').textContent = shown;
|
|
2474
|
+
});
|
|
2167
2475
|
// A different file in either role: the composite on screen is of the old
|
|
2168
2476
|
// one. Dropped here, before the state changes, so nothing downstream can
|
|
2169
2477
|
// narrate over it; maybeStart() re-renders once the fit is ready.
|
|
@@ -2171,7 +2479,7 @@ function setChosen(role, path, size, el, meta){
|
|
|
2171
2479
|
chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
|
|
2172
2480
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
2173
2481
|
$('#sw'+n).style.background = `center/cover no-repeat url("${srcURL(thumb, ver)}")`;
|
|
2174
|
-
$('#clear'+n).
|
|
2482
|
+
$('#clear'+n).classList.add('on');
|
|
2175
2483
|
// A fit the server recognised from this photograph's own pixels. Held
|
|
2176
2484
|
// rather than applied here: the quad only means anything once there is a
|
|
2177
2485
|
// screenshot to put inside it, which is what maybeStart() waits for.
|
|
@@ -2384,10 +2692,44 @@ function distToSeg(p,a,b){
|
|
|
2384
2692
|
return {d: vlen(sub(p, add(a, mul(ab,t)))), t};
|
|
2385
2693
|
}
|
|
2386
2694
|
|
|
2695
|
+
/* Loading the photograph is the one long step on this page that said nothing.
|
|
2696
|
+
`detect()` has narrated itself since it existed, and it runs AFTER this, so a
|
|
2697
|
+
4K photo spent its whole fetch-and-decode in silence with the picker already
|
|
2698
|
+
closed and the canvas still empty -- which reads as the click not having
|
|
2699
|
+
landed. Reported 20 Sep 2026, with detection as the suspected cause; it was
|
|
2700
|
+
not, and the measured order is: set img.src -> fetch the full-resolution file
|
|
2701
|
+
-> browser decode -> onload -> computeFit/draw -> only then detect().
|
|
2702
|
+
|
|
2703
|
+
Armed on a 250ms delay so a small photo never flashes a spinner it does not
|
|
2704
|
+
need, and it counts seconds past 2 for the same reason the Figma import does:
|
|
2705
|
+
a number that keeps moving is the difference between slow and stuck.
|
|
2706
|
+
|
|
2707
|
+
The previous status is CAPTURED and handed back on settle rather than
|
|
2708
|
+
blanked. One caller (setChosen, changing the screenshot under an existing
|
|
2709
|
+
fit) writes "the fit on the photo is kept" and then calls in here, and the
|
|
2710
|
+
branch below that keeps existing corners writes nothing -- so clearing would
|
|
2711
|
+
have eaten somebody else's message. */
|
|
2712
|
+
let photoBusy = null; // settle() for a load still in flight
|
|
2387
2713
|
async function maybeStart(){
|
|
2388
2714
|
if (!st.photo) return;
|
|
2389
2715
|
const ready = !!st.shot; // the screenshot gates the fit, not the photo
|
|
2716
|
+
const s = $('#detSt');
|
|
2717
|
+
if (photoBusy) photoBusy(true); // a second pick mid-load: retire the first
|
|
2718
|
+
const done = busy(s, 'Loading the photo');
|
|
2719
|
+
const settle = () => { done(true); photoBusy = null; };
|
|
2720
|
+
photoBusy = settle;
|
|
2721
|
+
// There was no onerror at all, so a photo that failed to decode left the page
|
|
2722
|
+
// silent for good: no canvas, no message, nothing to do next.
|
|
2723
|
+
img.onerror = () => {
|
|
2724
|
+
// Clearing the photo drops img's src on purpose; that is not a failure.
|
|
2725
|
+
if (!st.photo) { settle(); return; }
|
|
2726
|
+
settle();
|
|
2727
|
+
s.className = 'status err';
|
|
2728
|
+
s.textContent = 'That photo could not be opened — try another, or drop the file in again.';
|
|
2729
|
+
s.title = homePath(st.photo || '');
|
|
2730
|
+
};
|
|
2390
2731
|
img.onload = () => {
|
|
2732
|
+
settle();
|
|
2391
2733
|
computeFit(); setCanvasZoom(fitScale);
|
|
2392
2734
|
refreshRings();
|
|
2393
2735
|
if (!ready){
|
|
@@ -2596,9 +2938,11 @@ $('#scroller').addEventListener('pointerdown', e => {
|
|
|
2596
2938
|
pointAt(p[0], p[1]);
|
|
2597
2939
|
}, true);
|
|
2598
2940
|
async function pointAt(x, y){
|
|
2599
|
-
const s = $('#detSt');
|
|
2941
|
+
const s = $('#detSt');
|
|
2942
|
+
const settle = busy(s, 'Reading that point');
|
|
2600
2943
|
try {
|
|
2601
2944
|
const r = await api('/api/detect', {click: [x, y]});
|
|
2945
|
+
settle(false);
|
|
2602
2946
|
if (r.found){
|
|
2603
2947
|
st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
|
|
2604
2948
|
orientQuad();
|
|
@@ -2614,6 +2958,7 @@ async function pointAt(x, y){
|
|
|
2614
2958
|
s.title = r.message || '';
|
|
2615
2959
|
}
|
|
2616
2960
|
} catch (err) {
|
|
2961
|
+
settle(false);
|
|
2617
2962
|
s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
|
|
2618
2963
|
}
|
|
2619
2964
|
pick = {kind:'edge', i:0};
|
|
@@ -2628,8 +2973,29 @@ document.querySelectorAll('[data-jump]').forEach(b => {
|
|
|
2628
2973
|
});
|
|
2629
2974
|
|
|
2630
2975
|
async function detect(){
|
|
2631
|
-
const s = $('#detSt');
|
|
2632
|
-
|
|
2976
|
+
const s = $('#detSt');
|
|
2977
|
+
// The longest wait on the page: 11.2s measured on a 4000x4000 photo, because
|
|
2978
|
+
// detect.py runs at full resolution. The counter is what makes that read as
|
|
2979
|
+
// working rather than hung.
|
|
2980
|
+
const settle = busy(s, 'Detecting');
|
|
2981
|
+
let r;
|
|
2982
|
+
// There was no catch here at all, so a request that failed left 'Detecting…'
|
|
2983
|
+
// spinning for good -- the one state the page could never leave.
|
|
2984
|
+
try { r = await api('/api/detect', {}); }
|
|
2985
|
+
catch (e){
|
|
2986
|
+
settle(false);
|
|
2987
|
+
s.className = 'status err';
|
|
2988
|
+
s.textContent = 'Detection failed — try Point at screen, or place the edges by hand';
|
|
2989
|
+
s.title = e.message || '';
|
|
2990
|
+
suggestPointing(true);
|
|
2991
|
+
st.corners = defaultQuad(); st.quadFrom = 'default';
|
|
2992
|
+
if (!st.type) setType('phone');
|
|
2993
|
+
applyPreset();
|
|
2994
|
+
pick = {kind:'edge', i:0};
|
|
2995
|
+
draw(); drawStrip(); autoPreview();
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
settle(false);
|
|
2633
2999
|
if (r.found){
|
|
2634
3000
|
st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
|
|
2635
3001
|
orientQuad();
|
|
@@ -2890,7 +3256,16 @@ function draw(){
|
|
|
2890
3256
|
if (!$('#liveWrap').hidden) placeLiveVideo();
|
|
2891
3257
|
ctx.setTransform(cvDpr, 0, 0, cvDpr, 0, 0); // CSS px from here on; the store is cvDpr times larger
|
|
2892
3258
|
ctx.clearRect(0,0,cvW,cvH);
|
|
2893
|
-
|
|
3259
|
+
// st.photo FIRST, and this is the whole bug reported 21 Sep 2026: clear the
|
|
3260
|
+
// photo, then click anywhere in the fit pane, and the photograph came back
|
|
3261
|
+
// while the chip still read "Choose a photo…". The guard here was
|
|
3262
|
+
// `img.naturalWidth` alone -- the <img> keeps its decoded pixels until
|
|
3263
|
+
// something replaces them, and clearSource only wiped the CANVAS, so the
|
|
3264
|
+
// image was still 4000x4000 and perfectly drawable. The click ran
|
|
3265
|
+
// cv.onpointerdown -> no hit -> draw(), and draw() repainted what it still
|
|
3266
|
+
// had. Asking the <img> whether there is a photo was the mistake: the <img>
|
|
3267
|
+
// is a cache, st.photo is the answer.
|
|
3268
|
+
if (!(st.photo && img.naturalWidth)) return;
|
|
2894
3269
|
ctx.drawImage(img, 0,0, cvW, cvH);
|
|
2895
3270
|
if (!st.corners) return;
|
|
2896
3271
|
const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
|
|
@@ -3041,9 +3416,21 @@ cv.onpointerup = cv.onpointercancel = () => { drag = null; draw(); drawStrip();
|
|
|
3041
3416
|
cv.onpointerleave = () => { if (!drag){ hover = null; draw(); drawStrip(); } };
|
|
3042
3417
|
|
|
3043
3418
|
window.addEventListener('keydown', e => {
|
|
3044
|
-
|
|
3419
|
+
// Escape also releases the canvas, so Tab-cycling is never a one-way door.
|
|
3420
|
+
if (e.key === 'Escape'){ closePop(); if (document.activeElement === cv) cv.blur(); return; }
|
|
3045
3421
|
if (!st.corners || document.querySelector('.pop.on')) return;
|
|
3046
3422
|
if (e.key === 'Tab'){
|
|
3423
|
+
// Tab is the BROWSER's focus key everywhere except the fit canvas itself.
|
|
3424
|
+
// This was taken at window level with no target check, so the moment a fit
|
|
3425
|
+
// existed a keyboard user could never reach the top bar or the rail: focus
|
|
3426
|
+
// stayed where it was and the EDGE advanced instead. Reported 20 Sep with
|
|
3427
|
+
// the focus ring visibly stuck on a device chip while the edge cycled —
|
|
3428
|
+
// WCAG 2.1.1, and the KEYS legend's "Tab next edge" is why it looked
|
|
3429
|
+
// deliberate rather than broken.
|
|
3430
|
+
// The canvas is tabindex="-1": it holds focus only after a click on it,
|
|
3431
|
+
// which is exactly when cycling edges is what Tab should mean. Escape
|
|
3432
|
+
// above gives it back.
|
|
3433
|
+
if (document.activeElement !== cv) return;
|
|
3047
3434
|
e.preventDefault();
|
|
3048
3435
|
const i = pick && pick.kind==='edge' ? (pick.i+1)%4 : 0;
|
|
3049
3436
|
pick = {kind:'edge', i}; draw(); drawStrip(); return;
|
|
@@ -3704,15 +4091,25 @@ let outNat = 0, autoTimer = null;
|
|
|
3704
4091
|
would do. On a tool whose whole pitch is that the output is exact, a label
|
|
3705
4092
|
that disagrees with its action is not a small thing. */
|
|
3706
4093
|
function saveLabel(){ return st.video ? 'Render' : 'Save'; }
|
|
3707
|
-
/* The
|
|
3708
|
-
and the in-flight 'Saving…' / progress text must survive a source change, so
|
|
4094
|
+
/* The in-flight 'Saving…' / progress text must survive a source change, so
|
|
3709
4095
|
relabelling only touches a button that is currently offering the action. */
|
|
3710
4096
|
function relabelSave(){
|
|
3711
4097
|
const b = $('#save');
|
|
3712
4098
|
if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
|
|
3713
4099
|
}
|
|
3714
4100
|
function markStale(){
|
|
3715
|
-
|
|
4101
|
+
// This used to relabel the button 'Preview first'. Removed 20 Sep 2026 on
|
|
4102
|
+
// request, and the request was right on inspection: with the compare pane
|
|
4103
|
+
// open by default, EVERY caller of markStale that has all three inputs is
|
|
4104
|
+
// followed by autoPreview() within the same interaction -- pointerup after a
|
|
4105
|
+
// drag, the same line after a keyboard nudge, the end of pointAt(), the
|
|
4106
|
+
// callers of setFrac. So the label was only ever on screen for the 350ms
|
|
4107
|
+
// debounce. The one place it PERSISTED was a failed /api/preview, where it
|
|
4108
|
+
// told the user to do the thing they had just done and that had just failed.
|
|
4109
|
+
// Dead in the happy path, wrong in the sad one. The button keeps its own
|
|
4110
|
+
// verb and simply reads as unavailable; the status line says why.
|
|
4111
|
+
const b = $('#save'); b.disabled = true;
|
|
4112
|
+
b.textContent = saveLabel();
|
|
3716
4113
|
// Exactly one lit action at a time — the Figma Button component says the
|
|
3717
4114
|
// accent marks the NEXT action and that only one may be on screen. With no
|
|
3718
4115
|
// current render there is nothing to save, so nothing is lit: Preview stays
|
|
@@ -4180,8 +4577,11 @@ $('#save').onclick = async () => {
|
|
|
4180
4577
|
// Naming the fit file here is the whole reason it is discoverable: it is
|
|
4181
4578
|
// written silently beside the mockup, and a file nobody knows about is a
|
|
4182
4579
|
// file nobody drags back in.
|
|
4580
|
+
// encodeURIComponent, not an HTML escape: the path goes into an attribute
|
|
4581
|
+
// and a filename may legally contain quotes. This cannot break out of it.
|
|
4183
4582
|
toast('ok', 'Saved' + toastPath(homePath(r.output))
|
|
4184
|
-
+ (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : '')
|
|
4583
|
+
+ (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : '')
|
|
4584
|
+
+ `<button type="button" class="act" data-reveal="${encodeURIComponent(r.output)}">Open folder</button>`,
|
|
4185
4585
|
{ ms: 9000, dismissible: true });
|
|
4186
4586
|
const imp = $('#imp');
|
|
4187
4587
|
imp.disabled = false; imp.textContent = 'Send to Claude';
|
|
@@ -4238,14 +4638,23 @@ $('#imp').onclick = async () => {
|
|
|
4238
4638
|
$('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
|
|
4239
4639
|
$('#ver').classList.toggle('dev', !!s.build);
|
|
4240
4640
|
}
|
|
4241
|
-
//
|
|
4242
|
-
//
|
|
4243
|
-
|
|
4244
|
-
|
|
4641
|
+
// There used to be a toast here on every load, saying "Fit the four edges,
|
|
4642
|
+
// then Preview" and naming the save folder. Removed 20 Sep 2026 on request.
|
|
4643
|
+
// It was the one message that fired when the user had done nothing, so it
|
|
4644
|
+
// greeted every single session -- including the hundredth -- with an
|
|
4645
|
+
// instruction and a long path, over the picker that had just opened by
|
|
4646
|
+
// itself. The instruction is already in the fit pane's own status line, and
|
|
4647
|
+
// the save folder is named in the toast that appears AFTER a save, which is
|
|
4648
|
+
// the moment it is actually wanted. Nothing replaced it: no toast on load.
|
|
4245
4649
|
const types = $('#types');
|
|
4246
4650
|
for (const t of ['phone','tablet','laptop','desktop']){
|
|
4247
4651
|
const b=document.createElement('button'); b.className='chip'; b.dataset.t=t; b.textContent=t; /* Chip is one size in Figma (5:6): h28, 12px */
|
|
4248
|
-
|
|
4652
|
+
// Re-clicking the device you are ALREADY on used to re-apply its preset and
|
|
4653
|
+
// silently overwrite a hand-set corner radius — phone → phone jumped 10.2%
|
|
4654
|
+
// back to 15.4% (found 20 Sep). Picking the device you already have is a
|
|
4655
|
+
// no-op; the preset dropdown is the deliberate way to re-apply one.
|
|
4656
|
+
b.onclick=()=>{ if (st.type === t) return; setType(t); applyPreset(); autoPreview(); };
|
|
4657
|
+
types.appendChild(b);
|
|
4249
4658
|
}
|
|
4250
4659
|
setLoupeMode(loupeMode);
|
|
4251
4660
|
setStripHC(stripHC);
|