Mobile UI pass: responsive tables, deck-view drill-down, dialog fixes

- Library/Card Search/Deck tables stack into dense card-style rows below
  720px instead of scrolling horizontally (floated thumbnail, wrapping
  chips for MV/Owned/color pips).
- Deck view: fix desktop min-widths and a non-wrapping button row that
  forced horizontal scroll on phones; split the deck list and the
  deck-building pane into separate drill-down screens with a back button
  so a long deck list doesn't bury the builder.
- Suggested Cards modal: fix action buttons crushing the card name down to
  one word per line by giving them their own full-width row.
- Add a close (X) button to the shared dialog title bar — mobile has no
  Escape key and wide dialogs leave little backdrop to tap.
- Info tab, bottom-nav loading/safe-area fixes, and Gradle NDK_HOME
  inheritance fix carried over from the Android bring-up work.
This commit is contained in:
2026-08-02 16:10:25 -05:00
parent 94e4f25ec4
commit 663af32562
10 changed files with 646 additions and 99 deletions

View File

@@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover: required for env(safe-area-inset-*) to report real
values rather than 0. The Android window already draws edge-to-edge
behind the status/nav bars (that's why content was overlapping them);
this is what lets CSS react to it instead of ignoring it. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>mtgc</title>
</head>
<body>

View File

@@ -1,4 +1,5 @@
import java.io.File
import java.util.Properties
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
@@ -50,9 +51,23 @@ open class BuildTask : DefaultTask() {
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
// `project.exec` inherits whatever environment the Gradle daemon has —
// which is whatever launched Android Studio, not necessarily a shell
// where ANDROID_HOME/NDK_HOME were exported. Without NDK_HOME, cargo's
// build script for `ring` (and rusqlite's bundled SQLite) can't find
// the NDK's clang and rustBuild*Debug/Release fails outright. Read
// local.properties instead — the same file AGP itself reads sdk.dir
// from — so this works regardless of how Studio was started.
val localProps = Properties().apply {
val f = project.rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
localProps.getProperty("sdk.dir")?.let { environment("ANDROID_HOME", it) }
localProps.getProperty("ndk.dir")?.let { environment("NDK_HOME", it) }
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")

View File

@@ -53,7 +53,17 @@ fn status_of(s: &AppState) -> Status {
#[tauri::command]
pub fn get_status(db: Db) -> Res<Status> {
Ok(status_of(&db.lock().unwrap()))
let status = status_of(&db.lock().unwrap());
// Only ever called once per launch (from the frontend's one status-fetch
// effect, retried on failure/timeout) — cheap to log unconditionally, and
// it's the one way to tell "this invoke never reached Rust" (nothing
// logged at all) apart from "it reached Rust and returned not-ready".
crate::mlog!(
"get_status: reference_ready={} card_count={}",
status.reference_ready,
status.card_count
);
Ok(status)
}
#[tauri::command]

View File

@@ -58,8 +58,20 @@ impl AppState {
index: None,
ingest_cancel: None,
};
// Best-effort open; failures just leave the catalog unloaded.
let _ = s.reopen();
crate::mlog!(
"AppState::new: library={} reference={} (exists={})",
s.library_path.display(),
s.reference_path.display(),
s.reference_path.exists()
);
// Best-effort open; failures just leave the catalog unloaded (the UI
// falls back to "no card data yet" and offers Download/Import again).
// That fallback previously made a real open failure indistinguishable
// from a first run — log it so it shows up in `adb logcat -s
// RustStdoutStderr` instead of silently reappearing every launch.
if let Err(e) = s.reopen() {
crate::mlog!("AppState::new: reopen failed: {e}");
}
s
}

View File

@@ -17,7 +17,7 @@ import { LibraryBrowser } from "./views/LibraryBrowser";
import { CardSearch } from "./views/CardSearch";
import { DeckView } from "./views/DeckView";
type Tab = "add" | "library" | "search" | "decks";
type Tab = "add" | "library" | "search" | "decks" | "info";
const svg = (children: ReactNode, size = 20) => (
<svg
@@ -118,6 +118,20 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
},
];
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
* on desktop that lives in the topbar instead, so this isn't in `NAV`. */
const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
id: "info",
label: "Info",
icon: svg(
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>,
),
};
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
@@ -133,6 +147,59 @@ function formatRelativeTime(epochSeconds: number): string {
return `${Math.round(months / 12)}y ago`;
}
/** "Your library" summary: unique/copy/deck counts, color distribution, and
* the reference-db freshness stamp. Shared by the desktop nav rail's footer
* and the mobile Info tab, which otherwise have no equivalent place for it. */
function LibrarySummary({
ready,
libStats,
status,
}: {
ready: boolean;
libStats: LibraryStats | null;
status: Status | null;
}) {
if (!ready) {
return (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
);
}
return (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
);
}
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
@@ -158,6 +225,9 @@ export default function App() {
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
// Set by the ingest-progress listener below; reset before each
// updateCardData() call so it can detect a stalled invoke — see there.
const gotProgress = useRef(false);
// 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 —
@@ -198,12 +268,41 @@ export default function App() {
}
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
// This is the *only* place status gets fetched — nothing else re-polls it.
// The Android IPC bridge has been observed to silently drop an invoke()
// outright: the promise never resolves *or* rejects (confirmed
// separately for update_card_data — same symptom, no backend log line at
// all, so the call never even reached Rust). A try/catch alone doesn't
// help against that, since nothing ever throws; the race against a
// per-attempt timeout below is what actually turns a hang into a
// retryable failure. Without it, this ran once, hung forever, and the
// app was permanently stuck on "no card data yet" every launch — even
// with a fully loaded reference.db on the backend, since nothing else
// ever asks it again.
for (let attempt = 0; ; attempt++) {
try {
const s = await Promise.race([
api.getStatus(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("get_status timed out")), 5000),
),
]);
setStatus(s);
return;
} catch (err) {
if (attempt >= 4) {
console.error("getStatus failed after retries:", err);
return;
}
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
}, []);
useEffect(() => {
refreshStatus();
const un = listen<IngestProgress>("ingest-progress", (e) => {
gotProgress.current = true;
lastProgress.current = e.payload;
setProgress(e.payload);
});
@@ -221,6 +320,14 @@ export default function App() {
}, []);
const ready = status?.reference_ready ?? false;
// On Android the very first IPC call after launch has been observed taking
// several seconds (likely one-time JIT/class-verification warm-up on the
// native bridge, not a hang — it does complete). `!ready` was previously
// used both for "we don't know yet" and "confirmed no card data", so during
// that warm-up the app showed the full "Download card data" prompt — which
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Keep the sidebar's library summary fresh: fetch once card data is ready,
// then poll (cards/decks can change from any tab without a shared event bus).
@@ -241,8 +348,27 @@ export default function App() {
async function updateCardData() {
setBusy(true);
gotProgress.current = false;
let stallTimer: ReturnType<typeof setTimeout> | undefined;
try {
const s = await api.updateCardData();
// Android's Tauri IPC bridge has been observed to silently drop an
// invoke() during a dev-mode webview reload: the promise never settles,
// no ingest-progress event ever arrives, and the button was stuck
// "Downloading…" forever with no way to retry (the backend never even
// logged that it started). A real ingest always emits its first
// progress event within milliseconds — it happens before any network
// I/O — so treat "no progress after a generous window" as a dropped
// call rather than hang indefinitely.
const s = await Promise.race([
api.updateCardData(),
new Promise<never>((_, reject) => {
stallTimer = setTimeout(() => {
if (!gotProgress.current) {
reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app."));
}
}, 8000);
}),
]);
setStatus(s);
setProgress({
stage: "done",
@@ -270,6 +396,7 @@ export default function App() {
});
}
} finally {
clearTimeout(stallTimer);
setBusy(false);
}
}
@@ -355,42 +482,7 @@ export default function App() {
</div>
<div className="nav-foot">
{!ready ? (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
) : (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
)}
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
</nav>
@@ -414,10 +506,14 @@ export default function App() {
</button>
</>
)}
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
{/* On mobile this lives in the bottom nav's Info tab instead —
see below — rather than crowding the topbar. */}
{status && !status.is_mobile && (
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
)}
</header>
{progress && (
@@ -458,7 +554,14 @@ export default function App() {
)}
<div className="content">
{!ready ? (
{!statusLoaded ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<p className="hint">Loading…</p>
</div>
) : !ready ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
@@ -493,11 +596,58 @@ export default function App() {
<LibraryBrowser onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch onOfferUndo={offerUndo} />
) : (
) : tab === "decks" ? (
<DeckView refreshSignal={deckRefreshSignal} />
) : (
// Mobile-only tab (see INFO_TAB) — the desktop equivalent is
// split between the topbar's Update card data button (hidden
// here on mobile) and the nav rail's footer summary (which
// has nowhere to live once the rail collapses to the bottom
// nav), so both land here together.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
{status?.library_path}
</div>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Update card data"}
</button>
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
)}
</div>
</div>
{/* Android-customary bottom tab bar — replaces the rail above on
narrow viewports (see the ≤720px CSS breakpoint), rather than the
rail collapsing to an icon strip in place. Hidden while the
initial status fetch is still pending: there's nothing to
navigate to yet, and showing it makes the loading screen look
further along than it is. Carries an extra "Info" tab beyond
`NAV` — card-database management, which lives in the topbar on
desktop instead (see the button below). */}
{statusLoaded && (
<nav className="bottom-nav">
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`bottom-nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
<span>{n.label}</span>
</button>
))}
</nav>
)}
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />

View File

@@ -138,7 +138,9 @@ export const Icons = {
/**
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
* command palette). Dismisses on Escape or backdrop click.
* command palette). Dismisses on Escape, backdrop click, or the title-bar
* close button — the explicit button matters most on mobile, where there's
* no Escape key and a `wide` dialog can leave little to no backdrop to tap.
*/
export function DialogFrame({
title,
@@ -161,7 +163,10 @@ export function DialogFrame({
return (
<div className="dialog-backdrop" onClick={onCancel}>
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
<h3 className="dialog-title">{title}</h3>
<div className="dialog-title-row">
<h3 className="dialog-title">{title}</h3>
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
</div>
{children}
</div>
</div>
@@ -1256,11 +1261,19 @@ export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => voi
export type ViewMode = "list" | "tiles";
/** View-mode state persisted to localStorage so it survives tab switches. */
/** View-mode state persisted to localStorage so it survives tab switches.
*
* With nothing persisted yet, the default itself is width-aware: List's wide
* multi-column table forces horizontal scrolling on a phone, so narrow
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
* This only picks the *default* — once a mode is explicitly chosen it's
* remembered and used everywhere, same as before. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(
() => (localStorage.getItem(key) as ViewMode) || "list",
);
const [mode, setMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(key) as ViewMode | null;
if (saved) return saved;
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list";
});
const set = useCallback(
(m: ViewMode) => {
localStorage.setItem(key, m);

View File

@@ -84,6 +84,9 @@
/* Interactive control height — bumped on coarse pointers (see bottom). */
--control-h: 34px;
/* Height of .bottom-nav's own content — excludes the safe-area inset added
on top of it, so consumers computing clearance must add that separately. */
--bottom-nav-h: 58px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
@@ -399,6 +402,57 @@ input[type="checkbox"] {
display: inline-flex;
color: inherit;
}
/* Android-style bottom tab bar — replaces .nav-rail on narrow viewports (see
the ≤720px block below), rather than the rail collapsing to an icon strip.
Hidden by default; enabled only in that breakpoint. Fixed to the viewport
(not `.app`) since `.app` uses `overflow: hidden` and z-indexes elsewhere
(dialogs 400+, the click-preview modal 450) are the reason this sits at
200 — above ordinary content, below anything that should cover the whole
screen. */
.bottom-nav {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
height: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--bg-rail);
border-top: 1px solid var(--border-soft);
}
.bottom-nav-item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
height: 100%;
padding: 0 2px;
border: none;
border-radius: 0;
background: transparent;
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.bottom-nav-item.active {
color: var(--accent);
}
.bottom-nav-item.active::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 3px;
border-radius: 0 0 3px 3px;
background: var(--accent);
}
.nav-foot {
margin-top: auto;
padding: 14px 10px 10px;
@@ -574,12 +628,19 @@ input[type="checkbox"] {
background: var(--bg-content);
}
.topbar {
height: 52px;
/* min-height (not height): on Android the window draws edge-to-edge behind
the status bar, so the safe-area inset has to ADD to the bar's height
rather than eat into it — a fixed height would squash the buttons under
the status bar instead of below it. Inert (0) anywhere without a system
bar to avoid, e.g. desktop. */
min-height: 52px;
padding-top: env(safe-area-inset-top);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
padding-left: 18px;
padding-right: 18px;
background: var(--bg-topbar);
border-bottom: 1px solid var(--border-soft);
}
@@ -715,6 +776,15 @@ input[type="checkbox"] {
margin: 4px 0 0;
}
/* Mobile's Info tab — card-database management, which lives in the topbar
instead on desktop (see INFO_TAB in App.tsx). */
.info-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 480px;
}
/* ============================================================
Typography helpers
============================================================ */
@@ -1056,8 +1126,15 @@ li:focus-within .row-actions {
max-height: 86vh;
overflow-y: auto;
}
.dialog-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.dialog-title {
margin: 0 0 10px;
margin: 0;
font-size: 16px;
font-weight: 600;
text-transform: none;
@@ -1363,6 +1440,107 @@ tbody tr {
tbody tr:hover {
background: rgba(255, 255, 255, 0.022);
}
/* On a phone, the List view's 8-column table has no room to lay out
side-by-side and forced horizontal scrolling of the whole page (not just
the table) — see the .content padding/overflow rules elsewhere, nothing
scoped the scroll to the table itself. Below this breakpoint each row
becomes its own stacked card instead: every cell drops to its own line,
headers (meaningless once stacked) are hidden, and cells that need a label
outside their normal column context get one from `data-label` (set in the
view components — see LibraryBrowser/CardSearch/DeckView's table JSX). */
@media (max-width: 720px) {
table,
tbody,
tr {
display: block;
width: 100%;
}
thead {
display: none;
}
table {
border-collapse: separate;
border-spacing: 0;
}
tbody tr {
position: relative;
overflow: hidden; /* contains .cell-thumb's float below */
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: var(--space-3);
padding-right: calc(var(--space-3) + 28px); /* clears .cell-check */
margin-bottom: var(--space-3);
}
tbody tr:hover {
background: var(--bg-panel);
}
td {
padding: 0;
border-bottom: none;
}
/* Selection checkbox: pinned to the row-card's corner instead of taking a
line of its own. */
.cell-check {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: auto;
}
/* Thumbnail floats so the name/type text wraps beside it — that's what
buys back the vertical space the old one-cell-per-line layout wasted. */
.cell-thumb {
float: left;
width: 56px;
margin-right: var(--space-3);
}
.cell-thumb .card-thumb,
.cell-thumb .thumb-empty {
display: block;
width: 56px;
height: auto;
}
td.card-name {
display: block;
font-size: var(--fs-body);
font-weight: 700;
color: var(--text-strong);
}
td.card-type {
display: block;
color: var(--muted);
font-size: var(--fs-caption);
}
/* Color pips and any other small cell (MV, Owned, In deck, Decks /
Location, ...) sit inline as wrapping chips instead of one per line —
as long as they fit within the floated thumbnail's height they tuck in
beside it, otherwise they wrap below it like normal text. */
td:has(.pips),
td[data-label] {
display: inline-flex;
align-items: center;
gap: 4px;
margin: var(--space-2) var(--space-3) 0 0;
font-size: var(--fs-caption);
}
td[data-label]::before {
content: attr(data-label) ":";
font-size: var(--fs-micro);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
}
/* Buttons (Add to deck, maybeboard toggle, ...) always get their own full
row below everything else. */
td:has(button) {
display: block;
clear: both;
margin-top: var(--space-2);
}
}
.over-assigned {
color: var(--warn);
font-weight: 600;
@@ -1459,6 +1637,28 @@ tbody tr:hover {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--space-4);
}
/* Wraps every field after "Name contains" so it can be hidden as a group on
narrow screens — a 9-10 field grid is a wall to scroll past on a phone
before any results appear. `display: contents` on the wrapper (rather than
e.g. a nested grid) keeps every field a direct child of `.filters` at every
width, so it doesn't disturb the shared grid's column sizing/auto-flow;
only the max-width:720px rule below actually hides anything. */
.filters-more {
display: contents;
}
/* Irrelevant above the breakpoint (every field is already visible there). */
.filters-toggle {
display: none;
}
@media (max-width: 720px) {
.filters-toggle {
display: inline-flex;
margin-top: var(--space-3);
}
.filters-more:not(.expanded) {
display: none;
}
}
/* The results toolbar strip at the bottom of the workspace panel: selection +
count on the left, group/sort/view on the right, divided from the filters. */
.workspace-bar {
@@ -1639,6 +1839,13 @@ tbody tr:hover {
justify-content: center;
overflow-y: auto;
padding: 24px 12px;
/* The close button pokes up above the (flex-centered) modal content, so on
a tall/near-fullscreen card image it can land right under the Android
status bar — same edge-to-edge issue as .topbar, same fix: pad the safe
area instead of a fixed inset. Degrades to the base 24px on anything
without a system bar to avoid. */
padding-top: calc(24px + env(safe-area-inset-top));
padding-bottom: calc(24px + env(safe-area-inset-bottom));
background: rgba(10, 10, 12, 0.72);
backdrop-filter: blur(2px);
cursor: zoom-out;
@@ -2036,6 +2243,10 @@ tbody tr:hover {
width: 240px;
flex-shrink: 0;
}
.deck-detail-pane {
flex: 1;
min-width: 0;
}
/* Surface from `.panel`; layout only here. */
.deck-meta {
@@ -2205,45 +2416,128 @@ tbody tr:hover {
}
}
/* Back button into the deck list — only meaningful once the list and the
deck-building pane become separate screens below, so it's hidden outside
that breakpoint (and on the tablet stacked layout, where both panes are
still visible together). */
.deck-back-btn {
display: none;
}
/* Compact: tighten page gutters and collapse the nav rail to an icon strip
(same items — labels, wordmark, and the library summary hide; hover/title
still names each tab). No separate mobile navigation is introduced. */
the left rail's job moves to .bottom-nav below instead, the customary
placement on Android). */
@media (max-width: 720px) {
.content {
padding: var(--space-4);
/* Clear the fixed bottom nav, or its last row of content is hidden
behind it — the safe-area inset is already baked into the nav's own
height, so it isn't added again here. */
padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4));
}
.nav-rail {
width: 60px;
padding: var(--space-3) var(--space-2) var(--space-2);
}
.brand {
justify-content: center;
padding: var(--space-1) 0 var(--space-4);
}
.brand-text {
display: none;
}
.nav-item {
justify-content: center;
padding: var(--space-2);
}
.nav-label {
display: none;
}
.nav-item.active::before {
left: -8px;
}
.nav-foot {
display: none;
.bottom-nav {
display: flex;
}
.topbar {
padding: 0 var(--space-3);
padding-left: var(--space-3);
padding-right: var(--space-3);
/* padding-top (the safe-area inset) is untouched here — this must not
reintroduce the shorthand `padding: 0 ...` that was zeroing it out. */
}
/* The library path is the first thing to go when horizontal space is tight —
the library actions stay reachable. */
.path {
display: none;
}
/* Deck view's header (name + Suggest/Export/Sample buttons) and several
panels below assume desktop width via `min-width` (280/220px) and a
non-wrapping `.row` — those don't fit a phone and forced the whole page
to scroll horizontally. Let everything wrap and drop the min-widths
instead. */
.row {
flex-wrap: wrap;
}
.commander-box,
.deck-stats-numbers,
.deck-stats-curve {
min-width: 0;
}
/* `.deck-meta` puts the Format selector and the commander search box side
by side as flex-wrap row items — with `.commander-box`'s min-width now
0, it no longer wraps onto its own line, it just gets squeezed into
whatever sliver is left next to "Format", crushing the search results.
Stack them instead. */
.deck-meta {
flex-direction: column;
align-items: stretch;
}
/* Deck list and deck-building pane become separate screens (only one
visible at a time) instead of stacking above one another — with many
decks, scrolling past the whole list to reach the builder doesn't
scale. `.deck-layout`'s show-list/show-detail class is toggled by
DeckView in response to picking a deck / the back button. */
.deck-back-btn {
display: inline-flex;
}
.deck-layout.show-list .deck-detail-pane {
display: none;
}
.deck-layout.show-detail .deck-sidebar {
display: none;
}
/* The Suggested Cards dialog: `.suggestion-actions`' two nowrap buttons
are flex-shrink:0, so they claimed their full fixed width no matter how
little room was left, crushing `.suggestion-main` down to almost
nothing — the name text then wrapped one word (sometimes one syllable)
per line instead of actually gaining any space. `flex-basis: 100%` on
`.suggestion-actions` forces it onto its own full-width line below the
thumbnail + name/reasons instead of competing with them for the same
line. */
.dialog-wide {
width: 96vw;
padding: 14px;
}
.suggestion-row {
flex-wrap: wrap;
}
.suggestion-head {
flex-wrap: wrap;
row-gap: 2px;
}
.suggestion-actions {
flex-basis: 100%;
flex-direction: row;
margin-top: 6px;
}
.suggestion-actions button {
flex: 1;
}
/* Clear the bottom nav the same way .content does, so the undo snackbar
doesn't land underneath it. */
.undo-bar {
bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-3));
}
/* The ingest bar's segmented layout assumes room for 4 side-by-side
stage labels — on a phone-width screen the tiny-weighted stages
("Index", "Indexing") can't fit theirs and it collides with its
neighbor's. Drop the per-segment labels (the header message above
already names the current stage in more detail) and let that header
message wrap instead of ellipsis-truncating, so status text like a
download's running byte count stays readable. */
.ingest-bar {
padding: var(--space-2) var(--space-3) var(--space-3);
}
.ingest-bar-message {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.ingest-segment-label {
display: none;
}
}
/* Touch input (future mobile / touchscreen laptops): grow tap targets to a

View File

@@ -50,6 +50,10 @@ export function CardSearch({
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [oracleText, setOracleText] = useState("");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
@@ -185,7 +189,7 @@ export function CardSearch({
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<td className="cell-thumb">
<CardThumb card={c} />
</td>
<td>
@@ -193,15 +197,15 @@ export function CardSearch({
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td>
<td data-label="MV">{c.mana_value}</td>
<td data-label="Owned">
<QtyStepper
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
</td>
<td>
<td data-label="In decks">
{c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
@@ -292,6 +296,7 @@ export function CardSearch({
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
@@ -351,6 +356,13 @@ export function CardSearch({
/>
</div>
</div>
</div>
<button
className="filters-toggle"
onClick={() => setFiltersExpanded((v) => !v)}
>
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
<div className="workspace-bar">
{hasActiveFilter && (

View File

@@ -346,6 +346,11 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [filterQuery, setFilterQuery] = useState("");
const [dialog, setDialog] = useState<DeckDialog>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
// On narrow screens the deck list and the deck-building interface are
// separate "screens" (only one visible at a time) rather than stacked —
// this only matters below the .deck-layout stacking breakpoint; CSS keeps
// both panes visible side by side above it regardless of this flag.
const [mobileShowList, setMobileShowList] = useState(true);
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState<CardRef[]>([]);
@@ -467,6 +472,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
setNewName("");
await loadDecks();
setSelected(d.id);
setMobileShowList(false);
}
async function exportDeck(d: Deck) {
@@ -497,6 +503,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
setDialogError(null);
await loadDecks();
setSelected(result.deck.id);
setMobileShowList(false);
} catch (err) {
setDialogError(friendlyDeckError(err));
}
@@ -638,7 +645,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
async function confirmDelete() {
if (dialog?.kind !== "delete") return;
await api.deleteDeck(dialog.deck.id);
if (selected === dialog.deck.id) setSelected(null);
if (selected === dialog.deck.id) {
setSelected(null);
setMobileShowList(true);
}
setDialog(null);
await loadDecks();
}
@@ -765,14 +775,14 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<tbody>
{entries.map((e) => (
<tr key={e.oracle_id}>
<td>
<td className="cell-check">
<input
type="checkbox"
checked={selectedCards.has(e.oracle_id)}
onChange={() => toggleCard(e.oracle_id)}
/>
</td>
<td>
<td className="cell-thumb">
<CardThumb card={e} />
</td>
<td>
@@ -783,7 +793,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
{e.name}
</td>
<td className="card-type">{e.type_line}</td>
<td>
<td data-label="In deck">
<QtyStepper
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
@@ -792,7 +802,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
pending={pendingRemoval.has(e.oracle_id)}
/>
</td>
<td>{e.quantity_owned}</td>
<td data-label="Owned">{e.quantity_owned}</td>
<td>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
</td>
@@ -818,7 +828,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
}
return (
<div className="deck-layout">
<div className={`deck-layout ${mobileShowList ? "show-list" : "show-detail"}`}>
<div className="deck-sidebar">
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>Decks</h2>
@@ -840,7 +850,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<li
key={d.id}
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
onClick={() => setSelected(d.id)}
onClick={() => {
setSelected(d.id);
setMobileShowList(false);
}}
>
<div className="deck-list-row">
<span className="card-name">{d.name}</span>
@@ -887,13 +900,22 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
</ul>
</div>
<div style={{ flex: 1 }}>
<div className="deck-detail-pane">
{selected === null || !deck ? (
<p className="hint">Select or create a deck.</p>
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>{deck.name}</h2>
<div className="row" style={{ gap: 8 }}>
<span className="deck-back-btn">
<IconButton
icon={Icons.arrowLeftCircle}
label="Back to decks"
onClick={() => setMobileShowList(true)}
/>
</span>
<h2>{deck.name}</h2>
</div>
<div className="row" style={{ gap: 8 }}>
<button
onClick={() => setSuggestOpen(true)}

View File

@@ -54,6 +54,10 @@ export function LibraryBrowser({
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
@@ -321,14 +325,14 @@ export function LibraryBrowser({
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<td className="cell-check">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td>
<td className="cell-thumb">
<CardThumb card={c} />
</td>
<td>
@@ -336,8 +340,11 @@ export function LibraryBrowser({
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
<td data-label="MV">{c.mana_value}</td>
<td
data-label="Owned"
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
>
<span className="row" style={{ gap: 8 }}>
<QtyStepper
value={c.quantity_owned}
@@ -353,7 +360,7 @@ export function LibraryBrowser({
)}
</span>
</td>
<td>{deckOrLocation(c)}</td>
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
@@ -522,6 +529,7 @@ export function LibraryBrowser({
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
@@ -600,6 +608,13 @@ export function LibraryBrowser({
/>
</div>
</div>
</div>
<button
className="filters-toggle"
onClick={() => setFiltersExpanded((v) => !v)}
>
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
</button>
<div className="workspace-bar">
<label className="workspace-bar-check" title="Select all filtered cards">