From 39e4bebf64e55ba464d331fca44e9c4de6e086d8 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 2 Oct 2026 06:00:08 +0300 Subject: [PATCH] feat(auth): draw code fields as one box per character The emailed code and the device code are now typed into a row of boxes, with a dash drawn between the device code's two halves. Typing moves to the next box, backspace on an empty box goes back, the arrow keys move, and a pasted or autofilled code, dashes and all, is spread across them. It is an enhancement of the field and not a replacement for it. The server still sends the single plain input, which works on its own with script off; a short inline script draws the boxes over it and keeps it in step. The real input is still the one that submits and the one whose validity dims the button, so nothing else had to learn about boxes. Pressing the button early moves to the first empty box, since the browser's own message would point at a field nobody can see. --- packages/auth/src/ui/css.ts | 50 ++++++++++++ packages/auth/src/ui/render.tsx | 57 +++++++++----- packages/auth/src/ui/segments.ts | 129 +++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 packages/auth/src/ui/segments.ts diff --git a/packages/auth/src/ui/css.ts b/packages/auth/src/ui/css.ts index 6ef407f4..0dad01af 100644 --- a/packages/auth/src/ui/css.ts +++ b/packages/auth/src/ui/css.ts @@ -486,6 +486,56 @@ form:has([data-component='input']:invalid) [data-component='button']:hover { font-variant-numeric: tabular-nums; } +/* A code field drawn as one box per character, by \`segments.ts\`. The real + field is still the one that submits and the one the button's dimming reads, + so it is hidden from sight and not removed. */ +[data-component='segments'] { + position: relative; + width: 100%; +} + +[data-component='segments'][data-enhanced] > [data-component='input'] { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} + +[data-component='segments'] [data-slot='boxes'] { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; +} + +[data-component='input'][data-variant='box'] { + flex: 1 1 0%; + min-width: 0; + padding: 0.9rem 0; + text-align: center; + font-family: var(--font-mona); + font-size: 1.5rem; + line-height: 2rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-transform: uppercase; +} + +/* A box selects its character on focus so typing replaces it; that is + behaviour, not something to show. */ +[data-component='input'][data-variant='box']::selection { + background: transparent; +} + +[data-component='segments'] [data-slot='separator'] { + flex: none; + color: var(--color-muted-foreground2); + font-family: var(--font-mona); + font-size: 1.5rem; + font-weight: 700; + user-select: none; +} + /* 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. */ diff --git a/packages/auth/src/ui/render.tsx b/packages/auth/src/ui/render.tsx index 70fadd9f..9a55d10b 100644 --- a/packages/auth/src/ui/render.tsx +++ b/packages/auth/src/ui/render.tsx @@ -17,6 +17,7 @@ /** @jsxImportSource hono/jsx */ import { Layout } from './base.js'; +import segments from './segments.js'; import type { Alert, ChooseScreen, @@ -52,7 +53,10 @@ export interface HtmlRendererOptions { theme?: Theme; } -/** The default renderer: server-rendered HTML, no client-side script. */ +/** + * The default renderer: server-rendered HTML that works with script off. The + * one script, `segments.ts`, only redraws a code field as boxes. + */ export function HtmlRenderer(options?: HtmlRendererOptions): Renderer { const theme = options?.theme; @@ -243,27 +247,38 @@ function Input(props: { field: Field }) { const sizes = Array.from({ length: groups }, (_, i) => Math.min(field.group ?? field.length, field.length - i * (field.group ?? field.length)) ); + // One box per character once `segments.ts` has run; this single field + // until then, and for good if it never does. return ( - `${char}{${n}}`).join('[\\- ]?')} - size={field.length + groups - 1} - required - spellcheck={false} - autocapitalize="characters" - inputmode={field.numeric ? 'numeric' : 'text'} - autocomplete={field.autocomplete} - autofocus={field.autofocus} - /> + <> +
+ `${char}{${n}}`).join('[\\- ]?')} + size={field.length + groups - 1} + required + spellcheck={false} + autocapitalize="characters" + inputmode={field.numeric ? 'numeric' : 'text'} + autocomplete={field.autocomplete} + autofocus={field.autofocus} + /> +
+