Skip to content

Commit c848539

Browse files
jeremyfeltclaude
andcommitted
Make blink mode's speed adjustable and add an optional crossfade
Blink mode alternated the baseline and current capture on a fixed 650ms hard cut. This makes the dwell time adjustable — a speed slider, or `[` / `]` to slow down / speed up — and adds an optional crossfade (`f`, or the Fade button) that eases between the two images instead of swapping instantly, which can make a small shift easier to catch. The dwell time and fade preference both persist across slugs and reloads. The keyboard-shortcuts overlay and the in-view hint bar document the new keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 411681d commit c848539

3 files changed

Lines changed: 157 additions & 19 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ and renders three views client-side from the URL hash — a triage overview
167167
view (swipe, side-by-side, onion skin, diff overlay, blink), and a unified HTML
168168
diff. It opens straight from disk with no network access.
169169

170+
### Blink mode
171+
172+
Blink mode alternates between the baseline and the current capture in place, so
173+
a shift shows up as movement. Its dwell time is adjustable — a speed slider, or
174+
`[` / `]` to slow down / speed up — and an optional crossfade (`f`, or the Fade
175+
button) eases between the two images instead of hard-cutting, which can make a
176+
small change easier to catch than an abrupt swap. Both preferences persist
177+
across pages and reloads.
178+
170179
## Development
171180

172181
```bash

templates/assets/app.js

Lines changed: 141 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,12 @@
10951095
flip: function () {
10961096
blink.flip();
10971097
},
1098+
nudgeDelay: function (d) {
1099+
blink.nudgeDelay(d);
1100+
},
1101+
toggleFade: function () {
1102+
blink.toggleFade();
1103+
},
10981104
});
10991105
}
11001106

@@ -1111,16 +1117,24 @@
11111117
);
11121118
}
11131119
root.appendChild(stage);
1114-
root.appendChild(
1115-
hintbar([
1116-
{ keys: ['1', '5'], label: 'modes' },
1117-
{ keys: ['←', '→'], label: 'adjust' },
1118-
{ keys: ['n', 'p'], label: 'next / prev changed' },
1119-
{ keys: ['v'], label: 'viewport' },
1120-
{ keys: ['h'], label: 'HTML' },
1121-
{ keys: ['esc'], label: 'overview' },
1122-
])
1120+
var hints = [
1121+
{ keys: ['1', '5'], label: 'modes' },
1122+
{ keys: ['←', '→'], label: 'adjust' },
1123+
];
1124+
if (mode === 'blink') {
1125+
hints.push(
1126+
{ keys: ['space'], label: 'play / pause' },
1127+
{ keys: ['[', ']'], label: 'speed' },
1128+
{ keys: ['f'], label: 'fade' }
1129+
);
1130+
}
1131+
hints.push(
1132+
{ keys: ['n', 'p'], label: 'next / prev changed' },
1133+
{ keys: ['v'], label: 'viewport' },
1134+
{ keys: ['h'], label: 'HTML' },
1135+
{ keys: ['esc'], label: 'overview' }
11231136
);
1137+
root.appendChild(hintbar(hints));
11241138
}
11251139

11261140
/**
@@ -1328,31 +1342,80 @@
13281342
};
13291343
}
13301344

1345+
// Blink dwell time (ms each image is held) and crossfade preference persist
1346+
// across slugs and reloads so a chosen rhythm sticks while triaging.
1347+
var BLINK_MIN = 100;
1348+
var BLINK_MAX = 3000;
1349+
var BLINK_STEP = 50;
1350+
13311351
/**
1332-
* Build the blink stage and its play/pause control.
1352+
* Read the saved blink dwell, clamped to the supported range.
1353+
*
1354+
* @returns {number} The dwell in milliseconds.
1355+
*/
1356+
function blinkDelay() {
1357+
var saved = parseInt(sessionStorage.getItem('rg-blink-delay'), 10);
1358+
var ms = Number.isFinite(saved) ? saved : 650;
1359+
return Math.max(BLINK_MIN, Math.min(BLINK_MAX, ms));
1360+
}
1361+
1362+
/**
1363+
* Build the blink stage and its play/pause, speed, and fade controls.
1364+
*
1365+
* Stacks the baseline over the current image and alternates which is
1366+
* opaque. The dwell time is adjustable (slider or `[` / `]`); an optional
1367+
* crossfade (`f`) eases between the two rather than hard-cutting, which can
1368+
* make a small shift easier to spot than an abrupt swap.
13331369
*
13341370
* @param {HTMLElement} stage The stage container to fill.
13351371
* @param {HTMLElement} bar2 The mode-control bar.
13361372
* @param {object} img The result's image paths.
1337-
* @returns {object} `{ setPlaying, toggle, flip }` controls.
1373+
* @returns {object} `{ setPlaying, toggle, flip, nudgeDelay, toggleFade }`.
13381374
*/
13391375
function buildBlink(stage, bar2, img) {
13401376
var showBase = false;
13411377
var playing = true;
1342-
var layer = h(
1343-
'div',
1344-
{ class: 'layer', style: { visibility: 'hidden' } },
1345-
[h('img', { src: img.control, alt: 'Baseline screenshot' })]
1346-
);
1378+
var delay = blinkDelay();
1379+
var fade = sessionStorage.getItem('rg-blink-fade') === '1';
1380+
1381+
var layer = h('div', { class: 'layer blink-layer' }, [
1382+
h('img', { src: img.control, alt: 'Baseline screenshot' }),
1383+
]);
13471384
var label = h('span', { class: 'taglabel l', text: 'current' });
13481385
var readout = h('b', {
13491386
style: { color: 'var(--text)' },
13501387
text: 'current',
13511388
});
13521389
var playBtn = h('button', { class: 'ghost-btn', text: 'Pause' });
1390+
var slider = h('input', {
1391+
type: 'range',
1392+
min: String(BLINK_MIN),
1393+
max: String(BLINK_MAX),
1394+
step: String(BLINK_STEP),
1395+
value: String(delay),
1396+
'aria-label': 'Blink dwell time in milliseconds',
1397+
});
1398+
var delayOut = h('span', {
1399+
class: 'blink-delay',
1400+
text: delay + 'ms',
1401+
});
1402+
var fadeBtn = h('button', {
1403+
class: 'ghost-btn',
1404+
text: 'Fade',
1405+
'aria-pressed': fade ? 'true' : 'false',
1406+
title: 'Crossfade between images (f)',
1407+
});
1408+
1409+
// Ease no longer than half the dwell so each image still reaches its
1410+
// own full opacity before the next swap starts.
1411+
var applyFade = function () {
1412+
layer.style.transition = fade
1413+
? 'opacity ' + Math.min(delay / 2, 400) + 'ms linear'
1414+
: 'none';
1415+
};
13531416
var setShowBase = function (b) {
13541417
showBase = b;
1355-
layer.style.visibility = b ? 'visible' : 'hidden';
1418+
layer.style.opacity = b ? '1' : '0';
13561419
label.textContent = b ? 'baseline' : 'current';
13571420
readout.textContent = b ? 'baseline' : 'current';
13581421
};
@@ -1366,14 +1429,51 @@
13661429
if (p) {
13671430
blinkTimer = setInterval(function () {
13681431
setShowBase(!showBase);
1369-
}, 650);
1432+
}, delay);
13701433
}
13711434
};
1435+
var setDelay = function (ms) {
1436+
delay = Math.max(BLINK_MIN, Math.min(BLINK_MAX, ms));
1437+
sessionStorage.setItem('rg-blink-delay', String(delay));
1438+
delayOut.textContent = delay + 'ms';
1439+
slider.value = String(delay);
1440+
applyFade();
1441+
// Restart the interval so a change takes effect immediately.
1442+
if (playing) {
1443+
setPlaying(true);
1444+
}
1445+
};
1446+
var setFade = function (on) {
1447+
fade = on;
1448+
sessionStorage.setItem('rg-blink-fade', on ? '1' : '0');
1449+
fadeBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
1450+
applyFade();
1451+
};
1452+
13721453
playBtn.addEventListener('click', function () {
13731454
setPlaying(!playing);
13741455
});
1456+
slider.addEventListener('input', function () {
1457+
setDelay(parseInt(slider.value, 10));
1458+
});
1459+
fadeBtn.addEventListener('click', function () {
1460+
setFade(!fade);
1461+
});
1462+
1463+
applyFade();
1464+
setShowBase(false);
1465+
13751466
bar2.appendChild(
1376-
h('span', { class: 'modectl' }, [playBtn, 'showing: ', readout])
1467+
h('span', { class: 'modectl' }, [
1468+
playBtn,
1469+
'showing: ',
1470+
readout,
1471+
h('span', { class: 'modectl-div' }),
1472+
'speed',
1473+
slider,
1474+
delayOut,
1475+
fadeBtn,
1476+
])
13771477
);
13781478
stage.appendChild(
13791479
h('div', { class: 'shot' }, [
@@ -1391,6 +1491,12 @@
13911491
setPlaying(false);
13921492
setShowBase(!showBase);
13931493
},
1494+
nudgeDelay: function (d) {
1495+
setDelay(delay + d * BLINK_STEP);
1496+
},
1497+
toggleFade: function () {
1498+
setFade(!fade);
1499+
},
13941500
};
13951501
}
13961502

@@ -1439,6 +1545,19 @@
14391545
if (ctx.togglePlay) {
14401546
ctx.togglePlay();
14411547
}
1548+
} else if (
1549+
(e.key === '[' || e.key === ']') &&
1550+
ctx.mode === 'blink'
1551+
) {
1552+
e.preventDefault();
1553+
if (ctx.nudgeDelay) {
1554+
ctx.nudgeDelay(e.key === ']' ? 1 : -1);
1555+
}
1556+
} else if (e.key === 'f' && ctx.mode === 'blink') {
1557+
e.preventDefault();
1558+
if (ctx.toggleFade) {
1559+
ctx.toggleFade();
1560+
}
14421561
}
14431562
};
14441563
}
@@ -1709,6 +1828,9 @@
17091828
cmp: [
17101829
['Swipe / Side / Onion / Diff / Blink', ['1', '5']],
17111830
['Adjust slider / flip blink', ['←', '→']],
1831+
['Play / pause blink', ['space']],
1832+
['Blink speed (slower / faster)', ['[', ']']],
1833+
['Toggle blink crossfade', ['f']],
17121834
['Next / prev changed result', ['n', 'p']],
17131835
['Cycle viewport', ['v']],
17141836
['HTML diff', ['h']],

templates/assets/reglance.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ kbd {
349349

350350
.modectl { display: flex; align-items: center; gap: 10px; color: var(--text3); font-size: 12.5px; white-space: nowrap; }
351351
.modectl input[type="range"] { accent-color: var(--accent); width: 150px; }
352+
.modectl-div { width: 1px; align-self: stretch; background: var(--line); margin: 2px 2px; }
353+
.modectl .blink-delay { font-family: var(--font-mono); color: var(--text2); min-width: 52px; }
354+
.modectl button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
355+
356+
/* Blink crossfade: the easing is set inline (tied to the dwell time); this
357+
just establishes opacity as the animated property. */
358+
.blink-layer { opacity: 0; }
352359

353360
.stage { max-width: 1100px; margin: 6px auto 80px; padding: 0 22px; }
354361
.stage.wide { max-width: 1500px; }

0 commit comments

Comments
 (0)