screengraft 0.63.4 → 0.63.6

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/ui/index.html +105 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.63.4",
3
+ "version": "0.63.6",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/ui/index.html CHANGED
@@ -558,6 +558,11 @@
558
558
  .pane{min-width:0;min-height:0;display:grid;grid-template-rows:minmax(0,1fr);
559
559
  grid-template-columns:minmax(0,1fr);
560
560
  position:relative; /* the frame the rails are anchored to */
561
+ /* An isolated group: the blend rings on the rails and pills composite
562
+ against THIS pane's content -- the well, the canvas, the result
563
+ image -- as one unit, rather than against whatever layer the
564
+ compositor happened to have on hand at first paint. */
565
+ isolation:isolate;
561
566
  border-right:1px solid var(--line)}
562
567
  .panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
563
568
  box-shadow:inset 0 -1px 0 var(--line);font-size:12px;flex-wrap:wrap;min-height:48px}
@@ -600,6 +605,17 @@
600
605
  .bgroup::after{--ring:var(--group-edge-lum);mix-blend-mode:luminosity;z-index:2}
601
606
  .bgroup > .ring{--ring:var(--group-edge-hue);mix-blend-mode:hue;z-index:3}
602
607
  .bgroup > .ring.lift{--ring:var(--group-edge-lift);mix-blend-mode:soft-light;z-index:4}
608
+ /* WebKit does not blend against an accelerated <canvas> (or, reliably, an
609
+ <img>), so under html.noblend the four rings collapse to ONE plain ring
610
+ whose four side colours are computed in JS from the pixels under each
611
+ edge, through the same four blends (paintRingsFallback). Chrome keeps
612
+ the live per-pixel blend. */
613
+ .noblend .bgroup::before,.noblend .bgroup::after,.noblend .bgroup > .ring:not(.lift),
614
+ .noblend .pillwrap > .ring:not(.lift){display:none}
615
+ .noblend .bgroup > .ring.lift{box-shadow:none;mix-blend-mode:normal;
616
+ inset:calc(-1 * var(--group-bw));border:var(--group-bw) solid transparent;
617
+ border-radius:calc(var(--r-sm) + var(--group-bw))}
618
+ .noblend .pillwrap > .ring.lift{mix-blend-mode:normal;border-color:transparent}
603
619
  .bgroup.row{flex-direction:row}
604
620
  .bgroup > .segbtn:first-of-type{border-top-left-radius:var(--r-sm);border-top-right-radius:var(--r-sm)}
605
621
  .bgroup > .segbtn:last-of-type{border-bottom-left-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
@@ -2121,6 +2137,7 @@ async function maybeStart(){
2121
2137
  const ready = !!st.shot; // the screenshot gates the fit, not the photo
2122
2138
  img.onload = () => {
2123
2139
  computeFit(); setCanvasZoom(fitScale);
2140
+ refreshRings();
2124
2141
  if (!ready){
2125
2142
  // Photo only: show it immediately so choosing one visibly does
2126
2143
  // something. No quad yet — there is nothing to fit into it.
@@ -2502,6 +2519,89 @@ $('#resetquad').onclick = () => {
2502
2519
  draw(); drawStrip(); autoPreview();
2503
2520
  };
2504
2521
 
2522
+ // The blend rings on the rails and pills are painted against what is under
2523
+ // them at the time; when a NEW picture lands in the canvas or the result pane
2524
+ // they can keep a result blended against the empty well until something else
2525
+ // invalidates them (reported 14 Sep 2026: the stroke appeared only after the
2526
+ // picture was moved). Toggling display forces a fresh paint of each ring.
2527
+ function refreshRings(){
2528
+ paintRingsFallback();
2529
+ document.querySelectorAll('.bgroup > .ring, .pillwrap > .ring').forEach(r => {
2530
+ r.style.display = 'none'; void r.offsetHeight; r.style.display = '';
2531
+ });
2532
+ // ::before / ::after rings ride on the group; nudge the group itself.
2533
+ document.querySelectorAll('.bgroup').forEach(g => {
2534
+ g.style.willChange = 'transform'; void g.offsetHeight; g.style.willChange = '';
2535
+ });
2536
+ }
2537
+ // ---- WebKit fallback for the blended strokes -------------------------------
2538
+ // The W3C compositing blends (the same maths mix-blend-mode uses), applied in
2539
+ // JS to the average backdrop under each side of a group, so Safari shows the
2540
+ // same adaptive hairline Chrome gets from the compositor. Paints are
2541
+ // [colour, opacity, mode] bottom to top, as in the design file.
2542
+ const IS_WEBKIT = /AppleWebKit/.test(navigator.userAgent) && !/Chrome|Chromium|CriOS|Edg/.test(navigator.userAgent);
2543
+ if (IS_WEBKIT) document.documentElement.classList.add('noblend');
2544
+ const RING_PAINTS = [[[15,107,255],.2,'saturation'],[[255,70,70],.15,'luminosity'],[[126,126,126],.2,'hue'],[[255,255,255],.4,'soft-light']];
2545
+ const _lum = c => .3*c[0] + .59*c[1] + .11*c[2];
2546
+ function _clipColor(c){ const l = _lum(c), n = Math.min(...c), x = Math.max(...c);
2547
+ if (n < 0) c = c.map(v => l + (v - l) * l / (l - n));
2548
+ if (x > 1) c = c.map(v => l + (v - l) * (1 - l) / (x - l)); return c; }
2549
+ const _setLum = (c, l) => { const d = l - _lum(c); return _clipColor(c.map(v => v + d)); };
2550
+ const _sat = c => Math.max(...c) - Math.min(...c);
2551
+ function _setSat(c, s){ const i = [0,1,2].sort((a,b) => c[a]-c[b]); const [mn, md, mx] = i; const o = [0,0,0];
2552
+ if (c[mx] > c[mn]){ o[md] = (c[md]-c[mn]) * s / (c[mx]-c[mn]); o[mx] = s; } return o; }
2553
+ function _blend(mode, b, s){
2554
+ if (mode === 'saturation') return _setLum(_setSat(b, _sat(s)), _lum(b));
2555
+ if (mode === 'luminosity') return _setLum(b, _lum(s));
2556
+ if (mode === 'hue') return _setLum(_setSat(s, _sat(b)), _lum(b));
2557
+ if (mode === 'soft-light') return b.map((cb, i) => { const cs = s[i];
2558
+ if (cs <= .5) return cb - (1 - 2*cs) * cb * (1 - cb);
2559
+ const d = cb <= .25 ? ((16*cb - 12) * cb + 4) * cb : Math.sqrt(cb);
2560
+ return cb + (2*cs - 1) * (d - cb); });
2561
+ return s;
2562
+ }
2563
+ function ringColour(rgb){ let b = rgb.map(v => v/255);
2564
+ for (const [col, a, mode] of RING_PAINTS){ const s = col.map(v => v/255); const r = _blend(mode, b, s); b = b.map((v, i) => v*(1-a) + r[i]*a); }
2565
+ return 'rgb(' + b.map(v => Math.round(Math.max(0, Math.min(1, v))*255)).join(',') + ')'; }
2566
+ // One small copy of each pane's picture to sample from (a readback per draw,
2567
+ // not per pixel). Fit pane: the canvas; result pane: the composite <img>.
2568
+ const _sampleCv = document.createElement('canvas'), _sampleCx = _sampleCv.getContext('2d', {willReadFrequently: true});
2569
+ function _backdropSampler(source, rect){
2570
+ const W = 256, H = Math.max(1, Math.round(256 * rect.height / Math.max(rect.width, 1)));
2571
+ _sampleCv.width = W; _sampleCv.height = H;
2572
+ try { _sampleCx.drawImage(source, 0, 0, W, H); } catch (e) { return null; }
2573
+ const data = _sampleCx.getImageData(0, 0, W, H).data;
2574
+ return (x, y) => { const px = Math.max(0, Math.min(W-1, Math.round((x - rect.left) * W / rect.width)));
2575
+ const py = Math.max(0, Math.min(H-1, Math.round((y - rect.top) * H / rect.height)));
2576
+ const i = (py*W + px) * 4; return data[i+3] ? [data[i], data[i+1], data[i+2]] : null; };
2577
+ }
2578
+ function _sideColours(sample, r, out){
2579
+ const N = 9, sides = {top: [], right: [], bottom: [], left: []};
2580
+ for (let k = 0; k < N; k++){ const t = (k + .5) / N;
2581
+ sides.top.push(sample(r.left + r.width*t, r.top - 1)); sides.bottom.push(sample(r.left + r.width*t, r.bottom + 1));
2582
+ sides.left.push(sample(r.left - 1, r.top + r.height*t)); sides.right.push(sample(r.right + 1, r.top + r.height*t)); }
2583
+ for (const [side, pts] of Object.entries(sides)){ const ok = pts.filter(Boolean);
2584
+ const avg = ok.length ? [0,1,2].map(i => ok.reduce((a, p) => a + p[i], 0) / ok.length) : [11,11,11];
2585
+ out.style['border' + side[0].toUpperCase() + side.slice(1) + 'Color'] = ringColour(avg); }
2586
+ }
2587
+ let _ringsQueued = false;
2588
+ function paintRingsFallback(){
2589
+ if (!IS_WEBKIT || _ringsQueued) return;
2590
+ _ringsQueued = true;
2591
+ requestAnimationFrame(() => { _ringsQueued = false;
2592
+ const panes = [[$('#c'), $('.pane')], [$('#outImg'), $('#outPane')]];
2593
+ for (const [src, pane] of panes){
2594
+ if (!src || !pane) continue;
2595
+ const ok = src.tagName === 'CANVAS' ? src.width > 0 : (!src.hidden && src.naturalWidth > 0);
2596
+ const rect = src.getBoundingClientRect();
2597
+ const sample = ok && rect.width > 0 ? _backdropSampler(src, rect) : null;
2598
+ pane.querySelectorAll('.bgroup > .ring.lift, .pillwrap > .ring.lift').forEach(ring => {
2599
+ const r = ring.parentElement.getBoundingClientRect();
2600
+ if (sample) _sideColours(sample, r, ring); else ['Top','Right','Bottom','Left'].forEach(sd => ring.style['border' + sd + 'Color'] = ringColour([11,11,11]));
2601
+ });
2602
+ }
2603
+ });
2604
+ }
2505
2605
  function syncCornerRail(){
2506
2606
  const i = pick && pick.kind === 'corner' ? pick.i : -1;
2507
2607
  document.querySelectorAll('#lrail [data-jump]').forEach(b => {
@@ -2512,6 +2612,7 @@ function syncCornerRail(){
2512
2612
  }
2513
2613
  function draw(){
2514
2614
  syncCornerRail();
2615
+ paintRingsFallback();
2515
2616
  // The clip follows the quad while it is being dragged: the live layer IS the
2516
2617
  // fit, so leaving it on the old corners would make the two panes disagree
2517
2618
  // mid-drag — which is the one thing the side-by-side exists to prevent.
@@ -3323,6 +3424,7 @@ function markStale(){
3323
3424
  $('#imp').classList.remove('primary');
3324
3425
  }
3325
3426
  function syncOut(){
3427
+ paintRingsFallback();
3326
3428
  // The live layer is a photo plus a transformed video, so it re-places rather
3327
3429
  // than being resized: the transform is computed FROM the zoom, and a stale
3328
3430
  // matrix puts the clip somewhere the fit is not.
@@ -3346,6 +3448,7 @@ function syncOut(){
3346
3448
  paintGizmo();
3347
3449
  }
3348
3450
  $('#scroller').addEventListener('scroll', () => {
3451
+ paintRingsFallback();
3349
3452
  syncOut();
3350
3453
  });
3351
3454
  function autoPreview(){
@@ -3598,6 +3701,8 @@ async function renderPreview(){
3598
3701
  const im = $('#outImg');
3599
3702
  im.onload = () => {
3600
3703
  outNat = im.naturalWidth;
3704
+ refreshRings();
3705
+ paintRingsFallback();
3601
3706
  // autoPreview runs on every drag, and it must not yank the pane away from
3602
3707
  // a clip that is playing. The still is still rendered and waiting behind.
3603
3708
  if ($('#liveWrap').hidden) showResult('still');