screengraft 0.13.1

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/ui/index.html ADDED
@@ -0,0 +1,1902 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>screengraft</title>
7
+ <style>
8
+ /* Tokens — see the project's visual-language notes.
9
+ SURFACES STEP: well (recessed) < bg < sunk < card < raise < float. The
10
+ previous build used one tone for the top bar, rail, dock, popovers AND
11
+ buttons, so nothing read as a control sitting on a panel — it read as
12
+ regions of one sheet divided by lines. Depth now comes from tone steps
13
+ plus a 1px top inner highlight (--hi), which is what actually separates
14
+ a raised control from a div in a dark UI; shadow is reserved for things
15
+ that genuinely float.
16
+ Status colours keep three distinct LUMINANCE steps, not three hues —
17
+ measured, --ok and --warn once differed by 1.01:1, invisible in greyscale
18
+ and to red-green colour blindness. --line divides; --edge bounds anything
19
+ clickable and clears the 3:1 non-text bar. See the project's visual-critique notes */
20
+ :root{
21
+ /* Ported by hand from the project's Figma design file
22
+ (4 Sep 2026). Figma is where visual changes start; there is no
23
+ sync, so these names mirror the variable collection deliberately:
24
+ surface/* -> --well/--bg/--sunk/--card/--raise/--raise-hi,
25
+ border/* -> --line/--edge/--edge-hi, text/* -> --ink/--mute/--faint.
26
+
27
+ DARK ONLY. The light palette was dropped rather than invented: the
28
+ design is dark throughout, and a light counterpart for a #ec4c13 accent
29
+ needs a different shade to pass contrast on white — a decision that
30
+ belongs in Figma, not guessed here. */
31
+ --well:#0a0a0b; --bg:#111112; --sunk:#141415; --card:#161618;
32
+ /* A four-step raised ladder, added in the design file to fix two collapsed chip states: hover used to
33
+ step DOWN to --float and selected-hover had collapsed onto --edge. */
34
+ --raise:#2b2b30; --raise-mid:#333337; --raise-hi:#3a3a40; --raise-highest:#46464e;
35
+ --panel:#161618;
36
+ /* surface/float is a real token (#26262b, the chip hover surface). The
37
+ canvas overlay is a separate thing and gets its own name. */
38
+ --float:#26262b;
39
+ --overlay:rgba(17,17,18,.98);
40
+ --ink:#e9ecf1; --mute:#8f939a; --faint:#8f939ab2;
41
+ --line:#1d1e20; --edge:#3a3b41; --edge-mid:#494a50; --edge-hi:#6d6f79;
42
+
43
+ /* Accent, per the Button variant matrix (4:14). Primary at REST is the
44
+ accent at 85%, not solid — solid is the hover state. */
45
+ /* accent/acc + accent/acc_btn_rest re-picked in Figma 5 Sep:
46
+ #ec4c13 -> #f23b0d. --acc-soft is derived from the accent, so it follows. */
47
+ --acc:#f23b0d; --acc-btn-rest:rgba(242,59,13,.85); --acc-press:#bb2b00;
48
+ --acc-stroke:#ff6833; --acc-ink:#fbf9f9; --acc-soft:rgba(242,59,13,.14);
49
+ /* Neutral button hover/pressed are explicit surfaces in Figma, not a
50
+ brightness filter on the rest state. */
51
+ --btn-hover:#333337; --btn-hover-edge:#3f4045;
52
+ --btn-press:#1c1c20; --btn-press-edge:#2b2c31;
53
+ /* Accent disabled keeps the label READABLE rather than fading the whole
54
+ button — the Button component in Figma says so explicitly. */
55
+ --acc-dis:#bb2b00b2; --acc-dis-edge:#ff6833b2; --acc-dis-ink:#fbf9f966;
56
+
57
+ --warn:#f0c375;
58
+ /* --ok is status/ok, re-picked in the design file (#8fe3c0 -> #4fbf8b).
59
+ --err has no counterpart in the collection (the Status pill has only OK and
60
+ Warn variants), so it stays chosen here.
61
+
62
+ Status is encoded by glyph + weight + LUMINANCE, never hue alone. What the
63
+ new green bought, measured (scripts/contrast_audit.py, before -> after):
64
+ ok|warn normal 1.09 -> 1.39 deuteranopia 1.01 -> 1.67 trit 1.21 -> 1.99
65
+ That pair is the one an earlier finding raised, and it is the one that matters: ok and
66
+ warn are the two outcomes of the SAME detection, so they appear in
67
+ sequence and get compared.
68
+
69
+ Two shortfalls remain, both accepted with reasons, both still printed by
70
+ the audit on every run:
71
+ - ok|warn under PROTANOPIA is 1.24, essentially unmoved (1.23 before). A
72
+ red-blind viewer sees the amber lose its red and drift toward the green.
73
+ - ok|err regressed (2.45 -> 1.61 normal, 1.62 -> 1.01 tritanopia) because
74
+ the new green sits in the same luminance band as any usable error red.
75
+ Searched, not assumed: of 7056 candidate reds/pinks meeting 4.5:1 on
76
+ --card, exactly 59 clear the 1.35 floor and every one is a saturated
77
+ magenta — not an error colour, and it fights the accent. Accepted
78
+ because ok and err are mutually exclusive states of one pill and are
79
+ never on screen together.
80
+ Don't "fix" either by moving --ok back; that reopens the pair that counts. */
81
+ --ok:#4fbf8b; --err:#e5534b;
82
+
83
+ --hi:inset 0 1px 0 rgba(255,255,255,.06);
84
+ --shadow:0 16px 40px rgba(0,0,0,.55), 0 3px 10px rgba(0,0,0,.4);
85
+ --r-sm:6px; --r-md:8px; --r-lg:12px;
86
+ --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s9:48px;
87
+ /* 120ms originally, per the visual-language notes. asked for a bit
88
+ slower on 5 Sep — one token, so retuning is a one-line change. */
89
+ --t:180ms cubic-bezier(.2,0,0,1);
90
+ --t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
91
+ /* Every control animates the SAME four properties on the same curve. Kept as
92
+ one token because the failure mode here is a control that transitions its
93
+ border but snaps its fill. */
94
+ --t-all:background-color var(--t), border-color var(--t), box-shadow var(--t), color var(--t);
95
+ }
96
+ @media (prefers-reduced-motion:reduce){ *{transition:none !important} }
97
+ *{box-sizing:border-box}
98
+ html,body{height:100%}
99
+ body{margin:0;background:var(--bg);color:var(--ink);
100
+ font:13px/1.45 -apple-system,BlinkMacSystemFont,"Inter",system-ui,sans-serif;overflow:hidden}
101
+
102
+ /* The shell IS the viewport. Nothing scrolls except the canvas, so the
103
+ fitting surface can never be pushed off-screen by finished work. */
104
+ .app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-height:0;
105
+ padding-bottom:32px;background:var(--bg)}
106
+
107
+ .topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
108
+ background:var(--card);border-bottom:1px solid var(--line);
109
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0 rgba(0,0,0,.25)}
110
+ .tb-left{display:flex;align-items:center;gap:var(--s9)}
111
+ .tb-chips{display:flex;align-items:center;gap:var(--s2)}
112
+ .tb-arrow{color:var(--mute)}
113
+ .tb-actions{display:flex;align-items:center;gap:var(--s2)}
114
+ /* Mona Sans is the brand face in Figma; it is not installed on a stock Mac,
115
+ so it degrades to the system UI face rather than to a serif. */
116
+ /* The wordmark ships WITH the plugin. Mona Sans is the brand face in Figma and
117
+ it happened to be installed locally, which was the only reason it ever rendered —
118
+ on any other designer's machine the logo silently became SF Pro Display, ~24px
119
+ narrower. This is a variable subset containing only the letters of
120
+ "Screengraft" (6.2KB, vs 349KB for the full family), inlined so the page stays
121
+ a single self-contained file and needs no new server route. SIL OFL; the
122
+ licence travels in ui/fonts/OFL.txt.
123
+ Figma 6:5 is Mona Sans SemiBold 20px at wdth 125. font-stretch:125% does NOT
124
+ reach the wdth axis — measured 115.81px against the real instance's 128.26px —
125
+ so the axis is driven explicitly. */
126
+ @font-face{
127
+ font-family:"Mona Sans Wordmark";
128
+ src:url(data:font/woff2;base64,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) format("woff2");
129
+ font-weight:100 900; font-stretch:75% 125%; font-display:block;
130
+ }
131
+ .brand{font-family:"Mona Sans Wordmark","Mona Sans","SF Pro Display",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
132
+ font-weight:600;font-size:20px;font-stretch:125%;letter-spacing:.1px;
133
+ font-variation-settings:"wdth" 125}
134
+ .brand span{color:var(--mute);font-weight:400;margin-left:8px;font-size:12px;
135
+ font-variant-numeric:tabular-nums}
136
+ .spacer{flex:1}
137
+ .gap-3{width:var(--s3)}
138
+ /* One state model for every control: rest / hover / active / focus /
139
+ selected / disabled. Active genuinely depresses (darker than rest, no top
140
+ highlight, half-pixel down) instead of doing nothing. */
141
+ /* ------------------------------------------------------------------
142
+ Button — the full variant matrix from Figma 4:14.
143
+ Default : raise surface, hover #333337/#3f4045, pressed #1c1c20/#2b2c31
144
+ Primary : accent at 85%, hover solid #ec4c13, pressed #bb2b00
145
+ Sizes : Md 28px/12px/13px (the default), Sm 24px/9px/12px.
146
+ Hover and pressed are explicit SURFACES in the design, not a brightness
147
+ filter over the rest state — a filter shifts hue and cannot be measured
148
+ against a token.
149
+ ------------------------------------------------------------------ */
150
+ button{font:inherit;color:var(--ink);background:var(--raise);
151
+ border:1px solid var(--edge);border-radius:var(--r-sm);
152
+ padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;box-shadow:var(--hi);
153
+ transition:var(--t-all), transform var(--t)}
154
+ button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
155
+ button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
156
+ box-shadow:none;transform:translateY(.5px)}
157
+ button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
158
+ /* Default disabled keeps the raised surface and dims only the LABEL — the
159
+ component description is explicit that opacity is not the mechanism. */
160
+ button[disabled]{background:var(--raise);border-color:var(--edge);
161
+ color:var(--faint);cursor:default;font-weight:400;
162
+ box-shadow:none;transform:none}
163
+ button[disabled]:hover{background:var(--raise);border-color:var(--edge)}
164
+ button.sm{height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
165
+ button.sm[disabled]{color:var(--mute)}
166
+
167
+ /* Primary. The accent means exactly one thing: the next action, so only one
168
+ of these is lit at a time (see the accent hand-off in the JS). */
169
+ button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
170
+ border-color:var(--acc-stroke);font-weight:600;
171
+ border-radius:var(--r-md);box-shadow:none}
172
+ button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
173
+ button.primary:active{background:var(--acc-press);border-color:var(--acc-dis-edge);
174
+ transform:translateY(.5px)}
175
+ button.primary.sm{border-radius:var(--r-sm)}
176
+ /* An accent-family button that is enabled but is NOT the next action reads
177
+ as an ordinary neutral control. */
178
+ button.accent:not(:disabled):not(.primary){
179
+ background:var(--raise); color:var(--ink); border-color:var(--edge);
180
+ font-weight:400; box-shadow:var(--hi); border-radius:var(--r-md)}
181
+ button.accent:disabled,button.primary:disabled{
182
+ background:var(--acc-dis); border-color:var(--acc-dis-edge);
183
+ color:var(--acc-dis-ink); font-weight:600;
184
+ border-radius:var(--r-md); box-shadow:none; opacity:1}
185
+
186
+ /* ------------------------------------------------------------------
187
+ Chip (Figma 5:6). Selection is a raised neutral surface plus a lighter
188
+ border — deliberately NOT accent-filled, because the accent is reserved
189
+ for the next action. The old accent ring is gone for that reason.
190
+ ------------------------------------------------------------------ */
191
+ .chip{border-radius:999px;padding:0 var(--s3);height:28px;font-size:12px;
192
+ background:var(--raise);border-color:var(--edge);box-shadow:var(--hi)}
193
+ /* Chip state matrix, ported from the Chip component set. Every state now
194
+ steps in the direction it should: hover UP from rest, pressed DOWN from it,
195
+ and selected-hover up again from selected-rest. Before the new tokens
196
+ existed, unselected hover stepped down onto --float and selected hover had
197
+ converged with --edge — reported and fixed. */
198
+ .chip:hover{background:var(--raise-mid);border-color:var(--edge-mid)}
199
+ .chip:active{background:var(--raise);border-color:var(--edge-mid)}
200
+ .chip.on{background:var(--raise-hi);border-color:var(--edge-hi);
201
+ color:var(--ink);font-weight:600;box-shadow:var(--hi)}
202
+ .chip.on:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
203
+ .chip.on:active{background:var(--raise-mid);border-color:var(--edge-hi)}
204
+ .chip[disabled]{opacity:.5;color:var(--mute);background:var(--raise);border-color:var(--edge)}
205
+ .chip.on[disabled]{background:var(--raise-hi);border-color:var(--edge-hi)}
206
+
207
+ /* A two-way choice should LOOK like one: a recessed track with a raised
208
+ thumb, not two identical buttons that happen to be adjacent. */
209
+ /* Segmented control — Figma 5:21 / 7:68: 146 x 28 overall, which is
210
+ 2 padding + 70 + 2 gap + 70 + 2 padding. It is a CONTROL, not a field, so
211
+ it hugs that width. `inline-flex` alone did not achieve this: the rail is
212
+ a grid and stretches its children, so the track spread to the full 272px
213
+ column and read like a segmented *bar*. justify-self:start is what
214
+ actually holds it to its content width. */
215
+
216
+ /* A stepper should look joined. */
217
+ /* Stepper (Figma 28:816). NOT a bespoke control: it is ordinary Default
218
+ buttons butted together, so every state — hover, pressed, disabled — is
219
+ the button's own. The previous version was borderless, transparent and
220
+ 24px tall, which is exactly why +/- read as a different species from the
221
+ buttons beside them. Only the corner radii and the shared middle border
222
+ are special. */
223
+ .stepper{display:inline-flex;align-items:center}
224
+ .stepper button{border-radius:0}
225
+ .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
226
+ .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
227
+ /* One border between neighbours rather than two stacked. */
228
+ .stepper button + button{margin-left:-1px}
229
+ .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
230
+ /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
231
+ .dock-step button{padding:0 var(--s3)}
232
+ /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
233
+ than inherited from the base button rule, so the filled state is explicit and
234
+ the .is-empty override below has something definite to override.
235
+ NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
236
+ centred placeholder-paragraph class (padding:24px), and the chip was
237
+ silently inheriting it and rendering 50px tall instead of 32. */
238
+ .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:300px;
239
+ height:32px;padding:0 12px 0 6px;border-radius:999px;
240
+ background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
241
+ .inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
242
+ background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}
243
+ .inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
244
+ /* Empty is the SAME box as filled — 32px tall, same padding, same 22px
245
+ avatar. Only the surface, border style and label colour change. It was
246
+ reading oversized because it had no surface to sit in. */
247
+ .inputchip.is-empty{background:var(--card);border-style:dashed;
248
+ color:var(--mute);box-shadow:none}
249
+ .inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
250
+
251
+ /* Status carries meaning in THREE channels: glyph, weight, luminance —
252
+ never hue alone. */
253
+ /* Status pill (Figma 5:13): h22, px10, gap 6, sunk surface, line border.
254
+ Glyph 11px and label 12px; WHICH of the two is semibold flips between OK
255
+ and Warn, which is a third encoding channel on top of glyph and colour. */
256
+ .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--mute);
257
+ height:22px;padding:0 10px;border-radius:999px;background:var(--sunk);
258
+ border:1px solid var(--line);box-shadow:none;transition:var(--t-all)}
259
+ .status::before{font-size:11px}
260
+ .status.ok{color:var(--ok);font-weight:400} .status.ok::before{content:"\2713";font-weight:600}
261
+ .status.warn{color:var(--warn);font-weight:600} .status.warn::before{content:"!";font-weight:400}
262
+ .status.err{color:var(--err);font-weight:600} .status.err::before{content:"\00d7"}
263
+ /* A real spinner, not a static glyph. The old "\22ef" ellipsis never moved,
264
+ so a wait that genuinely was in progress looked identical to a dead UI —
265
+ which is exactly what it felt like while the agent wasn't running. */
266
+ .status.busy::before{content:"";width:9px;height:9px;border-radius:50%;
267
+ border:1.5px solid var(--line);border-top-color:var(--accent);
268
+ animation:sg-spin .7s linear infinite}
269
+ @keyframes sg-spin{to{transform:rotate(360deg)}}
270
+ @media (prefers-reduced-motion:reduce){
271
+ .status.busy::before{animation-duration:2.4s}
272
+ }
273
+ .status .el{opacity:.65;font-variant-numeric:tabular-nums}
274
+ .hintline{font-size:11px;color:var(--mute);margin-top:6px;line-height:1.5}
275
+ .hintline b{color:var(--fg);font-weight:600}
276
+
277
+ /* 304px wrapped "desktop" onto a second line. The rail is sized to the
278
+ content it must hold — four device chips in one row — rather than to a
279
+ round number. */
280
+ .stage{display:grid;grid-template-columns:minmax(0,1fr) 344px;min-height:0;overflow:hidden}
281
+ .stage.compare{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px}
282
+ #types{flex-wrap:nowrap}
283
+ .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
284
+ position:relative; /* the frame the corner bar is anchored to */
285
+ border-right:1px solid var(--line)}
286
+ .panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
287
+ box-shadow:inset 0 -1px 0 var(--line);font-size:12px;flex-wrap:wrap;min-height:48px}
288
+ /* The Fit header pads 12px vertically around 28px controls (Figma 7:13),
289
+ which makes it 52px — deliberately taller than the Result header's 48. */
290
+ /* nowrap matches the design, but a long detection message must not be able
291
+ to widen the grid column — that is the v0.3.1 blowout, where an
292
+ unconstrained child forced the fixed rail off-screen. The pill truncates
293
+ instead; min-width:0 is what actually lets it. */
294
+ .fit-head{justify-content:space-between;flex-wrap:nowrap}
295
+ /* Both pane headers are the same height: they sit side by side, and 4px of
296
+ difference reads as a misalignment rather than as a hierarchy. */
297
+ .panehead{padding:var(--s3) var(--s4);min-height:52px}
298
+ .fit-head > .status{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
299
+ display:inline-block;line-height:20px}
300
+ .fit-head > .status::before{margin-right:6px}
301
+ .ph-left{display:flex;align-items:center;gap:var(--s6);min-width:0}
302
+ .ph-left-1{display:flex;align-items:center;gap:var(--s4)}
303
+ .ph-left-2{display:flex;align-items:center;gap:var(--s2)}
304
+ .zoomval{color:var(--mute);font-size:12px;font-variant-numeric:tabular-nums;min-width:38px}
305
+ .lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
306
+ .scroller{overflow:auto;background:var(--well);display:grid;
307
+ min-height:0;min-width:0;
308
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
309
+ canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto}
310
+
311
+ /* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
312
+ inside the scroller would drift with pan; fixed-to-the-pane absolute
313
+ keeps it where the eye expects while the picture moves under it. */
314
+ /* max-width so a narrow pane (compare view on a small window) can never
315
+ push the bar outside the frame it is anchored to. */
316
+ .cvbar{position:absolute;left:50%;transform:translateX(-50%);bottom:16px;z-index:3;
317
+ max-width:calc(100% - 32px);overflow-x:auto;
318
+ display:flex;align-items:center;gap:var(--s6);
319
+ padding:var(--s2) var(--s2) var(--s2) var(--s3);
320
+ background:var(--overlay);backdrop-filter:blur(5px);
321
+ -webkit-backdrop-filter:blur(5px);
322
+ border-radius:var(--r-lg);box-shadow:var(--shadow)}
323
+ .cvbar-actions{display:flex;align-items:center;gap:var(--s2);flex-wrap:nowrap}
324
+ .cvbar{white-space:nowrap}
325
+ /* `hidden` is a UA display:none, which any explicit display beats — and
326
+ .cvbar sets display:flex. Without this the bar stays visible while the
327
+ photo is chosen but the screenshot is not, offering corners to fit
328
+ nothing. */
329
+ .cvbar[hidden]{display:none}
330
+
331
+ /* Preview popover: the composite at whatever size fits, on the sunk surface
332
+ so the image edge is unambiguous against the panel. */
333
+ #previewWrap{margin-top:10px;background:var(--well);border-radius:var(--r-md);
334
+ box-shadow:inset 0 1px 3px rgba(0,0,0,.5);display:grid;
335
+ place-items:center;min-height:200px;max-height:70vh;overflow:auto}
336
+ #previewImg{display:block;max-width:100%;height:auto}
337
+
338
+ /* Switch — the full 8-variant matrix from Figma 17:61
339
+ (State Rest/Hover/Pressed/Disabled x Selected No/Yes).
340
+
341
+ The correction that mattered: THE TRACK NEVER FILLS WITH ACCENT. It stays
342
+ surface/bg in every state; selection is carried by the HANDLE turning
343
+ accent and the border stepping up to edge-hi. The previous version filled
344
+ the track orange and made the handle white, which is why it did not look
345
+ like the design.
346
+
347
+ Geometry: track 47x22 r5; handle 18x18 r3 inset 2px, and it STRETCHES to
348
+ 20px while pressed — that stretch is the press feedback, in place of a
349
+ colour change. The state word rides opposite the handle. */
350
+ /* Switch — rebuilt from the Figma component set 17:61 (8 variants:
351
+ State × Selected). The previous build was a 47×22 track with an 18px square
352
+ handle and an ON/OFF text label; none of that is in the component. What the
353
+ component actually specifies:
354
+ hit area 48×28, track 48×22 centred, radius 6, 1px border
355
+ handle 24×20, radius 4, inset 1px, left when off / right when on
356
+ grip a bar ON the handle — 4×6 dark when off, 2×12 accent when on
357
+ Pressed widens the handle to 26px (it squashes toward the far side) and
358
+ nudges the grip 1px with it. There is no text label in the component; state
359
+ reads from colour + handle side + grip. role=switch + aria-checked still
360
+ carry it for assistive tech, and the visible label above the control says
361
+ what it toggles. */
362
+ button.switch{background:none;border:none;box-shadow:none;padding:0;
363
+ width:48px;height:28px;display:inline-flex;align-items:center}
364
+ button.switch:hover{background:none}
365
+ button.switch:active{background:none;border:none;box-shadow:none;transform:none}
366
+
367
+ .sw-track{position:relative;display:block;width:48px;height:22px;border-radius:6px;
368
+ background:var(--bg);border:1px solid var(--edge);
369
+ transition:var(--t-all), box-shadow var(--t)}
370
+ .sw-handle{position:absolute;top:1px;bottom:1px;left:1px;width:24px;border-radius:4px;
371
+ background:var(--raise-hi);
372
+ transition:left var(--t), right var(--t), width var(--t), background-color var(--t), opacity var(--t)}
373
+ .sw-grip{position:absolute;border-radius:1px;background:var(--sunk);
374
+ left:11px;width:4px;top:8px;bottom:8px;
375
+ transition:left var(--t), width var(--t), top var(--t), bottom var(--t), background-color var(--t)}
376
+
377
+ /* --- OFF ------------------------------------------------------------- */
378
+ button.switch:hover .sw-handle{background:var(--float)}
379
+ button.switch:active .sw-handle{width:26px;background:var(--raise-hi)}
380
+ button.switch:active .sw-grip{left:12px}
381
+
382
+ /* --- ON -------------------------------------------------------------- */
383
+ button.switch[aria-checked="true"] .sw-track{background:var(--acc-btn-rest);border-color:var(--acc-stroke)}
384
+ button.switch[aria-checked="true"] .sw-handle{left:auto;right:1px;background:var(--acc-ink)}
385
+ button.switch[aria-checked="true"] .sw-grip{left:32px;width:2px;top:5px;bottom:5px;
386
+ background:var(--acc-btn-rest)}
387
+ button.switch[aria-checked="true"]:hover .sw-track{background:var(--acc);border-color:var(--acc-stroke)}
388
+ button.switch[aria-checked="true"]:hover .sw-grip{background:var(--acc)}
389
+ button.switch[aria-checked="true"]:active .sw-track{background:var(--acc-press);border-color:var(--acc-stroke)}
390
+ button.switch[aria-checked="true"]:active .sw-handle{width:26px;opacity:.8}
391
+ button.switch[aria-checked="true"]:active .sw-grip{left:31px;background:var(--acc-press)}
392
+
393
+ /* --- disabled --------------------------------------------------------
394
+ The component uses opacity here, against the project's usual rule. Kept as
395
+ designed rather than reinvented; it is a control you cannot operate, which
396
+ is the case WCAG 1.4.3 exempts. */
397
+ button.switch[disabled]{cursor:default}
398
+ button.switch[disabled] .sw-track{background:var(--bg);border-color:var(--line)}
399
+ button.switch[disabled] .sw-handle{background:var(--raise-hi);opacity:.4}
400
+ button.switch[disabled][aria-checked="true"] .sw-handle{background:var(--acc-dis);opacity:.4}
401
+ button.switch[disabled][aria-checked="true"] .sw-grip{display:none}
402
+
403
+ button.switch:focus-visible{outline:none}
404
+ button.switch:focus-visible .sw-track{box-shadow:0 0 0 3px var(--acc-soft);border-color:var(--acc)}
405
+
406
+ #outPane{display:none}
407
+ .stage.compare #outPane{display:grid}
408
+ #outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
409
+ #outImg{display:block;margin:auto}
410
+ .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
411
+
412
+ .rail{min-width:0;overflow:auto;padding:0;display:grid;align-content:start;background:var(--card)}
413
+ .rail > div{padding:var(--s4);border-bottom:1px solid var(--line);display:grid;gap:0;align-content:start}
414
+ .sect-top + *{margin-top:var(--s4)}
415
+ .rail > div:last-child{border-bottom:none}
416
+ .rail h4{margin:0;font-size:11px;color:var(--mute);text-transform:uppercase;
417
+ letter-spacing:.06em;font-weight:600}
418
+ .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
419
+
420
+ /* --- rail sections (Figma 7:46) -------------------------------------
421
+ Every section but Device is a switch with a body that only exists when the
422
+ switch is on. The description that used to sit permanently under each
423
+ control moved into a tooltip on the info icon: five paragraphs of
424
+ explanation made the rail a wall of prose you stopped reading after the
425
+ first fit, which is the opposite of what a reference panel is for. */
426
+ .sect-top{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}
427
+ .sect-left{display:flex;align-items:center;gap:var(--s2);min-width:0}
428
+ /* Collapsible body. `display:none` can't be transitioned and neither can
429
+ height:auto, so the height is MEASURED and animated in pixels by
430
+ setSectionOpen().
431
+ The obvious modern answer — transitioning grid-template-rows 1fr -> 0fr —
432
+ was tried and abandoned: WebKit reports support via CSS.supports() and then
433
+ leaves the transition stuck at the start value, so a section would collapse
434
+ once and afterwards just sit there open with data-on="0". Verified it was
435
+ the transition and not the rule: with `transition:none` the same rule
436
+ collapsed instantly. A measured pixel height is duller and always works.
437
+ Max-height is set back to `none` once open, so a body whose content grows
438
+ (a longer caption) is not clipped by a stale measurement.
439
+ The section's own gap is 0 and the spacing lives on the body's margin, or a
440
+ collapsed section leaves a 16px hole where its body used to be. */
441
+ .sect-body{overflow:hidden;max-height:none;
442
+ transition:max-height var(--t-pop), opacity var(--t), margin-top var(--t-pop)}
443
+ .sect-body-in{display:grid;gap:var(--s4);align-content:start}
444
+ .sect[data-on="0"] .sect-body{opacity:0;margin-top:0}
445
+ @media (prefers-reduced-motion:reduce){ .sect-body{transition:opacity 120ms} }
446
+
447
+ /* Info icon + tooltip. A button, not a span: it has to be reachable by
448
+ keyboard, and the tooltip shows on focus as well as hover — a hover-only
449
+ tooltip hides the only copy of the explanation from anyone not using a
450
+ mouse. */
451
+ .info{flex:none;width:16px;height:16px;padding:0;border:none;
452
+ background:none;box-shadow:none;color:var(--edge-hi);cursor:help;line-height:0}
453
+ .info svg{display:block;width:16px;height:16px}
454
+ .info:hover, .info:focus-visible{color:var(--mute)}
455
+ /* The tooltip is anchored to the section HEADER, not to the 16px icon, and
456
+ spans its full width. Anchored to the icon it extended past the rail's left
457
+ edge and .rail{overflow:auto} clipped it — half the sentence was cut off,
458
+ which a screenshot showed and the geometry did not. Spanning the header can
459
+ never overflow the rail, whatever the section or the icon's position. */
460
+ .sect-top{position:relative;z-index:2}
461
+ .sect-body{position:relative;z-index:1}
462
+ /* Tooltips live at BODY level, positioned by JS — not inside their section.
463
+ Nesting them was wrong twice over: .rail{overflow:auto} clipped one anchored
464
+ to the icon, and once .sect-top got a z-index to beat its own slider, that
465
+ z-index became a STACKING CONTEXT which trapped the tooltip inside it. Two
466
+ sections' headers then both sat at z-index 2 and the later one in the DOM
467
+ won, so Compare view's switch painted straight through Floating edge view's
468
+ tooltip. No amount of z-index inside a trapped context can escape it.
469
+ At body level with position:fixed there is no ancestor to be trapped by and
470
+ nothing to be clipped by — the same reason the toasts moved out of .pane. */
471
+ .tip{position:fixed;width:264px;max-width:calc(100vw - 32px);
472
+ padding:8px 10px;border-radius:var(--r-md);
473
+ background:var(--float);border:1px solid var(--edge);
474
+ box-shadow:var(--shadow), var(--hi);
475
+ color:var(--ink);font-size:12px;font-weight:400;line-height:1.45;
476
+ letter-spacing:normal;text-transform:none;text-align:left;white-space:normal;
477
+ z-index:70;opacity:0;visibility:hidden;pointer-events:none;
478
+ transform:translateY(-2px);
479
+ transition:opacity var(--t), transform var(--t), visibility 0s linear var(--t)}
480
+ .tip.on{opacity:1;visibility:visible;transform:none;
481
+ transition:opacity var(--t), transform var(--t), visibility 0s}
482
+
483
+ /* Device chips share the rail equally rather than sizing to their labels —
484
+ four ragged pills read as a list, four equal ones read as a choice. */
485
+ .chips{display:flex;gap:8px;width:100%}
486
+ .chips .chip{flex:1 1 0;min-width:0;padding:0 6px}
487
+ select,input[type=text]{width:100%;font:inherit;height:28px;padding:0 8px;
488
+ border-radius:var(--r-md);border:1px solid var(--edge);background:var(--sunk);color:var(--ink);
489
+ transition:var(--t-all)}
490
+ /* appearance:none is not cosmetic here. A native <select> is drawn by the
491
+ platform, and macOS paints its chevron ABOVE positioned web content — the
492
+ section tooltip has z-index 40 and the native arrow still sat on top of it.
493
+ Drawing our own chevron puts the control back under our own stacking rules,
494
+ and matches the design, which has no native control in it anywhere. */
495
+ select{appearance:none;-webkit-appearance:none;padding-right:26px;
496
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238f939a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
497
+ background-repeat:no-repeat;background-position:right 9px center}
498
+ select:hover,input[type=text]:hover{border-color:var(--edge-hi)}
499
+ select:focus-visible,input[type=text]:focus-visible{outline:2px solid var(--acc);outline-offset:1px}
500
+ /* The slider is the one thing you drag continuously — give it a real track
501
+ and a real thumb rather than the browser default in a box. */
502
+ /* margin:0 is load-bearing. The UA sheet gives a range input `margin:2px`,
503
+ so `width:100%` makes it 4px wider than its box — it overflows, and
504
+ .sect-body{overflow:hidden} (there to clip the collapse animation) cuts
505
+ whatever sticks out. In the Realism section the wrapper is
506
+ justify-items:end, which put the whole overflow on the LEFT, so the thumb
507
+ at minimum was sliced by the section's edge. Reported from Safari, which
508
+ insets the thumb less than Chromium and so cut visibly more of it.
509
+ padding-inline gives the thumb's 1px border and drop shadow somewhere to
510
+ land, since the thumb travels inside the track's content box. */
511
+ input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:20px;
512
+ margin:0;padding-inline:2px;background:transparent;box-sizing:border-box}
513
+ input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:999px;transition:var(--t-all);
514
+ background:linear-gradient(to right, var(--acc) 0 calc(var(--p,0) * 100%), var(--sunk) calc(var(--p,0) * 100%) 100%);
515
+ box-shadow:inset 0 0 0 1px var(--line)}
516
+ input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;
517
+ margin-top:-5px;border-radius:999px;background:var(--raise);border:1px solid var(--edge-hi);
518
+ box-shadow:var(--hi), 0 1px 3px rgba(0,0,0,.4);transition:var(--t-all)}
519
+ input[type=range]:hover::-webkit-slider-thumb{border-color:var(--acc)}
520
+ input[type=range]:focus-visible{outline:none}
521
+ input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--acc);outline-offset:2px}
522
+ /* Captions are text/faint in Figma. The port had quietly used --mute
523
+ instead; now they follow the token as designed. At the brightened
524
+ #8f939ab2 that measures 3.48:1 on card — up from 2.42:1, still under the
525
+ 4.5:1 AA needs for 12px text. a deliberate call, recorded rather than
526
+ silently re-deviated. */
527
+ .hint{color:var(--faint);font-size:12px;margin:8px 0 0;line-height:1.5}
528
+
529
+ /* Figma 8:28: 198px tall — 16 padding, 28 header, 8 gap, 130 strip, 16
530
+ padding. It is a fixed band and must never absorb leftover height. */
531
+ .dock{background:var(--card);padding:var(--s4);display:grid;gap:var(--s2);
532
+ grid-template-rows:28px 130px;flex:none;
533
+ /* The rule is an inset shadow, not a border, so it does not add a
534
+ pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
535
+ box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
536
+ .dock.hidden{display:none}
537
+ /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
538
+ shrink to hug them. */
539
+ .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
540
+ canvas#strip{display:block;width:100%;height:130px;border-radius:var(--r-md);
541
+ background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
542
+ /* Floating mode: the loupe follows the edge instead of taking a permanent band. */
543
+ .floatloupe{position:fixed;z-index:40;display:none;padding:8px;border-radius:var(--r-lg);
544
+ background:var(--float);border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi)}
545
+ .floatloupe.on{display:block}
546
+ .floatloupe canvas{display:block;width:340px;height:104px;border-radius:var(--r-sm);
547
+ background:var(--well);box-shadow:inset 0 0 0 1px var(--line)}
548
+ .floatloupe .cap{font-size:11px;color:var(--mute);padding-top:4px;max-width:340px}
549
+
550
+ /* Toast, with Sonner's stacking behaviour (5 Sep).
551
+ Sonner itself is React-only — sonner@2.0.8 ships a single React build with
552
+ a react/react-dom peer dep, and React 19 no longer publishes UMD, so taking
553
+ it literally meant ~1MB of vendored JS and a React runtime inside a 75KB
554
+ stdlib-served page. What makes Sonner feel like Sonner is the behaviour,
555
+ and that ports cleanly: a COLLAPSED STACK that expands on hover,
556
+ swipe-to-dismiss, and neighbours that glide rather than jump.
557
+
558
+ Layout: the container is zero-height and every toast sits on the same top
559
+ baseline, so a toast's place in the stack is purely a transform — which is
560
+ why re-layout animates for free.
561
+
562
+ TOP CENTRE, fixed to the viewport (5 Sep). It was bottom-left inside the
563
+ canvas pane; centred at the top it is clear of both the 56px top bar and the
564
+ 52px pane headers, so it covers only the canvas — and being viewport-fixed
565
+ it no longer has to dodge the corner bar, which is why the ResizeObserver
566
+ that measured it is gone. */
567
+ .toasts{position:fixed;top:116px;left:50%;transform:translateX(-50%);
568
+ z-index:60;width:min(380px, calc(100vw - 32px));height:0;
569
+ pointer-events:none}
570
+ .toast{position:absolute;left:0;top:0;width:100%;box-sizing:border-box;
571
+ display:flex;align-items:flex-start;gap:8px;padding:10px 12px;
572
+ border-radius:var(--r-md);background:var(--float);
573
+ border:1px solid var(--edge);box-shadow:var(--shadow), var(--hi);
574
+ font-size:12px;line-height:1.45;color:var(--ink);
575
+ pointer-events:auto;touch-action:none;transform-origin:50% 0;
576
+ will-change:transform,opacity;
577
+ overflow:hidden;
578
+ transition:transform 380ms cubic-bezier(.21,1.02,.73,1),
579
+ height 380ms cubic-bezier(.21,1.02,.73,1),
580
+ opacity 260ms cubic-bezier(.21,1.02,.73,1),
581
+ background-color var(--t), border-color var(--t)}
582
+ /* No easing while a finger is down — a drag must track the pointer exactly. */
583
+ .toast[data-dragging="true"]{transition:none;cursor:grabbing}
584
+ .toast[data-gone="true"]{opacity:0}
585
+ @media (prefers-reduced-motion:reduce){ .toast{transition:opacity 120ms} }
586
+
587
+ /* Same three-channel encoding as .status — glyph, weight, luminance — so a
588
+ toast and a status pill never disagree, and meaning survives without hue. */
589
+ .toast .g{flex:none;font-weight:600;line-height:1.45}
590
+ .toast.ok .g{color:var(--ok)} .toast.ok .g::before{content:"\2713"}
591
+ .toast.warn .g{color:var(--warn)} .toast.warn .g::before{content:"!"}
592
+ .toast.err .g{color:var(--err)} .toast.err .g::before{content:"\00d7"}
593
+ .toast.info .g{color:var(--mute)} .toast.info .g::before{content:"\2022"}
594
+ /* Error toast: the CONTAINER stays quiet — stroke close to its fill, the same
595
+ relationship a standard button has — and the message carries the colour,
596
+ rather than a tinted stroke and glyph shouting around neutral text. */
597
+ .toast.err{border-color:var(--edge)}
598
+ .toast.err .g, .toast.err .msg{color:var(--warn)}
599
+ .toast .msg{min-width:0;word-break:break-word}
600
+ .toast .x{flex:none;margin:-4px -4px 0 4px;width:22px;height:22px;padding:0;
601
+ background:none;border:none;box-shadow:none;color:var(--mute);
602
+ font-size:14px;line-height:22px;border-radius:var(--r-sm);opacity:0;
603
+ transition:opacity var(--t), background-color var(--t), color var(--t)}
604
+ /* Close is stack-level furniture: it shows when the stack is open, so a
605
+ resting stack stays clean. Always available to keyboard users. */
606
+ .toasts[data-expanded="true"] .toast .x, .toast .x:focus-visible{opacity:1}
607
+ .toast .x:hover{background:var(--raise);color:var(--ink)}
608
+
609
+ /* Popovers animate both ways. `display` can't be transitioned, so visibility
610
+ carries the hit-testing (it IS transitionable, discretely) while opacity and
611
+ transform carry the motion — no timers, no class-removal races. */
612
+ .scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;
613
+ opacity:0;visibility:hidden;
614
+ transition:opacity var(--t-pop), visibility 0s linear var(--t-pop)}
615
+ .scrim.on{opacity:1;visibility:visible;transition:opacity var(--t-pop), visibility 0s}
616
+ .pop{position:fixed;z-index:51;width:640px;max-width:calc(100vw - 24px);
617
+ max-height:78vh;overflow:auto;background:var(--float);border:1px solid var(--edge);
618
+ border-radius:var(--r-lg);box-shadow:var(--shadow), var(--hi);padding:16px;
619
+ opacity:0;visibility:hidden;transform:translateY(6px) scale(.985);
620
+ transform-origin:50% 0;
621
+ transition:opacity var(--t-pop), transform var(--t-pop),
622
+ visibility 0s linear var(--t-pop)}
623
+ .pop.on{opacity:1;visibility:visible;transform:none;
624
+ transition:opacity var(--t-pop), transform var(--t-pop), visibility 0s}
625
+ @media (prefers-reduced-motion:reduce){
626
+ .pop{transform:none}
627
+ }
628
+ .pop h3{margin:0 0 4px;font-size:14px;font-weight:600}
629
+ .pop .sub{color:var(--mute);font-size:12px;margin:0 0 16px;line-height:1.5}
630
+ .picker{display:grid;grid-template-columns:180px minmax(0,1fr);gap:12px;align-items:start}
631
+ .rail2{display:flex;flex-direction:column;gap:4px;max-height:300px;overflow:auto;padding:2px}
632
+ .th{display:flex;align-items:center;gap:10px;padding:6px;border-radius:var(--r-md);cursor:pointer;
633
+ border:1px solid transparent;background:var(--sunk);
634
+ transition:var(--t-all)}
635
+ .th:hover{border-color:var(--edge-hi);background:var(--raise)}
636
+ .th img{width:40px;height:40px;object-fit:cover;border-radius:6px;flex:none;background:var(--card)}
637
+ .th .cap{font-size:11.5px;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
638
+ .th.sel{border-color:var(--acc);background:var(--acc-soft)}
639
+ /* The preview frame sits INSIDE the popover, so --well (#0a0a0b, the canvas
640
+ tone) read as a hole punched in it. --sunk is the system's recessed surface:
641
+ lighter than the well, still clearly below --float, and it matches the
642
+ thumbnail rail beside it. The stroke moves to --edge so the frame still has
643
+ an edge now that its fill is closer to the surface behind it. */
644
+ .pv{border-radius:var(--r-md);background:var(--sunk);min-height:210px;
645
+ display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 0 1px var(--edge)}
646
+ .pv img{max-width:100%;max-height:300px;object-fit:contain;display:block}
647
+ .drop{border:1px dashed var(--edge);border-radius:var(--r-md);padding:14px;text-align:center;
648
+ color:var(--mute);cursor:pointer;margin-top:12px;font-size:12px;
649
+ transition:var(--t-all)}
650
+ .drop:hover{border-color:var(--edge-hi);color:var(--ink)}
651
+ .drop.over{border-color:var(--acc);color:var(--ink);background:var(--acc-soft)}
652
+ .sm{font-size:12px}
653
+ code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
654
+ border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
655
+ kbd{border:1px solid var(--edge);border-bottom-width:2px;border-radius:var(--r-sm);
656
+ padding:1px 5px;font:11px/1.6 inherit;color:var(--mute);background:var(--sunk)}
657
+ </style>
658
+ </head>
659
+ <body>
660
+ <div class="app">
661
+
662
+ <!-- Top bar (Figma 6:3). Two things moved OUT in the an earlier finding pass: the detection
663
+ status pill went to the Fit pane header, where the thing it describes
664
+ actually is, and Compare became a switch in the rail rather than a chip
665
+ competing with the actions. What is left is identity, inputs, actions. -->
666
+ <header class="topbar">
667
+ <div class="tb-left">
668
+ <span class="brand">Screengraft</span><span id="sess" hidden></span>
669
+ <div class="tb-chips">
670
+ <button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button>
671
+ <span class="tb-arrow">&rarr;</span>
672
+ <button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button>
673
+ </div>
674
+ </div>
675
+ <span class="spacer"></span>
676
+ <div class="tb-actions">
677
+ <button id="preview" hidden>Preview</button>
678
+ <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
679
+ <button id="save" class="accent" disabled>Preview first</button>
680
+ </div>
681
+ </header>
682
+
683
+ <div class="stage" id="stage">
684
+ <section class="pane">
685
+ <!-- Structure is Figma 7:13 exactly: a Left group (gap 24) holding
686
+ Left-1 (gap 16: label, stepper, zoom %) and Left-2 (gap 8: Fit,
687
+ 100%), with the status pill pushed to the far end by
688
+ justify-between. Buttons are Md — the same size as every other
689
+ button on screen, which is the point of the change. -->
690
+ <div class="panehead fit-head">
691
+ <div class="ph-left">
692
+ <div class="ph-left-1">
693
+ <span class="lbl">Fit</span>
694
+ <span class="stepper"><button id="czOut">&minus;</button><button id="czIn">+</button></span>
695
+ <span id="czSt" class="zoomval"></span>
696
+ </div>
697
+ <div class="ph-left-2">
698
+ <button id="czFit">Fit</button>
699
+ <button id="cz100">100%</button>
700
+ </div>
701
+ </div>
702
+ <span class="status" id="detSt"></span>
703
+ </div>
704
+ <div class="scroller" id="scroller">
705
+ <canvas id="c"></canvas>
706
+ </div>
707
+ <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
708
+ positioned inside a scroll container scrolls with the content, so
709
+ when it lived over the canvas it slid out of view the moment you
710
+ zoomed in. Anchored to the pane it stays put while the picture moves
711
+ underneath, which is what a floating control should do. -->
712
+ <div class="cvbar" id="cvbar">
713
+ <span class="sm" style="color:var(--mute)">Select corner</span>
714
+ <span class="cvbar-actions">
715
+ <span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
716
+ <button class="sm" id="redetect">Re-detect</button>
717
+ </span>
718
+ </div>
719
+ </section>
720
+
721
+ <section class="pane" id="outPane">
722
+ <div class="panehead">
723
+ <span class="lbl">Result</span>
724
+ <span class="gap-3"></span>
725
+ <span class="sm" id="outSt" style="color:var(--mute)">Press Preview</span>
726
+ <span class="spacer"></span>
727
+ <span class="sm" style="color:var(--mute)">shares zoom &amp; pan</span>
728
+ </div>
729
+ <div id="outWrap"><span class="empty" id="outEmpty">Press Preview — or turn on Compare view and this updates as you drag.</span><img id="outImg" alt="" hidden></div>
730
+ </section>
731
+
732
+ <aside class="rail">
733
+ <div class="sect" id="secDevice" data-on="1">
734
+ <div class="sect-top"><div class="sect-left"><h4>Device</h4></div></div>
735
+ <div class="chips" id="types"></div>
736
+ </div>
737
+ <div class="sect" id="secRadius" data-on="1">
738
+ <div class="sect-top">
739
+ <div class="sect-left">
740
+ <h4>Corner radius</h4>
741
+ <button class="info" type="button" aria-label="About corner radius"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Rounds the injected screen so it follows the device's own bezel. Measured from the photo when it can be, otherwise from a device preset. Off gives square corners.</span></button>
742
+ </div>
743
+ <button class="switch" id="radBtn" role="switch" aria-checked="true" aria-label="Corner radius">
744
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
745
+ </button>
746
+ </div>
747
+ <div class="sect-body"><div class="sect-body-in">
748
+ <select id="preset" aria-label="Corner radius device preset"></select>
749
+ <input type="range" id="radius" min="0" max="0.25" step="0.002" value="0" aria-label="Corner radius, percent of screen width">
750
+ <p class="hint" id="radSt"></p>
751
+ </div></div>
752
+ </div>
753
+ <div class="sect" id="secRealism" data-on="1">
754
+ <div class="sect-top">
755
+ <div class="sect-left">
756
+ <h4>Realism</h4>
757
+ <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing.</span></button>
758
+ </div>
759
+ <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
760
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
761
+ </button>
762
+ </div>
763
+ <div class="sect-body"><div class="sect-body-in">
764
+ <div style="display:grid;gap:var(--s2);justify-items:end">
765
+ <span class="sm" id="gradeVal" style="font-variant-numeric:tabular-nums"></span>
766
+ <input type="range" id="gradeAmt" min="0.1" max="1" step="0.05" value="0.35" aria-label="Realism strength">
767
+ </div>
768
+ </div></div>
769
+ </div>
770
+ <div class="sect" id="secEdge" data-on="1">
771
+ <div class="sect-top">
772
+ <div class="sect-left">
773
+ <h4>Floating edge view</h4>
774
+ <button class="info" type="button" aria-label="About floating edge view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">The magnified strip follows the edge you're dragging, so your eye doesn't travel. Off docks it under the canvas in a fixed band.</span></button>
775
+ </div>
776
+ <button class="switch" id="edgeBtn" role="switch" aria-checked="true" aria-label="Floating edge view">
777
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
778
+ </button>
779
+ </div>
780
+ </div>
781
+ <div class="sect" id="secCompare" data-on="1">
782
+ <div class="sect-top">
783
+ <div class="sect-left">
784
+ <h4>Compare view</h4>
785
+ <button class="info" type="button" aria-label="About compare view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Shows the fit and the composite side by side, sharing zoom and pan, so a corner can be judged against the same corner.</span></button>
786
+ </div>
787
+ <button class="switch" id="cmpBtn" role="switch" aria-checked="true" aria-label="Compare view">
788
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
789
+ </button>
790
+ </div>
791
+ </div>
792
+ <div class="sect" id="secKeys" data-on="1">
793
+ <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
794
+ <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
795
+ </div>
796
+ </aside>
797
+ </div>
798
+
799
+ <footer class="dock" id="dock">
800
+ <div class="striphead">
801
+ <span class="lbl">Edge view</span>
802
+ <span class="stepper dock-step"><button class="sm" id="stripOut">&minus;</button><button class="sm" id="stripIn">+</button></span>
803
+ <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
804
+ <span class="spacer"></span>
805
+ <span class="sm" id="stripSt" style="color:var(--mute)"></span>
806
+ </div>
807
+ <canvas id="strip" width="1280" height="130"></canvas>
808
+ </footer>
809
+ </div>
810
+
811
+ <div class="floatloupe" id="floatLoupe">
812
+ <canvas id="stripF" width="680" height="208"></canvas>
813
+ <div class="cap" id="stripFCap"></div>
814
+ </div>
815
+
816
+ <!-- Toasts. Viewport-fixed at top centre, so they live at body level: parented
817
+ inside .pane they still positioned correctly, but any future transform or
818
+ filter on an ancestor would silently turn position:fixed into
819
+ position:absolute and move them without warning.
820
+ aria-live=polite announces confirmations without interrupting; individual
821
+ error toasts set role="alert" for themselves. -->
822
+ <div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div>
823
+
824
+ <div class="scrim" id="scrim"></div>
825
+
826
+
827
+ <div class="pop" id="previewPop" style="width:min(900px,calc(100vw - 24px))">
828
+ <h3>Preview</h3>
829
+ <p class="sub" id="previewSt">Rendering…</p>
830
+ <div id="previewWrap"><img id="previewImg" alt="" hidden></div>
831
+ </div>
832
+
833
+ <div class="pop" id="pop1">
834
+ <h3>Photo of the device</h3>
835
+ <p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
836
+ <div class="picker">
837
+ <div class="rail2" id="recent1"></div>
838
+ <div class="pv" id="pv1"><span class="empty">Select a recent image</span></div>
839
+ </div>
840
+ <div class="drop" id="drop1">Drop a photo here or click to browse</div>
841
+ <input type="file" id="file1" accept="image/*" hidden>
842
+ </div>
843
+
844
+ <div class="pop" id="pop2">
845
+ <h3>Screenshot to put on the screen</h3>
846
+ <p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will export it for you.</p>
847
+ <div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">Export from Figma</button></div>
848
+ <div class="status sm" id="figmaSt" style="margin-bottom:10px"></div>
849
+ <div class="hintline" id="figmaHint" hidden style="margin-bottom:10px"></div>
850
+ <div class="picker">
851
+ <div class="rail2" id="recent2"></div>
852
+ <div class="pv" id="pv2"><span class="empty">Select a recent image</span></div>
853
+ </div>
854
+ <div class="drop" id="drop2">Drop a screenshot here or click to browse</div>
855
+ <input type="file" id="file2" accept="image/*" hidden>
856
+ </div>
857
+
858
+ <script>
859
+ const $ = s => document.querySelector(s);
860
+ let previewHinted = false; // the preview nudge is orientation, shown once
861
+
862
+
863
+ /* Tooltips. Each .info keeps its own <span class="tip"> in the markup so the
864
+ text stays associated with the control for assistive tech, but at boot every
865
+ tip is MOVED to <body> and shown by JS. Positioned in place it was clipped by
866
+ the rail's overflow, and once its section needed a z-index it was trapped in
867
+ that section's stacking context and painted under the next section's switch.
868
+ A body-level fixed element has neither problem.
869
+ Shown on hover AND focus: :focus-visible is a heuristic that misses, and this
870
+ is the only copy of each explanation. */
871
+ (() => {
872
+ const place = (btn, tip) => {
873
+ const r = btn.getBoundingClientRect();
874
+ const w = tip.offsetWidth, gap = 8;
875
+ // Prefer left-aligned to the icon; pull back inside the viewport if needed.
876
+ let left = Math.min(r.left, innerWidth - w - 16);
877
+ left = Math.max(16, left);
878
+ let top = r.bottom + gap;
879
+ if (top + tip.offsetHeight > innerHeight - 16) top = Math.max(16, r.top - gap - tip.offsetHeight);
880
+ tip.style.left = Math.round(left) + 'px';
881
+ tip.style.top = Math.round(top) + 'px';
882
+ };
883
+ document.querySelectorAll('.info').forEach((btn, i) => {
884
+ const tip = btn.querySelector('.tip');
885
+ if (!tip) return;
886
+ tip.id = tip.id || ('tip-' + i);
887
+ btn.setAttribute('aria-describedby', tip.id);
888
+ document.body.appendChild(tip); // out of every stacking context
889
+ const show = () => { tip.classList.add('on'); place(btn, tip); };
890
+ const hide = () => tip.classList.remove('on');
891
+ btn.addEventListener('pointerenter', show);
892
+ btn.addEventListener('pointerleave', hide);
893
+ btn.addEventListener('focus', show);
894
+ btn.addEventListener('blur', hide);
895
+ });
896
+ addEventListener('scroll', () => document.querySelectorAll('.tip.on').forEach(t => t.classList.remove('on')), true);
897
+ })();
898
+
899
+
900
+ /* ===== toasts (Sonner-style behaviour) ====================================
901
+ One sink for every transient outcome: saved, sent to Claude, exported from
902
+ Figma, and the failures of each. Before this they lived in #actSt, which the
903
+ v0.8.2 layout left permanently hidden.
904
+
905
+ Sonner is React-only (measured: sonner@2.0.8 has react/react-dom peer deps
906
+ and one React build; React 19 dropped UMD), so its BEHAVIOUR is ported rather
907
+ than the package: toasts collapse into a stack, hovering expands them, a left
908
+ swipe dismisses, and removing one glides the rest into place.
909
+
910
+ Rules that make it a tool's toast rather than a website's:
911
+ - successes and info clear themselves; ERRORS DO NOT. A failure you missed
912
+ because it timed out is exactly the bug this class is made of.
913
+ - hovering the stack pauses every countdown in it, so a long path in a Save
914
+ confirmation can actually be read.
915
+ - glyph + weight + colour, matching .status, so colour is never load-bearing. */
916
+ const TOASTS = { max: 3, ok: 4200, info: 6000, warn: 8000 }; // err: sticky
917
+ const T_STACK = { peek: 14, scale: .06, gap: 8, swipe: 60 };
918
+ let toastItems = []; // newest first
919
+ let toastHover = 0;
920
+
921
+ function layoutToasts(expanded) {
922
+ const wrap = $('#toasts');
923
+ if (!wrap) return;
924
+ wrap.dataset.expanded = expanded ? 'true' : 'false';
925
+
926
+ // Measure natural heights first, with any imposed height released. Messages
927
+ // differ in length, so the cards differ in height — and on a shared bottom
928
+ // baseline that made the ones behind poke out above the front one at random.
929
+ // Sonner's answer, and this one: while collapsed every card takes the FRONT
930
+ // card's height, so the stack reads as one object. Heights are always set in
931
+ // px (never auto) so the change can be transitioned.
932
+ toastItems.forEach(it => { it.el.style.height = 'auto'; });
933
+ toastItems.forEach(it => { it.h = it.el.offsetHeight; });
934
+ const front = toastItems.length ? toastItems[0].h : 0;
935
+
936
+ let stacked = 0;
937
+ toastItems.forEach((it, i) => {
938
+ // Anchored at the top, so the stack grows DOWNWARD: older toasts sit below
939
+ // and behind the newest one.
940
+ const y = expanded ? stacked : i * T_STACK.peek;
941
+ const sc = expanded ? 1 : Math.max(0, 1 - i * T_STACK.scale);
942
+ it.el.style.height = (expanded ? it.h : front) + 'px';
943
+ it.el.style.transform =
944
+ `translate3d(${it.dx || 0}px, ${y + (it.dy || 0)}px, 0) scale(${sc})`;
945
+ it.el.style.opacity = i >= TOASTS.max ? '0' : '1';
946
+ it.el.style.zIndex = String(100 - i);
947
+ stacked += it.h + T_STACK.gap;
948
+ });
949
+ }
950
+
951
+ function toast(kind, html, opts = {}) {
952
+ const wrap = $('#toasts');
953
+ if (!wrap) return null;
954
+
955
+ const el = document.createElement('div');
956
+ el.className = `toast ${kind}`;
957
+ if (kind === 'err') el.setAttribute('role', 'alert');
958
+ el.innerHTML = `<span class="g" aria-hidden="true"></span><div class="msg"></div>`;
959
+ el.querySelector('.msg').innerHTML = html;
960
+
961
+ const life = opts.ms !== undefined ? opts.ms : TOASTS[kind];
962
+ const item = { el, gone: false, dx: 0, dy: 0, timer: null };
963
+
964
+ item.disarm = () => clearTimeout(item.timer);
965
+ item.arm = () => { if (life && !item.gone) { clearTimeout(item.timer); item.timer = setTimeout(item.close, life); } };
966
+ item.close = () => {
967
+ if (item.gone) return;
968
+ item.gone = true;
969
+ clearTimeout(item.timer);
970
+ el.dataset.gone = 'true';
971
+ el.style.pointerEvents = 'none';
972
+ toastItems = toastItems.filter(t => t !== item);
973
+ layoutToasts(toastHover > 0); // survivors glide into place
974
+ setTimeout(() => el.remove(), 420);
975
+ };
976
+
977
+ const x = document.createElement('button');
978
+ x.className = 'x'; x.type = 'button'; x.setAttribute('aria-label', 'Dismiss');
979
+ x.textContent = '×'; x.onclick = item.close;
980
+ el.appendChild(x);
981
+
982
+ /* Swipe to dismiss. Centred at the top, the nearest edges are up and to
983
+ either side, so all three dismiss; dragging DOWN rubber-bands, because
984
+ there is nowhere down for it to go. */
985
+ let startX = 0, startY = 0, dragging = false;
986
+ el.addEventListener('pointerdown', e => {
987
+ if (e.target.closest('.x')) return;
988
+ dragging = true; startX = e.clientX; startY = e.clientY;
989
+ el.dataset.dragging = 'true';
990
+ try { el.setPointerCapture(e.pointerId); } catch (err) {}
991
+ });
992
+ el.addEventListener('pointermove', e => {
993
+ if (!dragging) return;
994
+ const rawY = e.clientY - startY;
995
+ item.dx = e.clientX - startX;
996
+ item.dy = rawY < 0 ? rawY : rawY * .25;
997
+ layoutToasts(toastHover > 0);
998
+ });
999
+ const endDrag = () => {
1000
+ if (!dragging) return;
1001
+ dragging = false;
1002
+ delete el.dataset.dragging;
1003
+ if (item.dy < -T_STACK.swipe || Math.abs(item.dx) > T_STACK.swipe) {
1004
+ if (item.dy < -T_STACK.swipe) item.dy = -200;
1005
+ else item.dx = item.dx > 0 ? 440 : -440;
1006
+ layoutToasts(toastHover > 0); item.close();
1007
+ } else { item.dx = 0; item.dy = 0; layoutToasts(toastHover > 0); }
1008
+ };
1009
+ el.addEventListener('pointerup', endDrag);
1010
+ el.addEventListener('pointercancel', endDrag);
1011
+
1012
+ // Hover expands the stack and pauses every countdown in it.
1013
+ el.addEventListener('pointerenter', () => {
1014
+ toastHover++; toastItems.forEach(t => t.disarm()); layoutToasts(true);
1015
+ });
1016
+ el.addEventListener('pointerleave', () => {
1017
+ toastHover = Math.max(0, toastHover - 1);
1018
+ if (!toastHover) { toastItems.forEach(t => t.arm()); layoutToasts(false); }
1019
+ });
1020
+
1021
+ wrap.appendChild(el);
1022
+ toastItems.unshift(item);
1023
+ // Start just above the baseline and let the first layout carry it down.
1024
+ el.style.transform = 'translate3d(0,-14px,0) scale(.96)';
1025
+ el.style.opacity = '0';
1026
+ requestAnimationFrame(() => layoutToasts(toastHover > 0));
1027
+
1028
+ // Trim past the visible depth, oldest first.
1029
+ while (toastItems.length > TOASTS.max + 1) toastItems[toastItems.length - 1].close();
1030
+
1031
+ item.arm();
1032
+ return item;
1033
+ }
1034
+
1035
+ const api = async (p, body, raw) => {
1036
+ const r = await fetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
1037
+ const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
1038
+ };
1039
+ const fileURL = p => '/file?path=' + encodeURIComponent(p);
1040
+ const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null, presets:[]};
1041
+ const remember = (k,v) => { try{ localStorage.setItem('sg.'+k, v); }catch(e){} };
1042
+ const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v===null?d:v; }catch(e){ return d; } };
1043
+
1044
+ /* ===== overlay drawing =====================================================
1045
+ A flat stroke colour cannot survive an arbitrary photograph. Swept against
1046
+ greys 0-255, the old iOS red bottomed out at 1.00:1 on mid-grey and the
1047
+ yellow at 1.02:1 on a bright wall — invisible on exactly the material this
1048
+ tool is for. So every overlay strokes TWICE: a dark casing, then a light
1049
+ core. Whatever is underneath, one of the two contrasts. State is carried by
1050
+ WEIGHT (thicker = active), not by hue alone. */
1051
+ /* Figma 33:856 draws the whole quad in the accent, so the accent is the core
1052
+ everywhere and STATE IS CARRIED BY WEIGHT, not hue — which is what v0.6.0
1053
+ concluded anyway. CORE_IDLE stays for the rectified strip's own rule. */
1054
+ /* Overlay cores, from Figma 33:856 (5 Sep). That frame carries TWO accent
1055
+ tokens, and the overlay has two jobs, so they map one to one:
1056
+ accent/acc #f23b0d the quad you are placing — edges and handles
1057
+ accent/acc_light #f5623d the secondary aids — dashed guide, corner dots,
1058
+ the strip's own rule
1059
+ The dark casing underneath is NOT optional and stays: flat accent measures
1060
+ ~1.06:1 against mid-tone photography, which is the failure the casing was
1061
+ introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
1062
+ const CASE_A = 'rgba(0,0,0,.62)', CORE_IDLE = 'rgba(255,255,255,.92)';
1063
+ const CORE_ACTIVE = '#f5623d';
1064
+ const CORE_QUAD = '#f23b0d';
1065
+ function cased(c, path, coreW, coreColor, dash){
1066
+ c.save();
1067
+ if (dash) c.setLineDash(dash);
1068
+ c.lineCap = 'round'; c.lineJoin = 'round';
1069
+ c.strokeStyle = CASE_A; c.lineWidth = coreW + 2.5; path(); c.stroke();
1070
+ c.strokeStyle = coreColor; c.lineWidth = coreW; path(); c.stroke();
1071
+ c.restore();
1072
+ }
1073
+ /* A FILLED handle, still cased. cased() strokes twice; a disc has to fill the
1074
+ core and put the dark casing around its rim, or the handle loses the
1075
+ readability guarantee that makes markers survive an arbitrary photograph. */
1076
+ function casedDisc(c, x, y, r, coreColor){
1077
+ c.save();
1078
+ c.strokeStyle = CASE_A; c.lineWidth = 2.5;
1079
+ c.beginPath(); c.arc(x, y, r + 0.75, 0, Math.PI*2); c.stroke();
1080
+ c.fillStyle = coreColor;
1081
+ c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.fill();
1082
+ c.restore();
1083
+ }
1084
+ function casedText(c, text, x, y, color){
1085
+ c.save();
1086
+ c.lineWidth = 3; c.strokeStyle = 'rgba(0,0,0,.75)'; c.lineJoin = 'round';
1087
+ c.strokeText(text, x, y);
1088
+ c.fillStyle = color; c.fillText(text, x, y);
1089
+ c.restore();
1090
+ }
1091
+
1092
+ /* ===== pickers (popovers over the canvas) ===== */
1093
+ async function loadRecent(){
1094
+ const {items} = await api('/api/recent?days=14&limit=40');
1095
+ for (const id of ['recent1','recent2']){
1096
+ const g = $('#'+id); g.innerHTML='';
1097
+ for (const it of items){
1098
+ const d = document.createElement('div'); d.className='th'; d.title = it.path;
1099
+ d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap">${it.name}</div>`;
1100
+ d.onclick = () => choose(id==='recent1'?'photo':'screenshot', it.path, d);
1101
+ g.appendChild(d);
1102
+ }
1103
+ if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute)">No recent images on Desktop/Downloads (last 14 days).</span>';
1104
+ }
1105
+ }
1106
+ async function choose(role, path, el){
1107
+ try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el); }
1108
+ catch(e){ alert(e.message); }
1109
+ }
1110
+ function setChosen(role, path, size, el){
1111
+ const n = role==='photo'?1:2;
1112
+ document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1113
+ if (el) el.classList.add('sel');
1114
+ $('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
1115
+ const chip = $('#chip'+n);
1116
+ chip.classList.remove('is-empty');
1117
+ chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`;
1118
+ $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
1119
+ if (role==='photo'){ st.photo = path; st.corners = null; } else st.shot = path;
1120
+ closePop();
1121
+ maybeStart();
1122
+ }
1123
+ async function upload(role, file){
1124
+ const buf = await file.arrayBuffer();
1125
+ const r = await api('/api/upload', null, {headers:{'X-Filename':encodeURIComponent(file.name),'X-Role':role}, body:buf});
1126
+ setChosen(role, r.path, r.size, null);
1127
+ }
1128
+ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
1129
+ const drop = $('#drop'+n), file = $('#file'+n);
1130
+ drop.onclick = () => file.click();
1131
+ file.onchange = () => file.files[0] && upload(role, file.files[0]);
1132
+ drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
1133
+ drop.ondragleave = () => drop.classList.remove('over');
1134
+ drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) upload(role, f); };
1135
+ $('#chip'+n).onclick = () => openPop(n);
1136
+ }
1137
+ /* One popover mechanic for every popover: anchor it under whatever opened it,
1138
+ clamped into the viewport. The pickers and the preview both use it. */
1139
+ function openPopAt(pop, anchor){
1140
+ closePop();
1141
+ pop.classList.add('on'); $('#scrim').classList.add('on');
1142
+ const w = pop.offsetWidth;
1143
+ pop.style.left = Math.max(12, Math.min(window.innerWidth - w - 12, anchor.left)) + 'px';
1144
+ pop.style.top = (anchor.bottom + 8) + 'px';
1145
+ }
1146
+ function openPop(n){ openPopAt($('#pop'+n), $('#chip'+n).getBoundingClientRect()); }
1147
+ function closePop(){
1148
+ document.querySelectorAll('.pop').forEach(p=>p.classList.remove('on'));
1149
+ $('#scrim').classList.remove('on');
1150
+ }
1151
+ $('#scrim').onclick = closePop;
1152
+
1153
+ /* The Figma export is the one step the page cannot do itself — it has no
1154
+ Figma credentials, the agent does. So this enqueues a request and waits.
1155
+
1156
+ Two things this used to get wrong. It claimed "(it watches this job)",
1157
+ which was false: the agent runs in turns and was not watching anything, so
1158
+ the user waited on something that would never happen unless they typed in
1159
+ chat. And it polled forever with no escape, so there was no point at which
1160
+ the page admitted it was stuck. Both are fixed here: the agent now parks in
1161
+ the MCP server's wait_for_job and is reached within ~150ms, and if that is
1162
+ somehow not running we say so and offer the manual route rather than
1163
+ spinning indefinitely. */
1164
+ const NUDGE_AFTER = 12; // seconds before we stop assuming and start explaining
1165
+
1166
+ /* Wait for the agent to answer the current job.
1167
+
1168
+ LONG POLL, deliberately not setInterval. Chrome throttles timers to about
1169
+ once a minute in a hidden tab, and this page is hidden for exactly the
1170
+ workflow it serves: paste a Figma link, switch to Figma. Measured 4 Sep
1171
+ 2026 — the agent answered in ~200ms and the page kept spinning, because its
1172
+ poll had been throttled. A pending fetch is not throttled, so the answer
1173
+ arrives when it exists. /api/job/wait blocks server-side and returns early
1174
+ the moment the status changes. */
1175
+ async function awaitJob({onDone, onError, cancelled}) {
1176
+ while (!cancelled()) {
1177
+ let j;
1178
+ try {
1179
+ j = await api('/api/job/wait?timeout=25');
1180
+ } catch (e) {
1181
+ // Server restarted or a transient blip: pause, then keep waiting rather
1182
+ // than declaring failure at the user.
1183
+ await new Promise(r => setTimeout(r, 1200));
1184
+ continue;
1185
+ }
1186
+ if (cancelled()) return;
1187
+ if (j.status === 'done') return onDone(j);
1188
+ if (j.status === 'error') return onError(j);
1189
+ if (j.status === 'none') {
1190
+ // The job file is gone — a wiped session, or a restarted server. The
1191
+ // server answers 'none' instantly, so looping on it would spin as fast
1192
+ // as the network allows. Stop and say so instead of hammering.
1193
+ return onError({message: 'the request disappeared (was the UI restarted?). Try again.'});
1194
+ }
1195
+ // 'pending' with waited:true — the long poll timed out server-side; go again.
1196
+ }
1197
+ }
1198
+ $('#figmaBtn').onclick = async () => {
1199
+ const url = $('#figma').value.trim(); if (!url) return;
1200
+ const s = $('#figmaSt'), hint = $('#figmaHint');
1201
+ const btn = $('#figmaBtn'); btn.disabled = true;
1202
+ const t0 = Date.now();
1203
+ let finished = false;
1204
+ const paint = () => {
1205
+ const el = Math.round((Date.now() - t0) / 1000);
1206
+ s.className = 'status sm busy';
1207
+ s.innerHTML = `Exporting from Figma <span class="el">${el}s</span>`;
1208
+ if (el >= NUDGE_AFTER && hint.hidden) {
1209
+ hint.hidden = false;
1210
+ hint.innerHTML = 'Taking longer than usual. Claude picks this up automatically while it\'s ' +
1211
+ 'waiting on the job — if it\'s busy in another task it will arrive when that ' +
1212
+ 'finishes. <b>Or export the frame as PNG yourself and drop it above</b> — ' +
1213
+ 'that always works and needs nobody.';
1214
+ }
1215
+ };
1216
+ paint();
1217
+ const tick = setInterval(paint, 1000);
1218
+ // The elapsed counter is a timer and so is throttled while hidden; repaint on
1219
+ // return so the number is right the moment the designer looks at it again.
1220
+ document.addEventListener('visibilitychange', paint);
1221
+ const stop = (cls, text) => {
1222
+ finished = true;
1223
+ clearInterval(tick);
1224
+ document.removeEventListener('visibilitychange', paint);
1225
+ btn.disabled = false; hint.hidden = true;
1226
+ s.className = 'status sm ' + cls; s.textContent = text;
1227
+ // The popover is usually closed by now — the in-place line would go unread.
1228
+ toast(cls === 'ok' ? 'ok' : 'err', cls === 'ok' ? 'Screenshot exported from Figma.' : text);
1229
+ };
1230
+ try { await api('/api/figma', {url}); }
1231
+ catch (e) { return stop('err', 'Could not queue the export: ' + e.message); }
1232
+ await awaitJob({
1233
+ cancelled: () => finished,
1234
+ onDone: async () => {
1235
+ try {
1236
+ const r = await api('/api/job/adopt', {});
1237
+ stop('ok', 'Exported');
1238
+ setChosen('screenshot', r.path, r.size, null);
1239
+ } catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
1240
+ },
1241
+ onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
1242
+ });
1243
+ };
1244
+
1245
+ /* ===== fitting the quad ====================================================
1246
+ EDGES are the grab targets. A rounded screen corner has no point to aim at;
1247
+ the two straight edges either side of it are unambiguous. Dragging edge i
1248
+ moves only its LINE — corners i and i+1 are recomputed as intersections
1249
+ with the two UNCHANGED adjacent lines. Corner handles stay for edges that
1250
+ run off-frame or sit behind an occluder. */
1251
+ const cv = $('#c'), ctx = cv.getContext('2d');
1252
+ const strip = $('#strip'), sctx = strip.getContext('2d');
1253
+ const stripF = $('#stripF'), fctx = stripF.getContext('2d');
1254
+ const img = new Image();
1255
+ let scale = 1, fitScale = 1, drag = null, hover = null, pick = null;
1256
+ let stripHalf = 14, loupeMode = recall('loupe','dock'), lastPointer = [0,0];
1257
+ const LBL = ['TL','TR','BR','BL'], EDGE_LBL = ['top','right','bottom','left'];
1258
+
1259
+ const sub=(a,b)=>[a[0]-b[0],a[1]-b[1]], add=(a,b)=>[a[0]+b[0],a[1]+b[1]];
1260
+ const mul=(a,k)=>[a[0]*k,a[1]*k], vlen=a=>Math.hypot(a[0],a[1]);
1261
+ const unit=a=>{const l=vlen(a)||1;return [a[0]/l,a[1]/l];};
1262
+ function interLines(p,d,q,e){
1263
+ const den = d[0]*e[1]-d[1]*e[0];
1264
+ if (Math.abs(den) < 1e-9) return null;
1265
+ const t = ((q[0]-p[0])*e[1]-(q[1]-p[1])*e[0])/den;
1266
+ return [p[0]+d[0]*t, p[1]+d[1]*t];
1267
+ }
1268
+ function distToSeg(p,a,b){
1269
+ const ab=sub(b,a), L2=ab[0]*ab[0]+ab[1]*ab[1];
1270
+ let t = L2 ? ((p[0]-a[0])*ab[0]+(p[1]-a[1])*ab[1])/L2 : 0;
1271
+ t = Math.max(0, Math.min(1, t));
1272
+ return {d: vlen(sub(p, add(a, mul(ab,t)))), t};
1273
+ }
1274
+
1275
+ async function maybeStart(){
1276
+ if (!st.photo) return;
1277
+ const ready = !!st.shot; // the screenshot gates the fit, not the photo
1278
+ img.onload = () => {
1279
+ computeFit(); setCanvasZoom(fitScale);
1280
+ if (!ready){
1281
+ // Photo only: show it immediately so choosing one visibly does
1282
+ // something. No quad yet — there is nothing to fit into it.
1283
+ draw();
1284
+ $('#cvbar').hidden = true;
1285
+ $('#detSt').className = 'status';
1286
+ $('#detSt').textContent = 'Now choose the screenshot to place on the screen.';
1287
+ return;
1288
+ }
1289
+ $('#cvbar').hidden = false;
1290
+ if (!st.corners) detect(); else { draw(); drawStrip(); }
1291
+ };
1292
+ img.src = fileURL(st.photo);
1293
+ }
1294
+ function computeFit(){
1295
+ const box = $('#scroller');
1296
+ const w = box.clientWidth || 800, h = box.clientHeight || 600;
1297
+ fitScale = Math.min(1, Math.min(w / img.naturalWidth, h / img.naturalHeight));
1298
+ }
1299
+ function setCanvasZoom(z, focus){
1300
+ const prev = scale;
1301
+ scale = Math.max(0.05, Math.min(4, z));
1302
+ cv.width = Math.round(img.naturalWidth * scale);
1303
+ cv.height = Math.round(img.naturalHeight * scale);
1304
+ $('#czSt').textContent = Math.round(scale*100) + '%';
1305
+ draw();
1306
+ const sc = $('#scroller');
1307
+ if (focus){
1308
+ sc.scrollLeft = focus[0]*scale - sc.clientWidth/2;
1309
+ sc.scrollTop = focus[1]*scale - sc.clientHeight/2;
1310
+ } else if (prev && prev !== scale){
1311
+ const cx = (sc.scrollLeft + sc.clientWidth/2)/prev, cy = (sc.scrollTop + sc.clientHeight/2)/prev;
1312
+ sc.scrollLeft = cx*scale - sc.clientWidth/2;
1313
+ sc.scrollTop = cy*scale - sc.clientHeight/2;
1314
+ }
1315
+ syncOut();
1316
+ }
1317
+ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
1318
+ $('#cz100').onclick = () => setCanvasZoom(1);
1319
+ $('#czIn').onclick = () => setCanvasZoom(scale*1.5);
1320
+ $('#czOut').onclick = () => setCanvasZoom(scale/1.5);
1321
+ document.querySelectorAll('[data-jump]').forEach(b => {
1322
+ b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
1323
+ pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
1324
+ });
1325
+
1326
+ async function detect(){
1327
+ const s = $('#detSt'); s.className='status busy'; s.textContent = 'Detecting…';
1328
+ const r = await api('/api/detect', {});
1329
+ if (r.found){
1330
+ st.corners = r.corners; st.measured = r.corner_radius;
1331
+ const corroborated = r.confidence === 'corroborated';
1332
+ s.className = 'status ' + (corroborated ? 'ok' : 'warn');
1333
+ s.textContent = corroborated ? `Both detectors agree (${r.method})`
1334
+ : `Unconfirmed ${r.method} guess — check all four edges`;
1335
+ if (!st.type) setType(r.type_guess || 'phone');
1336
+ if (r.corner_radius && r.corner_radius.confident){ setFrac(r.corner_radius.frac_of_width, 'measured from the photo'); }
1337
+ else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
1338
+ } else {
1339
+ const w = img.naturalWidth, h = img.naturalHeight;
1340
+ st.corners = [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
1341
+ s.className='status warn'; s.textContent = 'No screen found — place the edges by hand';
1342
+ if (!st.type) setType('phone'); applyPreset();
1343
+ }
1344
+ pick = {kind:'edge', i:0};
1345
+ draw(); drawStrip();
1346
+ autoPreview(); // if the compare pane is open, fill it straight away
1347
+ }
1348
+ $('#redetect').onclick = detect;
1349
+
1350
+ function draw(){
1351
+ ctx.clearRect(0,0,cv.width,cv.height);
1352
+ if (!img.naturalWidth) return;
1353
+ ctx.drawImage(img, 0,0, cv.width, cv.height);
1354
+ if (!st.corners) return;
1355
+ const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
1356
+ const active = drag || hover || pick;
1357
+
1358
+ /* Overlay geometry ported from Figma 33:856 — 1px edge, 9px hollow ring
1359
+ handles, 0.8px dashed guide, and the edge BREAKING 13px around each
1360
+ handle so the ring reads as a grab target rather than a bead on a wire.
1361
+
1362
+ The colour is the one deliberate deviation. The frame draws every element
1363
+ flat #EC4C13 over one photo that suits it; measured against real material
1364
+ the accent alone is 1.06:1 on mid grey, 1.07:1 on terracotta and 1.35:1 on
1365
+ the wood table — invisible on exactly the photos this tool is for, which
1366
+ is the same trap v0.6.0 found with the old yellow at 1.02:1. So the accent
1367
+ is the CORE and the dark casing stays underneath: the casing carries light
1368
+ backgrounds (6.19:1 on white), the accent carries dark ones (5.29:1 on
1369
+ black), and one of the two always separates. Deliberate. */
1370
+ // Handle sizing, 5 Sep. Corners went 4.5 -> 5.5 and are now FILLED rather than
1371
+ // hollow; the pivot pips went 2.5 -> 3.5 and are filled too, staying visibly
1372
+ // smaller than a corner so the two never read as the same control. The gap the
1373
+ // edge leaves for each corner grows with it, or the disc sits on the line.
1374
+ const HANDLE_R = 5.5, PIVOT_R = 3.5, HANDLE_GAP = 7.5;
1375
+
1376
+ // Guide: the active edge extended to infinity, so you can see the line it
1377
+ // is really on. Thinner and dashed — it is a reference, not a handle.
1378
+ if (active && active.kind === 'edge'){
1379
+ const a=P[active.i], b=P[(active.i+1)%4], d=unit(sub(b,a)), big=4000;
1380
+ cased(ctx, () => { ctx.beginPath();
1381
+ ctx.moveTo(a[0]-d[0]*big, a[1]-d[1]*big); ctx.lineTo(b[0]+d[0]*big, b[1]+d[1]*big); },
1382
+ 0.8, CORE_ACTIVE, [2,6]);
1383
+ }
1384
+
1385
+ for (let i=0;i<4;i++){
1386
+ const a=P[i], b=P[(i+1)%4];
1387
+ const on = active && active.kind==='edge' && active.i===i;
1388
+ const d = unit(sub(b,a)), L = vlen(sub(b,a));
1389
+ // Break the edge around both of its corner handles. Guard the degenerate
1390
+ // case where the edge is shorter than the two gaps, or the trimmed start
1391
+ // would overshoot the end and draw the segment backwards.
1392
+ const gap = Math.min(HANDLE_GAP, L/2 - 0.5);
1393
+ const s0 = add(a, mul(d, Math.max(0, gap)));
1394
+ const s1 = add(a, mul(d, Math.max(0, L - gap)));
1395
+ if (L > 1){
1396
+ cased(ctx, () => { ctx.beginPath(); ctx.moveTo(s0[0],s0[1]); ctx.lineTo(s1[0],s1[1]); },
1397
+ on ? 2 : 1, CORE_QUAD);
1398
+ }
1399
+ // Pivot pips on the active edge keep their meaning: grab near an end to
1400
+ // rotate. Filled, like the corners, but deliberately smaller — size is what
1401
+ // separates "rotate this edge" from "move this corner".
1402
+ if (on){
1403
+ for (const t of [0.16, 0.84]){
1404
+ const q = add(a, mul(d, L*t));
1405
+ casedDisc(ctx, q[0], q[1], PIVOT_R, CORE_ACTIVE);
1406
+ }
1407
+ }
1408
+ }
1409
+
1410
+ // Corner handles: FILLED discs, 11px, 13px while dragging.
1411
+ // These were hollow rings so the pixels under the handle stayed visible, which
1412
+ // matters when you are aiming at a point. filled was requested, and it holds
1413
+ // up because the ring was solving a problem the tool solves better elsewhere:
1414
+ // the strip loupe is the precision instrument, and it shows the boundary
1415
+ // rectified and magnified rather than through a 9px hole. A filled disc is
1416
+ // easier to see and to hit; the loupe is where you actually judge alignment.
1417
+ ctx.font='600 11px system-ui'; ctx.textBaseline='alphabetic';
1418
+ P.forEach(([x,y],i) => {
1419
+ const on = active && active.kind==='corner' && active.i===i;
1420
+ casedDisc(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
1421
+ casedText(ctx, LBL[i], x+13, y-11, 'rgba(255,255,255,.95)');
1422
+ });
1423
+ }
1424
+
1425
+ function pos(e){
1426
+ const r = cv.getBoundingClientRect();
1427
+ return [(e.clientX-r.left)*cv.width/r.width/scale, (e.clientY-r.top)*cv.height/r.height/scale];
1428
+ }
1429
+ function hitTest(p){
1430
+ if (!st.corners) return null;
1431
+ const tolC = 12/scale, tolE = 10/scale;
1432
+ let best=null, bd=tolC;
1433
+ st.corners.forEach((c,i) => { const d=vlen(sub(c,p)); if (d<bd){ bd=d; best={kind:'corner', i}; } });
1434
+ if (best) return best;
1435
+ bd = tolE;
1436
+ for (let i=0;i<4;i++){
1437
+ const {d,t} = distToSeg(p, st.corners[i], st.corners[(i+1)%4]);
1438
+ if (d < bd){ bd = d; best = {kind:'edge', i, mode: t<0.25?'rotB':(t>0.75?'rotA':'translate')}; }
1439
+ }
1440
+ return best;
1441
+ }
1442
+ function moveEdge(C, i, mode, delta){
1443
+ const A=C[i], B=C[(i+1)%4], P=C[(i+3)%4], Q=C[(i+2)%4];
1444
+ const dPrev=sub(A,P), dNext=sub(Q,B);
1445
+ const u=unit(sub(B,A)), n=[-u[1],u[0]];
1446
+ let p0, d;
1447
+ if (mode==='translate'){
1448
+ const off = delta[0]*n[0]+delta[1]*n[1];
1449
+ p0 = add(A, mul(n,off)); d = u;
1450
+ } else {
1451
+ const pivot = mode==='rotA' ? A : B, moving = mode==='rotA' ? B : A;
1452
+ p0 = pivot; d = unit(sub(add(moving,delta), pivot));
1453
+ }
1454
+ const nA = interLines(p0,d,P,dPrev), nB = interLines(p0,d,Q,dNext);
1455
+ if (!nA || !nB || ![...nA,...nB].every(Number.isFinite)) return null;
1456
+ const out = C.map(c => c.slice());
1457
+ out[i] = nA; out[(i+1)%4] = nB;
1458
+ return out;
1459
+ }
1460
+
1461
+ cv.onpointerdown = e => {
1462
+ const p = pos(e), h = hitTest(p);
1463
+ if (!h){ pick = null; draw(); drawStrip(); return; }
1464
+ drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
1465
+ pick = {kind:h.kind, i:h.i};
1466
+ cv.setPointerCapture(e.pointerId);
1467
+ lastPointer = [e.clientX, e.clientY];
1468
+ draw(); drawStrip();
1469
+ };
1470
+ cv.onpointermove = e => {
1471
+ const p = pos(e);
1472
+ lastPointer = [e.clientX, e.clientY];
1473
+ if (!drag){
1474
+ const h = hitTest(p);
1475
+ const changed = JSON.stringify(h) !== JSON.stringify(hover);
1476
+ hover = h;
1477
+ cv.style.cursor = h ? (h.kind==='corner' ? 'grab' : (h.mode==='translate' ? 'move' : 'crosshair')) : 'default';
1478
+ if (changed){ draw(); drawStrip(); }
1479
+ return;
1480
+ }
1481
+ const delta = sub(p, drag.grab);
1482
+ if (drag.kind === 'corner'){
1483
+ const C = drag.start.map(c => c.slice());
1484
+ C[drag.i] = [Math.max(0,Math.min(img.naturalWidth, p[0])), Math.max(0,Math.min(img.naturalHeight, p[1]))];
1485
+ st.corners = C;
1486
+ } else {
1487
+ const C = moveEdge(drag.start, drag.i, drag.mode, delta);
1488
+ if (C) st.corners = C;
1489
+ }
1490
+ draw(); drawStrip(); markStale();
1491
+ };
1492
+ cv.onpointerup = cv.onpointercancel = () => { drag = null; draw(); drawStrip(); autoPreview(); };
1493
+ cv.onpointerleave = () => { if (!drag){ hover = null; draw(); drawStrip(); } };
1494
+
1495
+ window.addEventListener('keydown', e => {
1496
+ if (e.key === 'Escape'){ closePop(); return; }
1497
+ if (!st.corners || document.querySelector('.pop.on')) return;
1498
+ if (e.key === 'Tab'){
1499
+ e.preventDefault();
1500
+ const i = pick && pick.kind==='edge' ? (pick.i+1)%4 : 0;
1501
+ pick = {kind:'edge', i}; draw(); drawStrip(); return;
1502
+ }
1503
+ if (!pick) return;
1504
+ const step = e.shiftKey ? 10 : 1;
1505
+ const d = {ArrowLeft:[-step,0], ArrowRight:[step,0], ArrowUp:[0,-step], ArrowDown:[0,step]}[e.key];
1506
+ if (!d) return;
1507
+ e.preventDefault();
1508
+ if (pick.kind === 'corner'){
1509
+ const C = st.corners.map(c => c.slice()); C[pick.i] = add(C[pick.i], d); st.corners = C;
1510
+ } else {
1511
+ const C = moveEdge(st.corners, pick.i, 'translate', d); if (C) st.corners = C;
1512
+ }
1513
+ draw(); drawStrip(); markStale(); autoPreview();
1514
+ });
1515
+
1516
+ /* ===== the rectified strip: the selected edge mapped onto the horizontal and
1517
+ magnified perpendicular, so misalignment reads as a wedge across its whole
1518
+ length. One affine setTransform + drawImage. ===== */
1519
+ $('#stripIn').onclick = () => { stripHalf = Math.max(3, Math.round(stripHalf/1.6)); drawStrip(); };
1520
+ $('#stripOut').onclick = () => { stripHalf = Math.min(80, Math.round(stripHalf*1.6)); drawStrip(); };
1521
+ /* Edge view is a switch now, not a two-way segmented control: ON = floating,
1522
+ OFF = docked. The segmented control implied two peers; in practice docked is
1523
+ the resting state and floating is the thing you turn on while working a
1524
+ corner, which is what a switch says. */
1525
+ function setLoupeMode(m){
1526
+ loupeMode = m; remember('loupe', m);
1527
+ setSwitch($('#edgeBtn'), m === 'float');
1528
+ $('#dock').classList.toggle('hidden', m!=='dock');
1529
+ if (m!=='float') $('#floatLoupe').classList.remove('on');
1530
+ if (img.naturalWidth){
1531
+ const wasFit = Math.abs(scale-fitScale)<1e-6;
1532
+ computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
1533
+ }
1534
+ drawStrip();
1535
+ }
1536
+ /* One place that paints a switch and the section it governs, so a section's
1537
+ body cannot get out of step with its own switch. */
1538
+ function setSwitch(btn, on){
1539
+ if (!btn) return;
1540
+ btn.setAttribute('aria-checked', on ? 'true' : 'false');
1541
+ const sect = btn.closest('.sect');
1542
+ if (sect) setSectionOpen(sect, on);
1543
+ }
1544
+
1545
+ /* Open/close a section's body by animating a MEASURED height. See the CSS note
1546
+ for why this isn't grid-template-rows. `booting` skips the animation on the
1547
+ first paint, so a section that starts closed doesn't visibly collapse itself
1548
+ in front of the user. */
1549
+ let sectionsBooted = false;
1550
+ function setSectionOpen(sect, on){
1551
+ const was = sect.dataset.on;
1552
+ sect.dataset.on = on ? '1' : '0';
1553
+ const body = sect.querySelector('.sect-body');
1554
+ if (!body) return;
1555
+ const inner = body.querySelector('.sect-body-in');
1556
+ const target = inner ? inner.scrollHeight : body.scrollHeight;
1557
+ clearTimeout(body._relax);
1558
+
1559
+ if (!sectionsBooted){ // no animation before the first paint
1560
+ body.style.transition = 'none';
1561
+ body.style.maxHeight = on ? 'none' : '0px';
1562
+ void body.offsetHeight;
1563
+ body.style.transition = '';
1564
+ return;
1565
+ }
1566
+ // Already open (or already closed) and settled: there is nothing to animate.
1567
+ // Without this the section re-plays its whole open animation — collapse to 0,
1568
+ // grow back to target — on every call. paintGrade() calls setSwitch() on every
1569
+ // `input` event, so dragging the Realism slider made the rail jump under the
1570
+ // cursor once per tick. Settled means the cap is at its resting value; a
1571
+ // pixel value means a previous animation was interrupted, so re-run it.
1572
+ const settled = body.style.maxHeight === (on ? 'none' : '0px');
1573
+ if (was === sect.dataset.on && settled) return;
1574
+ // Always animate FROM a real number. Going from `none` or from an interrupted
1575
+ // value is what left a section stuck: max-height:0 with data-on="1".
1576
+ if (on){
1577
+ body.style.maxHeight = '0px';
1578
+ void body.offsetHeight;
1579
+ body.style.maxHeight = target + 'px';
1580
+ // Release the cap once open so a body whose content grows isn't clipped by
1581
+ // a stale measurement. A timer, not transitionend — an interrupted
1582
+ // transition never fires it, and then the section stays capped for ever.
1583
+ body._relax = setTimeout(() => {
1584
+ if (sect.dataset.on === '1') body.style.maxHeight = 'none';
1585
+ }, 300);
1586
+ } else {
1587
+ body.style.maxHeight = target + 'px';
1588
+ void body.offsetHeight;
1589
+ body.style.maxHeight = '0px';
1590
+ }
1591
+ }
1592
+
1593
+ /* Realism (M2). ON by default from 5 Sep — a composite that ignores the light it
1594
+ sits in is the thing this pass exists to fix, so the default should be the
1595
+ corrected one. Off stays a first-class choice: it keeps the screenshot's own
1596
+ colour exactly, which is what you want when the UI's colour is the thing under
1597
+ review. Strength is a slider because the right amount is a judgement about the
1598
+ photograph, not a constant. */
1599
+ let gradeOn = recall('grade','1') === '1';
1600
+ let gradeAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
1601
+ function gradeValue(){ return gradeOn ? gradeAmt : 0; }
1602
+ function paintGrade(){
1603
+ setSwitch($('#gradeBtn'), gradeOn); // hides the slider when off
1604
+ $('#gradeVal').textContent = Math.round(gradeAmt * 100) + '%';
1605
+ const sl = $('#gradeAmt');
1606
+ sl.value = gradeAmt;
1607
+ sl.style.setProperty('--p', (gradeAmt - 0.1) / 0.9);
1608
+ }
1609
+ function setGrade(on, amt){
1610
+ gradeOn = on;
1611
+ if (amt !== undefined) gradeAmt = amt;
1612
+ remember('grade', on ? '1' : '0'); remember('gradeAmt', String(gradeAmt));
1613
+ paintGrade();
1614
+ }
1615
+ $('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
1616
+ // Repaint live while dragging, re-render only on release: a full compose per
1617
+ // input event would queue renders faster than they finish.
1618
+ $('#gradeAmt').oninput = e => { gradeAmt = parseFloat(e.target.value); paintGrade(); };
1619
+ $('#gradeAmt').onchange = e => { setGrade(true, parseFloat(e.target.value)); autoPreview(); };
1620
+
1621
+ $('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
1622
+
1623
+ function paintStrip(c, W, H, i){
1624
+ c.setTransform(1,0,0,1,0,0);
1625
+ c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
1626
+ const A = st.corners[i], B = st.corners[(i+1)%4];
1627
+ const L = vlen(sub(B,A));
1628
+ if (L < 2) return;
1629
+ const u = unit(sub(B,A)), n = [-u[1], u[0]];
1630
+ const k1 = W / L, k2 = H / (2*stripHalf);
1631
+ c.save();
1632
+ c.setTransform(k1*u[0], k2*n[0], k1*u[1], k2*n[1],
1633
+ -k1*(u[0]*A[0] + u[1]*A[1]),
1634
+ -k2*(n[0]*A[0] + n[1]*A[1]) + k2*stripHalf);
1635
+ c.imageSmoothingEnabled = true;
1636
+ c.drawImage(img, 0, 0);
1637
+ c.restore();
1638
+ cased(c, () => { c.beginPath(); c.moveTo(0, H/2); c.lineTo(W, H/2); }, 1.25, CORE_ACTIVE);
1639
+ cased(c, () => { c.beginPath();
1640
+ for (const f of [0.25,0.5,0.75]){ c.moveTo(W*f, H/2-9); c.lineTo(W*f, H/2+9); } },
1641
+ 1, 'rgba(255,255,255,.7)');
1642
+ const centre = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
1643
+ const inwardIsDown = (n[0]*(centre[0]-A[0]) + n[1]*(centre[1]-A[1])) > 0;
1644
+ c.font = '600 11px system-ui'; c.textBaseline = 'middle';
1645
+ casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
1646
+ casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
1647
+ }
1648
+ function drawStrip(){
1649
+ const a = drag || hover || pick;
1650
+ const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
1651
+ $('#stripZ').textContent = '±' + stripHalf + 'px';
1652
+ if (loupeMode === 'dock'){
1653
+ $('#floatLoupe').classList.remove('on');
1654
+ const W = strip.width, H = strip.height;
1655
+ if (!has){
1656
+ sctx.setTransform(1,0,0,1,0,0); sctx.fillStyle='#0c0c0b'; sctx.fillRect(0,0,W,H);
1657
+ $('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
1658
+ return;
1659
+ }
1660
+ paintStrip(sctx, W, H, a.i);
1661
+ $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — flat on the rule means aligned`;
1662
+ } else {
1663
+ const fl = $('#floatLoupe');
1664
+ if (!has){ fl.classList.remove('on'); return; }
1665
+ paintStrip(fctx, stripF.width, stripF.height, a.i);
1666
+ $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — flat on the rule means aligned`;
1667
+ fl.classList.add('on');
1668
+ const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
1669
+ let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
1670
+ if (x + w > window.innerWidth - 8) x = lastPointer[0] - w - 26;
1671
+ if (y < 8) y = lastPointer[1] + 26;
1672
+ fl.style.left = Math.max(8, x) + 'px';
1673
+ fl.style.top = Math.max(8, Math.min(window.innerHeight - h - 8, y)) + 'px';
1674
+ }
1675
+ }
1676
+
1677
+ /* ===== device / radius ===== */
1678
+ function setType(t){
1679
+ st.type = t;
1680
+ document.querySelectorAll('#types .chip').forEach(c => c.classList.toggle('on', c.dataset.t===t));
1681
+ const sel = $('#preset'); sel.innerHTML='';
1682
+ st.presets.filter(p=>p.type===t).forEach(p => { const o=document.createElement('option'); o.value=p.id; o.textContent=`${p.label} (${(p.frac*100).toFixed(1)}%)`; sel.appendChild(o); });
1683
+ st.preset = sel.value;
1684
+ }
1685
+ function applyPreset(){ const p = st.presets.find(p=>p.id===$('#preset').value); if (p) setFrac(p.frac, 'preset: '+p.label); }
1686
+ function setFrac(f, why){ st.frac = f; $('#radius').value = f; syncSlider();
1687
+ $('#radSt').textContent = `${(f*100).toFixed(1)}% of screen width — ${why}`; markStale(); }
1688
+ $('#preset').onchange = () => { applyPreset(); autoPreview(); };
1689
+ $('#radius').oninput = e => { setFrac(parseFloat(e.target.value), 'manual'); autoPreview(); };
1690
+
1691
+ /* Corner radius on/off. ON by default, because almost every device this tool is
1692
+ pointed at has rounded glass — square corners are the exception, not the
1693
+ resting state.
1694
+ Off does NOT throw the radius away: `st.frac` keeps its value and `radiusValue()`
1695
+ reports 0 while the switch is off, so turning it back on restores exactly the
1696
+ number you had rather than a preset guess. Same contract as Realism's
1697
+ strength — a toggle that forgets is a toggle you avoid using. */
1698
+ let radiusOn = recall('radiusOn','1') === '1';
1699
+ function radiusValue(){ return radiusOn ? st.frac : 0; }
1700
+ function setRadiusOn(on){
1701
+ radiusOn = on; remember('radiusOn', on ? '1' : '0');
1702
+ setSwitch($('#radBtn'), on);
1703
+ }
1704
+ $('#radBtn').onclick = () => { setRadiusOn(!radiusOn); autoPreview(); };
1705
+ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value - r.min)/(r.max - r.min)); }
1706
+
1707
+ /* ===== preview / compare / save ===== */
1708
+ let outNat = 0, autoTimer = null;
1709
+ function markStale(){
1710
+ const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
1711
+ // Exactly one lit action at a time — the Figma Button component says the
1712
+ // accent marks the NEXT action and that only one may be on screen. With no
1713
+ // current render there is nothing to save, so nothing is lit: Preview stays
1714
+ // a neutral raised button, as it is in the design.
1715
+ b.classList.remove('primary');
1716
+ $('#imp').classList.remove('primary');
1717
+ }
1718
+ function syncOut(){
1719
+ const im = $('#outImg');
1720
+ if (!im.hidden && outNat){
1721
+ im.style.width = Math.round(img.naturalWidth * scale) + 'px';
1722
+ const a = $('#scroller'), b = $('#outWrap');
1723
+ b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
1724
+ }
1725
+ }
1726
+ $('#scroller').addEventListener('scroll', () => {
1727
+ if ($('#stage').classList.contains('compare')) syncOut();
1728
+ });
1729
+ /* Compare is a switch now, not a chip. Its truth lives in aria-checked so the
1730
+ control is a real switch to assistive tech, and the label text follows it —
1731
+ a switch that says OFF while being on is worse than no label. */
1732
+ function setCompareUI(on){
1733
+ setSwitch($('#cmpBtn'), on);
1734
+ // No text label in the component (17:61) — aria-checked carries the state.
1735
+ // With compare on the result pane renders continuously, so a Preview button
1736
+ // would offer something already on screen. It belongs to the off state.
1737
+ $('#preview').hidden = on;
1738
+ }
1739
+ $('#cmpBtn').onclick = () => {
1740
+ const on = $('#stage').classList.toggle('compare');
1741
+ setCompareUI(on);
1742
+ remember('compare', on ? '1' : '0');
1743
+ if (img.naturalWidth){
1744
+ const wasFit = Math.abs(scale-fitScale)<1e-6;
1745
+ computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
1746
+ }
1747
+ if (on && $('#outImg').hidden) renderPreview(); else if (on) syncOut();
1748
+ };
1749
+ function autoPreview(){
1750
+ // Judge while you fit: with the compare pane open the composite re-renders
1751
+ // after each correction (compose() measures 0.06s server-side), so Preview
1752
+ // stops being a round trip.
1753
+ if (!$('#stage').classList.contains('compare')) return;
1754
+ clearTimeout(autoTimer);
1755
+ autoTimer = setTimeout(renderPreview, 350);
1756
+ }
1757
+ async function renderPreview(){
1758
+ if (!(st.photo && st.shot && st.corners)) return;
1759
+ const s = $('#outSt'); s.textContent = 'Rendering…';
1760
+ try{
1761
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1762
+ const im = $('#outImg');
1763
+ im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1764
+ im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1765
+ s.textContent = `radius ${r.radius_px}px on the screenshot`;
1766
+ const b = $('#save'); b.disabled = false; b.textContent = 'Save';
1767
+ b.classList.add('primary'); // Save is now the next action
1768
+ if (!previewHinted){ previewHinted = true;
1769
+ toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
1770
+ }
1771
+ }catch(e){ s.textContent = e.message; }
1772
+ }
1773
+ /* With compare off there is no result pane to draw into, so the composite
1774
+ opens in a popup over the canvas — the same surface the pickers use, so it
1775
+ is one modal idiom rather than two. */
1776
+ async function openPreviewPop(){
1777
+ const pop = $('#previewPop');
1778
+ const img = $('#previewImg'), st_ = $('#previewSt');
1779
+ openPopAt(pop, $('#preview').getBoundingClientRect());
1780
+ st_.textContent = 'Rendering…'; img.hidden = true;
1781
+ try{
1782
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1783
+ img.onload = () => { img.hidden = false; };
1784
+ img.src = `${fileURL(r.path)}&t=${Date.now()}`;
1785
+ st_.textContent = `radius ${r.radius_px}px on the screenshot`;
1786
+ const b = $('#save'); b.disabled = false; b.textContent = 'Save'; b.classList.add('primary');
1787
+ }catch(e){ st_.textContent = e.message; }
1788
+ }
1789
+ $('#preview').onclick = openPreviewPop;
1790
+ function prettyDir(p){
1791
+ // ~ for home, and show the last two segments of a long path: the interesting
1792
+ // part of a long output path is the end, not the volume.
1793
+ const home = st.home || '';
1794
+ let d = (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
1795
+ const seg = d.split('/');
1796
+ return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
1797
+ }
1798
+
1799
+ $('#save').onclick = async () => {
1800
+ const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1801
+ try{
1802
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1803
+ b.textContent = 'Save';
1804
+ // The real destination, not a hardcoded one: --out-dir means saves usually
1805
+ // land in the project folder now, and telling the user "~/Desktop" when
1806
+ // they aren't there is how you lose a file.
1807
+ toast('ok', `Saved <code>${r.output.split('/').pop()}</code> to <code>${prettyDir(st.outDir || '')}</code>`);
1808
+ const imp = $('#imp');
1809
+ imp.disabled = false; imp.textContent = 'Send to Claude';
1810
+ // Hand the accent on: the file exists, so sending it is what is next.
1811
+ imp.classList.add('primary'); b.classList.remove('primary');
1812
+ }catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent='Save'; }
1813
+ finally { b.disabled = false; }
1814
+ };
1815
+
1816
+ /* Send to Claude. The page cannot draw in the chat panel — only the agent
1817
+ can, with present_files — so this enqueues a request the agent's blocking
1818
+ wait picks up. It works at all only because --out-dir puts the file inside
1819
+ the project folder: present_files refuses paths outside a connected folder,
1820
+ which is why this button was impossible while saves went to the Desktop. */
1821
+ $('#imp').onclick = async () => {
1822
+ const b = $('#imp'); const t0 = Date.now();
1823
+ b.disabled = true;
1824
+ let finished = false;
1825
+ const paint = () => { if (!finished) b.textContent = `Sending… ${Math.round((Date.now()-t0)/1000)}s`; };
1826
+ paint();
1827
+ const tick = setInterval(paint, 1000);
1828
+ document.addEventListener('visibilitychange', paint);
1829
+ const done = (txt, ok) => {
1830
+ finished = true;
1831
+ clearInterval(tick);
1832
+ document.removeEventListener('visibilitychange', paint);
1833
+ b.textContent = ok ? 'Sent to Claude' : 'Send to Claude';
1834
+ b.disabled = ok; // nothing to re-send until the next save
1835
+ toast(ok ? 'ok' : 'err', txt);
1836
+ };
1837
+ try { await api('/api/import', {}); }
1838
+ catch (e) { return done('Could not queue the import: ' + e.message, false); }
1839
+ await awaitJob({
1840
+ cancelled: () => finished,
1841
+ onDone: () => done('Sent — it should be in the chat now.', true),
1842
+ onError: (j) => done('Claude could not show it: ' + (j.message || ''), false),
1843
+ });
1844
+ };
1845
+
1846
+ /* ===== boot ===== */
1847
+ (async () => {
1848
+ const s = await api('/api/state');
1849
+ st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
1850
+ st.outDir = s.out_dir; st.home = s.home;
1851
+ // Say up front where Save will put things. With --out-dir this is usually the
1852
+ // project folder, and a designer should not have to press Save to find out.
1853
+ toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
1854
+ { ms: 9000, dismissible: true });
1855
+ const types = $('#types');
1856
+ for (const t of ['phone','tablet','laptop','desktop']){
1857
+ 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 */
1858
+ b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
1859
+ }
1860
+ setLoupeMode(loupeMode);
1861
+ setGrade(gradeOn, gradeAmt);
1862
+ setRadiusOn(radiusOn);
1863
+ sectionsBooted = true; // animate from here on, not during the first paint
1864
+ // Compare is the DEFAULT view: judging a fit means comparing it with the
1865
+ // photo, so the side-by-side is the normal state and turning it off is the
1866
+ // deliberate act. recall() only overrides it if the user chose otherwise.
1867
+ if (recall('compare','1') === '1'){ $('#stage').classList.add('compare'); setCompareUI(true); }
1868
+ else setCompareUI(false);
1869
+ await loadRecent();
1870
+ // A session restored after a reload may already have a saved composite, in
1871
+ // which case Import is still meaningful — leaving it disabled would strand
1872
+ // a file the designer had already produced.
1873
+ if (s.output){ const imp = $('#imp'); imp.disabled = false; }
1874
+ // The server keeps the session; the page used to start blank on reload,
1875
+ // silently discarding a photo, a screenshot and any corner work.
1876
+ if (s.photo || s.screenshot){
1877
+ if (s.corners) st.corners = s.corners;
1878
+ if (s.radius_frac) st.frac = s.radius_frac;
1879
+ if (s.device) st.type = s.device;
1880
+ for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
1881
+ if (!path) continue;
1882
+ try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null); }
1883
+ catch(e){ /* file moved since: leave that slot empty */ }
1884
+ }
1885
+ // Populate the radius dropdown on the restore path too. detect() only
1886
+ // calls setType when st.type is unset, so a restored session used to come
1887
+ // back with an empty preset list and a zeroed slider.
1888
+ if (st.type) setType(st.type);
1889
+ if (s.radius_frac) setFrac(s.radius_frac, 'restored from this session');
1890
+ else applyPreset();
1891
+ if ($('#stage').classList.contains('compare') && st.corners) renderPreview();
1892
+ }
1893
+ if (!st.photo) openPop(1);
1894
+ window.addEventListener('resize', () => {
1895
+ if (!img.naturalWidth) return;
1896
+ const wasFit = Math.abs(scale - fitScale) < 1e-6;
1897
+ computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
1898
+ });
1899
+ })();
1900
+ </script>
1901
+ </body>
1902
+ </html>