From 1f8295403702f8191485e7a60e7164a26eb13d24 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 06:10:05 +0300 Subject: [PATCH] style(auth): ring a refused or wrong answer red while it shakes A code field silently dropped a character it does not accept, so typing a letter into the emailed code, which is digits only, looked exactly like a field that does not work. It now shakes the boxes and rings them red, the same answer a wrong code gets. Dashes and spaces are still taken quietly, since they are how a code is written. The shake an error page plays on load now rings the field red too, fading back to its usual border once it settles, on every field including the email address. Reduced motion keeps the red and drops the movement, since the red is the message. --- packages/auth/src/ui/css.ts | 25 +++++++++++++++++++----- packages/auth/src/ui/segments.ts | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index dacb86b0..78778f55 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -565,9 +565,10 @@ form:has([data-component='input']:invalid) [data-component='button']:hover { } -/* A wrong answer shakes the field it came from, once. Every error is a new - page, so an animation that runs on load runs exactly when one arrives — - no script needed to trigger it. */ +/* A wrong answer shakes the field it came from, once, and rings it red while + it does. Every error is a new page, so an animation that runs on load runs + exactly when one arrives — no script needed to trigger it. \`segments.ts\` + plays the same two when a code field refuses a character. */ @keyframes shake { 0%, 100% { translate: 0; } 15%, 55% { translate: -0.5rem; } @@ -575,8 +576,18 @@ form:has([data-component='input']:invalid) [data-component='button']:hover { 90% { translate: -0.2rem; } } +/* No end frame, so it fades back to whatever border the field has now. */ +@keyframes alarm { + 0%, 60% { + border-color: var(--color-red-600); + box-shadow: 0 0 0 1px var(--color-red-600); + } +} + form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] { - animation: shake 400ms ease-in-out; + animation: + shake 400ms ease-in-out, + alarm 1s ease-out; } [data-component='input'][data-variant='code']::placeholder { @@ -615,7 +626,11 @@ form:has([data-component='form-alert'][data-color='danger']) [data-component='in scale: 1; } - form:has([data-component='form-alert'][data-color='danger']) [data-component='input'], + /* The red stays: it is the message. Only the movement goes. */ + form:has([data-component='form-alert'][data-color='danger']) [data-component='input'] { + animation: alarm 1s ease-out; + } + [data-component='input'][data-variant='box'][data-active]:empty::after { animation: none; } diff --git a/packages/auth/src/ui/segments.ts b/packages/auth/src/ui/segments.ts index 0485f38d..6553fcb6 100644 --- a/packages/auth/src/ui/segments.ts +++ b/packages/auth/src/ui/segments.ts @@ -64,6 +64,34 @@ export default `(() => { if (real.selectionStart !== end || real.selectionEnd !== end) real.setSelectionRange(end, end); }; + // A refused character gets the same answer as a wrong code: the boxes + // shake and ring red. Silently dropping it looked exactly like a field + // that does not work. Played through script rather than a class, because + // the error page may already be running the stylesheet's own shake on + // these boxes, and toggling a class cannot restart an animation that is + // still named the same. + const refuse = () => { + const red = getComputedStyle(root).getPropertyValue('--color-red-600').trim() || 'red'; + const ring = { borderColor: red, boxShadow: '0 0 0 1px ' + red }; + const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + boxes.forEach((box) => { + box.animate([ring, Object.assign({ offset: 0.6 }, ring)], { duration: 1000, easing: 'ease-out' }); + if (!still) + box.animate( + [ + { translate: '0' }, + { translate: '-0.5rem', offset: 0.15 }, + { translate: '0.5rem', offset: 0.35 }, + { translate: '-0.5rem', offset: 0.55 }, + { translate: '0.5rem', offset: 0.75 }, + { translate: '-0.2rem', offset: 0.9 }, + { translate: '0' } + ], + { duration: 400, easing: 'ease-in-out' } + ); + }); + }; + const render = () => { const value = real.value; const focused = document.activeElement === real; @@ -75,6 +103,11 @@ export default `(() => { }; real.addEventListener('input', () => { + // Separators and spaces are part of how a code is written, not a + // mistake; anything else that \`clean\` drops is. + const written = real.value.replace(/[\\s-]/g, ''); + const allowed = numeric ? /^[0-9]*$/ : /^[0-9a-zA-Z]*$/; + if (!allowed.test(written)) refuse(); const cleaned = clean(real.value); if (cleaned !== real.value) real.value = cleaned; toEnd();