Blocks / Auth and flows

Sign in

A sign-in screen with email, password and two providers, beside a quiet look at the kitchen waiting.

Width

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-in

Built 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);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index