M1: phone app shell, settings, profile, dev login

Everything now renders inside a phone illustration on the entry screen,
with a five-tab bar. The frame lives in the root layout rather than one
page, so sign-in, onboarding and the job form are inside it too.

- Entry screen is the product running, not a marketing page: a live
  swipeable deck of real verified pros with a trade-filter strip above
  the card. deck.showcase is the only public procedure in that router
  and writes nothing, so an anonymous right swipe reaches no one.

- Settings: notification preferences (new table, defaults returned when
  no row exists), signed-in devices, GDPR export, deletion request.

  Closes the setEmail finding: an unverified address is no longer
  written to users.email, which is UNIQUE -- claiming a stranger's
  address used to block them from ever signing up with Google, and the
  uniqueness error leaked whether an address was registered. Now parked
  in email_change_requests until a token proves ownership.

- Profile: for a pro it leads with their REAL deck card, rendered by the
  same exported <Card> clients swipe, so the two cannot drift. Adds
  pro.previewCard (works at draft/pending, where publicProfile 404s) and
  pro.reorderMedia (photo position 0 is the deck card). Warns before an
  edit that would send a verified pro back for review, rather than after
  it silently drops them off the deck. Clients get a thin profile plus a
  route into pro onboarding -- supply is the launch blocker.

- Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV,
  an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It
  overwrites the stored code rather than skipping verification, so the
  real expiry, attempt cap and single-use consumption still apply.

- Seed uses portrait photos. The cards previously showed picsum stock
  scenery -- a locksmith standing on a railway track.

Fixes found along the way: the card's name rendered ink-950 navy on a
dark photo because globals.css sets h1..h6 colour in @layer base, which
beat the inherited text-white; and the card referenced --color-go-500,
--border and --card, none of which exist, so the SEND JOB stamp had no
colour.

Also adds public/sw.js as a kill-switch: a service worker left
registered on localhost:3000 by a different project was intercepting
this app's chunks.

typecheck, lint clean; 186 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-21 03:16:27 -04:00
co-authored by Claude Opus 5
parent 582f13fa99
commit 176ba187c8
51 changed files with 15622 additions and 85 deletions
@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth-client';
import { Button, useToast } from '@/components/ui';
/**
* "Continue with Google" — the one social route, offered wherever we ask
* someone to sign in.
*
* The button renders whether or not the server has Google credentials. Hiding
* it when the keys are missing would mean the sign-in screen quietly changes
* shape between environments, so a layout that works on a developer's machine
* is one nobody has actually seen in production — and the first person to
* notice would be a user. It is always here; when the server cannot honour it,
* the click says so out loud.
*
* `lib/auth.ts` registers the provider only when both AUTH_GOOGLE_ID and
* AUTH_GOOGLE_SECRET are set, so the unconfigured case comes back as a clean
* 404 PROVIDER_NOT_FOUND rather than a 500 from deep inside the OAuth builder.
* That is what makes "not set up" distinguishable here from "Google is down".
*/
export function GoogleButton({
callbackURL,
size = 'lg',
block = true,
label = 'Continue with Google',
}: {
/** Where to land after Google sends the browser back. */
callbackURL: string;
size?: 'sm' | 'md' | 'lg';
block?: boolean;
label?: string;
}) {
const toast = useToast();
const [busy, setBusy] = useState(false);
async function start() {
setBusy(true);
const { error } = await authClient.signIn.social({ provider: 'google', callbackURL });
// On success better-auth's redirect plugin has already sent the browser to
// Google, so this line is only ever reached on failure — but leave `busy`
// set in the success case rather than flicking the spinner off under a
// navigation that is already in flight.
if (!error) return;
setBusy(false);
if (error.status === 404 || error.code === 'PROVIDER_NOT_FOUND') {
toast('Sign in with your mobile number instead — it takes about the same time.', {
tone: 'warning',
title: 'Google sign-in is not set up yet',
});
return;
}
toast(error.message ?? 'Google did not respond. Try again, or use your mobile number.', {
tone: 'error',
title: 'Could not continue with Google',
});
}
return (
<Button type="button" variant="outline" size={size} block={block} busy={busy} onClick={start}>
{!busy && <GoogleMark />}
{label}
</Button>
);
}
/**
* Google's mark, per their branding terms: the four-colour G, never recoloured
* and never swapped for a monochrome icon-font glyph.
*/
function GoogleMark() {
return (
<svg className="h-5 w-5 shrink-0" viewBox="0 0 18 18" aria-hidden focusable="false">
<path
fill="#4285F4"
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"
/>
</svg>
);
}
+23 -5
View File
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { BackLink } from './back-link';
/**
* DESIGN.md §4. The one screen wrapper.
@@ -22,7 +23,7 @@ export function AppShell({
return (
<main
className={cn(
'mx-auto min-h-dvh max-w-app bg-page',
'h-full overflow-y-auto bg-page',
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
className,
)}
@@ -37,11 +38,28 @@ export function AppShell({
* A screen with no chrome and no heading — sign in, role choice, the entry
* screen. Vertically centred, because these are single-decision screens with
* little on them.
*
* `back` puts a link home in the top-left corner. It is absolutely positioned so
* that adding it does not push the centred content off centre — these screens
* are composed around the middle of the viewport, not around the top.
*/
export function BareShell({ children }: { children: React.ReactNode }) {
return <main className="mx-auto flex min-h-dvh max-w-app flex-col justify-center bg-page px-5 py-10">
export function BareShell({
back = false,
children,
}: {
back?: boolean;
children: React.ReactNode;
}) {
return (
<main className="relative flex h-full flex-col justify-center overflow-y-auto bg-page px-5 py-10">
{back && (
<div className="absolute left-5 top-[calc(0.5rem+env(safe-area-inset-top))] z-10">
<BackLink />
</div>
)}
{children}
</main>;
</main>
);
}
/**
@@ -50,7 +68,7 @@ export function BareShell({ children }: { children: React.ReactNode }) {
*/
export function DeckShell({ children }: { children: React.ReactNode }) {
return (
<main className="mx-auto flex min-h-dvh max-w-app flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
<main className="flex h-full flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
{children}
</main>
);
@@ -0,0 +1,41 @@
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* The way out of a dead end.
*
* DESIGN.md §6.6 hangs a back chevron off the app bar, but there is no app bar
* here — so on the bare screens it sits in the top-left corner of the screen
* instead, clear of the vertically centred content and of the Dynamic Island.
*
* A real <Link>, not `router.back()`: someone who landed on sign-in from a
* bookmark or an expired-session redirect has no history to go back to, and a
* button that sometimes does nothing is worse than no button.
*/
export function BackLink({
href = '/',
label = 'Home',
className,
}: {
href?: string;
label?: string;
className?: string;
}) {
return (
<Link
href={href}
className={cn(
// -ml-2 pulls the chevron out to the gutter so the label lines up with
// the screen text, while the tap target keeps its 44px. §6.1
'-ml-2 inline-flex h-11 items-center gap-0.5 rounded-pill pl-1 pr-3',
'font-display text-body-sm font-semibold text-muted',
'transition-colors duration-[120ms] ease-standard hover:text-strong',
className,
)}
>
<ChevronLeft className="h-5 w-5" aria-hidden />
{label}
</Link>
);
}
@@ -22,10 +22,14 @@ export function PhoneFrame({
return (
<div
className={cn(
// Phone: no frame at all, app fills the screen.
'h-dvh w-full',
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport.
'sm:h-[844px] sm:w-[390px] sm:shrink-0',
// Phone: no frame at all, app fills the screen. min-w-0 because this is
// a flex item, and a flex item defaults to min-width:auto -- without it
// the deck's intrinsic width stretches the frame past the viewport and
// the card hangs off the right edge.
'h-dvh w-full min-w-0',
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport —
// capped at the window height so the bezel is never clipped on a laptop.
'sm:h-[min(844px,92dvh)] sm:w-[390px] sm:shrink-0',
// The bezel. ink-950 rather than pure black, per DESIGN.md §2.2.
'sm:rounded-[3.25rem] sm:bg-ink-950 sm:p-[0.7rem] sm:shadow-lg',
// A hairline of light along the top edge reads as a chamfer and stops
@@ -0,0 +1,59 @@
'use client';
import { Flame, Search, Layers, UserRound, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
export type PhoneTab = 'swipe' | 'search' | 'jobs' | 'profile' | 'settings';
const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
{ id: 'swipe', label: 'Swipe', icon: Flame },
{ id: 'search', label: 'Search', icon: Search },
{ id: 'jobs', label: 'Past jobs', icon: Layers },
{ id: 'profile', label: 'Profile', icon: UserRound },
{ id: 'settings', label: 'Settings', icon: Settings },
];
/**
* Bottom tab bar, Tinder-style: icon-only, evenly spaced, active tab in the
* accent colour and inactive in grey. Lives inside the phone screen, not in the
* page — this is app chrome.
*/
export function PhoneTabs({
active,
onChange,
}: {
active: PhoneTab;
onChange: (tab: PhoneTab) => void;
}) {
return (
<nav
aria-label="Main"
className="flex shrink-0 items-center justify-around border-t border-hairline bg-page px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))] pt-2"
>
{TABS.map(({ id, label, icon: Icon }) => {
const isActive = id === active;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-label={label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex h-11 w-11 items-center justify-center rounded-pill',
'transition-colors duration-[120ms] ease-standard',
isActive ? 'text-accent' : 'text-faint hover:text-muted',
)}
>
<Icon
className="h-7 w-7"
strokeWidth={isActive ? 2.5 : 2}
fill={isActive && id === 'swipe' ? 'currentColor' : 'none'}
aria-hidden
/>
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,24 @@
import Link from 'next/link';
import { LogIn } from 'lucide-react';
import { buttonClasses } from '@/components/ui';
/**
* What a signed-in-only tab shows to an anonymous visitor.
*
* The tab stays tappable rather than being greyed out — a bar of dead icons on
* first open reads as a broken app, whereas this explains what is behind it.
*/
export function SignedOut({ title, body }: { title: string; body: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<LogIn className="h-8 w-8 text-accent" aria-hidden />
<div>
<h1 className="text-h3">{title}</h1>
<p className="mt-2 text-body-sm text-muted">{body}</p>
</div>
<Link href="/sign-in?next=/" className={buttonClasses({ variant: 'primary', size: 'md' })}>
Continue with phone
</Link>
</div>
);
}
+21 -8
View File
@@ -66,13 +66,20 @@ export function Deck({ cards, onDecide }: DeckProps) {
);
}
function Card({
/**
* One card.
*
* Exported so the profile screen can render a pro their OWN card, byte for byte
* what a client sees. A lookalike would drift the moment either side changed.
* Pass no `onDecide` to get a static, non-draggable card.
*/
export function Card({
card,
depth,
depth = 0,
onDecide,
}: {
card: DeckCard;
depth: number;
depth?: number;
onDecide?: (proId: string, direction: 'left' | 'right') => void;
}) {
const x = useMotionValue(0);
@@ -86,7 +93,7 @@ function Card({
<motion.article
className={cn(
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
'border-[var(--border)] bg-[var(--card)]',
'border-hairline bg-raised',
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
)}
style={{ x, rotate, zIndex: 10 - depth }}
@@ -119,7 +126,7 @@ function Card({
<>
<motion.div
style={{ opacity: hireOpacity }}
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-500)]"
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-600)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-600)]"
>
SEND JOB
</motion.div>
@@ -134,7 +141,13 @@ function Card({
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
<div className="mb-1 flex items-baseline gap-2">
<h2 className="text-2xl font-semibold">{card.name}</h2>
{/*
text-white must be on the h2 itself, not inherited from the wrapper:
globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer
base, and that rule beats the parent's colour. Without this the name
renders ink-950 navy on a dark photo and is unreadable.
*/}
<h2 className="text-2xl font-semibold text-white">{card.name}</h2>
{card.ratingCount > 0 ? (
<span className="flex items-center gap-1 text-sm">
<Star className="h-4 w-4 fill-current" aria-hidden />
@@ -187,10 +200,10 @@ function ActionButton({
aria-label={label}
title={label}
className={cn(
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg',
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg',
'transition hover:scale-105 active:scale-95',
isHire
? 'border-[var(--color-go-500)] text-[var(--color-go-500)]'
? 'border-[var(--color-go-600)] text-[var(--color-go-600)]'
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
)}
>
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, X } from 'lucide-react';
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared';
import { api } from '@/lib/trpc';
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
/**
* What this pro is actually good at, in their own words.
*
* Free text on purpose. The trade list is a closed set because matching and
* licence checks run on it; this is the line underneath that separates two
* plumbers who match the same job — "underfloor heating", "listed buildings",
* "emergency callouts". Nothing matches on it, so nothing here can distort the
* deck; it only has to read well.
*/
export function SkillsGroup({ skills }: { skills: string[] }) {
const utils = api.useUtils();
const [list, setList] = useState<string[]>(skills);
const [draft, setDraft] = useState('');
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// Adopt the server's list until the pro starts editing — after that the
// in-progress edit wins, or a refetch would wipe it mid-sentence.
useEffect(() => {
if (!dirty) setList(skills);
}, [skills, dirty]);
const save = api.pro.updateSkills.useMutation({
onSuccess: ({ skills: next }) => {
setList(next);
setDirty(false);
void utils.pro.me.invalidate();
},
onError: (e) => setError(e.message),
});
function add() {
const value = draft.trim();
if (!value) return;
if (value.length < 2) return setError('That is too short to mean anything.');
if (list.some((s) => s.toLocaleLowerCase() === value.toLocaleLowerCase())) {
setDraft('');
return setError('That one is already on the list.');
}
if (list.length >= MAX_SKILLS) {
return setError(`${MAX_SKILLS} is the most a customer will read.`);
}
setList([...list, value]);
setDraft('');
setError(null);
setDirty(true);
}
function remove(skill: string) {
setList(list.filter((s) => s !== skill));
setError(null);
setDirty(true);
}
const full = list.length >= MAX_SKILLS;
return (
<SettingsGroup
title="Skills"
note="Shown to customers. Your trades decide which jobs reach you; these say what you are best at within them."
>
<div className="flex flex-col gap-3 px-4 py-4">
{list.length > 0 ? (
<ul className="flex flex-wrap gap-2">
{list.map((skill) => (
<li key={skill}>
<span className="inline-flex items-center gap-1.5 rounded-pill border border-hairline py-1.5 pl-3 pr-1.5 text-body-sm text-strong">
{skill}
<button
type="button"
onClick={() => remove(skill)}
aria-label={`Remove ${skill}`}
className="flex h-6 w-6 items-center justify-center rounded-pill text-faint transition-colors duration-[120ms] ease-standard hover:bg-sunken hover:text-strong"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</span>
</li>
))}
</ul>
) : (
<p className="text-body-sm text-muted">
Nothing yet. Two or three specific ones beat a long generic list.
</p>
)}
{/*
Not a <form>: this sits inside the profile screen, and a nested form
would submit the wrong thing the day that screen grows one.
*/}
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
add();
}}
maxLength={MAX_SKILL_LENGTH}
placeholder="Boiler repair"
aria-label="Add a skill"
disabled={full}
/>
<Button
type="button"
variant="outline"
size="md"
onClick={add}
disabled={!draft.trim() || full}
>
<Plus className="h-4 w-4" aria-hidden />
Add
</Button>
</div>
<p className="text-meta text-faint tabular-nums">
{list.length}/{MAX_SKILLS}
</p>
{error && <FormError>{error}</FormError>}
<Button
type="button"
size="md"
block
disabled={!dirty}
busy={save.isPending}
onClick={() => save.mutate({ skills: list })}
>
{dirty ? 'Save skills' : 'Saved'}
</Button>
</div>
</SettingsGroup>
);
}
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useState } from 'react';
import { LocateFixed } from 'lucide-react';
import {
DEFAULT_SERVICE_RADIUS_M,
MAX_SERVICE_RADIUS_M,
MIN_SERVICE_RADIUS_M,
} from '@linkder/shared';
import { api } from '@/lib/trpc';
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre';
/**
* Where you are, and how far you will go.
*
* One group for both sides of the market, because it is one question — only the
* words change. What it writes does not: a pro's answer is their service area
* and lands on the pro profile the deck matches against, a customer's is a
* search preference and lands on the user. The server picks; this only renders.
*/
export function LocationGroup({ isPro }: { isPro: boolean }) {
const utils = api.useUtils();
const saved = api.user.location.useQuery();
const [addressText, setAddressText] = useState('');
const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000);
const [pin, setPin] = useState<{ lat: number; lng: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedJustNow, setSavedJustNow] = useState(false);
// Seeding the controls from the query would otherwise overwrite what someone
// is halfway through typing, every time the query refetches.
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!saved.data || dirty) return;
setAddressText(saved.data.addressText ?? '');
setRadiusKm(Math.round(saved.data.radiusM / 1000));
setPin(saved.data.location);
}, [saved.data, dirty]);
const update = api.user.updateLocation.useMutation({
onSuccess: () => {
setDirty(false);
setSavedJustNow(true);
void utils.user.location.invalidate();
// A customer's deck is filtered by this, so it cannot keep serving the
// results of the old radius.
void utils.deck.invalidate();
},
onError: (e) => setError(e.message),
});
function edit<T>(set: (value: T) => void) {
return (value: T) => {
set(value);
setDirty(true);
setSavedJustNow(false);
setError(null);
};
}
const title = isPro ? 'Where you work' : 'Where you are';
if (saved.isLoading) {
return (
<SettingsGroup title={title}>
<div className="h-44 animate-pulse bg-inset" />
</SettingsGroup>
);
}
// A pro who has not finished onboarding has no service area yet, and inventing
// one here would be a second source of truth for the wizard to fight.
if (saved.data?.needsProfile) {
return (
<SettingsGroup title={title} note="Finish your profile to set your base and radius.">
<div className="px-4 py-3.5 text-body-sm text-muted">
Your working area is part of your pro profile.
</div>
</SettingsGroup>
);
}
return (
<SettingsGroup
title={title}
note={
saved.data?.reviewOnChange
? 'Changing your base or radius sends your profile back for review.'
: isPro
? 'You are only shown jobs inside this radius.'
: 'Your deck is centred here, and only shows pros inside this range.'
}
>
<div className="flex flex-col gap-4 px-4 py-4">
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">
{isPro ? 'Base address' : 'Your address'}
</span>
<Input
value={addressText}
onChange={(e) => edit(setAddressText)(e.target.value)}
maxLength={255}
placeholder={`Neighbourhood, ${CITY_NAME}`}
/>
</label>
<p className="text-meta text-muted">
{pin
? `Pinned to ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}. `
: `No pin yet — we measure from ${CITY_NAME}. `}
{isPro
? 'Matching uses the pin, never the text.'
: 'Matching uses the pin; your address is only shared once you book.'}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => edit(setPin)({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. Type an address instead.'),
)
}
>
<LocateFixed className="h-4 w-4" aria-hidden />
Use my current location
</Button>
</div>
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">
{isPro ? 'How far will you travel?' : 'How far will you look?'}{' '}
<span className="text-muted tabular-nums">{radiusKm} km</span>
</span>
<input
type="range"
min={MIN_SERVICE_RADIUS_M / 1000}
max={MAX_SERVICE_RADIUS_M / 1000}
value={radiusKm}
onChange={(e) => edit(setRadiusKm)(Number(e.target.value))}
className="w-full accent-brand-500"
/>
<span className="flex justify-between text-meta text-faint tabular-nums">
<span>{MIN_SERVICE_RADIUS_M / 1000} km</span>
<span>{MAX_SERVICE_RADIUS_M / 1000} km</span>
</span>
</label>
{error && <FormError>{error}</FormError>}
<Button
type="button"
size="md"
block
disabled={!dirty}
busy={update.isPending}
onClick={() =>
update.mutate({
addressText,
radiusM: radiusKm * 1000,
...(pin ? { location: pin } : {}),
})
}
>
{savedJustNow && !dirty ? 'Saved' : 'Save location'}
</Button>
</div>
</SettingsGroup>
);
}
+2
View File
@@ -4,3 +4,5 @@ export { Card, EmptyState, Stat } from './card';
export { Chip, OptionCard, Tag } from './chip';
export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
export { ScreenIntro, Section, StickyAction } from './page';
export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row';
export { ToastProvider, useToast, type ToastTone } from './toast';
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/** A titled group of rows. Settings is a list of lists. */
export function SettingsGroup({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-6">
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2>
<div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
{children}
</div>
{note && <p className="mt-2 px-1 text-meta text-muted">{note}</p>}
</section>
);
}
/** A read-only or navigational row. */
export function SettingsRow({
label,
value,
hint,
onClick,
danger,
disabled,
}: {
label: string;
value?: React.ReactNode;
hint?: string;
onClick?: () => void;
danger?: boolean;
disabled?: boolean;
}) {
const interactive = Boolean(onClick) && !disabled;
const Tag = interactive ? 'button' : 'div';
return (
<Tag
{...(interactive ? { type: 'button' as const, onClick } : {})}
className={cn(
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
interactive && 'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
disabled && 'opacity-45',
)}
>
<span className="min-w-0 flex-1">
<span className={cn('block text-body-sm', danger ? 'text-stop-500' : 'text-strong')}>
{label}
</span>
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
</span>
{value !== undefined && (
<span className="shrink-0 text-body-sm text-muted">{value}</span>
)}
{interactive && <ChevronRight className="h-4 w-4 shrink-0 text-faint" aria-hidden />}
</Tag>
);
}
/**
* A row carrying a switch. The label is the control's accessible name, so the
* whole row is one tap target rather than a label and a separate 20px switch.
*/
export function SettingsToggle({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
disabled && 'pointer-events-none opacity-45',
)}
>
<span className="min-w-0 flex-1">
<span className="block text-body-sm text-strong">{label}</span>
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
</span>
<span
aria-hidden
className={cn(
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
'transition-colors duration-[120ms] ease-standard',
checked ? 'bg-brand-500' : 'bg-ink-300',
)}
>
<span
className={cn(
'absolute top-[0.2rem] h-[1.2rem] w-[1.2rem] rounded-pill bg-white shadow-sm',
'transition-[left] duration-[120ms] ease-standard',
checked ? 'left-[1.35rem]' : 'left-[0.2rem]',
)}
/>
</span>
</button>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Transient messages.
*
* Deliberately NOT a second visual language: a toast is §6.5's banner on a
* shadow, so a tone means the same thing wherever it appears. What it adds is
* placement — bottom of the viewport rather than in the flow — for things the
* user needs to be told but that do not belong to any one field.
*
* Use `<FormError>` for "this input is wrong" and a `<Banner>` for state a
* screen is permanently in. A toast is for the third case: an outcome that has
* no home on the screen, like a provider the server is not configured for.
*/
export type ToastTone = 'info' | 'success' | 'warning' | 'error';
export interface ToastOptions {
tone?: ToastTone;
title?: string;
/** Milliseconds on screen. `null` keeps it up until dismissed. */
duration?: number | null;
}
interface Toast extends ToastOptions {
id: number;
message: string;
}
type ToastFn = (message: string, options?: ToastOptions) => void;
const ToastContext = createContext<ToastFn | null>(null);
/**
* Throws rather than no-oping when the provider is missing. A toast that
* silently does nothing is worse than no toast — it looks like the click did
* not register, and the bug only shows up in the case nobody tests.
*/
export function useToast(): ToastFn {
const toast = useContext(ToastContext);
if (!toast) throw new Error('useToast must be used inside <ToastProvider>');
return toast;
}
const DEFAULT_DURATION = 6000;
const TONE_SURFACE: Record<ToastTone, string> = {
info: 'border-brand-200 bg-brand-100',
success: 'border-go-100 bg-go-50',
warning: 'border-sun-100 bg-sun-50',
error: 'border-stop-100 bg-stop-50',
};
const TONE_ICON = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
} as const;
const TONE_ICON_COLOR: Record<ToastTone, string> = {
info: 'text-brand-500',
success: 'text-go-600',
warning: 'text-sun-500',
error: 'text-stop-500',
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
// Cleared on dismiss so a hand-dismissed toast does not leave a timer that
// later removes whatever toast happens to be in its place.
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
setToasts((current) => current.filter((t) => t.id !== id));
}, []);
const toast = useCallback<ToastFn>(
(message, options = {}) => {
const id = nextId.current++;
const duration = options.duration === undefined ? DEFAULT_DURATION : options.duration;
// Three is the point where the stack starts covering the thing the user
// was looking at; drop the oldest rather than growing upward forever.
setToasts((current) => [...current, { ...options, id, message }].slice(-3));
if (duration !== null) {
timers.current.set(id, setTimeout(() => dismiss(id), duration));
}
},
[dismiss],
);
return (
<ToastContext.Provider value={toast}>
{children}
{/*
The live region is mounted always, empty or not: a region inserted at
the same moment as its content is not reliably announced.
*/}
<div
aria-live="polite"
// Bottom-centred and capped narrower than the app column so it reads as
// an overlay on the phone screen rather than a full-width bar.
className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))]"
>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const tone = toast.tone ?? 'info';
const Icon = TONE_ICON[tone];
return (
<motion.div
// §7 motion-base for enter/exit. The spring is decorative, so it collapses
// to a plain fade under reduced motion.
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
'pointer-events-auto flex w-full max-w-[22rem] gap-3 rounded-card border p-4',
'text-ink-950 shadow-lg',
TONE_SURFACE[tone],
)}
>
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[tone])} aria-hidden />
<div className="min-w-0 flex-1">
{toast.title && <p className="font-display text-h4 text-ink-950">{toast.title}</p>}
<p className={cn('text-body-sm text-ink-800', toast.title && 'mt-1')}>{toast.message}</p>
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="-m-1 h-8 w-8 shrink-0 rounded-pill p-1 text-ink-600 transition-colors duration-[120ms] ease-standard hover:bg-ink-950/8 hover:text-ink-950"
>
<X className="h-full w-full" aria-hidden />
</button>
</motion.div>
);
}