screengraft 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/index.html CHANGED
@@ -68,14 +68,32 @@
68
68
  polished Button matrix (4:14): hover's border is border/edge-mid, and
69
69
  pressed keeps border/edge rather than dimming to edge-low — pressing a
70
70
  control must not make it look unavailable. Both had drifted. */
71
- --btn-hover:#333337; --btn-hover-edge:#494a50;
72
- --btn-press:#1c1c20; --btn-press-edge:#3a3b41;
71
+ /* Re-read 20 Sep 2026 and the family is now COMPLETE: rest had no token
72
+ of its own, so the button borrowed --raise/--edge and had quietly
73
+ diverged from button/secondary/container|border/default. Every value
74
+ below is read straight off 4:14. --btn-rest-edge (#46464e) is the border
75
+ every raised control wears — the Default button at rest AND pressed, and
76
+ the segmented track — which is why it is one token used in three places
77
+ rather than a new step in the --edge ladder.
78
+ NOTE: hover's surface (#2b2b30) is now a hair DARKER than rest
79
+ (#2b2c31), so hover is carried almost entirely by the border stepping to
80
+ edge-mid. That is what the file says; it looks like the hover variant
81
+ kept the old rest value when rest moved. Flagged for the design file. */
82
+ --btn-rest:#2b2c31; --btn-rest-edge:#46464e;
83
+ --btn-hover:#2b2b30; --btn-hover-edge:#494a50;
84
+ --btn-press:#1c1c20; --btn-press-edge:#46464e;
73
85
  /* Accent disabled keeps the label READABLE rather than fading the whole
74
86
  button — the Button component in Figma says so explicitly. */
75
87
  /* Re-read 13 Sep 2026: primary/container/disabled is the SOLID press
76
88
  colour with the solid stroke and the solid label — the design fades
77
89
  nothing here, it recesses. Was three alpha variants. */
78
- --acc-dis:#bb2b00; --acc-dis-edge:#ff6833; --acc-dis-ink:#fbf9f9;
90
+ /* Re-read 20 Sep 2026: primary/container/disabled stepped down again,
91
+ #bb2b00 -> #992800, and its border is now the accent itself rather than
92
+ the lighter stroke. #bb2b00 is primary/container/PRESSED only, which is
93
+ --acc-press — the two had been sharing one value and the pressed state
94
+ was borrowing --acc-dis-edge for its border, so it now has its own. */
95
+ --acc-dis:#992800; --acc-dis-edge:#f23b0d; --acc-dis-ink:#fbf9f9;
96
+ --acc-press-edge:#f23b0d;
79
97
  /* Strokes are 0.5px throughout the component sets (button, chip, input
80
98
  chip, segmented, switch, button group). A half pixel snaps to one device
81
99
  pixel on any screen, so it never vanishes; on a Retina Mac it is the
@@ -159,7 +177,15 @@
159
177
  --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s9:48px;
160
178
  /* 120ms originally, per the visual-language notes. asked for a bit
161
179
  slower on 5 Sep — one token, so retuning is a one-line change. */
162
- --t:180ms cubic-bezier(.2,0,0,1);
180
+ --t-dur:180ms; /* the duration alone, for delays */
181
+ /* The source chip's grow/collapse runs slower than the app's general
182
+ motion on purpose: it is a shape change across ~100px, not a state
183
+ flip, and at 180ms it read as a snap rather than a movement. The cross
184
+ that follows it is quicker, and waits out this duration first. Tune
185
+ these two and the whole chip sequence moves with them. */
186
+ --t-chip:260ms;
187
+ --t-chip-cross:150ms;
188
+ --t:var(--t-dur) cubic-bezier(.2,0,0,1);
163
189
  --t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
164
190
  /* Every control animates the SAME four properties on the same curve. Kept as
165
191
  one token because the failure mode here is a control that transitions its
@@ -185,20 +211,49 @@
185
211
  grid-template-columns:minmax(0,1fr);min-height:0;
186
212
  padding-bottom:32px;background:var(--bg)}
187
213
 
214
+ /* The bottom rule is an INSET SHADOW, not a border, for the same reason the
215
+ dock's is — and here it is not only about height, it is about which device
216
+ pixel things land on. A 1px border under box-sizing:border-box leaves a
217
+ 55px content box; centring the 32px chip row in an ODD height puts it at
218
+ y=11.5. The chips carry a 0.5px stroke (--bw), which only survives because
219
+ a half CSS pixel snaps to one whole device pixel — and that assumption
220
+ holds only when the element's EDGES sit on whole device pixels. At 11.5 the
221
+ bottom edge straddles the boundary, renders faint or vanishes at dpr 2, and
222
+ the chip reads as pressed. Reported 20 Sep with a screenshot of exactly
223
+ that. As an inset shadow the content box is the full 56, the row centres on
224
+ y=12, and the rule is drawn in the same place it was before. */
188
225
  .topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
189
- background:var(--card);border-bottom:1px solid var(--line);
190
- box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0 rgba(0,0,0,.25)}
226
+ background:var(--card);
227
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04), inset 0 -1px 0 var(--line),
228
+ 0 1px 0 rgba(0,0,0,.25)}
191
229
  /* A nowrap flex row whose children keep min-width:auto cannot shrink
192
230
  below their content, so at 742px Save and Send to Claude sat at x=750..885
193
231
  — outside the viewport and unclickable. The chips carry a filename and are
194
232
  the part that should give; the actions are fixed-size and must not. */
195
233
  .topbar > *{min-width:0}
234
+ /* overflow:hidden clips the horizontal only in spirit — CSS couples the
235
+ axes — but nothing needs vertical room any more now that pressed does not
236
+ move. A long filename is still the part that gives. */
196
237
  .tb-chips{overflow:hidden}
197
- .tb-actions{flex:none}
238
+ /* Figma 15:108: three groups at 16px — the format control, the save PAIR,
239
+ and the rail toggle — with 8px inside the pair. Save and Send to Claude
240
+ belong together (272:1517 frames them); the toggle does not. */
241
+ .tb-actions{flex:none;display:flex;align-items:center;gap:var(--s4)}
242
+ .tb-save{display:flex;align-items:center;gap:var(--s2)}
198
243
  .tb-left{display:flex;align-items:center;gap:var(--s9)}
199
244
  .tb-chips{display:flex;align-items:center;gap:var(--s2)}
200
245
  .tb-arrow{color:var(--mute)}
201
- .tb-actions{display:flex;align-items:center;gap:var(--s2)}
246
+ /* Icon-only, but NOT squeezed: Figma 272:1494 is a secondary button keeping
247
+ the base 12px side padding around a 16px icon, so 40x28. An earlier version
248
+ of this forced it to 28 square and read as a different species from the
249
+ buttons beside it. */
250
+ .iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center}
251
+ .iconbtn svg{display:block;width:16px;height:16px}
252
+ /* Two glyphs, one button (icons / sidebar-opened 272:1489, sidebar-closed
253
+ 272:1463). Each shows the rail's CURRENT state in the width of its two
254
+ panels, while its chevron points the way the next click will move it. */
255
+ #railBtn .i-closed,.app.railoff #railBtn .i-open{display:none}
256
+ .app.railoff #railBtn .i-closed{display:block}
202
257
  /* Mona Sans is the brand face in Figma; it is not installed on a stock Mac,
203
258
  so it degrades to the system UI face rather than to a serif. */
204
259
  /* The wordmark ships WITH the plugin. Mona Sans is the brand face in Figma and
@@ -237,13 +292,20 @@
237
292
  ------------------------------------------------------------------ */
238
293
  /* Both sizes are radius/sm (6px) — re-read from Button 4:14 on 13 Sep 2026,
239
294
  where every variant carries the same radius. Md had been 8 since 11 Sep. */
240
- button{font:inherit;color:var(--ink);background:var(--raise);
241
- border:var(--bw) solid var(--edge);border-radius:var(--r-sm);
295
+ button{font:inherit;color:var(--ink);background:var(--btn-rest);
296
+ border:var(--bw) solid var(--btn-rest-edge);border-radius:var(--r-sm);
242
297
  padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;
243
298
  transition:var(--t-all), transform var(--t)}
244
299
  button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
300
+ /* Pressed does NOT move. It used to carry translateY(.5px) — half a CSS
301
+ pixel, so one device pixel at 2x — and that read as the button jumping on
302
+ every click rather than depressing. It also put the button's own 0.5px
303
+ stroke on a half-pixel boundary, and inside any clipping parent it pushed
304
+ the bottom edge out of the box: the top-bar chips visibly lost their
305
+ bottom stroke while held (20 Sep). The darker surface and the darker
306
+ border already say pressed, and they say it without motion. */
245
307
  button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
246
- box-shadow:none;transform:translateY(.5px)}
308
+ box-shadow:none}
247
309
  button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
248
310
  /* Default/Disabled RECESSES: surface/raise-low with border/edge-low, label at
249
311
  text/faint x 50%. It used to keep the raised surface and dim only the label.
@@ -268,22 +330,25 @@
268
330
 
269
331
  /* Primary. The accent means exactly one thing: the next action, so only one
270
332
  of these is lit at a time (see the accent hand-off in the JS). */
333
+ /* Weight 400, not 600: every one of the sixteen Button variants in 4:14 is
334
+ SF Pro Regular. The bold primary was a carry-over from before the matrix
335
+ existed, and it made the accent shout twice. */
271
336
  button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
272
- border-color:var(--acc-stroke);font-weight:600;box-shadow:none}
337
+ border-color:var(--acc-stroke);font-weight:400;box-shadow:none}
273
338
  button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
274
- button.primary:active{background:var(--acc-press);border-color:var(--acc-dis-edge);
275
- transform:translateY(.5px)}
276
- /* An accent-family button that is enabled but is NOT the next action reads
277
- as an ordinary neutral control. */
278
- button.accent:not(:disabled):not(.primary){
279
- background:var(--raise); color:var(--ink); border-color:var(--edge);
280
- font-weight:400}
281
- /* Radius is deliberately NOT restated in these three rules any more. It is the
282
- base button's, so .sm keeps winning on size — an accent .sm button used to
283
- be handed --r-md by specificity alone. */
284
- button.accent:disabled,button.primary:disabled{
339
+ button.primary:active{background:var(--acc-press);border-color:var(--acc-press-edge)}
340
+ /* The `.accent` class is gone (20 Sep 2026). Save and Send to Claude wore it
341
+ so that they could be accent-coloured while DISABLED — which is exactly
342
+ backwards: before anything is loaded the top bar showed two lit orange
343
+ buttons offering actions that could not be taken. They are ordinary
344
+ Default buttons now and gain `.primary` only when they genuinely are the
345
+ next action, which is the rule the Button component states.
346
+ Radius is deliberately NOT restated below. It is the base button's, so .sm
347
+ keeps winning on size — an accent .sm button used to be handed --r-md by
348
+ specificity alone. */
349
+ button.primary:disabled{
285
350
  background:var(--acc-dis); border-color:var(--acc-dis-edge);
286
- color:var(--acc-dis-ink); font-weight:600; box-shadow:none; opacity:1}
351
+ color:var(--acc-dis-ink); font-weight:400; box-shadow:none; opacity:1}
287
352
 
288
353
  /* ------------------------------------------------------------------
289
354
  Render, while it is rendering: the button IS the progress bar.
@@ -372,13 +437,6 @@
372
437
  column and read like a segmented *bar*. justify-self:start is what
373
438
  actually holds it to its content width. */
374
439
 
375
- /* A stepper should look joined. */
376
- /* Stepper (Figma 28:816). NOT a bespoke control: it is ordinary Default
377
- buttons butted together, so every state — hover, pressed, disabled — is
378
- the button's own. The previous version was borderless, transparent and
379
- 24px tall, which is exactly why +/- read as a different species from the
380
- buttons beside them. Only the corner radii and the shared middle border
381
- are special. */
382
440
  /* ------------------------------------------------------------------
383
441
  `hidden` must actually hide. THREE components have now needed this
384
442
  patched one at a time — .cvbar, #outImg/#outVid, and the format
@@ -394,16 +452,6 @@
394
452
  ------------------------------------------------------------------ */
395
453
  [hidden]{display:none !important}
396
454
 
397
- .stepper{display:inline-flex;align-items:center}
398
- .stepper button{border-radius:0}
399
- .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
400
- .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
401
- /* One border between neighbours rather than two stacked. */
402
- .stepper button + button{margin-left:calc(-1 * var(--bw))}
403
- .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
404
- /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
405
- .dock-step button{padding:0 var(--s3)}
406
-
407
455
  /* ------------------------------------------------------------------
408
456
  Segmented control (Figma 84:177). NOT a stepper: a stepper is ordinary
409
457
  buttons butted together, which is right for -/+ but wrong for a choice —
@@ -425,37 +473,45 @@
425
473
  the thumb is 24px tall at radius/xs. Hover lifts the thumb to raise-hi
426
474
  and the track's stroke to edge-mid; pressed drops the thumb to raise-mid. */
427
475
  .seg{display:inline-flex;align-items:center;gap:2px;padding:2px;
428
- background:var(--raise-low);border:var(--bw) solid var(--edge);
476
+ background:var(--raise-low);border:var(--bw) solid var(--btn-rest-edge);
429
477
  border-radius:var(--r-sm);transition:var(--t-all)}
430
478
  .seg:hover{border-color:var(--edge-mid)}
431
479
  .seg:active{border-color:var(--edge-mid)}
432
480
  /* The track carries the border and the elevation, so a segment is a label
433
481
  until it is selected: no border, no shadow, no lift on press. */
434
- .seg button{height:24px;padding:0 11px;font-size:12px;border-radius:var(--r-xs);
482
+ /* 70px each (Figma 84:161, so 2+70+2+70+2 = 146): a two-way choice reads as
483
+ one control rather than two adjacent buttons, and equal halves are most of
484
+ what sells that. min-width rather than width so the ghost below still
485
+ governs if a label ever outgrows 70. */
486
+ .seg button{height:24px;min-width:70px;padding:0 11px;font-size:12px;border-radius:var(--r-xs);
435
487
  background:transparent;border:0;box-shadow:none;color:var(--mute);
436
488
  font-weight:400;display:inline-flex;flex-direction:column;
437
489
  align-items:center;justify-content:center}
438
- /* Each segment is as wide as its label at the SELECTED weight, whichever
439
- weight it is showing. Without this the control reflowed on every switch:
440
- 600 is wider than 400, so selecting "ProRes" grew that segment, and the
441
- whole control — left edge included — moved (reported 12 Sep 2026).
442
- A zero-height ghost of the label at 600 sits under the visible one and
443
- sets the width; `data-label` carries the text. Not padding. */
444
- .seg button::after{content:attr(data-label);font-weight:600;height:0;
445
- overflow:hidden;visibility:hidden;pointer-events:none}
490
+ /* There used to be a zero-height ghost of the label at weight 600 here,
491
+ sizing each segment to its SELECTED width so the control did not reflow
492
+ when the selection moved (600 is wider than 400, so selecting "ProRes"
493
+ grew that segment and the whole control shifted — reported 12 Sep 2026).
494
+ The ghost is gone because its cause is: 84:177 has BOTH segments at
495
+ regular, selected and not, so no width changes on selection and the
496
+ `data-label` attributes in the markup are now inert. Selection is carried
497
+ by the thumb surface and the label colour, which is the stronger signal
498
+ anyway. Put the ghost back if a weight difference ever returns. */
446
499
  .seg button:hover{background:transparent;color:var(--ink)}
447
500
  .seg button:active{background:transparent;transform:none}
448
501
  /* The thumb. Its three surfaces are the Default button's own, which is the
449
502
  point of the component: one raised thing, behaving like every other raised
450
503
  thing on the page. */
451
- .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
504
+ .seg button.sel{background:var(--btn-rest);color:var(--ink);font-weight:400}
452
505
  .seg button.sel:hover{background:var(--raise-hi)}
453
- .seg button.sel:active{background:var(--raise-mid);transform:none}
454
- /* A toggle in the strip head. On = the raised surface every selected thing
455
- on this page has (the segmented thumb, the device chip), off = a label. */
456
- .striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
457
- .striphead button[aria-pressed]:hover,#dofBoth[aria-pressed]:hover{color:var(--ink)}
458
- .striphead button[aria-pressed="true"],#dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
506
+ .seg button.sel:active{background:var(--btn-hover);transform:none}
507
+ /* A text toggle that reads as a label until it is on. On = the raised
508
+ surface every selected thing on this page has (the segmented thumb, the
509
+ device chip). The edge view's Contrast toggle used to share this rule; it
510
+ is a Segment Button inside the strip's own zoom group now (Figma 8:28) and
511
+ takes .segbtn[aria-pressed="true"] instead. */
512
+ #dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
513
+ #dofBoth[aria-pressed]:hover{color:var(--ink)}
514
+ #dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
459
515
  /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
460
516
  than inherited from the base button rule, so the filled state is explicit and
461
517
  the .is-empty override below has something definite to override.
@@ -467,15 +523,30 @@
467
523
  chosen source reads as a selection. Empty = its own recessed, dashed
468
524
  surface with a mid-grey label. Active = a file is being dragged OVER the
469
525
  chip: accent stroke and label on the empty one, accent fill on the filled. */
470
- .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
526
+ /* The gap lives on the THUMB as a margin, not here as a flex gap: a gap
527
+ survives its neighbour collapsing to zero width, so an empty chip would
528
+ keep 8px of nothing between a thumbnail that is not there and its label.
529
+ As a margin it collapses with the thumb. The chip's own width is auto, so
530
+ it follows the thumb frame by frame — the only thing it animates itself is
531
+ the padding it gives back when the clear button leaves. */
532
+ .inputchip{display:inline-flex;align-items:center;max-width:220px;
471
533
  height:32px;padding:0 12px 0 6px;border-radius:999px;color:var(--ink);
472
- background:var(--raise-hi);border:var(--bw) solid var(--edge-hi)}
534
+ background:var(--raise-hi);border:var(--bw) solid var(--edge-hi);
535
+ transition:var(--t-all), padding var(--t-chip) cubic-bezier(.2,0,0,1),
536
+ width var(--t-chip) cubic-bezier(.2,0,0,1)}
473
537
  .inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
474
538
  .inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
475
539
  .inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
476
540
  color:var(--acc-ink)}
477
- .inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
478
- background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}
541
+ .inputchip .sw{width:22px;height:22px;margin-right:var(--s2);border-radius:999px;flex:none;
542
+ background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);
543
+ /* Scale from the left edge so the disc grows INTO the chip and
544
+ the label is pushed, rather than both sliding apart. */
545
+ transform-origin:left center;
546
+ transition:width var(--t-chip) cubic-bezier(.2,0,0,1),
547
+ margin-right var(--t-chip) cubic-bezier(.2,0,0,1),
548
+ transform var(--t-chip) cubic-bezier(.2,0,0,1),
549
+ opacity var(--t-chip) cubic-bezier(.2,0,0,1)}
479
550
  .inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
480
551
  /* Empty is the SAME box as filled — 32px tall, same padding, same 22px
481
552
  avatar. Only the surface, border style and label colour change. It was
@@ -484,9 +555,27 @@
484
555
  border-color:var(--edge);color:var(--inchip-ink);box-shadow:none}
485
556
  .inputchip.is-empty:hover{background:var(--float);border-color:var(--edge-mid)}
486
557
  .inputchip.is-empty:active{background:var(--raise-low);border-color:var(--edge-mid)}
487
- .inputchip.is-empty.over,.inputchip.is-empty.over:hover{background:var(--float);
488
- border-color:var(--acc-stroke);color:var(--acc-stroke)}
489
- .inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
558
+ /* Active on the UNSELECTED chip -- popover open, or a file dragged over it.
559
+ Re-read from Input chip 159:4117 on 20 Sep 2026: the stroke is grey
560
+ (input-chip/unselected/border/pressed, #494a50) and the surface is
561
+ raise-low, not the accent. The accent stroke shipped here was the one
562
+ place a control that is merely READY wore the colour reserved for "this
563
+ is the next action" -- and with an empty chip nothing has been chosen
564
+ yet, so it was promising an outcome the drop had not produced. The label
565
+ stays accent: that is what the file says, and one accent-coloured word
566
+ inside a grey pill reads as a target rather than a result.
567
+ The FILLED chip's Active state is unchanged and still solid accent
568
+ (input-chip/filled/container|border/active), which the file confirms. */
569
+ .inputchip.is-empty.over,.inputchip.is-empty.over:hover{background:var(--raise-low);
570
+ border-color:var(--edge-mid);color:var(--acc-stroke)}
571
+ /* Empty shows NO thumbnail — there is nothing to show, and a placeholder
572
+ disc read as a broken image. It collapses to zero width AND zero scale:
573
+ the width is what moves the label and grows the chip, the scale is what
574
+ makes it look like the picture arrives rather than the box stretching. */
575
+ .inputchip.is-empty .sw{width:0;margin-right:0;transform:scale(0);opacity:0;
576
+ background:none;box-shadow:none}
577
+ /* No thumb to hug, so the label gets the same 12px both sides. */
578
+ .inputchip.is-empty{padding-left:12px}
490
579
  /* The clear control is a SIBLING of the chip in the markup, not a child:
491
580
  the chip is one <button>, and a button inside a button is invalid HTML
492
581
  that browsers repair unpredictably. It is drawn INSIDE the pill though --
@@ -503,10 +592,29 @@
503
592
  the root. */
504
593
  .chipwrap{position:relative;display:inline-flex;min-width:0;z-index:52}
505
594
  .chipwrap .inputchip:not(.is-empty){padding-right:30px}
506
- .chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
595
+ /* It waits for its space. The chip spends --t-dur growing and opening the
596
+ 30px of right padding this sits in; only then does the cross fade and
597
+ scale in, so it never appears on top of a label still sliding underneath
598
+ it. Leaving is the reverse and immediate — it goes first, then the chip
599
+ closes up behind it.
600
+ visibility, not [hidden]: the global [hidden]{display:none!important} rule
601
+ cannot animate, and visibility still takes the button out of the tab order
602
+ and the accessibility tree while it is away. */
603
+ .chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%) scale(.6);
507
604
  width:22px;height:22px;padding:0;border-radius:999px;flex:none;
508
605
  display:inline-flex;align-items:center;justify-content:center;
509
- color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge)}
606
+ opacity:0;visibility:hidden;pointer-events:none;
607
+ color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge);
608
+ transition:opacity var(--t-chip-cross) cubic-bezier(.2,0,0,1),
609
+ transform var(--t-chip-cross) cubic-bezier(.2,0,0,1),
610
+ background-color var(--t), border-color var(--t),
611
+ visibility 0s linear var(--t-chip-cross)}
612
+ .chipclear.on{opacity:1;visibility:visible;pointer-events:auto;
613
+ transform:translateY(-50%) scale(1);
614
+ transition:opacity var(--t-chip-cross) cubic-bezier(.2,0,0,1) var(--t-chip),
615
+ transform var(--t-chip-cross) cubic-bezier(.2,0,0,1) var(--t-chip),
616
+ background-color var(--t), border-color var(--t),
617
+ visibility 0s}
510
618
  /* The cross is drawn, not typed. As the &times; character at 15px it sat
511
619
  above centre in its disc -- a glyph carries its own ascent and side
512
620
  bearings and no line-height setting centres it in a 22px circle ("X icon
@@ -515,7 +623,7 @@
515
623
  glyphs took in v0.42.0. */
516
624
  .chipclear svg{display:block;width:10px;height:10px}
517
625
  .chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
518
- .chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
626
+ .chipclear:active{background:var(--raise-mid)}
519
627
 
520
628
  /* Status carries meaning in THREE channels: glyph, weight, luminance —
521
629
  never hue alone. */
@@ -526,14 +634,33 @@
526
634
  height:22px;padding:0 10px;border-radius:999px;background:var(--sunk);
527
635
  border:1px solid var(--line);box-shadow:none;transition:var(--t-all)}
528
636
  .status::before{font-size:11px}
637
+ /* The depth-of-field advanced row: two buttons and a whole sentence sharing
638
+ a ~296px rail. As a NOWRAP flex row every item shrank below its own label,
639
+ the labels wrapped inside boxes with a fixed height (button.sm is 24px,
640
+ .status is 22px), and .sect-body{overflow:hidden} sliced whatever stuck
641
+ out — "Both sides" rendered as "Both ·˙·" and the measured note stopped
642
+ mid-word (found 20 Sep by a UI test pass).
643
+ The fix is the ROW, not the container: the buttons keep their natural
644
+ width, and the note takes a line of its own and grows to fit. A fixed
645
+ height is right for a one-line pill and wrong for a sentence. */
646
+ .dof-adv{flex-wrap:wrap}
647
+ .dof-adv > button{flex:none}
648
+ .dof-adv > .status{flex:1 1 100%;height:auto;min-height:22px;
649
+ padding-top:3px;padding-bottom:3px;align-items:flex-start}
529
650
  .status.ok{color:var(--ok);font-weight:400} .status.ok::before{content:"\2713";font-weight:600}
530
651
  .status.warn{color:var(--warn);font-weight:600} .status.warn::before{content:"!";font-weight:400}
531
652
  .status.err{color:var(--err);font-weight:600} .status.err::before{content:"\00d7"}
532
653
  /* A real spinner, not a static glyph. The old "\22ef" ellipsis never moved,
533
654
  so a wait that genuinely was in progress looked identical to a dead UI —
534
655
  which is exactly what it felt like while the agent wasn't running. */
656
+ /* --acc, not --accent. It said `var(--accent)` from the day it was written
657
+ and there is no such token in this file, so the declaration was invalid at
658
+ computed-value time and border-top-color fell back to currentColor. The
659
+ ring still turned -- the lit arc was just the label's own grey instead of
660
+ the accent, which is why nobody caught it by looking. Second one of these
661
+ today; see the kbd font shorthand. */
535
662
  .status.busy::before{content:"";width:9px;height:9px;border-radius:50%;
536
- border:1.5px solid var(--line);border-top-color:var(--accent);
663
+ border:1.5px solid var(--line);border-top-color:var(--acc);
537
664
  animation:sg-spin .7s linear infinite}
538
665
  @keyframes sg-spin{to{transform:rotate(360deg)}}
539
666
  @media (prefers-reduced-motion:reduce){
@@ -551,18 +678,47 @@
551
678
  of this page where that is optional is a version where the check is
552
679
  optional. Two equal panes plus the rail. */
553
680
  .stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px;
554
- min-height:0;overflow:hidden}
681
+ min-height:0;overflow:hidden;
682
+ /* The COLUMN animates, not the rail's contents: the panes have to grow
683
+ into the space at exactly the rate the rail gives it up, and one
684
+ interpolating track does that for free. Engines that will not
685
+ interpolate grid-template-columns simply jump, which is the
686
+ behaviour this replaces. The global prefers-reduced-motion rule
687
+ near the top of this sheet switches it off. */
688
+ transition:grid-template-columns var(--t)}
555
689
  /* Below ~1100px two panes and a 344px rail no longer fit, and the page starts
556
690
  scrolling sideways — which the UI audit catches as a hard failure. Stack the
557
691
  panes instead of dropping one: losing the comparison is the one thing this
558
692
  layout exists to prevent, so the axis gives way before the pairing does.
559
693
  (Found by the audit after compare stopped being optional, at 742px.) */
694
+ /* Collapsed (the top bar's last button). The rail is 344px of window the two
695
+ panes are not getting, and nothing in it is needed while you are placing a
696
+ corner — so it folds away and they take the room. The state is remembered.
697
+ It also buys back the side-by-side comparison on a narrower window: the
698
+ rule below stacks the panes under 1100px because two of them PLUS a 344px
699
+ rail do not fit, and without the rail they do. */
700
+ /* A 0px THIRD track, not two tracks: the rail is still a grid item, and with
701
+ only two tracks declared it would be auto-placed into an implicit third
702
+ column and reappear at full width. */
703
+ .app.railoff .stage{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 0px}
704
+ /* display:none cannot animate, and a 0-wide rail with overflow:auto still
705
+ holds focusable buttons — so visibility does the hiding, delayed to the end
706
+ of the slide on the way out and immediate on the way in. */
707
+ .rail{transition:visibility 0s}
708
+ .app.railoff .rail{visibility:hidden;transition:visibility 0s linear var(--t-dur)}
560
709
  @media (max-width:1100px){
561
710
  .stage{grid-template-columns:minmax(0,1fr) 344px;
562
711
  grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
563
712
  .stage > .pane:first-of-type{grid-column:1;grid-row:1}
564
713
  .stage > #outPane{grid-column:1;grid-row:2;border-top:1px solid var(--line)}
565
714
  .stage > .rail{grid-column:2;grid-row:1 / span 2}
715
+ /* Collapsed at this width the panes are stacked and each one is now the
716
+ FULL width rather than sharing it with the rail, which is the better
717
+ trade here: side by side they would be ~370px each, narrower than the
718
+ stacked pair, and the comparison is what the layout exists for. */
719
+ /* Two tracks again rather than one, for the same reason as above: this
720
+ media query places the rail in column 2 explicitly. */
721
+ .app.railoff .stage{grid-template-columns:minmax(0,1fr) 0px}
566
722
  }
567
723
  #types{flex-wrap:nowrap}
568
724
  /* The same omission one level down: rows constrained, column implicit.
@@ -720,7 +876,6 @@
720
876
  max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
721
877
  display:inline-flex;line-height:20px}
722
878
  #detSt:empty,#outSt:empty{display:none}
723
- .lbl{color:var(--ink);text-transform:uppercase;letter-spacing:.06em;font-size:11px;font-weight:600}
724
879
  /* overscroll-behavior:none on both wells (14 Sep 2026): a trackpad pinch is a
725
880
  wheel event we zoom on, but macOS still owns the gesture's elastic bounce.
726
881
  While the fingers were moving the browser let the well rubber-band past
@@ -997,7 +1152,11 @@
997
1152
  tooltip hides the only copy of the explanation from anyone not using a
998
1153
  mouse. */
999
1154
  .info{flex:none;width:16px;height:16px;padding:0;border:none;
1000
- background:none;box-shadow:none;color:var(--edge-hi);cursor:help;line-height:0}
1155
+ /* The file draws it at #3a3a40 = --raise-hi (64:375), well below the
1156
+ --edge-hi it used to carry. It is an affordance, not information:
1157
+ the sentence it holds is reachable by hover AND focus, and the
1158
+ rule below lifts it to --mute the moment anyone goes looking. */
1159
+ background:none;box-shadow:none;color:var(--raise-hi);cursor:help;line-height:0}
1001
1160
  .info svg{display:block;width:16px;height:16px}
1002
1161
  .info:hover, .info:focus-visible{color:var(--mute)}
1003
1162
  /* The tooltip is anchored to the section HEADER, not to the 16px icon, and
@@ -1120,13 +1279,20 @@
1120
1279
  silently re-deviated. */
1121
1280
  .hint{color:var(--faint);font-size:12px;margin:8px 0 0;line-height:1.5}
1122
1281
 
1123
- /* Figma 8:28: 198px tall — 16 padding, 28 header, 8 gap, 130 strip, 16
1124
- padding. It is a fixed band and must never absorb leftover height. */
1125
- .dock{background:var(--card);padding:var(--s4);display:grid;gap:var(--s2);
1126
- grid-template-rows:28px 130px;flex:none;
1282
+ /* Figma 8:28, re-read 17 Sep: 146px tall — 8 padding, 130 strip, 8 padding.
1283
+ It is a fixed band and must never absorb leftover height. The 28px header
1284
+ that held the zoom stepper and the status line is gone; both float over
1285
+ the strip now, and the 52px it cost is given back to the picture. */
1286
+ .dock{background:var(--card);padding:var(--s2);display:grid;
1287
+ grid-template-rows:130px;flex:none;
1288
+ /* Anchors the two floating children. It must stay z-index:auto — a
1289
+ stacking context here would cut their blend rings off from the strip
1290
+ behind them, which is exactly what cost the video bar its stroke in
1291
+ v0.65.0. position:relative alone creates no context. */
1292
+ position:relative;
1127
1293
  /* The rule is an inset shadow, not a border, so it does not add a
1128
- pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
1129
- box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
1294
+ pixel to the band's height: 8+130+8 = 146 exactly. */
1295
+ box-shadow:inset 0 1px 0 var(--line)}
1130
1296
  .dock.hidden{display:none}
1131
1297
  /* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
1132
1298
  screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
@@ -1156,9 +1322,24 @@
1156
1322
  #pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
1157
1323
  #pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
1158
1324
  .scroller.pointing canvas#c{cursor:crosshair}
1159
- /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
1160
- shrink to hug them. */
1161
- .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
1325
+ /* Both of these float OVER the strip (Figma 8:28). Their offsets in the file
1326
+ are from the STRIP's own top-left, and the strip starts at the dock's 8px
1327
+ padding, so each one carries that 8 too: the group at 8+8 / 8+17, the pill
1328
+ at 8+8 from the top and the right. An absolutely positioned child is laid
1329
+ out against its ancestor's PADDING box, so these are measured from the
1330
+ dock's own edges and the arithmetic is done here rather than by the box.
1331
+ .stripzoom is a WRAPPER, not a group (Figma's Frame 271): two Button
1332
+ Groups stacked with 8px between them, 32 wide and 104 tall in total. The
1333
+ zoom pair is one group and Contrast is a group of its own — the same
1334
+ separation the fit pane's left rail makes between its zoom group and Fit,
1335
+ and the reason for it is the same: a group reads as one family of actions,
1336
+ and magnification and tonal treatment are not one family. The file's 1x is
1337
+ not ported; an edge view has no "actual size" to return to. */
1338
+ .stripzoom{position:absolute;left:16px;top:25px;
1339
+ display:flex;flex-direction:column;gap:var(--s2);align-items:flex-start}
1340
+ /* left:auto undoes .pillwrap's own centring, which is right for the two pane
1341
+ pills and wrong for this one. */
1342
+ .pillwrap.stripstat{top:16px;left:auto;right:16px;margin:0}
1162
1343
  canvas#strip{display:block;width:100%;height:130px;border-radius:var(--r-md);
1163
1344
  background:var(--well);box-shadow:inset 0 0 0 1px var(--line), inset 0 1px 3px rgba(0,0,0,.5)}
1164
1345
  /* Floating mode: the loupe follows the edge instead of taking a permanent band. */
@@ -1233,6 +1414,11 @@
1233
1414
  .toast.err{border-color:var(--edge)}
1234
1415
  .toast.err .g, .toast.err .msg{color:var(--warn)}
1235
1416
  .toast .msg{min-width:0;word-break:break-word}
1417
+ /* The one action a toast may carry. Sm height and the neutral surface: it is
1418
+ an offer beside the message, not the next step, so it takes no accent —
1419
+ that means "the next action" and Send to Claude already has it. */
1420
+ .toast .act{display:inline-flex;align-items:center;margin-top:8px;
1421
+ height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
1236
1422
  .toast .x{flex:none;margin:-3px -4px 0 4px;width:22px;height:22px;padding:0;
1237
1423
  background:none;border:none;box-shadow:none;color:var(--mute);
1238
1424
  font-size:15px;line-height:22px;border-radius:var(--r-sm);opacity:0;
@@ -1315,8 +1501,36 @@
1315
1501
  .sm{font-size:12px}
1316
1502
  code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
1317
1503
  border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
1504
+ /* LONGHAND, deliberately. This was `font:11px/1.6 inherit`, which is an
1505
+ INVALID shorthand -- `inherit` is not a permitted font-family value there,
1506
+ so the browser dropped the whole declaration and every key cap silently
1507
+ rendered at .hint's inherited 12px/1.5. The 11px never once shipped.
1508
+ Caught 20 Sep 2026 while measuring cap heights, because the computed
1509
+ line-height came back 18px and 19.5px when it had just been set to 14.
1510
+ 13px, up a step by request: a key cap is a label you hunt for mid-fit, not
1511
+ body copy. `kbd.sym` goes one further because a bare glyph -- an arrow, the
1512
+ cmd or shift sign -- fills a fraction of its em box, so at a shared size it
1513
+ reads smaller and lighter than a cap holding a word like Tab.
1514
+ The line box is pinned in px rather than a ratio so both sizes yield caps
1515
+ of the SAME height; on a ratio they came out 20 and 19, and a row of caps
1516
+ disagreeing by a pixel reads as a wobble rather than a set. */
1318
1517
  kbd{border:1px solid var(--edge);border-bottom-width:2px;border-radius:var(--r-sm);
1319
- padding:1px 5px;font:11px/1.6 inherit;color:var(--mute);background:var(--sunk)}
1518
+ padding:1px 5px;font-family:inherit;font-size:13px;line-height:15px;
1519
+ /* inline-block, and this is the load-bearing part: on a plain inline box
1520
+ the painted height comes from the FONT's content area, not line-height,
1521
+ so pinning the line box did nothing and the 14px caps still measured
1522
+ 21.5 against the 13px caps' 20. An inline-block takes its height from
1523
+ its line box, so both land on 20. */
1524
+ display:inline-block;
1525
+ color:var(--mute);background:var(--sunk)}
1526
+ kbd.sym{font-size:14px}
1527
+ /* The Keys legend. max-content on the first column sizes it to the widest
1528
+ cap, so every description starts on the same x and the caps form a single
1529
+ column the eye can run down. */
1530
+ dl.keys{display:grid;grid-template-columns:max-content 1fr;gap:6px 10px;
1531
+ margin:8px 0 0;align-items:baseline}
1532
+ dl.keys dt{color:var(--faint);font-size:12px;white-space:nowrap}
1533
+ dl.keys dd{margin:0;color:var(--faint);font-size:12px;line-height:1.5}
1320
1534
  </style>
1321
1535
  </head>
1322
1536
  <body>
@@ -1330,9 +1544,9 @@
1330
1544
  <div class="tb-left">
1331
1545
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
1332
1546
  <div class="tb-chips">
1333
- <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1547
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1334
1548
  <span class="tb-arrow">&rarr;</span>
1335
- <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1549
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1336
1550
  </div>
1337
1551
  </div>
1338
1552
  <span class="spacer"></span>
@@ -1349,8 +1563,11 @@
1349
1563
  <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
1350
1564
  <button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
1351
1565
  </span>
1352
- <button id="save" class="accent" disabled>Preview first</button>
1353
- <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1566
+ <span class="tb-save">
1567
+ <button id="save" disabled>Save</button>
1568
+ <button id="imp" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1569
+ </span>
1570
+ <button id="railBtn" class="iconbtn" aria-expanded="true" aria-controls="rail" aria-label="Settings rail"><svg class="i-open" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M4.76855 1C4.50954 1.2888 4.30589 1.62677 4.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H4.17383C4.30589 14.3732 4.50954 14.7112 4.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H4.76855Z"/><path fill="var(--icon)" d="M7 15C5.89543 15 5 14.1046 5 13V3C5 1.96435 5.78722 1.113 6.7959 1.01074L7 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H7ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H7C6.44771 2 6 2.44771 6 3V13C6 13.5523 6.44772 14 7 14H13ZM7.14648 8.64648L7.79297 8L7.14648 7.35352L7.85352 6.64648L9.20703 8L7.85352 9.35352L7.14648 8.64648Z"/></svg><svg class="i-closed" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M8.76855 1C8.50954 1.2888 8.30589 1.62677 8.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H8.17383C8.30589 14.3732 8.50954 14.7112 8.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H8.76855Z"/><path fill="var(--icon)" d="M11 15C9.89543 15 9 14.1046 9 13V3C9 1.96435 9.78722 1.113 10.7959 1.01074L11 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H11ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H11C10.4477 2 10 2.44771 10 3V13C10 13.5523 10.4477 14 11 14H13ZM10.793 8L12.1465 6.64648L12.8535 7.35352L12.207 8L12.8535 8.64648L12.1465 9.35352L10.793 8Z"/></svg></button>
1354
1571
  </div>
1355
1572
  </header>
1356
1573
 
@@ -1380,10 +1597,10 @@
1380
1597
  <button class="segbtn" id="czFit" aria-label="Fit the photo to the pane" title="Fit the whole photo in the pane"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="var(--mute)" d="M14.1025 1.00488C14.6067 1.05621 15 1.48232 15 2V14L14.9951 14.1025C14.9472 14.573 14.573 14.9472 14.1025 14.9951L14 15H2C1.48232 15 1.05621 14.6067 1.00488 14.1025L1 14V2C1 1.44772 1.44772 1 2 1H14L14.1025 1.00488ZM2 14H14V2H2V14Z"/><path stroke="var(--icon)" d="M7 11.5H4.5V9M4.5 11.5L7.5 8.5"/><path stroke="var(--icon)" d="M9 4.5H11.5V7M11.5 4.5L8.5 7.5"/></svg></button>
1381
1598
  </div>
1382
1599
  <div class="bgroup corners" role="group" aria-label="Select corner"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1383
- <button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2Z"/></svg></button>
1384
- <button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M10 2H13C13.5523 2 14 2.44772 14 3V6H15V3C15 1.89543 14.1046 1 13 1H10V2Z"/></svg></button>
1385
- <button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M6 14H3C2.44772 14 2 13.5523 2 13V10H1V13C1 14.1046 1.89543 15 3 15H6V14Z"/></svg></button>
1386
- <button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M2 13C2 13.5523 2.44772 14 3 14H6V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V10H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H10V14H13C13.5523 14 14 13.5523 14 13V10H15V13ZM6 2H3C2.44772 2 2 2.44772 2 3V6H1V3C1 1.89543 1.89543 1 3 1H6V2ZM13 1C14.1046 1 15 1.89543 15 3V6H14V3C14 2.44772 13.5523 2 13 2H10V1H13Z"/><path fill="var(--acc-ink)" d="M10 14H13C13.5523 14 14 13.5523 14 13V10H15V13C15 14.1046 14.1046 15 13 15H10V14Z"/></svg></button>
1600
+ <button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M5 2.5H3C2.72386 2.5 2.5 2.72386 2.5 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2.5Z"/></svg></button>
1601
+ <button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M11 2.5H13C13.2761 2.5 13.5 2.72386 13.5 3V5H15V3C15 1.89543 14.1046 1 13 1H11V2.5Z"/></svg></button>
1602
+ <button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M5 13.5H3C2.72386 13.5 2.5 13.2761 2.5 13V11H1V13C1 14.1046 1.89543 15 3 15H5V13.5Z"/></svg></button>
1603
+ <button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M11 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V11H15V13C15 14.1046 14.1046 15 13 15H11V13.5Z"/></svg></button>
1387
1604
  </div>
1388
1605
  </div>
1389
1606
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
@@ -1414,7 +1631,13 @@
1414
1631
  fitted quad by the browser. Not the composite — the composite already
1415
1632
  has a frame burned into it, so playing over that would show two screens.
1416
1633
  --><div id="liveWrap" hidden><img id="liveBg" alt=""><video id="liveVid" loop muted playsinline></video></div></div>
1417
- <span class="pillwrap"><i class="ring sat" aria-hidden="true"></i><i class="ring lum" aria-hidden="true"></i><i class="ring hue" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i><span class="status" id="outSt">Press Preview</span></span>
1634
+ <span class="pillwrap"><i class="ring sat" aria-hidden="true"></i><i class="ring lum" aria-hidden="true"></i><i class="ring hue" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i><!-- Empty on purpose. It read "Press Preview" until 20 Sep 2026, which named
1635
+ a button that does not exist: the compare pane renders on its own after
1636
+ every correction, so there is nothing to press. The pane already says
1637
+ what it is waiting for in its own body text, and .pillwrap:has(>
1638
+ .status:empty) hides the pill until there is something true to put in
1639
+ it. -->
1640
+ <span class="status" id="outSt"></span></span>
1418
1641
  <!-- Video controls. Hidden entirely for a still source: a scrubber over a
1419
1642
  screenshot is a control that can only confuse. The fit itself is
1420
1643
  unchanged — you match the edges on one frame and every frame gets the
@@ -1454,7 +1677,7 @@
1454
1677
  </div>
1455
1678
  </section>
1456
1679
 
1457
- <aside class="rail">
1680
+ <aside class="rail" id="rail">
1458
1681
  <div class="sect" id="secDevice" data-on="1">
1459
1682
  <div class="sect-top"><div class="sect-left"><h4>Device</h4></div></div>
1460
1683
  <div class="chips" id="types"></div>
@@ -1522,7 +1745,7 @@
1522
1745
  <div class="sect-body"><div class="sect-body-in">
1523
1746
  <div style="display:grid;gap:var(--s2)">
1524
1747
  <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
1525
- <span class="sm" style="color:var(--mute)">Blur grows toward (of the screen)</span>
1748
+ <span class="sm" style="color:var(--mute)">Blur grows toward</span>
1526
1749
  <span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
1527
1750
  </div>
1528
1751
  <input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
@@ -1531,7 +1754,7 @@
1531
1754
  <span class="sm" id="dofStrVal" style="font-variant-numeric:tabular-nums"></span>
1532
1755
  </div>
1533
1756
  <input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
1534
- <div style="display:flex;align-items:center;gap:var(--s2)">
1757
+ <div class="dof-adv" style="display:flex;align-items:center;gap:var(--s2)">
1535
1758
  <button class="sm" id="dofBoth" aria-pressed="false" title="Blur on both sides of the focus line — the depth of field's near and far limits, for a phone whose middle is sharp and both ends soft. Off, the plane of focus is at the screen's edge and blur grows one way.">Both sides</button>
1536
1759
  <button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
1537
1760
  <span class="status sm" id="dofSt" style="min-width:0"></span>
@@ -1552,26 +1775,61 @@
1552
1775
  </div>
1553
1776
  <div class="sect" id="secKeys" data-on="1">
1554
1777
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
1555
- <!-- Two lines, split by what they act on: the QUAD you are fitting, and
1556
- the VIEW you are fitting it in. Grouping them by that rather than
1557
- by modifier is what makes the list scannable while you are mid-fit
1558
- and looking for one of them. -->
1559
- <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
1560
- <p class="hint"><kbd>&#8984;</kbd>+scroll zoom to pointer · <kbd>Space</kbd>+drag pan · scroll pans</p>
1778
+ <!-- One row per key, in the order you meet them: place the quad, move
1779
+ between its parts, then move the view you are placing it in.
1780
+
1781
+ A <dl>, because these ARE term/description pairs -- saying so in the
1782
+ markup is also what lets a screen reader read a key and its meaning
1783
+ as one unit. It ran as prose lines until 20 Sep 2026, which read
1784
+ fine in a wide rail and badly in a narrow one: the caps ended up
1785
+ mid-sentence and mid-wrap, so finding one meant reading the line
1786
+ rather than scanning a column.
1787
+
1788
+ Checked line by line against the handlers the same day and it held,
1789
+ with two gaps, both fixed here. (a) The arrows do nothing until an
1790
+ edge or corner is SELECTED (`if (!pick) return`) -- "nudge 1px"
1791
+ implied they always work. (b) Escape was undocumented, and it is the
1792
+ way out of the canvas's Tab cycle, so the one line that told you how
1793
+ to get IN had no counterpart telling you how to get out. Also named
1794
+ the trackpad pinch: the wheel handler takes ctrlKey as well as
1795
+ metaKey, so a pinch has always zoomed, unmentioned, on the gesture
1796
+ most people reach for first on this hardware.
1797
+
1798
+ Those two conditions sit in the note BELOW the list rather than in
1799
+ parentheses on a row, because each qualifies several rows. -->
1800
+ <dl class="keys">
1801
+ <dt><kbd class="sym">&larr;&uarr;&rarr;&darr;</kbd></dt><dd>nudge 1px</dd>
1802
+ <dt><kbd class="sym">&#8679;</kbd>+arrow</dt><dd>nudge 10px</dd>
1803
+ <dt><kbd>Tab</kbd></dt><dd>next edge</dd>
1804
+ <dt><kbd>Esc</kbd></dt><dd>release the photo</dd>
1805
+ <dt><kbd class="sym">&#8984;</kbd>+scroll or pinch</dt><dd>zoom to the pointer</dd>
1806
+ <dt><kbd>Space</kbd>+drag</dt><dd>pan</dd>
1807
+ <dt><kbd>scroll</kbd></dt><dd>pan</dd>
1808
+ </dl>
1809
+ <p class="hint">Arrows act on the selected edge or corner. <kbd>Tab</kbd> and <kbd>Esc</kbd> need a click on the photo first.</p>
1561
1810
  </div>
1562
1811
  </aside>
1563
1812
  </div>
1564
1813
 
1565
1814
  <footer class="dock" id="dock">
1566
- <div class="striphead">
1567
- <span class="lbl">Edge view</span>
1568
- <span class="stepper dock-step"><button id="stripOut" aria-label="Zoom the edge view out">&minus;</button><button id="stripIn" aria-label="Zoom the edge view in">+</button></span>
1569
- <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
1570
- <button class="sm" id="stripHC" aria-pressed="false" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched.">Contrast</button>
1571
- <span class="spacer"></span>
1572
- <span class="sm" id="stripSt" style="color:var(--mute)"></span>
1573
- </div>
1574
1815
  <canvas id="strip" width="1280" height="130"></canvas>
1816
+ <!-- Both float over the strip and come AFTER it in the DOM, so they paint
1817
+ on top of it without a z-index — the same reason #outSt sits after
1818
+ #outWrap. A z-index here would cost them their blend rings. -->
1819
+ <div class="stripzoom">
1820
+ <div class="bgroup" role="group" aria-label="Edge view magnification"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1821
+ <button class="segbtn" id="stripIn" aria-label="Zoom the edge view in" title="Zoom the edge view in — a narrower band across the boundary"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--icon)" d="M8.5 7.5H12.5V8.5H8.5V12.5H7.5V8.5H3.5V7.5H7.5V3.5H8.5V7.5Z"/></svg></button>
1822
+ <button class="segbtn" id="stripOut" aria-label="Zoom the edge view out" title="Zoom the edge view out — a wider band across the boundary"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--icon)" d="M12.5 8.5H3.5V7.5H12.5V8.5Z"/></svg></button>
1823
+ </div>
1824
+ <!-- Its own group (Figma 271:1337), a single segment, so it takes the
1825
+ radius on all four corners from .bgroup > .segbtn:only-of-type.
1826
+ Glyph is icons / contrast (272:1421), which replaced the half-disc
1827
+ drawn here while the file still had the Fit icon on this button. -->
1828
+ <div class="bgroup"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1829
+ <button class="segbtn" id="stripHC" aria-pressed="false" aria-label="High contrast" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched."><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M7 2.08398C4.16237 2.56013 2 5.02706 2 8C2 10.9729 4.16245 13.4388 7 13.915V14.9268C3.60787 14.4414 1 11.5264 1 8C1 4.4736 3.6078 1.55758 7 1.07227V2.08398Z"/><path fill="var(--icon)" d="M8 1C9.90691 1 11.635 1.76326 12.8975 3H13V3.10156C14.237 4.36408 15 6.09284 15 8C15 9.90691 14.2367 11.635 13 12.8975V13H12.8975C11.635 14.2367 9.90691 15 8 15V1ZM9 13.915C9.34419 13.8573 9.67834 13.7709 10 13.6572V2.3418C9.67838 2.22811 9.34414 2.14173 9 2.08398V13.915ZM11 13.1953C11.3584 12.9879 11.6934 12.7452 12 12.4707V3.52832C11.6935 3.25392 11.3584 3.01105 11 2.80371V13.1953ZM13 11.3164C13.6315 10.3663 14 9.22632 14 8C14 6.77339 13.6317 5.63287 13 4.68262V11.3164Z"/></svg></button>
1830
+ </div>
1831
+ </div>
1832
+ <span class="pillwrap stripstat"><i class="ring sat" aria-hidden="true"></i><i class="ring lum" aria-hidden="true"></i><i class="ring hue" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i><span class="status" id="stripSt"></span></span>
1575
1833
  </footer>
1576
1834
  </div>
1577
1835
 
@@ -1730,6 +1988,20 @@ function toastPath(p){
1730
1988
  return `<span class="path">${esc.replace(/\//g, '/<wbr>')}</span>`;
1731
1989
  }
1732
1990
 
1991
+ /* "Open folder" on the save toast. A browser cannot open a file manager from
1992
+ an http page, so the page asks the server, which runs on the machine.
1993
+ Delegated from the container rather than wired per toast: a toast is created
1994
+ and destroyed constantly, and one listener outlives all of them. It fails
1995
+ soft on purpose — the page has a second host with no filesystem at all, and
1996
+ there the route simply is not there. */
1997
+ $('#toasts')?.addEventListener('click', async (e) => {
1998
+ const b = e.target.closest('[data-reveal]');
1999
+ if (!b) return;
2000
+ b.disabled = true;
2001
+ try { await api('/api/reveal', { path: decodeURIComponent(b.dataset.reveal) }); }
2002
+ catch (err) { b.disabled = false; toast('err', 'Could not open the folder: ' + err.message); }
2003
+ });
2004
+
1733
2005
  function toast(kind, html, opts = {}) {
1734
2006
  const wrap = $('#toasts');
1735
2007
  if (!wrap) return null;
@@ -1766,7 +2038,11 @@ function toast(kind, html, opts = {}) {
1766
2038
  there is nowhere down for it to go. */
1767
2039
  let startX = 0, startY = 0, dragging = false;
1768
2040
  el.addEventListener('pointerdown', e => {
1769
- if (e.target.closest('.x')) return;
2041
+ // Anything clickable in a toast must bail out BEFORE the capture below:
2042
+ // setPointerCapture retargets the resulting click to the toast, so a
2043
+ // button inside it never sees one. The dismiss × has always needed this;
2044
+ // .act (Open folder) needs it for exactly the same reason.
2045
+ if (e.target.closest('.x, .act')) return;
1770
2046
  dragging = true; startX = e.clientX; startY = e.clientY;
1771
2047
  el.dataset.dragging = 'true';
1772
2048
  try { el.setPointerCapture(e.pointerId); } catch (err) {}
@@ -1940,12 +2216,66 @@ function casedText(c, text, x, y, color){
1940
2216
  The page reads the bytes and the SERVER judges them: whether this is the
1941
2217
  photograph the fit was made for is a question only the side holding the
1942
2218
  pixels can answer. */
2219
+ /* One busy state for every operation on this page that can take a while.
2220
+
2221
+ Measured 20 Sep 2026, because the report was "the 4K image takes time to
2222
+ load" and the guess attached to it was detection. The guess was right and
2223
+ the description was not: a cold 4000x4000 photo fetches and decodes in about
2224
+ 150ms, while `scripts/detect.py` on the same file takes **11.2s** -- it runs
2225
+ at full resolution, it does not downscale. So the wait is real, it is
2226
+ detection, and it was already being narrated -- just not well enough to be
2227
+ believed, because a static "Detecting..." for eleven seconds is
2228
+ indistinguishable from a page that has stopped.
2229
+
2230
+ Two things fix that, and neither is a bigger spinner. A counter that keeps
2231
+ moving is the difference between slow and stuck, which the Figma import has
2232
+ understood since it was written -- this is that pattern, lifted out of it so
2233
+ three more callers can have it. And the 250ms delay means a fast operation
2234
+ never flashes a spinner it did not need, which is why this can be used on
2235
+ everything without making the page feel busier than it is.
2236
+
2237
+ `settle(restore)` ends it. Pass true when the caller does NOT write its own
2238
+ status afterwards and the line held something worth putting back; pass false
2239
+ when the caller is about to overwrite it anyway. */
2240
+ function busy(el, label, opts){
2241
+ const o = opts || {};
2242
+ const delay = o.delay == null ? 250 : o.delay;
2243
+ const countFrom = o.countFrom == null ? 2 : o.countFrom;
2244
+ const t0 = Date.now();
2245
+ const prev = {cls: el.className, html: el.innerHTML, title: el.title};
2246
+ // Keep a size modifier the caller had set; only the state word changes.
2247
+ const cls = 'status ' + (/\bsm\b/.test(prev.cls) ? 'sm ' : '') + 'busy';
2248
+ let armed = false, tick = null;
2249
+ const paint = () => {
2250
+ const n = Math.round((Date.now() - t0) / 1000);
2251
+ el.className = cls;
2252
+ el.innerHTML = n >= countFrom ? `${label} <span class="el">${n}s</span>` : `${label}…`;
2253
+ };
2254
+ const arm = setTimeout(() => { armed = true; paint(); tick = setInterval(paint, 1000); }, delay);
2255
+ // A timer is throttled to about once a minute in a hidden tab, and this is
2256
+ // exactly the wait somebody switches away from. Repaint on return so the
2257
+ // number is right the moment they look at it again.
2258
+ document.addEventListener('visibilitychange', paint);
2259
+ return function settle(restore){
2260
+ clearTimeout(arm);
2261
+ if (tick){ clearInterval(tick); tick = null; }
2262
+ document.removeEventListener('visibilitychange', paint);
2263
+ if (armed && restore){ el.className = prev.cls; el.innerHTML = prev.html; el.title = prev.title; }
2264
+ armed = false;
2265
+ };
2266
+ }
1943
2267
  async function loadFitFile(file){
1944
2268
  const s = $('#detSt');
2269
+ // Declared out here so the catch can end the counter too.
2270
+ let settle = () => {};
1945
2271
  try {
1946
2272
  const text = await file.text();
1947
- s.className = 'status busy'; s.textContent = 'Reading that fit…';
2273
+ // /api/fit hashes the photograph's decoded pixels to decide whether this
2274
+ // fit belongs to it, so it costs more on a big photo, for the same reason
2275
+ // detection does.
2276
+ settle = busy(s, 'Reading that fit');
1948
2277
  const r = await api('/api/fit', {fit: JSON.parse(text)});
2278
+ settle(false);
1949
2279
  st.corners = r.corners.map(c => c.slice());
1950
2280
  st.measured = null;
1951
2281
  st.quadFrom = 'file';
@@ -1968,6 +2298,7 @@ async function loadFitFile(file){
1968
2298
  pick = {kind:'edge', i:0};
1969
2299
  draw(); drawStrip(); autoPreview();
1970
2300
  } catch (e) {
2301
+ settle(false);
1971
2302
  s.className = 'status warn'; s.textContent = 'That fit could not be used';
1972
2303
  s.title = e.message || '';
1973
2304
  toast('err', 'Could not load that fit: ' + (e.message || e));
@@ -2016,17 +2347,59 @@ async function loadRecent(){
2016
2347
  the corners, the screenshot owns the video state and the fitted frame,
2017
2348
  either owns the result. The server does the same to the session
2018
2349
  (/api/clear), so a reload agrees with what is on screen. */
2350
+ /* The chip is sized by its content, and content changes in ONE frame: the
2351
+ label swaps between "Choose a photo…" and a filename, and the box arrives at
2352
+ its new width before the thumbnail has moved at all. That is the jump — the
2353
+ thumb and the padding were animating politely while the width had already
2354
+ finished. `auto` cannot be transitioned, so the width is measured on both
2355
+ sides of the change and animated explicitly between the two.
2356
+ Released back to auto once it lands, because a fixed width would stop a
2357
+ later filename or a window resize from reflowing the chip normally.
2358
+ Found 20 Sep by reading a screen recording frame by frame. */
2359
+ const chipMs = () => parseFloat(getComputedStyle(document.documentElement)
2360
+ .getPropertyValue('--t-chip')) || 260;
2361
+ function reshapeChip(chip, apply){
2362
+ const from = chip.getBoundingClientRect().width;
2363
+ // Pin BEFORE the content changes, so the swap cannot paint at its new
2364
+ // natural width even for a frame — and so the measurement below, which
2365
+ // forces a layout, cannot commit that width as the transition's start.
2366
+ chip.style.width = from + 'px';
2367
+ apply();
2368
+ // Measure the target on a GHOST, not on the live chip. The live thumbnail is
2369
+ // mid-transition at this instant and still reports width 0, so measuring
2370
+ // here lands ~30px short and the chip finishes with a second, untimed step
2371
+ // when the pin is released. The ghost carries the same classes and content
2372
+ // with transitions off, so it is already at rest.
2373
+ const ghost = chip.cloneNode(true);
2374
+ ghost.removeAttribute('id');
2375
+ ghost.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
2376
+ ghost.style.cssText = 'position:absolute;left:-9999px;top:0;width:auto;' +
2377
+ 'visibility:hidden;pointer-events:none;transition:none';
2378
+ chip.parentElement.appendChild(ghost);
2379
+ const to = ghost.getBoundingClientRect().width;
2380
+ ghost.remove();
2381
+ if (Math.abs(to - from) < 0.5){ chip.style.width = ''; return; }
2382
+ void chip.offsetWidth; // commit the start
2383
+ chip.style.width = to + 'px';
2384
+ // Back to auto once it lands: a fixed width would stop a later filename or a
2385
+ // window resize from reflowing the chip normally.
2386
+ clearTimeout(chip._reshape);
2387
+ chip._reshape = setTimeout(() => { chip.style.width = ''; }, chipMs() + 40);
2388
+ }
2389
+
2019
2390
  async function clearSource(role){
2020
2391
  try { await api('/api/clear', {role}); }
2021
2392
  catch(e){ alert(e.message); return; }
2022
2393
  const n = role==='photo'?1:2;
2023
2394
  const chip = $('#chip'+n);
2024
- chip.classList.add('is-empty');
2025
- chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
2395
+ reshapeChip(chip, () => {
2396
+ chip.classList.add('is-empty');
2397
+ chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
2398
+ });
2026
2399
  chip.title = '';
2027
2400
  $('#sw'+n).style.background = '';
2028
2401
  $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
2029
- $('#clear'+n).hidden = true;
2402
+ $('#clear'+n).classList.remove('on');
2030
2403
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
2031
2404
  dropResult();
2032
2405
  if (role==='photo'){
@@ -2035,6 +2408,10 @@ async function clearSource(role){
2035
2408
  // Nothing to draw on: the canvas goes back to its empty state rather
2036
2409
  // than showing the last photograph with no way to act on it.
2037
2410
  const c = $('#c'); c.getContext('2d').clearRect(0, 0, c.width, c.height);
2411
+ // Belt and braces, and worth it on its own: a 4000x4000 decode is ~64MB of
2412
+ // pixels held for a photograph the user has just removed. Dropping it makes
2413
+ // the stale-repaint above impossible rather than merely unpainted.
2414
+ img.removeAttribute('src');
2038
2415
  // ... and the edge view, which would otherwise keep showing a strip of a
2039
2416
  // photograph that is no longer here.
2040
2417
  for (const id of ['strip', 'stripF']) {
@@ -2085,14 +2462,16 @@ function setChosen(role, path, size, el, meta){
2085
2462
  const ver = (meta && meta.mtime) || Date.now();
2086
2463
  $('#pv'+n).innerHTML = `<img src="${srcURL(thumb, ver)}" alt="">`;
2087
2464
  const chip = $('#chip'+n);
2088
- chip.classList.remove('is-empty');
2089
2465
  // The file's own name and nothing else. The size and, for a clip, the frame
2090
2466
  // count used to follow it in the same 12px, which pushed the one thing that
2091
2467
  // identifies the file into an ellipsis; they live on hover now, with the
2092
2468
  // full path. An upload's session copy is "<role>-<epoch>-<name>", so the
2093
2469
  // server sends the original name back as `name`.
2094
2470
  const shown = (meta && meta.name) || path.split('/').pop();
2095
- chip.querySelector('.nm').textContent = shown;
2471
+ reshapeChip(chip, () => {
2472
+ chip.classList.remove('is-empty');
2473
+ chip.querySelector('.nm').textContent = shown;
2474
+ });
2096
2475
  // A different file in either role: the composite on screen is of the old
2097
2476
  // one. Dropped here, before the state changes, so nothing downstream can
2098
2477
  // narrate over it; maybeStart() re-renders once the fit is ready.
@@ -2100,7 +2479,7 @@ function setChosen(role, path, size, el, meta){
2100
2479
  chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
2101
2480
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
2102
2481
  $('#sw'+n).style.background = `center/cover no-repeat url("${srcURL(thumb, ver)}")`;
2103
- $('#clear'+n).hidden = false;
2482
+ $('#clear'+n).classList.add('on');
2104
2483
  // A fit the server recognised from this photograph's own pixels. Held
2105
2484
  // rather than applied here: the quad only means anything once there is a
2106
2485
  // screenshot to put inside it, which is what maybeStart() waits for.
@@ -2313,10 +2692,44 @@ function distToSeg(p,a,b){
2313
2692
  return {d: vlen(sub(p, add(a, mul(ab,t)))), t};
2314
2693
  }
2315
2694
 
2695
+ /* Loading the photograph is the one long step on this page that said nothing.
2696
+ `detect()` has narrated itself since it existed, and it runs AFTER this, so a
2697
+ 4K photo spent its whole fetch-and-decode in silence with the picker already
2698
+ closed and the canvas still empty -- which reads as the click not having
2699
+ landed. Reported 20 Sep 2026, with detection as the suspected cause; it was
2700
+ not, and the measured order is: set img.src -> fetch the full-resolution file
2701
+ -> browser decode -> onload -> computeFit/draw -> only then detect().
2702
+
2703
+ Armed on a 250ms delay so a small photo never flashes a spinner it does not
2704
+ need, and it counts seconds past 2 for the same reason the Figma import does:
2705
+ a number that keeps moving is the difference between slow and stuck.
2706
+
2707
+ The previous status is CAPTURED and handed back on settle rather than
2708
+ blanked. One caller (setChosen, changing the screenshot under an existing
2709
+ fit) writes "the fit on the photo is kept" and then calls in here, and the
2710
+ branch below that keeps existing corners writes nothing -- so clearing would
2711
+ have eaten somebody else's message. */
2712
+ let photoBusy = null; // settle() for a load still in flight
2316
2713
  async function maybeStart(){
2317
2714
  if (!st.photo) return;
2318
2715
  const ready = !!st.shot; // the screenshot gates the fit, not the photo
2716
+ const s = $('#detSt');
2717
+ if (photoBusy) photoBusy(true); // a second pick mid-load: retire the first
2718
+ const done = busy(s, 'Loading the photo');
2719
+ const settle = () => { done(true); photoBusy = null; };
2720
+ photoBusy = settle;
2721
+ // There was no onerror at all, so a photo that failed to decode left the page
2722
+ // silent for good: no canvas, no message, nothing to do next.
2723
+ img.onerror = () => {
2724
+ // Clearing the photo drops img's src on purpose; that is not a failure.
2725
+ if (!st.photo) { settle(); return; }
2726
+ settle();
2727
+ s.className = 'status err';
2728
+ s.textContent = 'That photo could not be opened — try another, or drop the file in again.';
2729
+ s.title = homePath(st.photo || '');
2730
+ };
2319
2731
  img.onload = () => {
2732
+ settle();
2320
2733
  computeFit(); setCanvasZoom(fitScale);
2321
2734
  refreshRings();
2322
2735
  if (!ready){
@@ -2525,9 +2938,11 @@ $('#scroller').addEventListener('pointerdown', e => {
2525
2938
  pointAt(p[0], p[1]);
2526
2939
  }, true);
2527
2940
  async function pointAt(x, y){
2528
- const s = $('#detSt'); s.className = 'status busy'; s.textContent = 'Reading that point…';
2941
+ const s = $('#detSt');
2942
+ const settle = busy(s, 'Reading that point');
2529
2943
  try {
2530
2944
  const r = await api('/api/detect', {click: [x, y]});
2945
+ settle(false);
2531
2946
  if (r.found){
2532
2947
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
2533
2948
  orientQuad();
@@ -2543,6 +2958,7 @@ async function pointAt(x, y){
2543
2958
  s.title = r.message || '';
2544
2959
  }
2545
2960
  } catch (err) {
2961
+ settle(false);
2546
2962
  s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
2547
2963
  }
2548
2964
  pick = {kind:'edge', i:0};
@@ -2557,8 +2973,29 @@ document.querySelectorAll('[data-jump]').forEach(b => {
2557
2973
  });
2558
2974
 
2559
2975
  async function detect(){
2560
- const s = $('#detSt'); s.className='status busy'; s.textContent = 'Detecting…';
2561
- const r = await api('/api/detect', {});
2976
+ const s = $('#detSt');
2977
+ // The longest wait on the page: 11.2s measured on a 4000x4000 photo, because
2978
+ // detect.py runs at full resolution. The counter is what makes that read as
2979
+ // working rather than hung.
2980
+ const settle = busy(s, 'Detecting');
2981
+ let r;
2982
+ // There was no catch here at all, so a request that failed left 'Detecting…'
2983
+ // spinning for good -- the one state the page could never leave.
2984
+ try { r = await api('/api/detect', {}); }
2985
+ catch (e){
2986
+ settle(false);
2987
+ s.className = 'status err';
2988
+ s.textContent = 'Detection failed — try Point at screen, or place the edges by hand';
2989
+ s.title = e.message || '';
2990
+ suggestPointing(true);
2991
+ st.corners = defaultQuad(); st.quadFrom = 'default';
2992
+ if (!st.type) setType('phone');
2993
+ applyPreset();
2994
+ pick = {kind:'edge', i:0};
2995
+ draw(); drawStrip(); autoPreview();
2996
+ return;
2997
+ }
2998
+ settle(false);
2562
2999
  if (r.found){
2563
3000
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
2564
3001
  orientQuad();
@@ -2784,7 +3221,12 @@ function paintRingsFallback(){
2784
3221
  if (!IS_WEBKIT || _ringsQueued) return;
2785
3222
  _ringsQueued = true;
2786
3223
  requestAnimationFrame(() => { _ringsQueued = false;
2787
- const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')]];
3224
+ // The edge-view dock is a third surface carrying groups (Figma 8:28, 17
3225
+ // Sep), and its backdrop is the strip canvas rather than either pane's
3226
+ // picture. Without this pair the dock's zoom group and status pill get no
3227
+ // ring colour at all in Safari: they sit outside both panes, so the loop
3228
+ // never reached them and .noblend leaves the ring transparent.
3229
+ const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')], [strip, $('#dock')]];
2788
3230
  for (const [src, pane] of panes){
2789
3231
  if (!src || !pane) continue;
2790
3232
  const ok = src.tagName === 'CANVAS' ? src.width > 0 : (!src.hidden && src.naturalWidth > 0);
@@ -2814,7 +3256,16 @@ function draw(){
2814
3256
  if (!$('#liveWrap').hidden) placeLiveVideo();
2815
3257
  ctx.setTransform(cvDpr, 0, 0, cvDpr, 0, 0); // CSS px from here on; the store is cvDpr times larger
2816
3258
  ctx.clearRect(0,0,cvW,cvH);
2817
- if (!img.naturalWidth) return;
3259
+ // st.photo FIRST, and this is the whole bug reported 21 Sep 2026: clear the
3260
+ // photo, then click anywhere in the fit pane, and the photograph came back
3261
+ // while the chip still read "Choose a photo…". The guard here was
3262
+ // `img.naturalWidth` alone -- the <img> keeps its decoded pixels until
3263
+ // something replaces them, and clearSource only wiped the CANVAS, so the
3264
+ // image was still 4000x4000 and perfectly drawable. The click ran
3265
+ // cv.onpointerdown -> no hit -> draw(), and draw() repainted what it still
3266
+ // had. Asking the <img> whether there is a photo was the mistake: the <img>
3267
+ // is a cache, st.photo is the answer.
3268
+ if (!(st.photo && img.naturalWidth)) return;
2818
3269
  ctx.drawImage(img, 0,0, cvW, cvH);
2819
3270
  if (!st.corners) return;
2820
3271
  const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
@@ -2965,9 +3416,21 @@ cv.onpointerup = cv.onpointercancel = () => { drag = null; draw(); drawStrip();
2965
3416
  cv.onpointerleave = () => { if (!drag){ hover = null; draw(); drawStrip(); } };
2966
3417
 
2967
3418
  window.addEventListener('keydown', e => {
2968
- if (e.key === 'Escape'){ closePop(); return; }
3419
+ // Escape also releases the canvas, so Tab-cycling is never a one-way door.
3420
+ if (e.key === 'Escape'){ closePop(); if (document.activeElement === cv) cv.blur(); return; }
2969
3421
  if (!st.corners || document.querySelector('.pop.on')) return;
2970
3422
  if (e.key === 'Tab'){
3423
+ // Tab is the BROWSER's focus key everywhere except the fit canvas itself.
3424
+ // This was taken at window level with no target check, so the moment a fit
3425
+ // existed a keyboard user could never reach the top bar or the rail: focus
3426
+ // stayed where it was and the EDGE advanced instead. Reported 20 Sep with
3427
+ // the focus ring visibly stuck on a device chip while the edge cycled —
3428
+ // WCAG 2.1.1, and the KEYS legend's "Tab next edge" is why it looked
3429
+ // deliberate rather than broken.
3430
+ // The canvas is tabindex="-1": it holds focus only after a click on it,
3431
+ // which is exactly when cycling edges is what Tab should mean. Escape
3432
+ // above gives it back.
3433
+ if (document.activeElement !== cv) return;
2971
3434
  e.preventDefault();
2972
3435
  const i = pick && pick.kind==='edge' ? (pick.i+1)%4 : 0;
2973
3436
  pick = {kind:'edge', i}; draw(); drawStrip(); return;
@@ -3422,7 +3885,7 @@ function setStripHC(on){
3422
3885
  $('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
3423
3886
  }
3424
3887
  $('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
3425
- function paintStrip(c, W, H, i, mode){
3888
+ function paintStrip(c, W, H, i, mode, padLeft = 9){
3426
3889
  c.setTransform(1,0,0,1,0,0);
3427
3890
  c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
3428
3891
  const A = st.corners[i], B = st.corners[(i+1)%4];
@@ -3462,8 +3925,8 @@ function paintStrip(c, W, H, i, mode){
3462
3925
  const centre = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
3463
3926
  const inwardIsDown = (n[0]*(centre[0]-A[0]) + n[1]*(centre[1]-A[1])) > 0;
3464
3927
  c.font = '600 11px system-ui'; c.textBaseline = 'middle';
3465
- casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
3466
- casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
3928
+ casedText(c, 'screen', padLeft, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
3929
+ casedText(c, 'outside', padLeft, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
3467
3930
  }
3468
3931
  function swingText(mode){
3469
3932
  if (mode === 'rotA') return 'the right end swings, pivot on the left';
@@ -3510,11 +3973,31 @@ function paintCorner(c, W, H, i){
3510
3973
  casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
3511
3974
  }
3512
3975
  const HANDLE_GAP_S = 4 + 1 + CASE_PAD/2 + 1; // the same break the canvas leaves around a ring
3976
+ /* Figma 8:28 puts the strip's labels at x=59, which is exactly where the
3977
+ floating zoom group ends (8 left + 32 wide) plus 19 of air. Two things stop
3978
+ that 59 from being a literal here.
3979
+ The canvas is a fixed 1280 wide stretched to whatever the band measures, so
3980
+ a number in CSS pixels and a number in canvas pixels only agree at one window
3981
+ width — hence the conversion.
3982
+ And the gap is measured off the GROUP rather than assumed: the labels are
3983
+ drawn from JS while the group is laid out by CSS, so anything that changes
3984
+ the group's width (a longer icon set, a different radius, a zoom level) would
3985
+ silently slide it under the text. Reading its real right edge means the air
3986
+ between them is the design's 19px whatever the group does, and the design's
3987
+ 59 is the floor for the case where the group has not been laid out yet —
3988
+ width 0 would otherwise put the labels at the strip's edge, UNDER the group.
3989
+ The floating loupe has no group over it and keeps the default. */
3990
+ function stripLabelInset(W){
3991
+ const r = strip.getBoundingClientRect();
3992
+ if (!(r.width > 0)) return Math.round(59 * W / 1280);
3993
+ const g = document.querySelector('.stripzoom');
3994
+ const gRight = g ? g.getBoundingClientRect().right - r.left : 40;
3995
+ return Math.round(Math.max(59, gRight + 19) * W / r.width);
3996
+ }
3513
3997
  function drawStrip(){
3514
3998
  const a = drag || hover || pick;
3515
3999
  const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
3516
4000
  const corner = has && a.kind === 'corner';
3517
- $('#stripZ').textContent = '±' + stripHalf + 'px';
3518
4001
  if (loupeMode === 'dock'){
3519
4002
  $('#floatLoupe').classList.remove('on');
3520
4003
  const W = strip.width, H = strip.height;
@@ -3527,7 +4010,7 @@ function drawStrip(){
3527
4010
  paintCorner(sctx, W, H, a.i);
3528
4011
  $('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*cornerHalf())).toFixed(1)}× — the whole arc, and where each edge lands on it`;
3529
4012
  } else {
3530
- paintStrip(sctx, W, H, a.i, a.mode);
4013
+ paintStrip(sctx, W, H, a.i, a.mode, stripLabelInset(W));
3531
4014
  $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
3532
4015
  }
3533
4016
  } else {
@@ -3608,15 +4091,25 @@ let outNat = 0, autoTimer = null;
3608
4091
  would do. On a tool whose whole pitch is that the output is exact, a label
3609
4092
  that disagrees with its action is not a small thing. */
3610
4093
  function saveLabel(){ return st.video ? 'Render' : 'Save'; }
3611
- /* The labels that mean "there is something to do". markStale's 'Preview first'
3612
- and the in-flight 'Saving…' / progress text must survive a source change, so
4094
+ /* The in-flight 'Saving…' / progress text must survive a source change, so
3613
4095
  relabelling only touches a button that is currently offering the action. */
3614
4096
  function relabelSave(){
3615
4097
  const b = $('#save');
3616
4098
  if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
3617
4099
  }
3618
4100
  function markStale(){
3619
- const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
4101
+ // This used to relabel the button 'Preview first'. Removed 20 Sep 2026 on
4102
+ // request, and the request was right on inspection: with the compare pane
4103
+ // open by default, EVERY caller of markStale that has all three inputs is
4104
+ // followed by autoPreview() within the same interaction -- pointerup after a
4105
+ // drag, the same line after a keyboard nudge, the end of pointAt(), the
4106
+ // callers of setFrac. So the label was only ever on screen for the 350ms
4107
+ // debounce. The one place it PERSISTED was a failed /api/preview, where it
4108
+ // told the user to do the thing they had just done and that had just failed.
4109
+ // Dead in the happy path, wrong in the sad one. The button keeps its own
4110
+ // verb and simply reads as unavailable; the status line says why.
4111
+ const b = $('#save'); b.disabled = true;
4112
+ b.textContent = saveLabel();
3620
4113
  // Exactly one lit action at a time — the Figma Button component says the
3621
4114
  // accent marks the NEXT action and that only one may be on screen. With no
3622
4115
  // current render there is nothing to save, so nothing is lit: Preview stays
@@ -4084,8 +4577,11 @@ $('#save').onclick = async () => {
4084
4577
  // Naming the fit file here is the whole reason it is discoverable: it is
4085
4578
  // written silently beside the mockup, and a file nobody knows about is a
4086
4579
  // file nobody drags back in.
4580
+ // encodeURIComponent, not an HTML escape: the path goes into an attribute
4581
+ // and a filename may legally contain quotes. This cannot break out of it.
4087
4582
  toast('ok', 'Saved' + toastPath(homePath(r.output))
4088
- + (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : ''),
4583
+ + (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : '')
4584
+ + `<button type="button" class="act" data-reveal="${encodeURIComponent(r.output)}">Open folder</button>`,
4089
4585
  { ms: 9000, dismissible: true });
4090
4586
  const imp = $('#imp');
4091
4587
  imp.disabled = false; imp.textContent = 'Send to Claude';
@@ -4142,14 +4638,23 @@ $('#imp').onclick = async () => {
4142
4638
  $('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
4143
4639
  $('#ver').classList.toggle('dev', !!s.build);
4144
4640
  }
4145
- // Say up front where Save will put things. With --out-dir this is usually the
4146
- // project folder, and a designer should not have to press Save to find out.
4147
- toast('info', 'Fit the four edges, then Preview. Saves go to' + toastPath(homePath(s.out_dir || '')),
4148
- { ms: 9000, dismissible: true });
4641
+ // There used to be a toast here on every load, saying "Fit the four edges,
4642
+ // then Preview" and naming the save folder. Removed 20 Sep 2026 on request.
4643
+ // It was the one message that fired when the user had done nothing, so it
4644
+ // greeted every single session -- including the hundredth -- with an
4645
+ // instruction and a long path, over the picker that had just opened by
4646
+ // itself. The instruction is already in the fit pane's own status line, and
4647
+ // the save folder is named in the toast that appears AFTER a save, which is
4648
+ // the moment it is actually wanted. Nothing replaced it: no toast on load.
4149
4649
  const types = $('#types');
4150
4650
  for (const t of ['phone','tablet','laptop','desktop']){
4151
4651
  const b=document.createElement('button'); b.className='chip'; b.dataset.t=t; b.textContent=t; /* Chip is one size in Figma (5:6): h28, 12px */
4152
- b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
4652
+ // Re-clicking the device you are ALREADY on used to re-apply its preset and
4653
+ // silently overwrite a hand-set corner radius — phone → phone jumped 10.2%
4654
+ // back to 15.4% (found 20 Sep). Picking the device you already have is a
4655
+ // no-op; the preset dropdown is the deliberate way to re-apply one.
4656
+ b.onclick=()=>{ if (st.type === t) return; setType(t); applyPreset(); autoPreview(); };
4657
+ types.appendChild(b);
4153
4658
  }
4154
4659
  setLoupeMode(loupeMode);
4155
4660
  setStripHC(stripHC);
@@ -4200,6 +4705,51 @@ $('#imp').onclick = async () => {
4200
4705
  computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
4201
4706
  });
4202
4707
  })();
4708
+
4709
+ /* ---- the settings rail collapses -----------------------------------------
4710
+ Last button in the top bar, after Send to Claude. Nothing in the rail is
4711
+ needed while a corner is being placed, and it is 344px the two panes are not
4712
+ getting — so it folds away and they take the room. Remembered across
4713
+ sessions, like the loupe mode and the strip's contrast.
4714
+ This block sits at the very end of the script deliberately: it calls into
4715
+ drawStrip/syncOut on toggle, and those close over consts declared throughout
4716
+ the file, so it must not run while any of them is still in its temporal dead
4717
+ zone. */
4718
+ (() => {
4719
+ const app = document.querySelector('.app'), btn = $('#railBtn'), stage = $('#stage');
4720
+ let railWait;
4721
+ const apply = (open, relayout) => {
4722
+ app.classList.toggle('railoff', !open);
4723
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
4724
+ btn.title = open ? 'Hide the settings rail' : 'Show the settings rail';
4725
+ if (!relayout) return;
4726
+ // The panes just changed width, which is a resize in everything but name —
4727
+ // so do what the resize handler does, then repaint the things that are
4728
+ // MEASURED rather than laid out and have all just moved: the strip's
4729
+ // labels are positioned off the zoom group's box, and the blend rings'
4730
+ // WebKit fallback samples pixels at coordinates taken from the panes.
4731
+ // AFTER the slide, not during: measuring mid-transition fits the canvas to
4732
+ // a width the pane is still leaving. The wait is read off the transition
4733
+ // itself rather than written as a number, so it cannot drift from --t, and
4734
+ // it collapses to ~nothing under prefers-reduced-motion, where the rule at
4735
+ // the top of the sheet has already set the duration to zero.
4736
+ clearTimeout(railWait);
4737
+ const ms = (parseFloat(getComputedStyle(stage).transitionDuration) || 0) * 1000;
4738
+ railWait = setTimeout(() => {
4739
+ if (img.naturalWidth){
4740
+ const wasFit = Math.abs(scale - fitScale) < 1e-6;
4741
+ computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
4742
+ }
4743
+ drawStrip(); syncOut(); refreshRings();
4744
+ }, ms + 20);
4745
+ };
4746
+ btn.onclick = () => {
4747
+ const open = app.classList.contains('railoff'); // toggling TO open
4748
+ remember('rail', open ? '1' : '0');
4749
+ apply(open, true);
4750
+ };
4751
+ apply(recall('rail', '1') === '1', false);
4752
+ })();
4203
4753
  </script>
4204
4754
  </body>
4205
4755
  </html>