Transactional journeys
Signin
A finished page under Kiln. Copy it and it is yours outright: unlike a brickwork component or shell, it never upgrades under semver.
Scroll inside the frame to inspect the full page.
Paste into a static page. Link @bwui/css (and @bwui/js for behaviours).
<!DOCTYPE html>
<html lang="en-GB" data-bw-brand="northline">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign in | Northline</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,500;8..60,650;8..60,700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/product_templates/assets/brickwork.css" />
<script defer src="/static/product_templates/assets/behaviours.js"></script>
<style>
/* Northline brand pack (fictional): cool paper, teal accent, display serif.
Colour lives in --bw-* tokens only, so an L1 pack recolours this page. */
:root,
[data-bw-brand="northline"] {
--bw-color-surface: oklch(0.985 0.004 240);
--bw-color-fg: oklch(0.26 0.02 250);
--bw-color-border: oklch(0.90 0.01 240);
--bw-color-accent: oklch(0.42 0.05 230);
--bw-color-danger: oklch(0.55 0.18 25);
--bw-color-success: oklch(0.56 0.13 155);
--bw-color-warning: oklch(0.70 0.14 75);
--bw-color-fg-on-accent: oklch(0.99 0 0);
--bw-font-family-sans: "Source Sans 3", system-ui, sans-serif;
--bw-font-family-display: "Source Serif 4", Georgia, serif;
}
body {
margin: 0;
font-family: var(--bw-font-family-sans);
background: var(--bw-color-surface);
color: var(--bw-color-fg);
}
.nl-auth {
display: grid;
min-block-size: 100vh;
}
@media (min-width: 62rem) {
.nl-auth {
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
}
}
/* Brand panel: built from the accent token, so it recolours with the brand. */
.nl-aside {
position: relative;
display: none;
flex-direction: column;
justify-content: space-between;
gap: var(--bw-space-10);
padding: clamp(2rem, 4vw, 3.5rem);
color: var(--bw-color-fg-on-accent);
overflow: hidden;
background:
linear-gradient(165deg,
color-mix(in oklab, var(--bw-color-accent) 45%, black) 0%,
color-mix(in oklab, var(--bw-color-accent) 85%, black) 55%,
var(--bw-color-accent) 100%);
}
@media (min-width: 62rem) {
.nl-aside {
display: flex;
}
}
.nl-aside::after {
content: "";
position: absolute;
inset: auto -20% -35% 15%;
block-size: 80%;
background:
radial-gradient(ellipse at 40% 40%, color-mix(in oklab, white 16%, transparent) 0%, transparent 60%);
pointer-events: none;
}
.nl-aside > * {
position: relative;
z-index: 1;
}
.nl-brand {
display: inline-flex;
align-items: center;
gap: var(--bw-space-2);
font-weight: 700;
font-size: 1.125rem;
letter-spacing: -0.03em;
color: inherit;
text-decoration: none;
}
.nl-brand__mark {
inline-size: 1.125rem;
block-size: 1.125rem;
border-radius: 5px;
background: currentColor;
}
.nl-aside__heading {
margin: 0 0 var(--bw-space-4);
max-inline-size: 14ch;
font-family: var(--bw-font-family-display);
font-weight: 650;
font-size: clamp(2rem, 3.4vw, 3rem);
letter-spacing: -0.04em;
line-height: 1.08;
}
.nl-aside__lede {
margin: 0;
max-inline-size: 36ch;
line-height: 1.5;
opacity: 0.8;
}
.nl-aside__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--bw-space-5);
margin: 0;
padding-top: var(--bw-space-5);
border-top: 1px solid color-mix(in oklab, white 22%, transparent);
}
.nl-aside__stats dt {
font-family: var(--bw-font-family-display);
font-size: 2rem;
font-weight: 650;
letter-spacing: -0.03em;
line-height: 1;
}
.nl-aside__stats dd {
margin: var(--bw-space-1) 0 0;
font-size: var(--bw-font-size-sm);
opacity: 0.75;
}
.nl-main {
display: flex;
flex-direction: column;
padding: clamp(1.5rem, 4vw, 3rem);
}
.nl-main__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--bw-space-4);
font-size: var(--bw-font-size-sm);
color: var(--bw-color-fg-muted);
}
.nl-main__bar .nl-brand {
color: var(--bw-color-fg);
}
@media (min-width: 62rem) {
.nl-main__bar .nl-brand {
visibility: hidden;
}
}
.nl-main__bar a:not(.nl-brand) {
color: var(--bw-color-fg);
font-weight: 600;
text-underline-offset: 0.2em;
}
.nl-card {
inline-size: min(100%, 25rem);
margin: auto;
padding-block: var(--bw-space-8);
}
.nl-card__heading {
margin: 0 0 var(--bw-space-2);
font-family: var(--bw-font-family-display);
font-weight: 650;
font-size: 2rem;
letter-spacing: -0.03em;
line-height: 1.1;
}
.nl-card__sub {
margin: 0 0 var(--bw-space-6);
color: var(--bw-color-fg-muted);
line-height: 1.5;
}
.nl-form .bw-field,
.nl-form .bw-alert {
max-inline-size: none;
margin-block-end: var(--bw-space-4);
}
.nl-form .bw-alert__title {
font-size: var(--bw-font-size-sm);
}
.nl-label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--bw-space-3);
}
.nl-label-row a {
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-sm);
text-underline-offset: 0.2em;
}
.nl-check {
display: flex;
align-items: center;
gap: var(--bw-space-3);
margin-block: var(--bw-space-2) var(--bw-space-5);
font-size: var(--bw-font-size-sm);
}
.nl-check .bw-checkbox {
flex: none;
margin: 0;
}
.nl-form [hidden] {
display: none;
}
.nl-block {
inline-size: 100%;
}
.nl-form .bw-divider {
margin-block: var(--bw-space-5);
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nl-switch {
margin: var(--bw-space-6) 0 0;
text-align: center;
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-sm);
}
.nl-switch a {
color: var(--bw-color-fg);
font-weight: 600;
text-underline-offset: 0.2em;
}
.nl-legal {
margin: 0;
text-align: center;
color: var(--bw-color-fg-muted);
font-size: var(--bw-font-size-xs);
}
.nl-legal a {
color: inherit;
}
</style>
</head>
<body data-theme="light" data-bw-brand="northline">
<a class="bw-skip-link" href="#main">Skip to content</a>
<div class="nl-auth">
<aside class="nl-aside" aria-label="About Northline">
<a class="nl-brand" href="/templates/homepage/full/">
<span class="nl-brand__mark" aria-hidden="true"></span>
Northline
</a>
<div>
<p class="nl-aside__heading">Pick up the shift where the last one left off.</p>
<p class="nl-aside__lede">
Live ETAs, driver handoffs and depot boards, waiting for you exactly as the night team left them.
</p>
</div>
<dl class="nl-aside__stats">
<div>
<dt>18%</dt>
<dd>Fewer late arrivals</dd>
</div>
<div>
<dt>6 min</dt>
<dd>Average handoff time</dd>
</div>
</dl>
</aside>
<main id="main" class="nl-main">
<div class="nl-main__bar">
<a class="nl-brand" href="/templates/homepage/full/">
<span class="nl-brand__mark" aria-hidden="true"></span>
Northline
</a>
<span>New here? <a href="/templates/auth-signup/full/">Create an account</a></span>
</div>
<div class="nl-card">
<h1 class="nl-card__heading">Welcome back</h1>
<p class="nl-card__sub">Sign in with your work email to open your depot board.</p>
<form class="nl-form" action="#" method="post" novalidate data-nl-form>
<div class="bw-alert bw-alert--danger" role="alert" data-nl-alert>
<svg class="bw-alert__icon" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
<circle cx="10" cy="10" r="8" />
<path d="M10 5.75v5M10 14.1h.01" />
</svg>
<div class="bw-alert__body">
<p class="bw-alert__title">We could not sign you in</p>
<p class="bw-alert__message">Check your email and password and try again. After 5 attempts we pause sign-in for 15 minutes.</p>
</div>
</div>
<div class="bw-field">
<label class="bw-field__label" for="email">Work email</label>
<input class="bw-input" id="email" name="email" type="email" autocomplete="email" value="priya.raman@harbourfreight.example" required aria-describedby="email-error" data-msg-required="Enter your work email." data-msg-format="Enter a work email in the form name@company.com." />
<p class="bw-field__error" id="email-error" hidden></p>
</div>
<div class="bw-field bw-field--invalid">
<div class="nl-label-row">
<label class="bw-field__label" for="password">Password</label>
<a href="#forgot">Forgot password?</a>
</div>
<div class="bw-password">
<input class="bw-input" id="password" name="password" type="password" autocomplete="current-password" value="northline-old" required aria-invalid="true" aria-describedby="password-error" data-msg-required="Enter your password." />
<button type="button" class="bw-btn bw-btn--ghost bw-btn--sm bw-password__toggle" aria-pressed="false">Show</button>
</div>
<p class="bw-field__error" id="password-error">That password does not match this account.</p>
</div>
<label class="nl-check">
<input class="bw-checkbox" type="checkbox" name="remember" />
<span>Keep me signed in on this device</span>
</label>
<button class="bw-btn bw-btn--primary bw-btn--lg nl-block" type="submit">Sign in</button>
<div class="bw-divider bw-divider--labelled" role="separator">
<span class="bw-divider__rule"></span>
<span class="bw-divider__label">or</span>
<span class="bw-divider__rule"></span>
</div>
<a class="bw-btn bw-btn--secondary bw-btn--lg nl-block" href="#sso">Continue with single sign-on</a>
</form>
<p class="nl-switch">New to Northline? <a href="/templates/auth-signup/full/">Create an account</a></p>
</div>
<p class="nl-legal">
<a href="/templates/legal/full/">Terms and privacy</a> ·
<a href="/templates/contact/full/">Need help signing in?</a>
</p>
</main>
</div>
<script>
/* Inline form feedback: clears a message once the field is corrected.
Server-rendered errors use the same markup. */
(function () {
var form = document.querySelector("form[data-nl-form]");
if (!form) return;
var alertBox = form.querySelector("[data-nl-alert]");
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
var inputs = Array.prototype.slice.call(form.querySelectorAll("[data-msg-required]"));
function problem(input) {
var value = input.value.trim();
if (input.required && !value) return "required";
if (input.type === "email" && value && !EMAIL.test(value)) return "format";
return "";
}
function paint(input, kind) {
var field = input.closest(".bw-field");
var error = field.querySelector(".bw-field__error");
error.textContent = kind ? input.getAttribute("data-msg-" + kind) : "";
error.hidden = !kind;
field.classList.toggle("bw-field--invalid", !!kind);
if (kind) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}
inputs.forEach(function (input) {
input.addEventListener("input", function () {
if (input.getAttribute("aria-invalid") === "true") paint(input, problem(input));
});
input.addEventListener("blur", function () {
if (input.value.trim() !== "") paint(input, problem(input));
});
});
form.addEventListener("submit", function (event) {
var first = null;
inputs.forEach(function (input) {
var kind = problem(input);
paint(input, kind);
if (kind && !first) first = input;
});
if (first) {
event.preventDefault();
first.focus();
} else if (alertBox) {
alertBox.hidden = true;
}
});
})();
</script>
</body>
</html>
Copy into your project. This example is not on the template loader path.
{% extends "brickwork/shell/auth.html" %}
{% comment %}
Sign in.
COPY THIS FILE into your project and edit it. It is not on the template loader
path, so you cannot extend it (ADR-056).
What your view must supply:
form your login form
brickwork ships NO authentication view, form, model, or URL, and names no
field. That is deliberate: allauth calls the identifier field "login",
django.contrib.auth calls it "username", and a template that hard-coded either
would break the other. bw_form renders whatever fields your form has, so this
same file works with allauth, django.contrib.auth, or your own backend without
edits.
Which means the URL names below are PLACEHOLDERS. Replace them with your own,
or the page will 500 on render for a name that does not exist in your project.
For allauth they are account_login / account_signup / account_reset_password;
for django.contrib.auth, login / password_reset.
States: the form's own valid/invalid states; bw_form renders both
per-field and non-field errors automatically, so a failed login (a wrong
password) needs no extra wiring in this file.
Accessibility: inherits shell/auth.html's skip link, lang/dir/theme
attributes and themed bare-prose-link colour; the page's own <h1> names
the task. Covered by the archetype harness's full gate sweep (render,
axe WCAG 2.2 AA, no horizontal overflow, light/dark distinctness,
skip-link first-tab-stop with JS disabled) at every W0.1 breakpoint,
both themes, and directly by axe.spec.mjs against auth-signin-*.html,
the one auth archetype with its own dedicated hand-maintained fixture.
Responsive: no breakpoint switch of its own; inherits shell/auth.html's
fluid, no-breakpoint centred panel.
{% endcomment %}
{% load brickwork_components brickwork_forms %}
{% block page_title %}Sign in - Northwind{% endblock %}
{% block brand_wordmark %}Northwind{% endblock %}
{% block content %}
<div class="bw-section-stack">
<h1>Sign in</h1>
{% comment %}
You own the <form>; only you know your action URL. Non-field errors ("that
password is not right") render automatically through bw_form, so there is
nothing to wire for the failure case.
{% endcomment %}
<form method="post" action="/accounts/login/">
{% csrf_token %}
{% bw_form form %}
<div class="bw-form__actions">
{% bw_button "Sign in" type="submit" variant="primary" %}
</div>
</form>
{% comment %}
Secondary links. If you use allauth's social providers, their buttons go
here too.
{% endcomment %}
<p>
<a href="/accounts/password/reset/">Forgotten your password?</a>
</p>
<p>
New here? <a href="/accounts/signup/">Create an account</a>
</p>
</div>
{% endblock %}
Details
| Kind | Page example |
|---|---|
| Used in | Transactional journeys |
Composed from
| Template | Description |
|---|---|
brickwork/components/_button.html |
A button or link styled as a button, in several variants. |
brickwork/forms/_form.html |
Not a catalogue component |
brickwork/shell/auth.html |
The centred single-column shell for login and similar flows. |