Pattern: login
Who uses it: every user, before anything else — the
first screen and, for anyone who forgets a password, a recurring one.
What "done" looks like: signed in, or told exactly
why not without leaking which part was wrong. The one screen every ERP
app has and the one where getting the details wrong locks real users
out. Composed entirely from
existing components, zero new CSS: .bo-widget (the card primitive)
centers the form on the canvas, fields are plain .bo-form-fields
with the correct autocomplete tokens
(username / current-password — this is what lets
password managers fill the form), and errors use the same
validation-summary pattern as any
other form. Try it: submit the empty form.
Not for re-authentication mid-task — when a workspace
needs a step-up credential check without abandoning the screen the user
was on (confirming a destructive action, re-entering a password after a
session timeout), a full-page navigation throws away that context; use a
dialog carrying the same two
fields instead. This page is for the one navigation every session starts
with, not an interruption inside one.
The details that matter on a login screen
autocomplete="username"/"current-password"— the standardized tokens password managers key on. Omitting them (or usingoff) breaks credential autofill, which hurts the users who rely on managers for strong unique passwords.- Server-error recovery uses the same summary box — on a
failed sign-in your server re-renders the form with the
data-validation-summary-boxalert visible and a message like "The email or password you entered is incorrect." Say which action failed, never which field was wrong (that's credential enumeration). - No premature lockout messaging — if you rate-limit or lock accounts, tell the user how to recover ("try again in 15 minutes" / "reset your password"), in the same alert, as text — not a dead end.
- The password field never blocks paste — paste-blocking breaks password managers; nothing here does it and nothing should.
- One column, one card —
.bo-widgetwithmax-inline-sizecentered on the canvas; no new "login layout" CSS exists because none is needed.
Markup
<!-- inline-size: 100% matters: .bo-widget is a size container
(container-type: inline-size), so outside a .bo-widget-grid it needs an
explicit width — max-inline-size alone collapses it. -->
<div class="bo-widget" style="inline-size: 100%; max-inline-size: 24rem; margin-inline: auto">
<div class="bo-widget__header"><span class="bo-widget__title">Sign in</span></div>
<div class="bo-widget__body">
<form method="post" action="/session" data-validation-summary class="bo-stack bo-stack--tight">
<div class="bo-alert bo-alert--danger" data-validation-summary-box hidden role="alert">
<p>There is a problem</p>
<ul></ul>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="user">Email or username</label>
<input class="bo-input" id="user" name="username" autocomplete="username" required />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="pass">Password</label>
<input class="bo-input" id="pass" name="password" type="password"
autocomplete="current-password" required />
</div>
<div class="bo-form-actions">
<button class="bo-btn" type="submit">Sign in</button>
</div>
</form>
</div>
</div>
<script type="module">
import { initValidationSummary } from "@busy-office/ui/js";
initValidationSummary(); // client-side empty-field summary; server errors
// re-render the same alert box with their message
</script> Anatomy
- Brand block — your mark; the framework ships no logo component, it is your
<img>. - Form — a real
<form>posting to your endpoint; native validation first, server validation authoritative. - Error region —
.bo-alert--dangerabove the fields, rendered by the SERVER on failure (not JS), so it survives a hard reload. - Secondary links — reset password, SSO — a cluster below the submit.
Data contract
| Request | Returns / on failure |
|---|---|
| POST /login | redirect to the app on success |
| POST /login (bad credentials) | 401 → the same form re-rendered with the alert and the USERNAME preserved; never the password |
| GET /login?next=… | the form, carrying the post-login destination in a hidden field |
States
| State | What ships |
|---|---|
| Idle | submit enabled; autofocus the first empty field only |
| Invalid credentials | one generic message — never "no such user" (account enumeration) |
| Account locked | a distinct message with the unlock path; do NOT reuse the invalid-credentials copy |
| Session expired | arrive with an info alert explaining why, and the original destination preserved |
| Submitting | button disabled + aria-busy; the form is the only thing on screen, so no skeleton |
| No JS | the live demo form carries novalidate, which turns off the browser's own constraint validation — with no initValidationSummary() to call checkValidity() either, Submit fires with empty required fields unvalidated and the summary box never appears; the canonical form in the Markup section below omits novalidate, so a real deployment of it would fall back to native browser validation bubbles instead of losing validation entirely |
Components used
complexity 1 of 4