eb88138d15
Five parallel lanes plus an integration pass. The header, gallery, router and sitemap are all generated from the demo registry, so adding src/demos/<slug>/ puts a demo everywhere with zero edits to shared files — which is the whole reason demo nine cannot break demo one. check-demos enforces the twelve contract rules: 142 checks over one live demo. Two worth naming. The shell may not mention a specific slug, because an 'if (slug === wordle)' in src/components/demo/ is a contract bug wearing a patch. And a spec-status demo must ship a real specification — task, actions, grader, counterweight, eval command — since a coming-soon card reads worse than an honest empty gallery. Bundle budget holds: entry 108.79 kB gzipped against a 160 kB ceiling, the demo chunk 21.15 kB against 90 kB. recharts is 108 kB gzipped and lives behind a lazy import so it never touches the entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
94 lines
3.6 KiB
TypeScript
94 lines
3.6 KiB
TypeScript
import type { ComponentType } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { ArrowRight } from 'lucide-react';
|
|
import type { DemoMeta } from '@/lib/demo-kit/types';
|
|
import { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { DemoIcon } from '@/components/site/DemoIcon';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface DemoCardProps<T> {
|
|
meta: DemoMeta;
|
|
/** Defaults to the canonical demo route. */
|
|
href?: string;
|
|
/**
|
|
* The demo's OWN board, drawn compact, as the thumbnail. A screenshot would
|
|
* go stale the first time the board changed and nobody would notice; this
|
|
* cannot, because it is the same component the demo page renders.
|
|
*/
|
|
Surface?: ComponentType<{ state: T; compact?: boolean }>;
|
|
/** A representative state for the thumbnail — usually a solved board. */
|
|
thumbnailState?: T;
|
|
className?: string;
|
|
}
|
|
|
|
export function DemoCard<T>({ meta, href, Surface, thumbnailState, className }: DemoCardProps<T>) {
|
|
const to = href ?? `/demos/${meta.slug}`;
|
|
const isSpec = meta.status === 'spec';
|
|
const showSurface = Surface !== undefined && thumbnailState !== undefined;
|
|
|
|
return (
|
|
<article
|
|
className={cn(
|
|
'card group relative flex flex-col overflow-hidden transition-colors duration-2 ease-enter hover:border-brand/40',
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-3 p-4 pb-3">
|
|
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
|
|
<DemoIcon name={meta.icon} className="size-5" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="text-base font-semibold leading-tight">
|
|
{/* Stretched link: the whole card is the hit target, but there is
|
|
still exactly ONE link in the accessibility tree for it. */}
|
|
<Link to={to} className="after:absolute after:inset-0 after:content-['']">
|
|
{meta.title}
|
|
</Link>
|
|
</h3>
|
|
<p className="mt-0.5 text-sm leading-snug text-muted">{meta.tagline}</p>
|
|
</div>
|
|
{isSpec ? (
|
|
<Badge variant="outline" className="shrink-0 uppercase tracking-wide">
|
|
Spec
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
{showSurface ? (
|
|
<div className="mx-4 overflow-hidden rounded-lg bg-surface-2 p-3">
|
|
{/* Decorative: the title and tagline already name the demo, and a
|
|
board with no run behind it is not information. */}
|
|
<div aria-hidden="true">
|
|
<Surface state={thumbnailState as T} compact />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<dl className="mt-3 flex flex-wrap gap-x-4 gap-y-1 px-4 text-xs">
|
|
<div className="flex gap-1">
|
|
<dt className="text-muted">For</dt>
|
|
<dd className="font-medium">{meta.persona}</dd>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<dt className="text-muted">Vertical</dt>
|
|
<dd className="font-medium">{VERTICAL_LABELS[meta.vertical]}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<p className="mt-2 px-4 pb-4 text-xs leading-relaxed text-muted">
|
|
<span className="font-medium text-fg">Reward: </span>
|
|
{meta.rewardLine}
|
|
</p>
|
|
|
|
<p className="mt-auto flex items-center gap-1 border-t border-border px-4 py-2.5 text-sm font-medium text-accent-fg">
|
|
{isSpec ? 'Read the specification' : 'Open the demo'}
|
|
<ArrowRight
|
|
className="size-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5"
|
|
aria-hidden="true"
|
|
/>
|
|
</p>
|
|
</article>
|
|
);
|
|
}
|