Add deck synergy suggestions, a responsive deck layout, and undoable library bulk-add

Suggestion scoring is fully offline (tribal/mechanic/archetype/mana-base
signals derived at ingest, no external synergy database), with an "unusual
picks" mode that surfaces underappreciated cards instead of staples. The
deck page's top panels now use a CSS grid so wide windows don't waste
horizontal space. Library's bulk add-to-deck can clobber existing deck
quantities, so it now offers an undo (snackbar + command palette, the
latter with no timeout) that restores each card's prior state exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:04:36 -05:00
parent 008ab33723
commit 3c625a9830
14 changed files with 1596 additions and 117 deletions

View File

@@ -8,6 +8,7 @@ import {
IconButton,
Icons,
StatTile,
UndoBar,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
@@ -133,6 +134,12 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
// This only controls the snackbar's visibility — the underlying undo action
// stays available in the command palette well past this (see `undo` below),
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
@@ -140,6 +147,44 @@ export default function App() {
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
// so an undo that's missed in the snackbar is still reachable via Alt+/.
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<void> } | null>(
null,
);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Bumped once an undo actually runs, so views mounted elsewhere than where
// the undo was offered (e.g. the Decks tab, when the undo came from a
// Library bulk add-to-deck) know to refetch instead of showing stale data
// until the user leaves and comes back.
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
function offerUndo(message: string, run: () => void | Promise<void>) {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setUndo({ message, run });
setSnackbarVisible(true);
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
}
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
function dismissUndo() {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
}
function runUndo() {
if (!undo) return;
const { run } = undo;
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
}
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
@@ -393,18 +438,23 @@ export default function App() {
) : tab === "add" ? (
<CardEntry />
) : tab === "library" ? (
<LibraryBrowser />
<LibraryBrowser onOfferUndo={offerUndo} />
) : (
<DeckView />
<DeckView refreshSignal={deckRefreshSignal} />
)}
</div>
</div>
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
)}
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
);