Demo page: the running product beside the case for it

A page to present this to a client. Left column is the real app in the phone
frame — live, swipeable, the same deck and the same data, not a screenshot.
Right column is what somebody needs in order to judge it: how to get in, what it
does, what it is built on.

Credentials, both sides
- A one-sided demo of a two-sided marketplace shows half a product, so the panel
  carries a customer AND a tradesperson, each with a copy button. Reading a
  phone number off a screen into a form while a client watches is a small
  humiliation; mistyping one is a worse one.
- dev-login grows from one pinned number to a named DEMO_ACCOUNTS list. The
  three guards are unchanged — not production, explicitly enabled, and the
  number must be on the list. Both accounts map to seeded users that already own
  jobs, conversations and a completed booking, so the screens have something in
  them rather than five empty states.
- The page SAYS SO when sign-in is unavailable rather than letting somebody
  discover it mid-meeting. A fixed passcode is a login bypass and must never
  ship live; the honest fix for demoing against production is a real account and
  a real SMS, not a fourth flag.

Content lives in arrays at the top of demo-panel, so adding a feature or
swapping a dependency is one line and the layout is untouched. Four labelled
placeholder slots — roadmap, pricing, metrics, case study — reserve the space
and make it obvious what belongs where.

A deliberate exception to DESIGN.md §1.0 and §4, which ban desktop layouts and
marketing pages, and it is noted as one in the file. This is a frame around the
running app for a laptop, not a product screen; the app inside is untouched and
still mobile-only. Below `lg` the panel stacks under the phone, because a client
who opens the link on their own phone should still be able to read it.

Verified: both demo accounts complete the real OTP path end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-21 07:31:16 -04:00
co-authored by Claude Opus 5
parent 0c49aa9502
commit 7567aa4e75
6 changed files with 438 additions and 59 deletions
+45 -31
View File
@@ -117,14 +117,14 @@ export function Deck({
</AnimatePresence>
</div>
{/* DESIGN.md §6.8. Fixed order, two sizes: rewind · pass · watch · hire · ask.
Gaps are tighter around the small ones so the middle pair still reads
as the pair. */}
<div className="flex shrink-0 items-center gap-4 pb-2">
{/* DESIGN.md §6.8. rewind · pass · watch · hire · ask.
Wider gaps around the two decisions than between them and the helpers,
so the pair reads as a pair rather than as items 2 and 4 of five. */}
<div className="flex shrink-0 items-center gap-5 pb-2">
<ActionButton
label="Bring back the last one"
icon={Undo2}
tone="ink"
tone="neutral"
// Disabled rather than hidden: a row that changes length as you swipe
// moves the two big buttons out from under your thumb.
disabled={index === 0 || !onRewind}
@@ -140,7 +140,7 @@ export function Deck({
<ActionButton
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
icon={Eye}
tone="accent"
tone="neutral"
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
disabled={!onWatch}
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
@@ -155,7 +155,7 @@ export function Deck({
<ActionButton
label="Ask a question"
icon={MessageCircle}
tone="ink"
tone="neutral"
disabled={!onAsk}
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
/>
@@ -190,7 +190,7 @@ export function Card({
return (
<motion.article
className={cn(
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
'deck-card absolute inset-0 overflow-hidden rounded-deck border shadow-lg',
'border-hairline bg-raised',
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
)}
@@ -283,12 +283,25 @@ export function Card({
/**
* DESIGN.md §6.8. One of the five circles under the card.
*
* `size` IS the hierarchy, not decoration: the two actions that end the card are
* large and meet the thumb first; the three that do not are small — but never
* below 44px, which is the floor in §8.
* The row used to be five different colours and five drop shadows — a rainbow
* of outlined rings, each shouting as loudly as the next. Three rules replaced
* that, and they are the whole design:
*
* `active` fills the circle for the one control whose state persists (watch),
* and its label changes with it, because §8 forbids fill as the only signal.
* 1. TWO colours, not five. Only the two actions that decide the card are
* coloured, and they borrow the exact hues of the drag stamps they mirror
* (`PASS` in stop, `SEND JOB` in go). Rewind, watch and ask are ink: they
* are helpers, and a helper competing with the decision is noise.
* 2. NO elevation. The old row put a drop shadow under all five. §5 already
* lists three shadows on one screen as a Don't, and five discs each casting
* their own is worse than untidy — it muddies the colour underneath. Flat,
* and let size and hue carry the hierarchy instead.
* 3. SOLID, not ring and not tint. A filled disc with a white glyph is
* unambiguous and holds its weight above a full-bleed photo. An outlined
* ring reads as a diagram of a button; a pastel tint reads as a sticker.
*
* `active` is for the one control whose state persists (watch) and inverts it to
* a solid accent — with the label changing too, because §8 forbids fill as the
* only signal.
*/
function ActionButton({
label,
@@ -301,19 +314,13 @@ function ActionButton({
}: {
label: string;
icon: typeof Check;
tone: 'pass' | 'hire' | 'accent' | 'ink';
tone: 'pass' | 'hire' | 'neutral';
size?: 'sm' | 'lg';
active?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
const isLarge = size === 'lg';
const colour = {
pass: 'var(--color-stop-500)',
hire: 'var(--color-go-600)',
accent: 'var(--color-brand-500)',
ink: 'var(--color-ink-950)',
}[tone];
return (
<button
@@ -323,22 +330,29 @@ function ActionButton({
aria-label={label}
aria-pressed={active || undefined}
title={label}
style={{
borderColor: colour,
color: active ? undefined : colour,
backgroundColor: active ? colour : undefined,
}}
className={cn(
'flex items-center justify-center rounded-full border-2 shadow-lg',
'transition hover:scale-105 active:scale-95',
'disabled:pointer-events-none disabled:opacity-35 disabled:shadow-none',
'flex shrink-0 items-center justify-center rounded-full',
'transition-[transform,background-color,color] duration-[120ms] ease-standard',
'active:scale-90 disabled:pointer-events-none disabled:opacity-30',
// Focus is visible on a circle with no border, per §8.
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
isLarge ? 'h-16 w-16' : 'h-11 w-11',
active ? 'text-white' : 'bg-raised',
// Helpers: flat, ink, quiet. They recede on purpose.
tone === 'neutral' && !active && 'bg-inset text-muted hover:text-strong',
tone === 'neutral' && active && 'bg-accent text-white',
// Decisions: solid. Not a tint, not a ring — a filled disc with a white
// glyph is unambiguous at a glance and holds up on top of a photo. The
// pastel version this replaced read as a sticker; the outlined version
// before it read as a diagram of a button.
tone === 'pass' && 'bg-stop-500 text-white hover:bg-stop-600',
tone === 'hire' && 'bg-go-600 text-white hover:bg-go-700',
)}
>
<Icon
className={isLarge ? 'h-7 w-7' : 'h-5 w-5'}
strokeWidth={isLarge ? 3 : 2.5}
className={isLarge ? 'h-7 w-7' : 'h-[1.15rem] w-[1.15rem]'}
strokeWidth={isLarge ? 2.75 : 2.25}
aria-hidden
/>
</button>
@@ -0,0 +1,59 @@
'use client';
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A value the presenter needs to hand over verbatim, with one tap to copy.
*
* Reading a phone number off a screen into a form while somebody watches is a
* small humiliation, and mistyping one in front of a client is a worse one.
*
* The confirmation lives in the button's own label as well as its icon, because
* §8 forbids colour or iconography as the only carrier — and because a screen
* reader that only announces "Copy" after a successful copy is lying.
*/
export function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
// Long enough to register, short enough that the row is ready again
// before anybody reaches for it twice.
setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard access can be refused (insecure origin, permissions). Silent
// rather than an error banner: the value is on screen and readable, which
// is the fallback that always works.
}
}
return (
<div className="flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5">
<span className="w-16 shrink-0 text-meta text-faint">{label}</span>
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
{value}
</code>
<button
type="button"
onClick={copy}
aria-label={copied ? `${label} copied` : `Copy ${label.toLowerCase()}`}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
);
}