Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md' | 'icon';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-accent text-accent-ink hover:opacity-90 font-medium',
|
||||
secondary: 'bg-elevated text-ink hover:bg-line border border-line',
|
||||
ghost: 'text-muted hover:text-ink hover:bg-elevated',
|
||||
danger: 'bg-danger text-white hover:opacity-90 font-medium',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-sm rounded-md',
|
||||
md: 'h-10 px-4 text-sm rounded-md',
|
||||
icon: 'h-9 w-9 rounded-md grid place-items-center',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = 'secondary', size = 'md', type, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? 'button'}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** reset key — when it changes, the boundary clears its error */
|
||||
resetKey?: string | number;
|
||||
}
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/** Catches render errors so one broken screen never white-screens the whole app. */
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
override state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Render error caught by boundary:', error, info.componentStack);
|
||||
}
|
||||
|
||||
override componentDidUpdate(prev: Props): void {
|
||||
if (prev.resetKey !== this.props.resetKey && this.state.error) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="m-6 rounded-lg border border-danger/40 bg-danger/10 p-6">
|
||||
<h2 className="text-lg font-semibold text-danger">Something went wrong</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
This screen hit an error. Your data is safe — try again or reload.
|
||||
</p>
|
||||
<pre className="mt-3 max-h-40 overflow-auto rounded bg-surface p-3 text-xs text-muted">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
<Button className="mt-4" variant="secondary" onClick={() => this.setState({ error: null })}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const base =
|
||||
'w-full bg-surface border border-line rounded-md px-3 py-2 text-sm text-ink placeholder:text-muted ' +
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60';
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
function Input({ className, ...props }, ref) {
|
||||
return <input ref={ref} className={cn(base, className)} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
function Textarea({ className, ...props }, ref) {
|
||||
return <textarea ref={ref} className={cn(base, 'resize-y min-h-20', className)} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
|
||||
function Select({ className, ...props }, ref) {
|
||||
return <select ref={ref} className={cn(base, 'pr-8', className)} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export function Field({ label, children, htmlFor }: { label: string; children: React.ReactNode; htmlFor?: string }) {
|
||||
return (
|
||||
<label className="block space-y-1" htmlFor={htmlFor}>
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Accessible dialog: Escape to close, focus trapping, restores focus on close. */
|
||||
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||
const panel = panelRef.current;
|
||||
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' && panel) {
|
||||
const focusable = panel.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0]!;
|
||||
const last = focusable[focusable.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
previouslyFocused.current?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} aria-hidden />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-lg border border-line bg-panel shadow-2xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Integer input that never emits NaN — coerces blanks/garbage to a fallback. */
|
||||
export function NumberField({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
aria-label={ariaLabel}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(e) => {
|
||||
const raw = Number(e.target.value);
|
||||
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
||||
if (min !== undefined) n = Math.max(min, n);
|
||||
if (max !== undefined) n = Math.min(max, n);
|
||||
onChange(n);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
|
||||
export function Page({ children }: { children: ReactNode }) {
|
||||
return <div className="mx-auto max-w-6xl px-4 py-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold text-ink">{title}</h1>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-line bg-panel/50 p-10 text-center">
|
||||
<p className="font-medium text-ink">{title}</p>
|
||||
{hint && <p className="mt-1 text-sm text-muted">{hint}</p>}
|
||||
{action && <div className="mt-4 flex justify-center">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gate a screen on an active campaign; renders a prompt if none is selected. */
|
||||
export function RequireCampaign({ children }: { children: (campaign: Campaign) => ReactNode }) {
|
||||
const campaign = useActiveCampaign();
|
||||
if (!campaign) {
|
||||
return (
|
||||
<Page>
|
||||
<EmptyState
|
||||
title="No campaign selected"
|
||||
hint="Pick a campaign from the switcher up top, or create one to get started."
|
||||
action={
|
||||
<Link to="/" className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-ink">
|
||||
Go to Campaigns
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
return <>{children(campaign)}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user