Entry screen is the app: phone frame with a live deck inside
The homepage was a marketing brochure -- a hero paragraph, a "three steps" explainer and a tag list. It described the gesture in prose while the actual Tinder deck sat behind /deck/[jobId], reachable only after signing in AND posting a job. Nobody opening the app ever saw the product. Now / renders a phone illustration with the real app running inside it: the same <Deck>, the same drag physics, real verified pros. On a phone the bezel collapses and the deck simply fills the viewport -- drawing a picture of a phone on a phone is absurd, and it would eat the width the cards need. - Removed the fixed app bar and bottom tab bar. AppShell now renders the screen title as an in-flow h1; the bar owned the only h1 on every screen, so dropping it silently would have left every page headingless. The /jobs "post" action moved from bar chrome into the content, since the tab bar was its only other route there. - getShowcaseDeck(): a deck with no job behind it. getDeck is job-scoped (joins jobs for category and location, anti-joins swipes), which an anonymous visitor has none of, so this centres on the launch city. Eligibility rules are copied verbatim -- nobody may appear in the shop window who could not appear on a real deck. - deck.showcase: the only public procedure in the router. list and swipe stay behind clientProcedure. It reads nothing about the caller and writes nothing, so a right swipe on the entry screen is purely local. No real tradesperson is contacted until a job is posted. - <Deck> filled a hardcoded 560px desktop box; it now fills its container. The card counter moved out from between the two action buttons so the thumb zone holds nothing but the two controls. Verified against the seeded database: 22 eligible pros returned, and all three seeded traps excluded for the right reason -- Pau Ribas (22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not accepting). 7 new integration tests cover exactly that. Also corrects a label I had written as "Plumbers near you" -- the showcase deck is not category-filtered and shows every trade. Includes concurrent edits to the mobile shell, ui/ primitives and DESIGN.md made outside this session. typecheck, lint, build clean; 141 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §4. The one screen wrapper.
|
||||
*
|
||||
* Screens do not set their own gutters or safe-area padding — the whole point is
|
||||
* that they cannot drift.
|
||||
*
|
||||
* There is no fixed app bar and no bottom tab bar. The screen title is rendered
|
||||
* in flow as the page's `h1`: the bar used to own the only `h1` on every screen,
|
||||
* so removing it without this would leave every page with no heading at all.
|
||||
*/
|
||||
export function AppShell({
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'mx-auto min-h-dvh max-w-app bg-page',
|
||||
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h1 className="mb-6 text-h1">{title}</h1>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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">
|
||||
{children}
|
||||
</main>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deck. Full-bleed and full-height — swiping is a focused mode, and the
|
||||
* card needs the whole viewport rather than a box inside a padded screen.
|
||||
*/
|
||||
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))]">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A hardware illustration with the real app running inside it.
|
||||
*
|
||||
* This is the entry screen on a desktop browser: rather than describing the
|
||||
* product in prose, it shows the actual running app — the same <Deck>, the same
|
||||
* drag gesture, the same data — inside a phone. Everything inside `children` is
|
||||
* live and interactive, not a screenshot.
|
||||
*
|
||||
* On a phone there is no frame. Drawing a picture of a phone on a phone is
|
||||
* absurd, and it would waste the ~24px of width the deck actually needs, so
|
||||
* below `sm` the bezel collapses and the app simply fills the viewport.
|
||||
*/
|
||||
export function PhoneFrame({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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',
|
||||
// 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
|
||||
// the bezel looking like a flat black rectangle.
|
||||
'sm:ring-1 sm:ring-inset sm:ring-white/12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* The screen. overflow-hidden is what makes a swiped card disappear at
|
||||
the bezel instead of flying across the page. */}
|
||||
<div className="relative h-full w-full overflow-hidden bg-page sm:rounded-[2.6rem]">
|
||||
{/* Dynamic Island. Pointer-events-none so it never eats a drag that
|
||||
starts near the top of the card. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-2 z-30 hidden h-[1.85rem] w-[6.2rem] -translate-x-1/2 rounded-pill bg-ink-950 sm:block"
|
||||
/>
|
||||
{children}
|
||||
{/* Home indicator. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-2 left-1/2 z-30 hidden h-[0.3rem] w-[8.5rem] -translate-x-1/2 rounded-pill bg-ink-950/25 sm:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
const visible = remaining.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative h-[560px] w-full max-w-sm">
|
||||
<div className="flex h-full min-h-0 flex-col items-center gap-5">
|
||||
<div className="relative w-full min-h-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{visible
|
||||
.map((card, i) => (
|
||||
@@ -50,15 +50,12 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex shrink-0 items-center gap-12 pb-2">
|
||||
<ActionButton
|
||||
label="Not this one"
|
||||
variant="pass"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
|
||||
/>
|
||||
<p className="w-28 text-center text-sm text-[var(--muted)] tabular-nums">
|
||||
{remaining.length} left
|
||||
</p>
|
||||
<ActionButton
|
||||
label="Send this job"
|
||||
variant="hire"
|
||||
@@ -204,7 +201,7 @@ function ActionButton({
|
||||
|
||||
function EmptyDeck() {
|
||||
return (
|
||||
<div className="mx-auto flex h-[560px] max-w-sm flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-[var(--border)] p-8 text-center">
|
||||
<div className="mx-auto flex h-full flex-col items-center justify-center gap-3 rounded-deck border border-dashed border-hairline p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">That’s everyone nearby</h2>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
You’ve seen every verified pro who covers your area for this trade. We’ll notify
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.5.
|
||||
*
|
||||
* The tinted surfaces stay light in dark mode and pin their text to ink, rather
|
||||
* than inverting. Inverting would mean a second set of tints for every tone, and
|
||||
* a pale-on-pale failure the first time one was missed.
|
||||
*/
|
||||
const bannerClasses = cva(
|
||||
'flex gap-3 rounded-card border p-5 text-ink-950',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: 'info' },
|
||||
},
|
||||
);
|
||||
|
||||
const TONE_ICON = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
} as const;
|
||||
|
||||
const TONE_ICON_COLOR = {
|
||||
info: 'text-brand-500',
|
||||
success: 'text-go-600',
|
||||
warning: 'text-sun-500',
|
||||
error: 'text-stop-500',
|
||||
} as const;
|
||||
|
||||
type BannerProps = VariantProps<typeof bannerClasses> & {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** `status` for outcomes the user caused, `alert` for errors. */
|
||||
role?: 'status' | 'alert';
|
||||
};
|
||||
|
||||
export function Banner({ tone = 'info', title, children, className, role }: BannerProps) {
|
||||
const key = tone ?? 'info';
|
||||
const Icon = TONE_ICON[key];
|
||||
|
||||
return (
|
||||
<div role={role} className={cn(bannerClasses({ tone }), className)}>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[key])} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
{title && <p className="font-display text-h4 text-ink-950">{title}</p>}
|
||||
<div className={cn('text-body-sm text-ink-800', title && 'mt-1')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline form error. §6.2 — always announced. */
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" className="text-body-sm text-stop-500">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.3. Flat by default — a hairline, not a shadow. Interactive cards
|
||||
* raise one step and shift their border to the accent; they do not lift or scale.
|
||||
*/
|
||||
export function Card({
|
||||
interactive = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { interactive?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded-card border border-hairline bg-raised p-5 sm:p-6',
|
||||
interactive &&
|
||||
'transition-[border-color,box-shadow] duration-[120ms] ease-standard hover:border-brand-500 hover:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty-state panel — dashed hairline, centred, no shadow. */
|
||||
export function EmptyState({
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-card border border-dashed',
|
||||
'border-hairline px-6 py-14 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h2 className="text-h4">{title}</h2>
|
||||
<p className="max-w-[46ch] text-body-sm text-muted">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled figure in a stats grid. §3.3 — figures are tabular. */
|
||||
export function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-hairline p-4">
|
||||
<dt className="text-meta text-muted">{label}</dt>
|
||||
<dd className="mt-1 font-display text-h4 tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.4. Selection is carried by border + weight + a check icon, never
|
||||
* by fill alone — §8 forbids colour as the only signal.
|
||||
*/
|
||||
export function Chip({
|
||||
selected = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-pill border-[1.5px] px-4 py-3 text-body-sm',
|
||||
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
|
||||
'disabled:pointer-events-none disabled:opacity-45',
|
||||
selected
|
||||
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
|
||||
: 'border-hairline text-strong hover:border-brand-500',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="h-4 w-4" aria-hidden />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Non-interactive pill — trade names, statuses, counts. */
|
||||
export function Tag({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-pill border border-hairline px-3 py-1.5 text-body-sm text-strong',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-width option row — used where choices need a title and a description
|
||||
* and so cannot compress into a chip.
|
||||
*/
|
||||
export function OptionCard({
|
||||
selected = false,
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
selected?: boolean;
|
||||
title: string;
|
||||
body?: string;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-4 rounded-card border-[1.5px] bg-raised p-5 text-left',
|
||||
'transition-[border-color,background-color,box-shadow] duration-[120ms] ease-standard',
|
||||
'disabled:pointer-events-none disabled:opacity-45',
|
||||
selected
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-accent-soft'
|
||||
: 'border-hairline hover:border-brand-500 hover:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && <span className="mt-0.5 shrink-0 text-accent">{icon}</span>}
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-h4">{title}</span>
|
||||
{body && <span className="mt-1 block text-body-sm text-muted">{body}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.2. 16px text is not negotiable — iOS zooms the viewport on focus
|
||||
* for anything smaller, which fights the deck's fixed scale.
|
||||
*/
|
||||
const controlClasses = [
|
||||
'w-full rounded-lg border-[1.5px] border-hairline bg-raised px-4 text-body text-strong',
|
||||
'outline-none transition-[border-color,box-shadow] duration-[120ms] ease-standard',
|
||||
'focus:border-brand-500 focus:shadow-[0_0_0_3px_var(--color-brand-200)]',
|
||||
'disabled:opacity-45',
|
||||
].join(' ');
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
invalid,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<'input'> & { invalid?: boolean }) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(controlClasses, 'h-12', invalid && 'border-stop-500', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
invalid,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<'textarea'> & { invalid?: boolean }) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(controlClasses, 'resize-y py-3', invalid && 'border-stop-500', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single labelled control. The <label> wraps the input, so association needs
|
||||
* no ids and the whole thing works unchanged in a server component.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string | null;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={cn('flex flex-col gap-2', className)}>
|
||||
<span className="text-body-sm font-semibold text-strong">{label}</span>
|
||||
{hint && <span className="text-meta text-muted">{hint}</span>}
|
||||
{children}
|
||||
{error && (
|
||||
<span role="alert" className="text-body-sm text-stop-500">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled group of controls — chips, radio-style buttons, upload slots.
|
||||
* Uses fieldset/legend rather than a wrapping label, which would swallow clicks
|
||||
* meant for the buttons inside.
|
||||
*/
|
||||
export function FieldSet({
|
||||
label,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={cn('flex flex-col gap-2', className)}>
|
||||
<legend className="text-body-sm font-semibold text-strong">{label}</legend>
|
||||
{hint && <p className="mb-1 text-meta text-muted">{hint}</p>}
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Character counter / helper text under a control. */
|
||||
export function FieldNote({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-meta text-muted tabular-nums">{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Button, IconButton, buttonClasses } from './button';
|
||||
export { Banner, FormError } from './banner';
|
||||
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';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §4. Layout lives in AppShell; these are the in-screen blocks.
|
||||
*
|
||||
* There is deliberately no `<h1>` here — the app bar owns it (§6.6, §8).
|
||||
*/
|
||||
|
||||
/** Lead paragraph under the app bar. Sets up the screen in one sentence. */
|
||||
export function ScreenIntro({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <p className={cn('mb-8 text-body text-muted', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
/** A titled block within a screen. Its heading is an h2 — the app bar has the h1. */
|
||||
export function Section({
|
||||
title,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn('flex flex-col gap-4', className)}>
|
||||
{title && (
|
||||
<div>
|
||||
<h2 className="text-h3">{title}</h2>
|
||||
{hint && <p className="mt-2 text-body-sm text-muted">{hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticks the primary action to the bottom of the viewport on long forms, so it
|
||||
* stays in thumb reach instead of sitting below the fold. §9.
|
||||
*/
|
||||
export function StickyAction({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky bottom-0 -mx-5 mt-8 border-t border-hairline bg-page/95 px-5 pt-4 backdrop-blur-[12px]',
|
||||
'pb-[calc(1rem+env(safe-area-inset-bottom))]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user