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.
This commit is contained in:
Wanjohi
2026-10-02 06:10:05 +03:00
parent 890ada8ead
commit 1f82954037
2 changed files with 53 additions and 5 deletions
+20 -5
View File
@@ -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;
}
+33
View File
@@ -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();