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

@@ -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