Fixes batch 2: player quests, AI parse, advisor, iPad share

- Player view: a Quest log now syncs to players (snapshot += quests; broadcaster
  passes campaign quests; PlayerBoards renders titles + objective checkboxes).
- AI (DeepSeek etc.): robust JSON extraction — strip code fences anywhere + a
  brace-balanced scanner that ignores prose/braces in strings. Fixes "AI unavailable
  (parse)" with OpenAI-compatible providers. +4 tests.
- Encounter advisor: now bidirectional — for an over-tuned (too-hard) fight it
  suggests REMOVING/swapping monsters instead of only ever adding. +3 tests.
- Character share: works on iPad — native share sheet → clipboard → a copyable
  link modal fallback (was a silently-failing clipboard write).

230 unit + 34 e2e + 2 realtime green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:56:10 +02:00
parent 426824fd78
commit 3f524ce82c
15 changed files with 292 additions and 21 deletions
+43 -3
View File
@@ -13,6 +13,7 @@ import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
@@ -43,6 +44,9 @@ export function CharacterSheet({ character }: { character: Character }) {
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
// Manual fallback: holds the link to show in a selectable dialog when neither
// Web Share nor Clipboard is available (e.g. iPad without a secure context).
const [shareLink, setShareLink] = useState<string | null>(null);
const onPortrait = async (file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
@@ -52,9 +56,27 @@ export function CharacterSheet({ character }: { character: Character }) {
const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ }
setShared(true);
setTimeout(() => setShared(false), 1500);
// Prefer the native share sheet (best on iOS/iPadOS), then the clipboard,
// then a selectable dialog so the link is always recoverable.
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: `${c.name} — character link`, url: link });
return;
} catch (err) {
// User dismissed the share sheet: respect that and do nothing further.
if (err instanceof DOMException && err.name === 'AbortError') return;
// Otherwise fall through to clipboard / manual fallback.
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
setShared(true);
setTimeout(() => setShared(false), 1500);
return;
}
} catch { /* clipboard blocked (e.g. insecure context on iPad) */ }
setShareLink(link);
};
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
@@ -281,6 +303,24 @@ export function CharacterSheet({ character }: { character: Character }) {
{levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
)}
{shareLink !== null && (
<Modal
open
onClose={() => setShareLink(null)}
title="Share with player"
footer={<Button size="sm" variant="ghost" onClick={() => setShareLink(null)}>Done</Button>}
>
<p className="mb-2 text-sm text-muted">Copy this link and send it to the player:</p>
<Input
data-autofocus
readOnly
value={shareLink}
aria-label="Player link"
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
</Modal>
)}
</Page>
);
}