Phase 9: onboarding, mobile & accessibility

- First-run welcome on the Campaigns page: what-the-app-is blurb, three feature
  highlights, and one-click "Try the sample campaign" (seedSampleCampaign) plus
  "Start your first campaign".
- Map token palette starts collapsed on small screens so the map gets the width.
- (Reduced-motion is already honoured globally in styles/globals.css.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:53:33 +02:00
parent 7f1ab8f094
commit f01e8ffe32
3 changed files with 62 additions and 11 deletions
+16
View File
@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('onboarding: first-run welcome loads the sample campaign', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Welcome to TTRPG Manager' })).toBeVisible();
await page.getByRole('button', { name: 'Try the sample campaign' }).click();
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
});
+44 -10
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample';
import { SYSTEM_OPTIONS } from '@/lib/rules';
import { useUiStore } from '@/stores/uiStore';
import { useCampaigns } from './hooks';
import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
@@ -28,15 +29,7 @@ export function CampaignsPage() {
/>
{campaigns.length === 0 ? (
<EmptyState
title="No campaigns yet"
hint="Create your first campaign to start tracking characters and combat."
action={
<Button variant="primary" onClick={() => setCreating(true)}>
+ New campaign
</Button>
}
/>
<Welcome onCreate={() => setCreating(true)} />
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{campaigns.map((c) => (
@@ -51,6 +44,47 @@ export function CampaignsPage() {
);
}
function Welcome({ onCreate }: { onCreate: () => void }) {
const navigate = useNavigate();
const setActive = useUiStore((s) => s.setActiveCampaign);
const [loading, setLoading] = useState(false);
const loadSample = async () => {
setLoading(true);
const id = await seedSampleCampaign();
setActive(id);
void navigate({ to: '/dashboard' });
};
return (
<div className="mx-auto max-w-2xl rounded-xl border border-line bg-panel p-6 text-center">
<h2 className="font-display text-2xl font-bold text-accent">Welcome to TTRPG Manager</h2>
<p className="mx-auto mt-2 max-w-xl text-sm text-muted">
A local-first companion for D&amp;D 5e &amp; Pathfinder 2e guided character building, combat
tracking, a full battle-map VTT with fog &amp; dynamic vision, a searchable compendium, and live
play your players join from their own devices. Everything is stored on this device.
</p>
<div className="mt-4 grid gap-2 text-left sm:grid-cols-3">
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
</div>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
<Button variant="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button>
</div>
</div>
);
}
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div className="rounded-lg border border-line bg-surface p-3">
<div className="text-xl">{icon}</div>
<div className="mt-1 font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div>
</div>
);
}
function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () => void }) {
const navigate = useNavigate();
const setActive = useUiStore((s) => s.setActiveCampaign);
+2 -1
View File
@@ -53,7 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const [gmDraw, setGmDraw] = useState(true);
const [dims, setDims] = useState({ cols: 0, rows: 0 });
const [editToken, setEditToken] = useState<string | null>(null);
const [showPalette, setShowPalette] = useState(true);
// Collapsed by default on small screens so the map gets the width.
const [showPalette, setShowPalette] = useState(() => typeof window === 'undefined' || window.innerWidth >= 880);
// transient interaction state
const [overlay, setOverlay] = useState<Overlay>({});