Reset by holding the button, not by tapping it twice
A test user found the reset button, tapped it once and gave up. That is what the old feedback invited: a first tap brightened the icon and started a ring draining over 2s, and a draining ring reads as "wait" or "loading", not "press me again". The one thing that would have said otherwise — a word — is not available here, because the bar is shared by two players sitting opposite each other and every word in this app lives inside a panel that rotates to face one of them. "Tap this twice" has no wordless vocabulary. "Keep holding" has a very well-worn one, so reset is now a 975ms hold: a dim track ring appears whole the moment the finger lands, a bright arc fills over it from 12 o'clock, and letting go early makes the arc retreat. The retreat is the instruction. A stray hold is also far less likely than two stray taps inside 2s, so the live game this was guarding is guarded better than before. Three things that look incidental and are not: - Hiding the ring is an opacity that waits out the retreat, not display:none, which cut the retreat off at the instant of release — invisible, and it was the whole point. The fade has a real duration because a 0s transition with a delay may be treated as no transition at all, taking the delay with it. - width:auto on the ring: .btn svg sets a width for the bar icons, and inheriting it against the ring's new height draws an ellipse. - The ring's transition is exempted from the blanket prefers-reduced-motion rule. Collapsing it would fill the ring the instant you touched the button and claim the reset was done 975ms before it was. It reports state. Keyboard, switch and assistive activation cannot hold, and hold-only would have left those users unable to reset at all, one release after an accessibility pass. Clicks arriving with detail 0 — no pointer behind them — keep the old two-presses-within-2s and the draining ring. Success now sounds: the flag's falling triad, rising instead. Deliberately not another rising fifth, which sndSwap owns and plays on every handover. Also corrects a claim in RELEASING.md: the screenshots reproduce near-exactly, not exactly. Two runs of identical code differ by a 4x13 pixel sliver where the delay bar's fill edge lands mid-pixel, so a non-empty git diff after re-running proves nothing on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+144
-24
@@ -209,26 +209,73 @@
|
||||
.btn--vacant{visibility:hidden}
|
||||
.btn:focus-visible{outline:2px solid var(--icon-hot);outline-offset:-6px;border-radius:8px}
|
||||
|
||||
/* reset armed for a second tap — the ring unwinds over the same 2s as the timeout */
|
||||
/* Reset confirms two different ways, because the two input methods can't share
|
||||
one. A finger holds the button and the ring fills under it; lifting early
|
||||
springs the ring back, which is the only way to say "keep going" without
|
||||
words — and the bar carries no words by design, since it is shared by two
|
||||
players sitting opposite each other. Keyboard and assistive activation
|
||||
can't hold, so they keep the older two-presses-within-2s, where the ring
|
||||
drains to show the window closing. */
|
||||
#reset{position:relative}
|
||||
#reset.armed{color:var(--icon-hot)}
|
||||
#reset.armed,#reset.holding{color:var(--icon-hot)}
|
||||
#reset .ring{
|
||||
position:absolute;
|
||||
top:50%;left:50%;
|
||||
width:clamp(36px,9.6vw,48px);height:clamp(36px,9.6vw,48px);
|
||||
/* As big as the bar will take, so a thumb can't cover it: the button is the
|
||||
bar's full height, leaving 4px of dark above and below. width:auto is
|
||||
load-bearing — .btn svg sets a width for the icons, and inheriting it here
|
||||
would give the ring that width against this height and draw an ellipse.
|
||||
It ends up a few px wider than the button's own slot, which is only ever
|
||||
empty space; the neighbouring glyph stays a good 12px clear. */
|
||||
height:calc(100% - 8px);
|
||||
width:auto;
|
||||
aspect-ratio:1;
|
||||
transform:translate(-50%,-50%) rotate(-90deg); /* start the arc at 12 o'clock */
|
||||
display:none;
|
||||
opacity:0;
|
||||
/* Hiding waits out the spring-back, then fades. display:none here would cut
|
||||
the retreat off at the instant the finger lifts, and that retreat is the
|
||||
whole reason the gesture teaches itself. The 120ms is a real duration on
|
||||
purpose: a 0s transition with a delay is allowed to be treated as no
|
||||
transition at all, which would take the delay with it. */
|
||||
transition:opacity 120ms linear 180ms;
|
||||
pointer-events:none;
|
||||
}
|
||||
#reset.armed .ring{display:block}
|
||||
/* appearing is instant — no duration, no delay */
|
||||
#reset.armed .ring,#reset.holding .ring{opacity:1;transition:none}
|
||||
|
||||
#reset .ring circle{
|
||||
fill:none;
|
||||
stroke:currentColor; /* the same flash the buttons use */
|
||||
stroke-width:2.6;
|
||||
/* user units, so it scales with the ring: 2 of 40 lands between 2.5 and
|
||||
3.4 real px across phone sizes, a shade heavier than the icon strokes
|
||||
because this one has to read past a thumb */
|
||||
stroke-width:2;
|
||||
}
|
||||
/* the whole circle, dim, there from the moment the finger lands: it shows how
|
||||
far there is to go, which an arc growing out of nothing cannot */
|
||||
#reset .ring-track{stroke:var(--icon)}
|
||||
/* and the bright arc that covers it, from 12 o'clock clockwise */
|
||||
#reset .ring-fill{
|
||||
stroke:var(--icon-hot);
|
||||
stroke-linecap:round;
|
||||
stroke-dasharray:113.1; /* 2πr, r=18 */
|
||||
animation:unwind 2s linear forwards;
|
||||
stroke-dashoffset:113.1; /* empty */
|
||||
/* The spring back. 180ms is the ceiling, not the figure: reversing a
|
||||
transition part-way shortens it in proportion, so letting go at 41% takes
|
||||
74ms, and the arc never spends longer retreating than it spent filling.
|
||||
The dim track outlasts it either way — the ring holds for 180ms and then
|
||||
fades, so even a stray tap shows a whole circle appear and go, which is
|
||||
what says there was something to finish. */
|
||||
transition:stroke-dashoffset 180ms ease-out;
|
||||
}
|
||||
#reset.holding .ring-fill{
|
||||
stroke-dashoffset:0;
|
||||
transition-duration:975ms; /* keep in step with HOLD_MS */
|
||||
transition-timing-function:linear;
|
||||
}
|
||||
/* the keyboard path: a full ring draining over the 2s window. An animation
|
||||
beats a transition on the same property, which is fine here only because
|
||||
.armed and .holding are never set at once. */
|
||||
#reset.armed .ring-fill{animation:unwind 2s linear forwards}
|
||||
@keyframes unwind{
|
||||
from{stroke-dashoffset:0}
|
||||
to{stroke-dashoffset:113.1}
|
||||
@@ -362,6 +409,13 @@
|
||||
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*{transition-duration:.01ms!important}
|
||||
/* ...except the reset ring, which reports how far through the hold you are.
|
||||
Collapsing it would fill the ring the instant you touched the button and
|
||||
tell you it was done 650ms before it was. This one is state, not
|
||||
decoration. Higher specificity than the * above, so it wins despite both
|
||||
being !important. */
|
||||
#reset .ring-fill{transition-duration:180ms!important}
|
||||
#reset.holding .ring-fill{transition-duration:975ms!important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -387,8 +441,12 @@
|
||||
</section>
|
||||
|
||||
<div class="bar">
|
||||
<button class="btn" id="reset" aria-label="Reset clock">
|
||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="18"/></svg>
|
||||
<button class="btn" id="reset" aria-label="Reset clock, hold to confirm">
|
||||
<!-- ring-track/ring-fill, not track/fill: those two are the delay bar's -->
|
||||
<svg class="ring" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<circle class="ring-track" cx="20" cy="20" r="18"/>
|
||||
<circle class="ring-fill" cx="20" cy="20" r="18"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6v6h-6"/><path d="M20 12a8 8 0 1 0-2.3 5.3"/>
|
||||
</svg>
|
||||
@@ -717,6 +775,15 @@
|
||||
tone(659.25, 0.16, 0.20, 0.24, "triangle", false);
|
||||
tone(440, 0.32, 0.55, 0.26, "triangle", true);
|
||||
}
|
||||
// the flag's mirror: the same triad rising, and quicker — a fresh start.
|
||||
// Deliberately not another rising fifth; sndSwap owns that shape and plays on
|
||||
// every single handover, so nothing else may sound like it.
|
||||
function sndReset(){
|
||||
if(!cfg.sound) return;
|
||||
tone(523.25, 0.00, 0.16, 0.20, "triangle", false);
|
||||
tone(659.25, 0.07, 0.16, 0.20, "triangle", false);
|
||||
tone(783.99, 0.14, 0.40, 0.22, "triangle", true);
|
||||
}
|
||||
|
||||
/* ============ helpers ============ */
|
||||
function elapsed(){
|
||||
@@ -925,16 +992,19 @@
|
||||
|
||||
// clocks back to full for the match length; the score is not this function's business
|
||||
function resetClocks(){
|
||||
disarm();
|
||||
cancelHold();
|
||||
st.reserve = [matchTime(), matchTime()];
|
||||
newGame();
|
||||
saveGame();
|
||||
}
|
||||
|
||||
// the Reset button — a whole new match
|
||||
// the Reset button — a whole new match. The sound lives here rather than in
|
||||
// resetClocks(), which the settings sheet also calls: changing the match
|
||||
// length shouldn't sound like a reset.
|
||||
function resetAll(){
|
||||
st.score = [0, 0];
|
||||
resetClocks();
|
||||
sndReset();
|
||||
}
|
||||
|
||||
function clampScore(){
|
||||
@@ -987,20 +1057,67 @@
|
||||
});
|
||||
window.addEventListener("pagehide", saveGame);
|
||||
|
||||
/* reset needs two taps within 2s — one stray tap can't wipe a live game */
|
||||
var armT = null;
|
||||
function disarm(){
|
||||
/* Reset is a hold, not a tap. It used to be two taps inside 2s, and a test
|
||||
user found the button, tapped it once and gave up — which is exactly what
|
||||
the old feedback invited, since a draining ring reads as "wait", not "press
|
||||
me again". There is no wordless way to say "do that again"; there is a very
|
||||
well-worn one for "keep holding", and letting go early springs the ring
|
||||
back, which teaches the gesture without a word in the bar.
|
||||
|
||||
A stray 650ms hold is also far less likely than two stray taps in 2s, so
|
||||
the live game it was guarding is better guarded than before. */
|
||||
var HOLD_MS = 975; // keep in step with the .holding transition
|
||||
var SLOP = 12; // px a press may drift and still count; also the panels'
|
||||
var holdT = null, armT = null, holdDown = null;
|
||||
|
||||
function cancelHold(){
|
||||
if(holdT){ clearTimeout(holdT); holdT = null; }
|
||||
if(armT){ clearTimeout(armT); armT = null; }
|
||||
holdDown = null;
|
||||
btnReset.classList.remove("holding");
|
||||
btnReset.classList.remove("armed");
|
||||
btnReset.setAttribute("aria-label", "Reset clock");
|
||||
btnReset.setAttribute("aria-label", "Reset clock, hold to confirm");
|
||||
}
|
||||
|
||||
btnReset.addEventListener("pointerdown", function(ev){
|
||||
ev.preventDefault();
|
||||
audio(); // unlocking on the press is a valid gesture, and earlier is better
|
||||
cancelHold();
|
||||
holdDown = { id: ev.pointerId, x: ev.clientX, y: ev.clientY };
|
||||
// so the lift still lands here if the finger drifts off the button
|
||||
try{ btnReset.setPointerCapture(ev.pointerId); }catch(e){}
|
||||
btnReset.classList.add("holding");
|
||||
holdT = setTimeout(function(){
|
||||
cancelHold(); // before, not after: resetAll() hides this button
|
||||
resetAll();
|
||||
}, HOLD_MS);
|
||||
});
|
||||
|
||||
btnReset.addEventListener("pointermove", function(ev){
|
||||
var d = holdDown;
|
||||
if(!d || ev.pointerId !== d.id) return;
|
||||
// a finger that has travelled this far was on its way somewhere else
|
||||
if(Math.abs(ev.clientX - d.x) > SLOP || Math.abs(ev.clientY - d.y) > SLOP) cancelHold();
|
||||
});
|
||||
|
||||
function endHold(ev){
|
||||
if(holdDown && ev.pointerId !== holdDown.id) return;
|
||||
cancelHold(); // the spring back is the whole lesson
|
||||
}
|
||||
btnReset.addEventListener("pointerup", endHold);
|
||||
btnReset.addEventListener("pointercancel", endHold);
|
||||
|
||||
/* The keyboard and switch path, which can't hold: two presses inside 2s, as
|
||||
the whole button used to work. detail is 0 only for activation that didn't
|
||||
come from a pointer — touch and mouse were dealt with above, and the click
|
||||
trailing their press must not arm anything. */
|
||||
function armReset(){
|
||||
if(armT){ disarm(); resetAll(); return; } // second tap inside the window
|
||||
if(armT){ cancelHold(); resetAll(); return; } // second press inside the window
|
||||
btnReset.classList.remove("armed");
|
||||
void btnReset.offsetWidth; // restart the drain animation
|
||||
btnReset.classList.add("armed");
|
||||
btnReset.setAttribute("aria-label", "Tap again to reset");
|
||||
armT = setTimeout(disarm, 2000);
|
||||
btnReset.setAttribute("aria-label", "Press again to reset");
|
||||
armT = setTimeout(cancelHold, 2000);
|
||||
}
|
||||
|
||||
/* ============ theme ============ */
|
||||
@@ -1135,7 +1252,7 @@
|
||||
// can reach the timer controls once the match is under way
|
||||
if(!matchPristine()) return;
|
||||
pauseNow(); // nobody's clock runs while a sheet is open
|
||||
disarm();
|
||||
cancelHold();
|
||||
paintSettings();
|
||||
openSheet(sheet);
|
||||
});
|
||||
@@ -1175,7 +1292,7 @@
|
||||
// set themselves survives
|
||||
resumeOnClose = (st.phase === RUN);
|
||||
pauseNow();
|
||||
disarm();
|
||||
cancelHold();
|
||||
// seed from what the panels are showing, not from the banked reserve — for
|
||||
// the interrupted turn those differ by the delay already spent
|
||||
edit = { score: st.score.slice(), time: [shownTime(0), shownTime(1)] };
|
||||
@@ -1230,7 +1347,10 @@
|
||||
closeScore();
|
||||
});
|
||||
|
||||
btnReset.addEventListener("click", armReset);
|
||||
btnReset.addEventListener("click", function(ev){
|
||||
if(ev.detail !== 0) return; // the tail of a press we already handled
|
||||
armReset();
|
||||
});
|
||||
btnPlay.addEventListener("click", togglePause);
|
||||
|
||||
/* ============ panel taps ============ */
|
||||
@@ -1238,8 +1358,8 @@
|
||||
// would steal the turn from Android's swipe-up-from-the-bottom gesture: the
|
||||
// bottom panel owns that edge, and starting a swipe home is a finger landing.
|
||||
// A swipe travels past SLOP, or the system claims it and we get pointercancel
|
||||
// instead of a lift. Either way there's no turn change.
|
||||
var SLOP = 12; // px of travel a press may drift and still count
|
||||
// instead of a lift. Either way there's no turn change. (SLOP is declared
|
||||
// with the reset hold, which measures drift the same way.)
|
||||
var wantFullscreen = true;
|
||||
panels.forEach(function(p){
|
||||
var down = null;
|
||||
|
||||
Reference in New Issue
Block a user