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:
@@ -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();
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { campaignsRepo } from '@/lib/db/repositories';
|
import { campaignsRepo } from '@/lib/db/repositories';
|
||||||
|
import { seedSampleCampaign } from '@/lib/sample';
|
||||||
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useCampaigns } from './hooks';
|
import { useCampaigns } from './hooks';
|
||||||
import type { Campaign } from '@/lib/schemas';
|
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 { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
|
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
|
||||||
@@ -28,15 +29,7 @@ export function CampaignsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{campaigns.length === 0 ? (
|
{campaigns.length === 0 ? (
|
||||||
<EmptyState
|
<Welcome onCreate={() => setCreating(true)} />
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{campaigns.map((c) => (
|
{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&D 5e & Pathfinder 2e — guided character building, combat
|
||||||
|
tracking, a full battle-map VTT with fog & 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 }) {
|
function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setActive = useUiStore((s) => s.setActiveCampaign);
|
const setActive = useUiStore((s) => s.setActiveCampaign);
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const [gmDraw, setGmDraw] = useState(true);
|
const [gmDraw, setGmDraw] = useState(true);
|
||||||
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
const [dims, setDims] = useState({ cols: 0, rows: 0 });
|
||||||
const [editToken, setEditToken] = useState<string | null>(null);
|
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
|
// transient interaction state
|
||||||
const [overlay, setOverlay] = useState<Overlay>({});
|
const [overlay, setOverlay] = useState<Overlay>({});
|
||||||
|
|||||||
Reference in New Issue
Block a user