895807d6c9
Layout: - CombatantRow: action controls (damage/AC/move/remove) grouped into one right-aligned wrapper so they wrap together instead of scattering on narrow widths. - top bar: the campaign switcher now shrinks/truncates instead of overflowing on tablet widths. - character sheet header: stat coins go full-width grid on mobile; the name truncates and scales down on small screens. UX consistency: - player Cast and resource Spend/Regain no longer fail silently — Cast shows the reason, resource −/+ disable at their bounds. - the AI encounter builder now catches load failures and reports them; level-up advisor shows the human error message, not the error-kind enum. - section headers standardized on the .smallcaps design token across 16 files (replacing an ad-hoc uppercase class). - player HP bar uses the shared Meter primitive. pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued. FeatCard: dropped a dead text-xl wrapper left from the emoji purge. Left as documented (functional, off-theme, refactor-risky): the native confirm/prompt in Settings cloud-conflict and the session-password flow. Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
5.2 KiB
TypeScript
114 lines
5.2 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Bell, X } from 'lucide-react';
|
|
import type { Campaign } from '@/lib/schemas';
|
|
import { buildSignals } from '@/lib/assistant/signals';
|
|
import type { Suggestion } from '@/lib/assistant/advisors';
|
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
|
import { useCharacters } from '@/features/characters/hooks';
|
|
import { useNotes, useQuests } from '@/features/world/hooks';
|
|
import { useEncounters } from '@/features/combat/hooks';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { cn } from '@/lib/cn';
|
|
import { useSignalAction } from './useSignalAction';
|
|
|
|
const DOT: Record<Suggestion['severity'], string> = {
|
|
danger: 'bg-danger',
|
|
warn: 'bg-warning',
|
|
info: 'bg-info',
|
|
};
|
|
|
|
/**
|
|
* Ambient, app-wide "what needs my attention" surface. Watches the active
|
|
* campaign's state and surfaces one-click signals from anywhere — the proactive
|
|
* core of the assistant. Renders nothing without an active campaign.
|
|
*/
|
|
export function SignalsBell() {
|
|
const campaign = useActiveCampaign();
|
|
if (!campaign) return null;
|
|
return <SignalsBellInner campaign={campaign} />;
|
|
}
|
|
|
|
function SignalsBellInner({ campaign }: { campaign: Campaign }) {
|
|
const characters = useCharacters(campaign.id);
|
|
const notes = useNotes(campaign.id);
|
|
const quests = useQuests(campaign.id);
|
|
const encounters = useEncounters(campaign.id);
|
|
const activeEnc = encounters.find((e) => e.status === 'active');
|
|
|
|
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
|
const [open, setOpen] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const runAction = useSignalAction(campaign.id, setMsg);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const signals = useMemo(
|
|
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }).filter((s) => !dismissed.has(s.id)),
|
|
[activeEnc, characters, notes, quests, dismissed],
|
|
);
|
|
// The badge counts only things that genuinely want attention.
|
|
const attention = signals.filter((s) => s.severity !== 'info').length;
|
|
|
|
// Close the popover on an outside click or Escape.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
|
document.addEventListener('mousedown', onDown);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
aria-label={`Signals${attention > 0 ? ` (${attention} need attention)` : ''}`}
|
|
title="Signals — things that may need your attention"
|
|
className="relative grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink"
|
|
>
|
|
<Bell size={18} aria-hidden />
|
|
{attention > 0 && (
|
|
<span className="absolute -right-0.5 -top-0.5 grid min-w-[16px] place-items-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-4 text-white">
|
|
{attention}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
|
|
<div className="flex items-center justify-between px-2 py-1">
|
|
<span className="smallcaps">Signals</span>
|
|
{signals.length > 0 && (
|
|
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
|
|
)}
|
|
</div>
|
|
{msg && <p className="px-2 pb-1 text-xs text-success" aria-live="polite">{msg}</p>}
|
|
{signals.length === 0 ? (
|
|
<p className="px-2 py-3 text-sm text-muted">Nothing needs your attention.</p>
|
|
) : (
|
|
<ul className="max-h-[60vh] space-y-1 overflow-y-auto">
|
|
{signals.map((s) => (
|
|
<li key={s.id} className="flex items-start gap-2 rounded-lg border border-line/70 bg-surface p-2">
|
|
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', DOT[s.severity])} aria-hidden />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-ink">{s.title}</div>
|
|
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
|
|
{s.action && (
|
|
<Button size="sm" variant="secondary" className="mt-1.5" onClick={() => { void runAction(s.action!); if (s.action!.type !== 'goto') setMsg(null); setOpen(s.action!.type !== 'goto'); }}>
|
|
{s.action.label}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<button className="text-faint hover:text-ink" aria-label="Dismiss" onClick={() => setDismissed((d) => new Set(d).add(s.id))}>
|
|
<X size={13} aria-hidden />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|