Blocks / Auth and flows
Sign in
A sign-in screen with email, password and two providers, beside a quiet look at the kitchen waiting.
Larder
Sign in to Larder
Pick up where the last count left off.
New to Larder? Create a kitchen account
Install
Writes the block to components/blocks/sign-in.tsx and its .css, and every part it uses to components/equip/, with the parts those are built from.
npx https://equip.studio311.app/equip.tgz add block:sign-inBuilt from 14 parts
Notes
- Validation runs through the Form part: native constraints first, then validate, with an error summary that takes focus and links to each field.
- Replace the action with your auth call; return { errors: { password: "..." } } to show a wrong password on the field and in the summary.
- The provider marks are generic drawings, not the companies' logos; swap in the official assets their brand rules ask for.
- The kitchen panel is one role=img with a sentence for a label and inert parts inside, so it adds no tab stops.
Source
components/blocks/sign-in.tsx174 lines
"use client";
import { useId } from "react";
import { Badge } from "@/components/equip/badge";
import { Button } from "@/components/equip/button";
import { Card, CardBody, CardHeader } from "@/components/equip/card";
import { Checkbox } from "@/components/equip/checkbox";
import { Divider } from "@/components/equip/divider";
import { Field } from "@/components/equip/field";
import { Form, FormActions } from "@/components/equip/form";
import { Heading } from "@/components/equip/heading";
import { Link } from "@/components/equip/link";
import { List, ListItem } from "@/components/equip/list";
import { PasswordField } from "@/components/equip/password-field";
import { Stat } from "@/components/equip/stat";
import { StatGroup } from "@/components/equip/stat-group";
import { Text } from "@/components/equip/text";
import "./sign-in.css";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/* Generic marks for the two identity providers: drawn shapes, not the companies' logos. */
function RingMark() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 9.5A7.5 7.5 0 1 0 19.5 13H12" />
</svg>
);
}
function TilesMark() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="4" width="7" height="7" rx="1" />
<rect x="13" y="4" width="7" height="7" rx="1" />
<rect x="4" y="13" width="7" height="7" rx="1" />
<rect x="13" y="13" width="7" height="7" rx="1" />
</svg>
);
}
/*
* A sign-in screen: the form on one side, and on wide screens a quiet look at the kitchen
* waiting on the other, so the person knows which Larder they are walking back into.
*/
export default function SignIn() {
const id = useId();
const titleId = `${id}-title`;
return (
<section className="blk-sign-in" aria-labelledby={titleId}>
<div className="blk-sign-in-inner">
<div className="blk-sign-in-pane">
<div className="blk-sign-in-brand">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10" /><path d="M8 4v2.5a3 3 0 0 1-1.5 2.6A3 3 0 0 0 5 11.7V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.3a3 3 0 0 0-1.5-2.6A3 3 0 0 1 16 6.5V4" /><path d="M5 13h14" /></svg>
<span>Larder</span>
</div>
<header className="blk-sign-in-head">
<Heading level={1} id={titleId} className="blk-sign-in-title">
Sign in to Larder
</Heading>
<Text tone="quiet">Pick up where the last count left off.</Text>
</header>
<Form
className="blk-sign-in-form"
successMessage="Signed in. Opening Marrow."
summaryTitle={(count) => (count === 1 ? "Check 1 thing to sign in" : `Check ${count} things to sign in`)}
validate={(values) => {
const email = String(values.email ?? "").trim();
const domain = email.split("@")[1] ?? "";
if (email && domain && !domain.includes(".")) {
return { email: "Add the end of the address, such as marrow.co.uk." };
}
}}
action={async () => {
await wait(900);
}}
>
{({ errors, pending }) => (
<>
<Field
id={`${id}-email`}
name="email"
type="email"
label="Work email"
placeholder="ines@marrow.co.uk"
autoComplete="username"
required
error={errors.email}
/>
<PasswordField
id={`${id}-password`}
name="password"
label="Password"
autoComplete="current-password"
required
error={errors.password}
/>
<div className="blk-sign-in-row">
<Checkbox name="remember" label="Keep me signed in" defaultChecked />
<Link href="#forgot-password" className="blk-sign-in-forgot">
Forgot password?
</Link>
</div>
<FormActions className="blk-sign-in-actions">
<Button type="submit" variant="accent" size="l" loading={pending}>
Sign in
</Button>
</FormActions>
<Divider label="or" />
<div className="blk-sign-in-providers">
<Button variant="outline" lead={<RingMark />} disabled={pending}>
Continue with Google
</Button>
<Button variant="outline" lead={<TilesMark />} disabled={pending}>
Continue with Microsoft
</Button>
</div>
</>
)}
</Form>
<Text size="s" tone="quiet" className="blk-sign-in-foot">
New to Larder? <Link href="#sign-up">Create a kitchen account</Link>
</Text>
</div>
<aside className="blk-sign-in-aside" aria-label="Your kitchen">
<div
className="blk-sign-in-visual"
role="img"
aria-label="Marrow tonight: 64 covers booked, 212 of 230 items counted, butter and shallots running low"
>
<div className="blk-sign-in-stage" inert>
<Card tone="raised" className="blk-sign-in-card">
<CardHeader
title="Marrow, Friday dinner"
description="Wilton Way. Counted at 17:40 by Ines"
actions={<Badge tone="ok" dot>Open</Badge>}
/>
<CardBody>
<StatGroup size="s" label="Tonight">
<Stat label="Covers booked" value={64} previous={58} goodWhen="up" locale="en-GB" />
<Stat label="Items counted" value="212 of 230" />
</StatGroup>
<div className="blk-sign-in-low">
<Text size="s" weight="strong">Short before service</Text>
<List density="compact" dividers>
<ListItem title="Cultured butter" meta="0.6 days" trail={<Badge tone="danger">Low</Badge>} />
<ListItem title="Banana shallots" meta="1.2 days" trail={<Badge tone="warn">Order</Badge>} />
<ListItem title="Double cream" meta="1.5 days" trail={<Badge tone="warn">Order</Badge>} />
</List>
</div>
</CardBody>
</Card>
</div>
</div>
<figure className="blk-sign-in-quote">
<blockquote>
<Text>
We stopped counting butter on the back of a ticket. Now it tells us before Tom has to ask.
</Text>
</blockquote>
<figcaption>
<Text size="s" tone="quiet">Ines Duarte, head chef at Marrow</Text>
</figcaption>
</figure>
</aside>
</div>
</section>
);
}components/blocks/sign-in.css139 lines
@layer blocks {
.blk-sign-in {
container: blk-sign-in / inline-size;
background: var(--eq-bg);
color: var(--eq-ink);
}
.blk-sign-in-inner {
display: grid;
gap: calc(var(--eq-space-6) * 1.5);
max-width: 72rem;
margin-inline: auto;
padding: calc(var(--eq-space-6) * 1.25) var(--eq-space-4);
}
.blk-sign-in-pane {
display: grid;
align-content: start;
gap: var(--eq-space-5);
width: 100%;
max-width: 25rem;
margin-inline: auto;
}
.blk-sign-in-brand {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
font-weight: var(--eq-weight-strong);
}
.blk-sign-in-head {
display: grid;
gap: var(--eq-space-2);
}
.blk-sign-in-head > * {
margin: 0;
}
.blk-sign-in-title {
font-size: clamp(1.75rem, 5cqi, 2.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.blk-sign-in-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2) var(--eq-space-4);
}
.blk-sign-in-forgot {
font-size: var(--eq-text-s);
}
/* The submit spans the column, so the one accent fill reads as the way in. */
.blk-sign-in-actions > * {
flex: 1 1 100%;
}
.blk-sign-in-providers {
display: grid;
gap: var(--eq-space-2);
}
.blk-sign-in-foot {
margin: 0;
padding-top: var(--eq-space-4);
border-top: 1px solid var(--eq-line);
}
/* ---- the quiet side: the kitchen that is waiting ---- */
.blk-sign-in-aside {
display: grid;
align-content: center;
gap: var(--eq-space-5);
padding: var(--eq-space-5) var(--eq-space-3);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background:
radial-gradient(circle at 1px 1px, var(--eq-line-strong) 1px, transparent 0) 0 0 / 18px 18px,
color-mix(in oklab, var(--eq-surface) 70%, var(--eq-bg));
}
.blk-sign-in-card {
max-width: 24rem;
margin-inline: auto;
}
.blk-sign-in-low {
display: grid;
gap: var(--eq-space-2);
margin-top: var(--eq-space-4);
padding-top: var(--eq-space-3);
border-top: 1px solid var(--eq-line);
}
.blk-sign-in-low > p {
margin: 0;
}
.blk-sign-in-quote {
display: grid;
gap: var(--eq-space-2);
max-width: 24rem;
margin: 0 auto;
padding-inline-start: var(--eq-space-4);
border-inline-start: 2px solid var(--eq-line-strong);
}
.blk-sign-in-quote blockquote,
.blk-sign-in-quote p {
margin: 0;
}
.blk-sign-in-quote blockquote p {
text-wrap: pretty;
}
@container blk-sign-in (width >= 52rem) {
.blk-sign-in-inner {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: stretch;
padding-block: calc(var(--eq-space-6) * 2);
}
.blk-sign-in-pane {
align-self: center;
}
.blk-sign-in-aside {
padding: calc(var(--eq-space-6) * 1.25) var(--eq-space-6);
}
}
}