screengraft 0.66.0 → 0.68.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
@@ -160,6 +178,13 @@
160
178
  /* 120ms originally, per the visual-language notes. asked for a bit
161
179
  slower on 5 Sep — one token, so retuning is a one-line change. */
162
180
  --t-dur:180ms; /* the duration alone, for delays */
181
+ /* The source chip's grow/collapse runs slower than the app's general
182
+ motion on purpose: it is a shape change across ~100px, not a state
183
+ flip, and at 180ms it read as a snap rather than a movement. The cross
184
+ that follows it is quicker, and waits out this duration first. Tune
185
+ these two and the whole chip sequence moves with them. */
186
+ --t-chip:260ms;
187
+ --t-chip-cross:150ms;
163
188
  --t:var(--t-dur) cubic-bezier(.2,0,0,1);
164
189
  --t-pop:220ms cubic-bezier(.2,0,0,1); /* popovers travel further */
165
190
  /* Every control animates the SAME four properties on the same curve. Kept as
@@ -186,14 +211,29 @@
186
211
  grid-template-columns:minmax(0,1fr);min-height:0;
187
212
  padding-bottom:32px;background:var(--bg)}
188
213
 
214
+ /* The bottom rule is an INSET SHADOW, not a border, for the same reason the
215
+ dock's is — and here it is not only about height, it is about which device
216
+ pixel things land on. A 1px border under box-sizing:border-box leaves a
217
+ 55px content box; centring the 32px chip row in an ODD height puts it at
218
+ y=11.5. The chips carry a 0.5px stroke (--bw), which only survives because
219
+ a half CSS pixel snaps to one whole device pixel — and that assumption
220
+ holds only when the element's EDGES sit on whole device pixels. At 11.5 the
221
+ bottom edge straddles the boundary, renders faint or vanishes at dpr 2, and
222
+ the chip reads as pressed. Reported 20 Sep with a screenshot of exactly
223
+ that. As an inset shadow the content box is the full 56, the row centres on
224
+ y=12, and the rule is drawn in the same place it was before. */
189
225
  .topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
190
- background:var(--card);border-bottom:1px solid var(--line);
191
- 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)}
192
229
  /* A nowrap flex row whose children keep min-width:auto cannot shrink
193
230
  below their content, so at 742px Save and Send to Claude sat at x=750..885
194
231
  — outside the viewport and unclickable. The chips carry a filename and are
195
232
  the part that should give; the actions are fixed-size and must not. */
196
233
  .topbar > *{min-width:0}
234
+ /* overflow:hidden clips the horizontal only in spirit — CSS couples the
235
+ axes — but nothing needs vertical room any more now that pressed does not
236
+ move. A long filename is still the part that gives. */
197
237
  .tb-chips{overflow:hidden}
198
238
  /* Figma 15:108: three groups at 16px — the format control, the save PAIR,
199
239
  and the rail toggle — with 8px inside the pair. Save and Send to Claude
@@ -252,13 +292,20 @@
252
292
  ------------------------------------------------------------------ */
253
293
  /* Both sizes are radius/sm (6px) — re-read from Button 4:14 on 13 Sep 2026,
254
294
  where every variant carries the same radius. Md had been 8 since 11 Sep. */
255
- button{font:inherit;color:var(--ink);background:var(--raise);
256
- border:var(--bw) solid var(--edge);border-radius:var(--r-sm);
295
+ button{font:inherit;color:var(--ink);background:var(--btn-rest);
296
+ border:var(--bw) solid var(--btn-rest-edge);border-radius:var(--r-sm);
257
297
  padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;
258
298
  transition:var(--t-all), transform var(--t)}
259
299
  button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
300
+ /* Pressed does NOT move. It used to carry translateY(.5px) — half a CSS
301
+ pixel, so one device pixel at 2x — and that read as the button jumping on
302
+ every click rather than depressing. It also put the button's own 0.5px
303
+ stroke on a half-pixel boundary, and inside any clipping parent it pushed
304
+ the bottom edge out of the box: the top-bar chips visibly lost their
305
+ bottom stroke while held (20 Sep). The darker surface and the darker
306
+ border already say pressed, and they say it without motion. */
260
307
  button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
261
- box-shadow:none;transform:translateY(.5px)}
308
+ box-shadow:none}
262
309
  button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
263
310
  /* Default/Disabled RECESSES: surface/raise-low with border/edge-low, label at
264
311
  text/faint x 50%. It used to keep the raised surface and dim only the label.
@@ -283,22 +330,25 @@
283
330
 
284
331
  /* Primary. The accent means exactly one thing: the next action, so only one
285
332
  of these is lit at a time (see the accent hand-off in the JS). */
333
+ /* Weight 400, not 600: every one of the sixteen Button variants in 4:14 is
334
+ SF Pro Regular. The bold primary was a carry-over from before the matrix
335
+ existed, and it made the accent shout twice. */
286
336
  button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
287
- border-color:var(--acc-stroke);font-weight:600;box-shadow:none}
337
+ border-color:var(--acc-stroke);font-weight:400;box-shadow:none}
288
338
  button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
289
- button.primary:active{background:var(--acc-press);border-color:var(--acc-dis-edge);
290
- transform:translateY(.5px)}
291
- /* An accent-family button that is enabled but is NOT the next action reads
292
- as an ordinary neutral control. */
293
- button.accent:not(:disabled):not(.primary){
294
- background:var(--raise); color:var(--ink); border-color:var(--edge);
295
- font-weight:400}
296
- /* Radius is deliberately NOT restated in these three rules any more. It is the
297
- base button's, so .sm keeps winning on size — an accent .sm button used to
298
- be handed --r-md by specificity alone. */
299
- 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{
300
350
  background:var(--acc-dis); border-color:var(--acc-dis-edge);
301
- 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}
302
352
 
303
353
  /* ------------------------------------------------------------------
304
354
  Render, while it is rendering: the button IS the progress bar.
@@ -423,7 +473,7 @@
423
473
  the thumb is 24px tall at radius/xs. Hover lifts the thumb to raise-hi
424
474
  and the track's stroke to edge-mid; pressed drops the thumb to raise-mid. */
425
475
  .seg{display:inline-flex;align-items:center;gap:2px;padding:2px;
426
- background:var(--raise-low);border:var(--bw) solid var(--edge);
476
+ background:var(--raise-low);border:var(--bw) solid var(--btn-rest-edge);
427
477
  border-radius:var(--r-sm);transition:var(--t-all)}
428
478
  .seg:hover{border-color:var(--edge-mid)}
429
479
  .seg:active{border-color:var(--edge-mid)}
@@ -437,22 +487,23 @@
437
487
  background:transparent;border:0;box-shadow:none;color:var(--mute);
438
488
  font-weight:400;display:inline-flex;flex-direction:column;
439
489
  align-items:center;justify-content:center}
440
- /* Each segment is as wide as its label at the SELECTED weight, whichever
441
- weight it is showing. Without this the control reflowed on every switch:
442
- 600 is wider than 400, so selecting "ProRes" grew that segment, and the
443
- whole control — left edge included — moved (reported 12 Sep 2026).
444
- A zero-height ghost of the label at 600 sits under the visible one and
445
- sets the width; `data-label` carries the text. Not padding. */
446
- .seg button::after{content:attr(data-label);font-weight:600;height:0;
447
- 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. */
448
499
  .seg button:hover{background:transparent;color:var(--ink)}
449
500
  .seg button:active{background:transparent;transform:none}
450
501
  /* The thumb. Its three surfaces are the Default button's own, which is the
451
502
  point of the component: one raised thing, behaving like every other raised
452
503
  thing on the page. */
453
- .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
504
+ .seg button.sel{background:var(--btn-rest);color:var(--ink);font-weight:400}
454
505
  .seg button.sel:hover{background:var(--raise-hi)}
455
- .seg button.sel:active{background:var(--raise-mid);transform:none}
506
+ .seg button.sel:active{background:var(--btn-hover);transform:none}
456
507
  /* A text toggle that reads as a label until it is on. On = the raised
457
508
  surface every selected thing on this page has (the segmented thumb, the
458
509
  device chip). The edge view's Contrast toggle used to share this rule; it
@@ -472,15 +523,30 @@
472
523
  chosen source reads as a selection. Empty = its own recessed, dashed
473
524
  surface with a mid-grey label. Active = a file is being dragged OVER the
474
525
  chip: accent stroke and label on the empty one, accent fill on the filled. */
475
- .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;
476
533
  height:32px;padding:0 12px 0 6px;border-radius:999px;color:var(--ink);
477
- background:var(--raise-hi);border:var(--bw) solid var(--edge-hi)}
534
+ background:var(--raise-hi);border:var(--bw) solid var(--edge-hi);
535
+ transition:var(--t-all), padding var(--t-chip) cubic-bezier(.2,0,0,1),
536
+ width var(--t-chip) cubic-bezier(.2,0,0,1)}
478
537
  .inputchip:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
479
538
  .inputchip:active{background:var(--raise-mid);border-color:var(--edge-hi)}
480
539
  .inputchip.over,.inputchip.over:hover{background:var(--acc);border-color:var(--acc-stroke);
481
540
  color:var(--acc-ink)}
482
- .inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
483
- background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35)}
541
+ .inputchip .sw{width:22px;height:22px;margin-right:var(--s2);border-radius:999px;flex:none;
542
+ background:var(--sunk);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);
543
+ /* Scale from the left edge so the disc grows INTO the chip and
544
+ the label is pushed, rather than both sliding apart. */
545
+ transform-origin:left center;
546
+ transition:width var(--t-chip) cubic-bezier(.2,0,0,1),
547
+ margin-right var(--t-chip) cubic-bezier(.2,0,0,1),
548
+ transform var(--t-chip) cubic-bezier(.2,0,0,1),
549
+ opacity var(--t-chip) cubic-bezier(.2,0,0,1)}
484
550
  .inputchip .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
485
551
  /* Empty is the SAME box as filled — 32px tall, same padding, same 22px
486
552
  avatar. Only the surface, border style and label colour change. It was
@@ -489,9 +555,27 @@
489
555
  border-color:var(--edge);color:var(--inchip-ink);box-shadow:none}
490
556
  .inputchip.is-empty:hover{background:var(--float);border-color:var(--edge-mid)}
491
557
  .inputchip.is-empty:active{background:var(--raise-low);border-color:var(--edge-mid)}
492
- .inputchip.is-empty.over,.inputchip.is-empty.over:hover{background:var(--float);
493
- border-color:var(--acc-stroke);color:var(--acc-stroke)}
494
- .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}
495
579
  /* The clear control is a SIBLING of the chip in the markup, not a child:
496
580
  the chip is one <button>, and a button inside a button is invalid HTML
497
581
  that browsers repair unpredictably. It is drawn INSIDE the pill though --
@@ -508,10 +592,29 @@
508
592
  the root. */
509
593
  .chipwrap{position:relative;display:inline-flex;min-width:0;z-index:52}
510
594
  .chipwrap .inputchip:not(.is-empty){padding-right:30px}
511
- .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);
512
604
  width:22px;height:22px;padding:0;border-radius:999px;flex:none;
513
605
  display:inline-flex;align-items:center;justify-content:center;
514
- 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}
515
618
  /* The cross is drawn, not typed. As the &times; character at 15px it sat
516
619
  above centre in its disc -- a glyph carries its own ascent and side
517
620
  bearings and no line-height setting centres it in a 22px circle ("X icon
@@ -520,7 +623,7 @@
520
623
  glyphs took in v0.42.0. */
521
624
  .chipclear svg{display:block;width:10px;height:10px}
522
625
  .chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
523
- .chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
626
+ .chipclear:active{background:var(--raise-mid)}
524
627
 
525
628
  /* Status carries meaning in THREE channels: glyph, weight, luminance —
526
629
  never hue alone. */
@@ -531,14 +634,33 @@
531
634
  height:22px;padding:0 10px;border-radius:999px;background:var(--sunk);
532
635
  border:1px solid var(--line);box-shadow:none;transition:var(--t-all)}
533
636
  .status::before{font-size:11px}
637
+ /* The depth-of-field advanced row: two buttons and a whole sentence sharing
638
+ a ~296px rail. As a NOWRAP flex row every item shrank below its own label,
639
+ the labels wrapped inside boxes with a fixed height (button.sm is 24px,
640
+ .status is 22px), and .sect-body{overflow:hidden} sliced whatever stuck
641
+ out — "Both sides" rendered as "Both ·˙·" and the measured note stopped
642
+ mid-word (found 20 Sep by a UI test pass).
643
+ The fix is the ROW, not the container: the buttons keep their natural
644
+ width, and the note takes a line of its own and grows to fit. A fixed
645
+ height is right for a one-line pill and wrong for a sentence. */
646
+ .dof-adv{flex-wrap:wrap}
647
+ .dof-adv > button{flex:none}
648
+ .dof-adv > .status{flex:1 1 100%;height:auto;min-height:22px;
649
+ padding-top:3px;padding-bottom:3px;align-items:flex-start}
534
650
  .status.ok{color:var(--ok);font-weight:400} .status.ok::before{content:"\2713";font-weight:600}
535
651
  .status.warn{color:var(--warn);font-weight:600} .status.warn::before{content:"!";font-weight:400}
536
652
  .status.err{color:var(--err);font-weight:600} .status.err::before{content:"\00d7"}
537
653
  /* A real spinner, not a static glyph. The old "\22ef" ellipsis never moved,
538
654
  so a wait that genuinely was in progress looked identical to a dead UI —
539
655
  which is exactly what it felt like while the agent wasn't running. */
656
+ /* --acc, not --accent. It said `var(--accent)` from the day it was written
657
+ and there is no such token in this file, so the declaration was invalid at
658
+ computed-value time and border-top-color fell back to currentColor. The
659
+ ring still turned -- the lit arc was just the label's own grey instead of
660
+ the accent, which is why nobody caught it by looking. Second one of these
661
+ today; see the kbd font shorthand. */
540
662
  .status.busy::before{content:"";width:9px;height:9px;border-radius:50%;
541
- border:1.5px solid var(--line);border-top-color:var(--accent);
663
+ border:1.5px solid var(--line);border-top-color:var(--acc);
542
664
  animation:sg-spin .7s linear infinite}
543
665
  @keyframes sg-spin{to{transform:rotate(360deg)}}
544
666
  @media (prefers-reduced-motion:reduce){
@@ -1292,6 +1414,11 @@
1292
1414
  .toast.err{border-color:var(--edge)}
1293
1415
  .toast.err .g, .toast.err .msg{color:var(--warn)}
1294
1416
  .toast .msg{min-width:0;word-break:break-word}
1417
+ /* The one action a toast may carry. Sm height and the neutral surface: it is
1418
+ an offer beside the message, not the next step, so it takes no accent —
1419
+ that means "the next action" and Send to Claude already has it. */
1420
+ .toast .act{display:inline-flex;align-items:center;margin-top:8px;
1421
+ height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
1295
1422
  .toast .x{flex:none;margin:-3px -4px 0 4px;width:22px;height:22px;padding:0;
1296
1423
  background:none;border:none;box-shadow:none;color:var(--mute);
1297
1424
  font-size:15px;line-height:22px;border-radius:var(--r-sm);opacity:0;
@@ -1374,8 +1501,44 @@
1374
1501
  .sm{font-size:12px}
1375
1502
  code{font:12px ui-monospace,Menlo,monospace;background:var(--sunk);padding:2px 6px;
1376
1503
  border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px var(--line)}
1504
+ /* LONGHAND, deliberately. This was `font:11px/1.6 inherit`, which is an
1505
+ INVALID shorthand -- `inherit` is not a permitted font-family value there,
1506
+ so the browser dropped the whole declaration and every key cap silently
1507
+ rendered at .hint's inherited 12px/1.5. The 11px never once shipped.
1508
+ Caught 20 Sep 2026 while measuring cap heights, because the computed
1509
+ line-height came back 18px and 19.5px when it had just been set to 14.
1510
+ 13px, up a step by request: a key cap is a label you hunt for mid-fit, not
1511
+ body copy. `kbd.sym` goes one further because a bare glyph -- an arrow, the
1512
+ cmd or shift sign -- fills a fraction of its em box, so at a shared size it
1513
+ reads smaller and lighter than a cap holding a word like Tab.
1514
+ The line box is pinned in px rather than a ratio so both sizes yield caps
1515
+ of the SAME height; on a ratio they came out 20 and 19, and a row of caps
1516
+ disagreeing by a pixel reads as a wobble rather than a set. */
1377
1517
  kbd{border:1px solid var(--edge);border-bottom-width:2px;border-radius:var(--r-sm);
1378
- padding:1px 5px;font: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
+ /* Realism's two rows. label | slider | value, so the two read as a pair of
1531
+ the same control rather than two stacked blocks -- and it is shorter than
1532
+ the single stacked slider it replaces, on a rail that has no room. */
1533
+ .gr-row{display:grid;grid-template-columns:max-content 1fr max-content;
1534
+ align-items:center;gap:var(--s2)}
1535
+ .gr-row .gr-lbl{color:var(--mute)}
1536
+ .gr-row output{color:var(--ink);font-variant-numeric:tabular-nums;min-width:34px;
1537
+ text-align:right;font-size:12px}
1538
+ dl.keys{display:grid;grid-template-columns:max-content 1fr;gap:6px 10px;
1539
+ margin:8px 0 0;align-items:baseline}
1540
+ dl.keys dt{color:var(--faint);font-size:12px;white-space:nowrap}
1541
+ dl.keys dd{margin:0;color:var(--faint);font-size:12px;line-height:1.5}
1379
1542
  </style>
1380
1543
  </head>
1381
1544
  <body>
@@ -1389,9 +1552,9 @@
1389
1552
  <div class="tb-left">
1390
1553
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
1391
1554
  <div class="tb-chips">
1392
- <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." 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>
1555
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1393
1556
  <span class="tb-arrow">&rarr;</span>
1394
- <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." 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>
1557
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept."><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
1395
1558
  </div>
1396
1559
  </div>
1397
1560
  <span class="spacer"></span>
@@ -1409,8 +1572,8 @@
1409
1572
  <button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
1410
1573
  </span>
1411
1574
  <span class="tb-save">
1412
- <button id="save" class="accent" disabled>Preview first</button>
1413
- <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1575
+ <button id="save" disabled>Save</button>
1576
+ <button id="imp" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
1414
1577
  </span>
1415
1578
  <button id="railBtn" class="iconbtn" aria-expanded="true" aria-controls="rail" aria-label="Settings rail"><svg class="i-open" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M4.76855 1C4.50954 1.2888 4.30589 1.62677 4.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H4.17383C4.30589 14.3732 4.50954 14.7112 4.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H4.76855Z"/><path fill="var(--icon)" d="M7 15C5.89543 15 5 14.1046 5 13V3C5 1.96435 5.78722 1.113 6.7959 1.01074L7 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H7ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H7C6.44771 2 6 2.44771 6 3V13C6 13.5523 6.44772 14 7 14H13ZM7.14648 8.64648L7.79297 8L7.14648 7.35352L7.85352 6.64648L9.20703 8L7.85352 9.35352L7.14648 8.64648Z"/></svg><svg class="i-closed" viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--mute)" d="M8.76855 1C8.50954 1.2888 8.30589 1.62677 8.17383 2H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H8.17383C8.30589 14.3732 8.50954 14.7112 8.76855 15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V3C1 1.89543 1.89543 1 3 1H8.76855Z"/><path fill="var(--icon)" d="M11 15C9.89543 15 9 14.1046 9 13V3C9 1.96435 9.78722 1.113 10.7959 1.01074L11 1H13L13.2041 1.01074C14.1457 1.1062 14.8938 1.85435 14.9893 2.7959L15 3V13C15 14.1046 14.1046 15 13 15H11ZM13 14C13.5523 14 14 13.5523 14 13V3C14 2.44771 13.5523 2 13 2H11C10.4477 2 10 2.44771 10 3V13C10 13.5523 10.4477 14 11 14H13ZM10.793 8L12.1465 6.64648L12.8535 7.35352L12.207 8L12.8535 8.64648L12.1465 9.35352L10.793 8Z"/></svg></button>
1416
1579
  </div>
@@ -1442,10 +1605,10 @@
1442
1605
  <button class="segbtn" id="czFit" aria-label="Fit the photo to the pane" title="Fit the whole photo in the pane"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="var(--mute)" d="M14.1025 1.00488C14.6067 1.05621 15 1.48232 15 2V14L14.9951 14.1025C14.9472 14.573 14.573 14.9472 14.1025 14.9951L14 15H2C1.48232 15 1.05621 14.6067 1.00488 14.1025L1 14V2C1 1.44772 1.44772 1 2 1H14L14.1025 1.00488ZM2 14H14V2H2V14Z"/><path stroke="var(--icon)" d="M7 11.5H4.5V9M4.5 11.5L7.5 8.5"/><path stroke="var(--icon)" d="M9 4.5H11.5V7M11.5 4.5L8.5 7.5"/></svg></button>
1443
1606
  </div>
1444
1607
  <div class="bgroup corners" role="group" aria-label="Select corner"><i class="ring" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i>
1445
- <button class="segbtn" data-jump="0" aria-label="Top-left corner" title="Jump to the top-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--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>
1446
- <button class="segbtn" data-jump="1" aria-label="Top-right corner" title="Jump to the top-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--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>
1447
- <button class="segbtn" data-jump="3" aria-label="Bottom-left corner" title="Jump to the bottom-left corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--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>
1448
- <button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--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>
1608
+ <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>
1609
+ <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>
1610
+ <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>
1611
+ <button class="segbtn" data-jump="2" aria-label="Bottom-right corner" title="Jump to the bottom-right corner"><svg viewBox="0 0 16 16" aria-hidden="true"><path fill="var(--edge-hi)" d="M2 13C2 13.5523 2.44772 14 3 14H5V15H3L2.7959 14.9893C1.85435 14.8938 1.1062 14.1457 1.01074 13.2041L1 13V11H2V13ZM15 13C15 14.0357 14.2128 14.887 13.2041 14.9893L13 15H11V14H13C13.5523 14 14 13.5523 14 13V11H15V13ZM5 2H3C2.44772 2 2 2.44772 2 3V5H1V3C1 1.89543 1.89543 1 3 1H5V2ZM13 1C14.1046 1 15 1.89543 15 3V5H14V3C14 2.44772 13.5523 2 13 2H11V1H13Z"/><path fill="var(--acc-ink)" d="M11 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V11H15V13C15 14.1046 14.1046 15 13 15H11V13.5Z"/></svg></button>
1449
1612
  </div>
1450
1613
  </div>
1451
1614
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
@@ -1476,7 +1639,13 @@
1476
1639
  fitted quad by the browser. Not the composite — the composite already
1477
1640
  has a frame burned into it, so playing over that would show two screens.
1478
1641
  --><div id="liveWrap" hidden><img id="liveBg" alt=""><video id="liveVid" loop muted playsinline></video></div></div>
1479
- <span class="pillwrap"><i class="ring sat" aria-hidden="true"></i><i class="ring lum" aria-hidden="true"></i><i class="ring hue" aria-hidden="true"></i><i class="ring lift" aria-hidden="true"></i><span class="status" id="outSt">Press Preview</span></span>
1642
+ <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
1643
+ a button that does not exist: the compare pane renders on its own after
1644
+ every correction, so there is nothing to press. The pane already says
1645
+ what it is waiting for in its own body text, and .pillwrap:has(>
1646
+ .status:empty) hides the pill until there is something true to put in
1647
+ it. -->
1648
+ <span class="status" id="outSt"></span></span>
1480
1649
  <!-- Video controls. Hidden entirely for a still source: a scrubber over a
1481
1650
  screenshot is a control that can only confuse. The fit itself is
1482
1651
  unchanged — you match the edges on one frame and every frame gets the
@@ -1541,16 +1710,30 @@
1541
1710
  <div class="sect-top">
1542
1711
  <div class="sect-left">
1543
1712
  <h4>Realism</h4>
1544
- <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
1713
+ <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Sits the screen in the light around it, so it stops reading as pasted. <b>Light</b> matches the scene's exposure and leaves hue alone. <b>Colour</b> matches its white balance — this is the one that warms a brand colour, so set it to 0 when an exact hex is the point and keep Light. Off does both. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
1545
1714
  </div>
1546
1715
  <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
1547
1716
  <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
1548
1717
  </button>
1549
1718
  </div>
1550
1719
  <div class="sect-body"><div class="sect-body-in">
1551
- <div style="display:grid;gap:var(--s2);justify-items:end">
1552
- <span class="sm" id="gradeVal" style="font-variant-numeric:tabular-nums"></span>
1553
- <input type="range" id="gradeAmt" min="0.1" max="1" step="0.05" value="0.35" aria-label="Realism strength">
1720
+ <!-- Two halves of one measurement, not two features. See
1721
+ grade.light_params: Light is a bounded L shift, Colour is the
1722
+ chroma match. Colour starts at 0 in neither -- both inherit
1723
+ whatever single strength was remembered before the split. -->
1724
+ <div style="display:grid;gap:var(--s3)">
1725
+ <div class="gr-row">
1726
+ <span class="sm gr-lbl">Light</span>
1727
+ <input type="range" id="gradeLight" min="0" max="1" step="0.05" value="0.35"
1728
+ aria-label="Light — match the scene's exposure">
1729
+ <output for="gradeLight" id="gradeLightVal"></output>
1730
+ </div>
1731
+ <div class="gr-row">
1732
+ <span class="sm gr-lbl">Colour</span>
1733
+ <input type="range" id="gradeColour" min="0" max="1" step="0.05" value="0.35"
1734
+ aria-label="Colour — match the scene's white balance">
1735
+ <output for="gradeColour" id="gradeColourVal"></output>
1736
+ </div>
1554
1737
  </div>
1555
1738
  </div></div>
1556
1739
  </div>
@@ -1584,7 +1767,7 @@
1584
1767
  <div class="sect-body"><div class="sect-body-in">
1585
1768
  <div style="display:grid;gap:var(--s2)">
1586
1769
  <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
1587
- <span class="sm" style="color:var(--mute)">Blur grows toward (of the screen)</span>
1770
+ <span class="sm" style="color:var(--mute)">Blur grows toward</span>
1588
1771
  <span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
1589
1772
  </div>
1590
1773
  <input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
@@ -1593,7 +1776,7 @@
1593
1776
  <span class="sm" id="dofStrVal" style="font-variant-numeric:tabular-nums"></span>
1594
1777
  </div>
1595
1778
  <input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
1596
- <div style="display:flex;align-items:center;gap:var(--s2)">
1779
+ <div class="dof-adv" style="display:flex;align-items:center;gap:var(--s2)">
1597
1780
  <button class="sm" id="dofBoth" aria-pressed="false" title="Blur on both sides of the focus line — the depth of field's near and far limits, for a phone whose middle is sharp and both ends soft. Off, the plane of focus is at the screen's edge and blur grows one way.">Both sides</button>
1598
1781
  <button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
1599
1782
  <span class="status sm" id="dofSt" style="min-width:0"></span>
@@ -1614,12 +1797,38 @@
1614
1797
  </div>
1615
1798
  <div class="sect" id="secKeys" data-on="1">
1616
1799
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
1617
- <!-- Two lines, split by what they act on: the QUAD you are fitting, and
1618
- the VIEW you are fitting it in. Grouping them by that rather than
1619
- by modifier is what makes the list scannable while you are mid-fit
1620
- and looking for one of them. -->
1621
- <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
1622
- <p class="hint"><kbd>&#8984;</kbd>+scroll zoom to pointer · <kbd>Space</kbd>+drag pan · scroll pans</p>
1800
+ <!-- One row per key, in the order you meet them: place the quad, move
1801
+ between its parts, then move the view you are placing it in.
1802
+
1803
+ A <dl>, because these ARE term/description pairs -- saying so in the
1804
+ markup is also what lets a screen reader read a key and its meaning
1805
+ as one unit. It ran as prose lines until 20 Sep 2026, which read
1806
+ fine in a wide rail and badly in a narrow one: the caps ended up
1807
+ mid-sentence and mid-wrap, so finding one meant reading the line
1808
+ rather than scanning a column.
1809
+
1810
+ Checked line by line against the handlers the same day and it held,
1811
+ with two gaps, both fixed here. (a) The arrows do nothing until an
1812
+ edge or corner is SELECTED (`if (!pick) return`) -- "nudge 1px"
1813
+ implied they always work. (b) Escape was undocumented, and it is the
1814
+ way out of the canvas's Tab cycle, so the one line that told you how
1815
+ to get IN had no counterpart telling you how to get out. Also named
1816
+ the trackpad pinch: the wheel handler takes ctrlKey as well as
1817
+ metaKey, so a pinch has always zoomed, unmentioned, on the gesture
1818
+ most people reach for first on this hardware.
1819
+
1820
+ Those two conditions sit in the note BELOW the list rather than in
1821
+ parentheses on a row, because each qualifies several rows. -->
1822
+ <dl class="keys">
1823
+ <dt><kbd class="sym">&larr;&uarr;&rarr;&darr;</kbd></dt><dd>nudge 1px</dd>
1824
+ <dt><kbd class="sym">&#8679;</kbd>+arrow</dt><dd>nudge 10px</dd>
1825
+ <dt><kbd>Tab</kbd></dt><dd>next edge</dd>
1826
+ <dt><kbd>Esc</kbd></dt><dd>release the photo</dd>
1827
+ <dt><kbd class="sym">&#8984;</kbd>+scroll or pinch</dt><dd>zoom to the pointer</dd>
1828
+ <dt><kbd>Space</kbd>+drag</dt><dd>pan</dd>
1829
+ <dt><kbd>scroll</kbd></dt><dd>pan</dd>
1830
+ </dl>
1831
+ <p class="hint">Arrows act on the selected edge or corner. <kbd>Tab</kbd> and <kbd>Esc</kbd> need a click on the photo first.</p>
1623
1832
  </div>
1624
1833
  </aside>
1625
1834
  </div>
@@ -1801,6 +2010,20 @@ function toastPath(p){
1801
2010
  return `<span class="path">${esc.replace(/\//g, '/<wbr>')}</span>`;
1802
2011
  }
1803
2012
 
2013
+ /* "Open folder" on the save toast. A browser cannot open a file manager from
2014
+ an http page, so the page asks the server, which runs on the machine.
2015
+ Delegated from the container rather than wired per toast: a toast is created
2016
+ and destroyed constantly, and one listener outlives all of them. It fails
2017
+ soft on purpose — the page has a second host with no filesystem at all, and
2018
+ there the route simply is not there. */
2019
+ $('#toasts')?.addEventListener('click', async (e) => {
2020
+ const b = e.target.closest('[data-reveal]');
2021
+ if (!b) return;
2022
+ b.disabled = true;
2023
+ try { await api('/api/reveal', { path: decodeURIComponent(b.dataset.reveal) }); }
2024
+ catch (err) { b.disabled = false; toast('err', 'Could not open the folder: ' + err.message); }
2025
+ });
2026
+
1804
2027
  function toast(kind, html, opts = {}) {
1805
2028
  const wrap = $('#toasts');
1806
2029
  if (!wrap) return null;
@@ -1837,7 +2060,11 @@ function toast(kind, html, opts = {}) {
1837
2060
  there is nowhere down for it to go. */
1838
2061
  let startX = 0, startY = 0, dragging = false;
1839
2062
  el.addEventListener('pointerdown', e => {
1840
- if (e.target.closest('.x')) return;
2063
+ // Anything clickable in a toast must bail out BEFORE the capture below:
2064
+ // setPointerCapture retargets the resulting click to the toast, so a
2065
+ // button inside it never sees one. The dismiss × has always needed this;
2066
+ // .act (Open folder) needs it for exactly the same reason.
2067
+ if (e.target.closest('.x, .act')) return;
1841
2068
  dragging = true; startX = e.clientX; startY = e.clientY;
1842
2069
  el.dataset.dragging = 'true';
1843
2070
  try { el.setPointerCapture(e.pointerId); } catch (err) {}
@@ -2011,12 +2238,66 @@ function casedText(c, text, x, y, color){
2011
2238
  The page reads the bytes and the SERVER judges them: whether this is the
2012
2239
  photograph the fit was made for is a question only the side holding the
2013
2240
  pixels can answer. */
2241
+ /* One busy state for every operation on this page that can take a while.
2242
+
2243
+ Measured 20 Sep 2026, because the report was "the 4K image takes time to
2244
+ load" and the guess attached to it was detection. The guess was right and
2245
+ the description was not: a cold 4000x4000 photo fetches and decodes in about
2246
+ 150ms, while `scripts/detect.py` on the same file takes **11.2s** -- it runs
2247
+ at full resolution, it does not downscale. So the wait is real, it is
2248
+ detection, and it was already being narrated -- just not well enough to be
2249
+ believed, because a static "Detecting..." for eleven seconds is
2250
+ indistinguishable from a page that has stopped.
2251
+
2252
+ Two things fix that, and neither is a bigger spinner. A counter that keeps
2253
+ moving is the difference between slow and stuck, which the Figma import has
2254
+ understood since it was written -- this is that pattern, lifted out of it so
2255
+ three more callers can have it. And the 250ms delay means a fast operation
2256
+ never flashes a spinner it did not need, which is why this can be used on
2257
+ everything without making the page feel busier than it is.
2258
+
2259
+ `settle(restore)` ends it. Pass true when the caller does NOT write its own
2260
+ status afterwards and the line held something worth putting back; pass false
2261
+ when the caller is about to overwrite it anyway. */
2262
+ function busy(el, label, opts){
2263
+ const o = opts || {};
2264
+ const delay = o.delay == null ? 250 : o.delay;
2265
+ const countFrom = o.countFrom == null ? 2 : o.countFrom;
2266
+ const t0 = Date.now();
2267
+ const prev = {cls: el.className, html: el.innerHTML, title: el.title};
2268
+ // Keep a size modifier the caller had set; only the state word changes.
2269
+ const cls = 'status ' + (/\bsm\b/.test(prev.cls) ? 'sm ' : '') + 'busy';
2270
+ let armed = false, tick = null;
2271
+ const paint = () => {
2272
+ const n = Math.round((Date.now() - t0) / 1000);
2273
+ el.className = cls;
2274
+ el.innerHTML = n >= countFrom ? `${label} <span class="el">${n}s</span>` : `${label}…`;
2275
+ };
2276
+ const arm = setTimeout(() => { armed = true; paint(); tick = setInterval(paint, 1000); }, delay);
2277
+ // A timer is throttled to about once a minute in a hidden tab, and this is
2278
+ // exactly the wait somebody switches away from. Repaint on return so the
2279
+ // number is right the moment they look at it again.
2280
+ document.addEventListener('visibilitychange', paint);
2281
+ return function settle(restore){
2282
+ clearTimeout(arm);
2283
+ if (tick){ clearInterval(tick); tick = null; }
2284
+ document.removeEventListener('visibilitychange', paint);
2285
+ if (armed && restore){ el.className = prev.cls; el.innerHTML = prev.html; el.title = prev.title; }
2286
+ armed = false;
2287
+ };
2288
+ }
2014
2289
  async function loadFitFile(file){
2015
2290
  const s = $('#detSt');
2291
+ // Declared out here so the catch can end the counter too.
2292
+ let settle = () => {};
2016
2293
  try {
2017
2294
  const text = await file.text();
2018
- s.className = 'status busy'; s.textContent = 'Reading that fit…';
2295
+ // /api/fit hashes the photograph's decoded pixels to decide whether this
2296
+ // fit belongs to it, so it costs more on a big photo, for the same reason
2297
+ // detection does.
2298
+ settle = busy(s, 'Reading that fit');
2019
2299
  const r = await api('/api/fit', {fit: JSON.parse(text)});
2300
+ settle(false);
2020
2301
  st.corners = r.corners.map(c => c.slice());
2021
2302
  st.measured = null;
2022
2303
  st.quadFrom = 'file';
@@ -2039,6 +2320,7 @@ async function loadFitFile(file){
2039
2320
  pick = {kind:'edge', i:0};
2040
2321
  draw(); drawStrip(); autoPreview();
2041
2322
  } catch (e) {
2323
+ settle(false);
2042
2324
  s.className = 'status warn'; s.textContent = 'That fit could not be used';
2043
2325
  s.title = e.message || '';
2044
2326
  toast('err', 'Could not load that fit: ' + (e.message || e));
@@ -2046,15 +2328,26 @@ async function loadFitFile(file){
2046
2328
  }
2047
2329
  // Capture phase, so the per-role drop zones never see a fit file and try to
2048
2330
  // decode it as an image.
2049
- addEventListener('dragover', e => {
2050
- if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) e.preventDefault();
2051
- }, true);
2331
+ // Is a file being dragged? Ask `types`, not `items`: Safari leaves `items`
2332
+ // empty during dragover, so an items-only test never cancelled the drag there,
2333
+ // the drop event never fired, and Safari navigated away to the dropped .json --
2334
+ // taking the whole workbench with it.
2335
+ function draggingFiles(e){
2336
+ const dt = e.dataTransfer; if (!dt) return false;
2337
+ if ([...(dt.types || [])].includes('Files')) return true;
2338
+ return [...(dt.items || [])].some(i => i.kind === 'file');
2339
+ }
2340
+ addEventListener('dragover', e => { if (draggingFiles(e)) e.preventDefault(); }, true);
2052
2341
  addEventListener('drop', e => {
2053
2342
  const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
2054
2343
  if (!f || !/\.json$/i.test(f.name)) return; // not ours: let the zones have it
2055
2344
  e.preventDefault(); e.stopPropagation();
2056
2345
  loadFitFile(f);
2057
2346
  }, true);
2347
+ // A file dropped where nothing takes it must not become a navigation: the page
2348
+ // is the session, and leaving it loses the fit. Bubble phase, so the zones and
2349
+ // the fit loader above have already had their turn.
2350
+ addEventListener('drop', e => { if (draggingFiles(e)) e.preventDefault(); });
2058
2351
 
2059
2352
  /* ===== pickers (popovers over the canvas) ===== */
2060
2353
  // "Used 3 h ago", "used yesterday" -- when a source was last chosen.
@@ -2087,17 +2380,59 @@ async function loadRecent(){
2087
2380
  the corners, the screenshot owns the video state and the fitted frame,
2088
2381
  either owns the result. The server does the same to the session
2089
2382
  (/api/clear), so a reload agrees with what is on screen. */
2383
+ /* The chip is sized by its content, and content changes in ONE frame: the
2384
+ label swaps between "Choose a photo…" and a filename, and the box arrives at
2385
+ its new width before the thumbnail has moved at all. That is the jump — the
2386
+ thumb and the padding were animating politely while the width had already
2387
+ finished. `auto` cannot be transitioned, so the width is measured on both
2388
+ sides of the change and animated explicitly between the two.
2389
+ Released back to auto once it lands, because a fixed width would stop a
2390
+ later filename or a window resize from reflowing the chip normally.
2391
+ Found 20 Sep by reading a screen recording frame by frame. */
2392
+ const chipMs = () => parseFloat(getComputedStyle(document.documentElement)
2393
+ .getPropertyValue('--t-chip')) || 260;
2394
+ function reshapeChip(chip, apply){
2395
+ const from = chip.getBoundingClientRect().width;
2396
+ // Pin BEFORE the content changes, so the swap cannot paint at its new
2397
+ // natural width even for a frame — and so the measurement below, which
2398
+ // forces a layout, cannot commit that width as the transition's start.
2399
+ chip.style.width = from + 'px';
2400
+ apply();
2401
+ // Measure the target on a GHOST, not on the live chip. The live thumbnail is
2402
+ // mid-transition at this instant and still reports width 0, so measuring
2403
+ // here lands ~30px short and the chip finishes with a second, untimed step
2404
+ // when the pin is released. The ghost carries the same classes and content
2405
+ // with transitions off, so it is already at rest.
2406
+ const ghost = chip.cloneNode(true);
2407
+ ghost.removeAttribute('id');
2408
+ ghost.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
2409
+ ghost.style.cssText = 'position:absolute;left:-9999px;top:0;width:auto;' +
2410
+ 'visibility:hidden;pointer-events:none;transition:none';
2411
+ chip.parentElement.appendChild(ghost);
2412
+ const to = ghost.getBoundingClientRect().width;
2413
+ ghost.remove();
2414
+ if (Math.abs(to - from) < 0.5){ chip.style.width = ''; return; }
2415
+ void chip.offsetWidth; // commit the start
2416
+ chip.style.width = to + 'px';
2417
+ // Back to auto once it lands: a fixed width would stop a later filename or a
2418
+ // window resize from reflowing the chip normally.
2419
+ clearTimeout(chip._reshape);
2420
+ chip._reshape = setTimeout(() => { chip.style.width = ''; }, chipMs() + 40);
2421
+ }
2422
+
2090
2423
  async function clearSource(role){
2091
2424
  try { await api('/api/clear', {role}); }
2092
2425
  catch(e){ alert(e.message); return; }
2093
2426
  const n = role==='photo'?1:2;
2094
2427
  const chip = $('#chip'+n);
2095
- chip.classList.add('is-empty');
2096
- chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
2428
+ reshapeChip(chip, () => {
2429
+ chip.classList.add('is-empty');
2430
+ chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
2431
+ });
2097
2432
  chip.title = '';
2098
2433
  $('#sw'+n).style.background = '';
2099
2434
  $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
2100
- $('#clear'+n).hidden = true;
2435
+ $('#clear'+n).classList.remove('on');
2101
2436
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
2102
2437
  dropResult();
2103
2438
  if (role==='photo'){
@@ -2106,6 +2441,10 @@ async function clearSource(role){
2106
2441
  // Nothing to draw on: the canvas goes back to its empty state rather
2107
2442
  // than showing the last photograph with no way to act on it.
2108
2443
  const c = $('#c'); c.getContext('2d').clearRect(0, 0, c.width, c.height);
2444
+ // Belt and braces, and worth it on its own: a 4000x4000 decode is ~64MB of
2445
+ // pixels held for a photograph the user has just removed. Dropping it makes
2446
+ // the stale-repaint above impossible rather than merely unpainted.
2447
+ img.removeAttribute('src');
2109
2448
  // ... and the edge view, which would otherwise keep showing a strip of a
2110
2449
  // photograph that is no longer here.
2111
2450
  for (const id of ['strip', 'stripF']) {
@@ -2156,14 +2495,16 @@ function setChosen(role, path, size, el, meta){
2156
2495
  const ver = (meta && meta.mtime) || Date.now();
2157
2496
  $('#pv'+n).innerHTML = `<img src="${srcURL(thumb, ver)}" alt="">`;
2158
2497
  const chip = $('#chip'+n);
2159
- chip.classList.remove('is-empty');
2160
2498
  // The file's own name and nothing else. The size and, for a clip, the frame
2161
2499
  // count used to follow it in the same 12px, which pushed the one thing that
2162
2500
  // identifies the file into an ellipsis; they live on hover now, with the
2163
2501
  // full path. An upload's session copy is "<role>-<epoch>-<name>", so the
2164
2502
  // server sends the original name back as `name`.
2165
2503
  const shown = (meta && meta.name) || path.split('/').pop();
2166
- chip.querySelector('.nm').textContent = shown;
2504
+ reshapeChip(chip, () => {
2505
+ chip.classList.remove('is-empty');
2506
+ chip.querySelector('.nm').textContent = shown;
2507
+ });
2167
2508
  // A different file in either role: the composite on screen is of the old
2168
2509
  // one. Dropped here, before the state changes, so nothing downstream can
2169
2510
  // narrate over it; maybeStart() re-renders once the fit is ready.
@@ -2171,7 +2512,7 @@ function setChosen(role, path, size, el, meta){
2171
2512
  chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
2172
2513
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
2173
2514
  $('#sw'+n).style.background = `center/cover no-repeat url("${srcURL(thumb, ver)}")`;
2174
- $('#clear'+n).hidden = false;
2515
+ $('#clear'+n).classList.add('on');
2175
2516
  // A fit the server recognised from this photograph's own pixels. Held
2176
2517
  // rather than applied here: the quad only means anything once there is a
2177
2518
  // screenshot to put inside it, which is what maybeStart() waits for.
@@ -2203,7 +2544,7 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
2203
2544
  // onto "Choose a photo" and it lands in that role without opening the
2204
2545
  // popover. Same upload path as the popover's drop zone.
2205
2546
  const chip = $('#chip'+n);
2206
- chip.ondragover = e => { if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) { e.preventDefault(); chip.classList.add('over'); } };
2547
+ chip.ondragover = e => { if (draggingFiles(e)) { e.preventDefault(); chip.classList.add('over'); } };
2207
2548
  // Leaving with a drag only un-lights the chip if its popover is not the
2208
2549
  // reason it is lit.
2209
2550
  const popOpen = () => $('#pop'+n).classList.contains('on');
@@ -2384,10 +2725,44 @@ function distToSeg(p,a,b){
2384
2725
  return {d: vlen(sub(p, add(a, mul(ab,t)))), t};
2385
2726
  }
2386
2727
 
2728
+ /* Loading the photograph is the one long step on this page that said nothing.
2729
+ `detect()` has narrated itself since it existed, and it runs AFTER this, so a
2730
+ 4K photo spent its whole fetch-and-decode in silence with the picker already
2731
+ closed and the canvas still empty -- which reads as the click not having
2732
+ landed. Reported 20 Sep 2026, with detection as the suspected cause; it was
2733
+ not, and the measured order is: set img.src -> fetch the full-resolution file
2734
+ -> browser decode -> onload -> computeFit/draw -> only then detect().
2735
+
2736
+ Armed on a 250ms delay so a small photo never flashes a spinner it does not
2737
+ need, and it counts seconds past 2 for the same reason the Figma import does:
2738
+ a number that keeps moving is the difference between slow and stuck.
2739
+
2740
+ The previous status is CAPTURED and handed back on settle rather than
2741
+ blanked. One caller (setChosen, changing the screenshot under an existing
2742
+ fit) writes "the fit on the photo is kept" and then calls in here, and the
2743
+ branch below that keeps existing corners writes nothing -- so clearing would
2744
+ have eaten somebody else's message. */
2745
+ let photoBusy = null; // settle() for a load still in flight
2387
2746
  async function maybeStart(){
2388
2747
  if (!st.photo) return;
2389
2748
  const ready = !!st.shot; // the screenshot gates the fit, not the photo
2749
+ const s = $('#detSt');
2750
+ if (photoBusy) photoBusy(true); // a second pick mid-load: retire the first
2751
+ const done = busy(s, 'Loading the photo');
2752
+ const settle = () => { done(true); photoBusy = null; };
2753
+ photoBusy = settle;
2754
+ // There was no onerror at all, so a photo that failed to decode left the page
2755
+ // silent for good: no canvas, no message, nothing to do next.
2756
+ img.onerror = () => {
2757
+ // Clearing the photo drops img's src on purpose; that is not a failure.
2758
+ if (!st.photo) { settle(); return; }
2759
+ settle();
2760
+ s.className = 'status err';
2761
+ s.textContent = 'That photo could not be opened — try another, or drop the file in again.';
2762
+ s.title = homePath(st.photo || '');
2763
+ };
2390
2764
  img.onload = () => {
2765
+ settle();
2391
2766
  computeFit(); setCanvasZoom(fitScale);
2392
2767
  refreshRings();
2393
2768
  if (!ready){
@@ -2596,9 +2971,11 @@ $('#scroller').addEventListener('pointerdown', e => {
2596
2971
  pointAt(p[0], p[1]);
2597
2972
  }, true);
2598
2973
  async function pointAt(x, y){
2599
- const s = $('#detSt'); s.className = 'status busy'; s.textContent = 'Reading that point…';
2974
+ const s = $('#detSt');
2975
+ const settle = busy(s, 'Reading that point');
2600
2976
  try {
2601
2977
  const r = await api('/api/detect', {click: [x, y]});
2978
+ settle(false);
2602
2979
  if (r.found){
2603
2980
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
2604
2981
  orientQuad();
@@ -2614,6 +2991,7 @@ async function pointAt(x, y){
2614
2991
  s.title = r.message || '';
2615
2992
  }
2616
2993
  } catch (err) {
2994
+ settle(false);
2617
2995
  s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
2618
2996
  }
2619
2997
  pick = {kind:'edge', i:0};
@@ -2628,8 +3006,29 @@ document.querySelectorAll('[data-jump]').forEach(b => {
2628
3006
  });
2629
3007
 
2630
3008
  async function detect(){
2631
- const s = $('#detSt'); s.className='status busy'; s.textContent = 'Detecting…';
2632
- const r = await api('/api/detect', {});
3009
+ const s = $('#detSt');
3010
+ // The longest wait on the page: 11.2s measured on a 4000x4000 photo, because
3011
+ // detect.py runs at full resolution. The counter is what makes that read as
3012
+ // working rather than hung.
3013
+ const settle = busy(s, 'Detecting');
3014
+ let r;
3015
+ // There was no catch here at all, so a request that failed left 'Detecting…'
3016
+ // spinning for good -- the one state the page could never leave.
3017
+ try { r = await api('/api/detect', {}); }
3018
+ catch (e){
3019
+ settle(false);
3020
+ s.className = 'status err';
3021
+ s.textContent = 'Detection failed — try Point at screen, or place the edges by hand';
3022
+ s.title = e.message || '';
3023
+ suggestPointing(true);
3024
+ st.corners = defaultQuad(); st.quadFrom = 'default';
3025
+ if (!st.type) setType('phone');
3026
+ applyPreset();
3027
+ pick = {kind:'edge', i:0};
3028
+ draw(); drawStrip(); autoPreview();
3029
+ return;
3030
+ }
3031
+ settle(false);
2633
3032
  if (r.found){
2634
3033
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
2635
3034
  orientQuad();
@@ -2890,7 +3289,16 @@ function draw(){
2890
3289
  if (!$('#liveWrap').hidden) placeLiveVideo();
2891
3290
  ctx.setTransform(cvDpr, 0, 0, cvDpr, 0, 0); // CSS px from here on; the store is cvDpr times larger
2892
3291
  ctx.clearRect(0,0,cvW,cvH);
2893
- if (!img.naturalWidth) return;
3292
+ // st.photo FIRST, and this is the whole bug reported 21 Sep 2026: clear the
3293
+ // photo, then click anywhere in the fit pane, and the photograph came back
3294
+ // while the chip still read "Choose a photo…". The guard here was
3295
+ // `img.naturalWidth` alone -- the <img> keeps its decoded pixels until
3296
+ // something replaces them, and clearSource only wiped the CANVAS, so the
3297
+ // image was still 4000x4000 and perfectly drawable. The click ran
3298
+ // cv.onpointerdown -> no hit -> draw(), and draw() repainted what it still
3299
+ // had. Asking the <img> whether there is a photo was the mistake: the <img>
3300
+ // is a cache, st.photo is the answer.
3301
+ if (!(st.photo && img.naturalWidth)) return;
2894
3302
  ctx.drawImage(img, 0,0, cvW, cvH);
2895
3303
  if (!st.corners) return;
2896
3304
  const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
@@ -3041,9 +3449,21 @@ cv.onpointerup = cv.onpointercancel = () => { drag = null; draw(); drawStrip();
3041
3449
  cv.onpointerleave = () => { if (!drag){ hover = null; draw(); drawStrip(); } };
3042
3450
 
3043
3451
  window.addEventListener('keydown', e => {
3044
- if (e.key === 'Escape'){ closePop(); return; }
3452
+ // Escape also releases the canvas, so Tab-cycling is never a one-way door.
3453
+ if (e.key === 'Escape'){ closePop(); if (document.activeElement === cv) cv.blur(); return; }
3045
3454
  if (!st.corners || document.querySelector('.pop.on')) return;
3046
3455
  if (e.key === 'Tab'){
3456
+ // Tab is the BROWSER's focus key everywhere except the fit canvas itself.
3457
+ // This was taken at window level with no target check, so the moment a fit
3458
+ // existed a keyboard user could never reach the top bar or the rail: focus
3459
+ // stayed where it was and the EDGE advanced instead. Reported 20 Sep with
3460
+ // the focus ring visibly stuck on a device chip while the edge cycled —
3461
+ // WCAG 2.1.1, and the KEYS legend's "Tab next edge" is why it looked
3462
+ // deliberate rather than broken.
3463
+ // The canvas is tabindex="-1": it holds focus only after a click on it,
3464
+ // which is exactly when cycling edges is what Tab should mean. Escape
3465
+ // above gives it back.
3466
+ if (document.activeElement !== cv) return;
3047
3467
  e.preventDefault();
3048
3468
  const i = pick && pick.kind==='edge' ? (pick.i+1)%4 : 0;
3049
3469
  pick = {kind:'edge', i}; draw(); drawStrip(); return;
@@ -3144,27 +3564,55 @@ function setSectionOpen(sect, on){
3144
3564
  colour exactly, which is what you want when the UI's colour is the thing under
3145
3565
  review. Strength is a slider because the right amount is a judgement about the
3146
3566
  photograph, not a constant. */
3147
- let gradeOn = recall('grade','1') === '1';
3148
- let gradeAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
3149
- function gradeValue(){ return gradeOn ? gradeAmt : 0; }
3567
+ let gradeOn = recall('grade','1') === '1';
3568
+ /* One strength became two on 21 Sep 2026, and the reason is a real report: with
3569
+ realism on, a brand orange visibly moved toward coral and a pure-black status
3570
+ bar lifted to slate. Both are CORRECT -- nothing on a real phone renders #000
3571
+ -- but one control was doing two jobs and the people using this check hex
3572
+ values. The cut follows what grade.light_params already computes separately:
3573
+
3574
+ Light -> a bounded mean shift on L. Sits the screen in the scene's
3575
+ exposure and leaves hue alone (measured: d(a,b) 0.002).
3576
+ Colour -> the chroma match, mean and spread. The half that warmed the
3577
+ orange (measured: 12.98 against 0.00 with it off).
3578
+
3579
+ Anyone who had a strength saved keeps it: both halves inherit it, so the
3580
+ first render after this ships is the one they had. */
3581
+ const _oldAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
3582
+ let gLight = parseFloat(recall('gradeLight', String(_oldAmt)));
3583
+ let gColour = parseFloat(recall('gradeColour', String(_oldAmt)));
3584
+ if (!(gLight >= 0)) gLight = _oldAmt;
3585
+ if (!(gColour >= 0)) gColour = _oldAmt;
3586
+ /* The master value. Everything server-side that asks "is the realism pass doing
3587
+ anything at all" -- grain, chiefly -- is keyed on `grade`, so it is the
3588
+ larger of the two rather than either one. */
3589
+ function gradeValue(){ return gradeOn ? Math.max(gLight, gColour) : 0; }
3590
+ function gradeLightValue(){ return gradeOn ? gLight : 0; }
3591
+ function gradeColourValue(){ return gradeOn ? gColour : 0; }
3150
3592
  function paintGrade(){
3151
- setSwitch($('#gradeBtn'), gradeOn); // hides the slider when off
3152
- $('#gradeVal').textContent = Math.round(gradeAmt * 100) + '%';
3153
- const sl = $('#gradeAmt');
3154
- sl.value = gradeAmt;
3155
- sl.style.setProperty('--p', (gradeAmt - 0.1) / 0.9);
3593
+ setSwitch($('#gradeBtn'), gradeOn); // hides the sliders when off
3594
+ for (const [id, v] of [['gradeLight', gLight], ['gradeColour', gColour]]){
3595
+ const sl = $('#' + id);
3596
+ sl.value = v;
3597
+ sl.style.setProperty('--p', v); // span is 0-1 now, so no rescale
3598
+ $('#' + id + 'Val').textContent = Math.round(v * 100) + '%';
3599
+ }
3156
3600
  }
3157
- function setGrade(on, amt){
3601
+ function setGrade(on){
3158
3602
  gradeOn = on;
3159
- if (amt !== undefined) gradeAmt = amt;
3160
- remember('grade', on ? '1' : '0'); remember('gradeAmt', String(gradeAmt));
3603
+ remember('grade', on ? '1' : '0');
3604
+ remember('gradeLight', String(gLight));
3605
+ remember('gradeColour', String(gColour));
3161
3606
  paintGrade();
3162
3607
  }
3163
3608
  $('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
3164
3609
  // Repaint live while dragging, re-render only on release: a full compose per
3165
3610
  // input event would queue renders faster than they finish.
3166
- $('#gradeAmt').oninput = e => { gradeAmt = parseFloat(e.target.value); paintGrade(); };
3167
- $('#gradeAmt').onchange = e => { setGrade(true, parseFloat(e.target.value)); autoPreview(); };
3611
+ for (const [id, set] of [['gradeLight', v => gLight = v],
3612
+ ['gradeColour', v => gColour = v]]){
3613
+ $('#' + id).oninput = e => { set(parseFloat(e.target.value)); paintGrade(); };
3614
+ $('#' + id).onchange = e => { set(parseFloat(e.target.value)); setGrade(true); autoPreview(); };
3615
+ }
3168
3616
 
3169
3617
  /* Emissive screen. A display emits light AND reflects the room; `replace` models
3170
3618
  only the first, which is why a true-black UI lands as a hole. Off by default so
@@ -3704,15 +4152,25 @@ let outNat = 0, autoTimer = null;
3704
4152
  would do. On a tool whose whole pitch is that the output is exact, a label
3705
4153
  that disagrees with its action is not a small thing. */
3706
4154
  function saveLabel(){ return st.video ? 'Render' : 'Save'; }
3707
- /* The labels that mean "there is something to do". markStale's 'Preview first'
3708
- and the in-flight 'Saving…' / progress text must survive a source change, so
4155
+ /* The in-flight 'Saving…' / progress text must survive a source change, so
3709
4156
  relabelling only touches a button that is currently offering the action. */
3710
4157
  function relabelSave(){
3711
4158
  const b = $('#save');
3712
4159
  if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
3713
4160
  }
3714
4161
  function markStale(){
3715
- const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
4162
+ // This used to relabel the button 'Preview first'. Removed 20 Sep 2026 on
4163
+ // request, and the request was right on inspection: with the compare pane
4164
+ // open by default, EVERY caller of markStale that has all three inputs is
4165
+ // followed by autoPreview() within the same interaction -- pointerup after a
4166
+ // drag, the same line after a keyboard nudge, the end of pointAt(), the
4167
+ // callers of setFrac. So the label was only ever on screen for the 350ms
4168
+ // debounce. The one place it PERSISTED was a failed /api/preview, where it
4169
+ // told the user to do the thing they had just done and that had just failed.
4170
+ // Dead in the happy path, wrong in the sad one. The button keeps its own
4171
+ // verb and simply reads as unavailable; the status line says why.
4172
+ const b = $('#save'); b.disabled = true;
4173
+ b.textContent = saveLabel();
3716
4174
  // Exactly one lit action at a time — the Figma Button component says the
3717
4175
  // accent marks the NEXT action and that only one may be on screen. With no
3718
4176
  // current render there is nothing to save, so nothing is lit: Preview stays
@@ -3937,6 +4395,8 @@ async function playClip(){
3937
4395
  try{
3938
4396
  await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen',
3939
4397
  device: st.type, grade: gradeValue(),
4398
+ grade_light: gradeLightValue(),
4399
+ grade_colour: gradeColourValue(),
3940
4400
  reflection: emisValue(), fit_frame: +$('#vframe').value});
3941
4401
  }catch(e){
3942
4402
  building = false;
@@ -3994,7 +4454,7 @@ async function renderPreview(){
3994
4454
  const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
3995
4455
  say('Rendering…');
3996
4456
  try{
3997
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
4457
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
3998
4458
  const im = $('#outImg');
3999
4459
  im.onload = () => {
4000
4460
  outNat = im.naturalWidth;
@@ -4127,7 +4587,9 @@ async function renderVideo(){
4127
4587
  setRenderProgress(0);
4128
4588
  try{
4129
4589
  await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type,
4130
- grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
4590
+ grade: gradeValue(), grade_light: gradeLightValue(),
4591
+ grade_colour: gradeColourValue(),
4592
+ reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
4131
4593
  }catch(e){
4132
4594
  setRenderProgress(null);
4133
4595
  toast('err','Could not start the render: '+e.message);
@@ -4172,7 +4634,7 @@ $('#save').onclick = async () => {
4172
4634
  if (st.video) return renderVideo();
4173
4635
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
4174
4636
  try{
4175
- const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
4637
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
4176
4638
  b.textContent = saveLabel();
4177
4639
  // The real destination, not a hardcoded one: --out-dir means saves usually
4178
4640
  // land in the project folder now, and telling the user "~/Desktop" when
@@ -4180,8 +4642,11 @@ $('#save').onclick = async () => {
4180
4642
  // Naming the fit file here is the whole reason it is discoverable: it is
4181
4643
  // written silently beside the mockup, and a file nobody knows about is a
4182
4644
  // file nobody drags back in.
4645
+ // encodeURIComponent, not an HTML escape: the path goes into an attribute
4646
+ // and a filename may legally contain quotes. This cannot break out of it.
4183
4647
  toast('ok', 'Saved' + toastPath(homePath(r.output))
4184
- + (r.fit_file ? `<span class="path" style="margin-top:8px;color:var(--mute)">The fit is beside it as ${r.fit_file.split('/').pop()} — drop that back on this page to reuse these corners.</span>` : ''),
4648
+ + (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>` : '')
4649
+ + `<button type="button" class="act" data-reveal="${encodeURIComponent(r.output)}">Open folder</button>`,
4185
4650
  { ms: 9000, dismissible: true });
4186
4651
  const imp = $('#imp');
4187
4652
  imp.disabled = false; imp.textContent = 'Send to Claude';
@@ -4238,18 +4703,30 @@ $('#imp').onclick = async () => {
4238
4703
  $('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
4239
4704
  $('#ver').classList.toggle('dev', !!s.build);
4240
4705
  }
4241
- // Say up front where Save will put things. With --out-dir this is usually the
4242
- // project folder, and a designer should not have to press Save to find out.
4243
- toast('info', 'Fit the four edges, then Preview. Saves go to' + toastPath(homePath(s.out_dir || '')),
4244
- { ms: 9000, dismissible: true });
4706
+ // There used to be a toast here on every load, saying "Fit the four edges,
4707
+ // then Preview" and naming the save folder. Removed 20 Sep 2026 on request.
4708
+ // It was the one message that fired when the user had done nothing, so it
4709
+ // greeted every single session -- including the hundredth -- with an
4710
+ // instruction and a long path, over the picker that had just opened by
4711
+ // itself. The instruction is already in the fit pane's own status line, and
4712
+ // the save folder is named in the toast that appears AFTER a save, which is
4713
+ // the moment it is actually wanted. Nothing replaced it: no toast on load.
4245
4714
  const types = $('#types');
4246
4715
  for (const t of ['phone','tablet','laptop','desktop']){
4247
4716
  const b=document.createElement('button'); b.className='chip'; b.dataset.t=t; b.textContent=t; /* Chip is one size in Figma (5:6): h28, 12px */
4248
- b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
4717
+ // Re-clicking the device you are ALREADY on used to re-apply its preset and
4718
+ // silently overwrite a hand-set corner radius — phone → phone jumped 10.2%
4719
+ // back to 15.4% (found 20 Sep). Picking the device you already have is a
4720
+ // no-op; the preset dropdown is the deliberate way to re-apply one.
4721
+ b.onclick=()=>{ if (st.type === t) return; setType(t); applyPreset(); autoPreview(); };
4722
+ types.appendChild(b);
4249
4723
  }
4250
4724
  setLoupeMode(loupeMode);
4251
4725
  setStripHC(stripHC);
4252
- setGrade(gradeOn, gradeAmt);
4726
+ // One argument since the Light/Colour split: the two amounts are module
4727
+ // state, already recalled above. Passing the old `gradeAmt` here threw a
4728
+ // ReferenceError that killed the rest of boot.
4729
+ setGrade(gradeOn);
4253
4730
  setEmis(emisOn, emisAmt);
4254
4731
  setDof(dofOn, dofAng, dofStr);
4255
4732
  setRadiusOn(radiusOn);