Files
mtgc/src/styles.css
Garrett Mills 04e9c3cbbb Rework library sync around a JSON document; add card filters
Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00

2639 lines
64 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
mtgc — design system
Terracotta accent (#C8553D) on a layered, Discord-inspired dark UI.
============================================================ */
:root {
/* Layered surfaces (darkest → lightest). A warm charcoal ramp (low-saturation
~28° hue) rather than cool blue-grey — reads as "collection in warm light"
and harmonizes with the terracotta accent. Elevation model:
rail/app (chrome) → content (page) → panel (surface) → input (inset). */
--bg-rail: #191714;
--bg-app: #1d1a16;
--bg-topbar: #221e19;
--bg-content: #27221d;
--bg-panel: #322c25;
--bg-input: #3b342d;
--bg-hover: #443c34;
--bg-elev: #4c433a;
--border: #3d372f;
--border-soft: rgba(255, 248, 240, 0.06);
/* Tactile 1px top inner-highlight for raised surfaces (see .panel). */
--panel-highlight: inset 0 1px 0 rgba(255, 246, 236, 0.045);
--text: #e3ddd3;
--text-strong: #f8f4ec;
--muted: #a69d90;
--faint: #7d7568;
/* Terracotta accent (hue unchanged — the app's signature) */
--accent: #c8553d;
--accent-hover: #d86e56;
--accent-active: #a8412d;
--accent-subtle: rgba(200, 85, 61, 0.15);
--accent-glow: rgba(200, 85, 61, 0.35);
/* Quiet accent for decorative/label use, so full terracotta stays reserved
for primary actions + active state (accent hierarchy). */
--accent-quiet: #c69a86;
/* Complementary / status */
--info: #46a6bd; /* teal complement */
--gold: #e0b24a;
--success: #4bb073;
--warn: #e0a34a;
--danger: #d8503f;
/* WUBRG mana colors, shared by pips + the library color-distribution bar */
--mtg-w: #f4efdd;
--mtg-u: #3a8fd0;
--mtg-b: #38343d;
--mtg-r: var(--accent);
--mtg-g: #479b63;
--mtg-c: #9aa0ab;
/* Spacing scale — every gap/margin/padding resolves to one of these. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 48px;
/* Radius scale */
--radius-sm: 6px;
--radius: 9px;
--radius-lg: 14px;
--radius-card: 12px; /* card-art corners (matches Scryfall image framing) */
--radius-pill: 999px;
/* Type scale */
--fs-micro: 10px; /* dense data labels (under stat numbers, chart axes) */
--fs-caption: 12px;
--fs-body: 14px;
--fs-label: 11px; /* canonical uppercase micro-label, see .section-label */
--fs-title: 16px;
--fs-display: 21px;
--tracking-label: 0.06em;
/* Motion */
--dur-fast: 0.12s;
--dur: 0.2s;
/* 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);
--shadow-lg: 0 14px 44px rgba(0, 0, 0, 0.55);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
background: var(--bg-app);
color: var(--text);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-subtle);
}
/* Thin, unobtrusive scrollbars */
*::-webkit-scrollbar {
width: 11px;
height: 11px;
}
*::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* -------- Controls -------- */
button {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 13px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s;
}
button svg {
flex-shrink: 0;
}
button:hover {
background: var(--bg-hover);
border-color: var(--bg-elev);
}
button:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
button.primary {
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
border: none;
color: #fff;
box-shadow: var(--shadow-sm);
}
button.primary:hover {
filter: brightness(1.07);
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
}
button.primary:active {
background: var(--accent-active);
}
button.danger {
background: var(--danger);
border: none;
color: #fff;
box-shadow: var(--shadow-sm);
}
button.danger:hover {
filter: brightness(1.1);
}
button.danger:active {
filter: brightness(0.92);
}
button.big {
padding: 11px 22px;
font-size: 16px;
border-radius: 10px;
}
input,
select {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 11px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
}
input::placeholder {
color: var(--faint);
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
/* Native <select> chrome (height, arrow, popup panel) ignores color/border
overrides unless its OS-drawn appearance is dropped first. */
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 30px;
cursor: pointer;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239a9ea8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 14px;
}
select:hover {
border-color: var(--bg-elev);
}
/* The dropdown's own option list stays OS-native (no cross-browser hook to
restyle it), but at least match its swatch to the app's dark theme. */
select option {
background: var(--bg-panel);
color: var(--text);
}
input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
/* ============================================================
Primitives: shared surface / label / page-header building blocks.
These replace treatments that were previously hand-copied (with drift)
across the views — edit here to change all of them at once.
============================================================ */
/* Canonical raised surface. Replaces the ~6 hand-copied panel treatments
(deck-meta, commander-hero, deck-stats, filters, legality-tile base,
preview-info). The top inner-highlight gives a subtle tactile edge. */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--panel-highlight);
}
/* Recessed variant for wells nested inside a panel. */
.panel-inset {
background: var(--bg-app);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: var(--space-3);
}
/* Canonical uppercase section marker (panel/section headers). Uppercase +
tracking reads as structural; `--muted` (not faint) keeps it legible. */
.section-label {
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--muted);
}
/* Standard page header: title on the left, control cluster on the right,
wrapping onto two lines on a narrow viewport. Replaces the ad-hoc
`<div class="row" style="justify-content:space-between">` each view used. */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.page-header-title {
display: flex;
align-items: center;
gap: var(--space-3);
margin: 0;
min-width: 0;
}
/* The right-hand control cluster (group-by + sort + view toggle + page
actions). Also usable standalone. */
.page-header-actions,
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-4);
}
/* ============================================================
App shell: nav rail + main
============================================================ */
.app {
display: flex;
/* `dvh` tracks the *dynamic* viewport, so an on-screen keyboard (or a mobile
browser's collapsing chrome) shrinks the shell instead of pushing content
out of an over-tall, unscrollable `100vh` box. Falls back for older
engines that don't know dvh. */
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.nav-rail {
width: 220px;
flex-shrink: 0;
background: var(--bg-rail);
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
padding: 14px 10px 10px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 4px 8px 16px;
}
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--bg-elev), var(--bg-rail));
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
}
.brand-text {
font-weight: 700;
font-size: 16px;
color: var(--text-strong);
letter-spacing: 0.2px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 9px 12px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
font-size: 15px;
font-weight: 550;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.nav-item.active {
background: var(--accent-subtle);
color: var(--text-strong);
}
.nav-item.active::before {
content: "";
position: absolute;
left: -10px;
top: 9px;
bottom: 9px;
width: 4px;
border-radius: 0 4px 4px 0;
background: var(--accent);
}
.nav-icon {
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;
border-top: 1px solid var(--border-soft);
position: relative;
}
.nav-foot::before {
/* A faint accent hairline atop the divider, echoing the active-tab bar. */
content: "";
position: absolute;
top: -1px;
left: 10px;
right: 10px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-glow), transparent);
}
.nav-foot-simple {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.on {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.off {
background: var(--faint);
}
/* Sidebar "your library" summary panel. */
.lib-stats-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.lib-stats-title {
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--muted);
}
.lib-stats-loading {
font-size: 12px;
color: var(--faint);
padding-bottom: 10px;
}
/* Generic numeric-stat tile row + segmented color bar — used by both the
sidebar "Your library" panel and the per-deck analysis panel. */
.stat-tiles-row {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.stat-tile {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 2px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
transition: border-color 0.15s, transform 0.15s;
}
.stat-tile:hover {
border-color: var(--accent-glow);
transform: translateY(-1px);
}
.stat-tile-value {
font-size: 17px;
font-weight: 800;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
text-shadow: 0 0 14px var(--accent-glow);
}
.stat-tile-label {
font-size: var(--fs-micro);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
}
.color-bar {
display: flex;
height: 8px;
border-radius: 999px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
margin-bottom: 9px;
}
.color-bar-seg {
min-width: 3px;
transition: flex-grow 0.4s ease;
}
.color-bar-seg.W { background: var(--mtg-w); }
.color-bar-seg.U { background: var(--mtg-u); }
.color-bar-seg.B { background: var(--mtg-b); }
.color-bar-seg.R { background: var(--mtg-r); }
.color-bar-seg.G { background: var(--mtg-g); }
.color-bar-seg.C { background: var(--mtg-c); }
/* Mana-value curve histogram (per-deck analysis panel). */
.mana-curve {
display: flex;
align-items: flex-end;
gap: 6px;
flex: 1;
min-height: 84px;
margin-top: 4px;
}
.mana-curve-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
gap: 3px;
}
.mana-curve-count {
font-size: 10px;
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.mana-curve-bar {
width: 100%;
min-height: 2px;
border-radius: 3px 3px 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: height 0.25s ease;
}
.mana-curve-seg {
width: 100%;
min-height: 1px;
}
.mana-curve-seg.W { background: var(--mtg-w); }
.mana-curve-seg.U { background: var(--mtg-u); }
.mana-curve-seg.B { background: var(--mtg-b); }
.mana-curve-seg.R { background: var(--mtg-r); }
.mana-curve-seg.G { background: var(--mtg-g); }
.mana-curve-seg.C { background: var(--mtg-c); }
.mana-curve-label {
font-size: 10px;
color: var(--faint);
}
.lib-stats-sub {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: var(--faint);
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-content);
}
.topbar {
/* 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-left: 18px;
padding-right: 18px;
background: var(--bg-topbar);
border-bottom: 1px solid var(--border-soft);
}
.path {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--faint);
background: rgba(0, 0, 0, 0.25);
padding: 4px 10px;
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spacer {
flex: 1;
}
/* Ingest (card-data download/index) progress: one multi-segment bar spanning
all stages, below the topbar. */
.ingest-bar {
padding: 8px 18px 10px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border-soft);
font-size: 13px;
}
.ingest-bar-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.ingest-bar-message {
color: var(--muted);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ingest-bar-cancel {
flex-shrink: 0;
padding: 3px 10px;
font-size: 12px;
color: var(--muted);
}
.ingest-bar-cancel:hover {
color: var(--danger);
border-color: var(--danger);
}
.ingest-segments {
display: flex;
gap: 4px;
}
.ingest-segment {
min-width: 0;
}
.ingest-segment-track {
height: 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.ingest-segment-fill {
height: 100%;
border-radius: 999px;
background: var(--border);
transition: width 0.2s ease;
}
.ingest-segment.active .ingest-segment-fill {
background: linear-gradient(90deg, var(--accent-hover), var(--accent));
}
.ingest-segment-label {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ingest-segment.active .ingest-segment-label {
color: var(--accent-hover);
font-weight: 600;
}
.ingest-bar-done .ingest-segment-fill {
background: var(--success);
}
.ingest-bar-done .ingest-bar-message {
color: var(--success);
}
.ingest-bar-error .ingest-segment-fill {
background: var(--danger);
}
.ingest-bar-error .ingest-bar-message {
color: var(--danger);
}
.ingest-bar-cancelled .ingest-segment-fill {
background: var(--warn);
}
.ingest-bar-cancelled .ingest-bar-message {
color: var(--warn);
}
.content {
flex: 1;
overflow: auto;
padding: var(--space-5) var(--space-6);
}
/* Empty / welcome state */
.empty-state {
max-width: 480px;
margin: 9vh auto 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.empty-mark {
width: 88px;
height: 88px;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(150deg, var(--bg-elev), var(--bg-rail));
box-shadow: 0 10px 30px var(--accent-glow);
}
.empty-state h2 {
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;
}
/* The currently chosen synced folder. */
.sync-folder {
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-strong);
overflow-wrap: anywhere;
}
/* Outcome of the synced-folder self-test. Not a `.hint`: whether sync works is
the point of the section, so it reads at body size rather than as
de-emphasized supporting text. */
.sync-ok,
.sync-bad {
font-size: var(--fs-body);
line-height: 1.45;
overflow-wrap: anywhere;
}
.sync-ok {
color: var(--success);
}
.sync-bad {
color: var(--danger);
}
/* ============================================================
Typography helpers
============================================================ */
h2 {
margin: var(--space-1) 0 var(--space-4);
font-size: var(--fs-display);
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.2px;
}
h3 {
margin: var(--space-5) 0 var(--space-3);
font-size: var(--fs-title);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-strong);
border-bottom: 2px solid var(--border-soft);
padding-bottom: var(--space-3);
}
h4 {
margin: var(--space-4) 0 var(--space-2);
font-size: var(--fs-body);
font-weight: 600;
/* Quiet accent — full terracotta is reserved for primary actions. */
color: var(--accent-quiet);
}
/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a
* section) — sticky within .content's scroll area so the current group
* stays visible while scrolling through a long list, instead of scrolling
* out of view along with its cards. Applied via class (not the bare h3/h4
* tag) since h3 is also used for .dialog-title, which must not be sticky. */
.group-header {
position: sticky;
top: 0;
z-index: 5;
margin: var(--space-4) 0 var(--space-2);
padding: var(--space-2) 0;
background: var(--bg-app);
font-size: var(--fs-body);
font-weight: 700;
text-transform: none;
letter-spacing: normal;
color: var(--text-strong);
/* Quiet warm underline rather than full terracotta (accent hierarchy). */
border-bottom: 2px solid var(--accent-quiet);
}
.muted {
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
background: var(--accent-subtle);
color: var(--accent-quiet);
border-radius: var(--radius-pill);
padding: 1px var(--space-2);
font-size: var(--fs-caption);
font-weight: 700;
}
/* Deck format-legality panel: a prominent, colorful tile, matching
.commander-hero / .deck-stats in weight, above the fold on every deck. */
.legality-tile {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.legality-tile.legal {
background: rgba(75, 176, 115, 0.16);
border-color: rgba(75, 176, 115, 0.4);
}
.legality-tile.illegal {
background: rgba(216, 80, 63, 0.16);
border-color: rgba(216, 80, 63, 0.4);
}
.legality-tile.unchecked {
background: var(--bg-panel);
border-color: var(--border-soft);
}
.legality-tile-head {
display: flex;
align-items: center;
gap: 14px;
}
.legality-tile-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 999px;
font-size: 20px;
font-weight: 700;
}
.legality-tile.legal .legality-tile-icon {
background: var(--success);
color: #fff;
}
.legality-tile.illegal .legality-tile-icon {
background: var(--danger);
color: #fff;
}
.legality-tile.unchecked .legality-tile-icon {
background: var(--muted);
color: var(--bg-panel);
}
.legality-tile-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.legality-tile-status {
font-weight: 700;
font-size: 14px;
}
.legality-tile.legal .legality-tile-status {
color: var(--success);
}
.legality-tile.illegal .legality-tile-status {
color: var(--danger);
}
.legality-tile-detail {
font-size: 12px;
color: var(--muted);
}
.legality-tile-reasons {
margin: 0 0 0 54px;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text);
}
/* ============================================================
Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards
and both Library views (list + tile) for a consistent look.
============================================================ */
.qty-stepper {
display: inline-flex;
align-items: center;
height: 26px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
overflow: hidden;
transition: opacity 0.25s ease, background 0.15s ease, border-color 0.15s ease;
}
.qty-stepper-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 100%;
padding: 0;
background: none;
border: none;
border-radius: 0;
color: var(--muted);
font-size: 16px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.qty-stepper-btn:hover:not(:disabled) {
background: var(--accent-subtle);
color: var(--accent-hover);
}
.qty-stepper-btn:active:not(:disabled) {
background: var(--accent);
color: #fff;
transform: none;
}
.qty-stepper-btn:disabled {
opacity: 0.3;
cursor: default;
}
.qty-stepper-value {
min-width: 22px;
text-align: center;
padding: 0 2px;
font-size: 14px;
font-weight: 700;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
}
.qty-stepper.warn {
border-color: var(--warn);
}
.qty-stepper.warn .qty-stepper-value {
color: var(--warn);
}
.qty-stepper.pending {
opacity: 0.5;
}
/* Overlay variant: dark translucent pill for use atop card art. */
.qty-stepper-overlay {
height: 24px;
background: rgba(0, 0, 0, 0.78);
border: none;
backdrop-filter: blur(2px);
}
.qty-stepper-overlay .qty-stepper-btn {
width: 22px;
color: rgba(255, 255, 255, 0.85);
}
.qty-stepper-overlay .qty-stepper-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.qty-stepper-overlay .qty-stepper-btn:active:not(:disabled) {
background: var(--accent);
color: #fff;
}
.qty-stepper-overlay .qty-stepper-value {
color: #fff;
}
.qty-stepper-overlay.warn {
background: var(--warn);
}
.qty-stepper-overlay.warn .qty-stepper-value,
.qty-stepper-overlay.warn .qty-stepper-btn {
color: #241700;
}
.qty-stepper-overlay.warn .qty-stepper-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.12);
}
/* ============================================================
Icon buttons: rename, duplicate, delete, dismiss, close — one
consistent look used everywhere a bare icon-only action appears.
============================================================ */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--muted);
}
.icon-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
border-color: var(--bg-elev);
}
.icon-btn:active:not(:disabled) {
transform: translateY(1px);
}
.icon-btn:disabled {
opacity: 0.35;
}
.icon-btn.icon-btn-danger:hover:not(:disabled) {
background: rgba(216, 80, 63, 0.15);
color: var(--danger);
border-color: var(--danger);
}
/* Overlay variant: dark translucent, for sitting on top of card art. */
.icon-btn-overlay {
width: 26px;
height: 26px;
background: rgba(0, 0, 0, 0.78);
border: none;
color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(2px);
}
.icon-btn-overlay:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.icon-btn-overlay.icon-btn-danger:hover:not(:disabled) {
background: var(--danger);
color: #fff;
}
/* Row-level action icons (e.g. rename/duplicate/delete a deck): hidden until
* the row is hovered/focused, so the collapsed list stays uncluttered. */
.row-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
li:hover .row-actions,
li:focus-within .row-actions {
opacity: 1;
}
/* ============================================================
Dialogs: confirm (delete) / prompt (rename, duplicate-as)
============================================================ */
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 10, 12, 0.72);
backdrop-filter: blur(2px);
animation: preview-fade-in 0.12s ease;
}
.dialog {
width: min(380px, 90vw);
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-lg);
}
.dialog-wide {
width: min(900px, 92vw);
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;
font-size: 16px;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
color: var(--text-strong);
border-bottom: none;
padding-bottom: 0;
}
.dialog-message {
margin: 0 0 18px;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.dialog-list {
margin: 0 0 18px;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
overflow-y: auto;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.dialog-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.dialog-field input {
width: 100%;
}
.dialog-error {
margin: -6px 0 14px;
color: var(--danger);
font-size: 13px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ============================================================
Lists (search results, deck list)
============================================================ */
.result-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
}
.result-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
transition: background 0.1s;
}
.result-list li:last-child {
border-bottom: none;
}
.result-list li:hover {
background: var(--bg-hover);
}
.card-name {
font-weight: 600;
color: var(--text-strong);
}
/* Deck list: name+format on one row, actions on their own row below, so
* long names don't push the action icons off the edge of the sidebar. */
.result-list.deck-list li {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.deck-list-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.deck-list-row .card-name {
overflow-wrap: break-word;
}
.deck-list-actions-row {
justify-content: space-between;
}
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
* synergy reason chips per row, with stacked actions on the right. Denser
* than the default .result-list row (smaller padding/gaps, chips instead of
* a bulleted list) since a card can carry several reasons at once. */
.result-list.suggestion-list li {
cursor: default;
align-items: stretch;
padding: 8px 12px;
}
.suggestion-row {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.suggestion-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.suggestion-head {
display: flex;
align-items: center;
gap: 6px;
}
.suggestion-score {
font-weight: 700;
color: var(--accent-hover);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.suggestion-reasons {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 999px;
padding: 1px 8px 1px 2px;
font-size: 11px;
line-height: 1.6;
background: var(--bg-elev);
color: var(--muted);
}
.reason-chip-category {
border-radius: 999px;
padding: 1px 7px;
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #fff;
}
.reason-chip-tribal .reason-chip-category {
background: var(--accent);
}
.reason-chip-mechanic .reason-chip-category {
background: var(--mtg-u);
}
.reason-chip-archetype .reason-chip-category {
background: var(--warn);
}
.reason-chip-mana-base .reason-chip-category {
background: var(--success);
}
.suggestion-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.suggestion-actions button {
font-size: 12px;
padding: 5px 10px;
white-space: nowrap;
}
/* Toggleable "filter by why" chips above the suggestion list — same color
* coding as .reason-chip-category, but as clickable, stateful buttons. */
.suggestion-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.filter-chip {
border-radius: 999px;
padding: 3px 12px;
font-size: 12px;
font-weight: 600;
background: var(--bg-elev);
color: var(--muted);
border: 1px solid var(--border-soft);
}
.filter-chip:hover {
background: var(--bg-hover);
}
.filter-chip.active {
color: #fff;
border-color: transparent;
}
.filter-chip-tribal.active {
background: var(--accent);
}
.filter-chip-mechanic.active {
background: var(--mtg-u);
}
.filter-chip-archetype.active {
background: var(--warn);
}
.filter-chip-mana-base.active {
background: var(--success);
}
.filter-chip-clear {
background: none;
border: none;
color: var(--muted);
font-size: 12px;
text-decoration: underline;
padding: 3px 4px;
}
.card-type {
color: var(--muted);
font-size: 13px;
}
/* ============================================================
Color pips (WUBRG)
============================================================ */
.pips {
display: inline-flex;
gap: 3px;
}
.pip {
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 11px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.pip.W { background: var(--mtg-w); color: #4a3f2a; }
.pip.U { background: var(--mtg-u); color: #fff; }
.pip.B { background: var(--mtg-b); color: #cbc3d4; }
.pip.R { background: var(--mtg-r); color: #fff; }
.pip.G { background: var(--mtg-g); color: #fff; }
.pip.C { background: var(--mtg-c); color: #23242a; }
.color-toggles {
display: flex;
gap: 5px;
}
.color-toggles .pip {
cursor: pointer;
opacity: 0.32;
transition: opacity 0.1s, box-shadow 0.1s;
}
.color-toggles .pip.on {
opacity: 1;
box-shadow: 0 0 0 2px var(--accent-hover);
}
/* ============================================================
Tables
============================================================ */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--muted);
position: sticky;
top: 0;
background: var(--bg-content);
border-bottom: 1px solid var(--border);
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
vertical-align: middle;
}
tbody tr {
transition: background 0.08s;
}
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;
}
.deck-chip {
display: inline-flex;
align-items: center;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: var(--text);
margin-right: 4px;
}
/* ============================================================
Bulk-selection action bar (Library tab)
============================================================ */
.bulk-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
background: var(--accent-subtle);
border: 1px solid var(--accent-glow);
border-radius: var(--radius-lg);
padding: 10px 16px;
margin-bottom: 16px;
}
.bulk-bar-count {
font-weight: 700;
font-size: 13px;
color: var(--accent-hover);
white-space: nowrap;
}
.bulk-bar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.bulk-bar-delta {
width: 48px;
text-align: center;
padding: 8px 4px;
}
/* Floating "Undo" snackbar (e.g. after a bulk add-to-deck). Fixed so it stays
reachable regardless of scroll position; sits below dialogs (z-index 400+)
since it's only ever shown against the current view, not over a modal. */
.undo-bar {
position: fixed;
left: 50%;
bottom: 28px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 10px 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 300;
}
.undo-bar-close {
background: transparent;
border: none;
color: var(--muted);
font-size: 16px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
}
.undo-bar-close:hover {
color: var(--text-strong);
}
/* ============================================================
Filters panel
============================================================ */
/* The workspace panel unifies the filter grid and the results toolbar into
one surface (Library + Card Search), so the display controls read as part
of the filtering apparatus instead of floating loosely above it. */
.workspace {
margin-bottom: var(--space-4);
}
/* Just the responsive field grid; surface comes from the wrapping `.panel`. */
.filters {
display: grid;
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 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border-soft);
}
.workspace-bar-check {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--fs-caption);
color: var(--muted);
}
.workspace-bar-count {
font-size: var(--fs-caption);
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Form field labels: sentence-case and higher-contrast rather than tiny faint
all-caps — clearer to scan above each input than a structural section marker. */
.field > label {
font-size: var(--fs-caption);
font-weight: 600;
color: var(--muted);
}
/* Compact icon filter toggles, following .color-toggles' visual grammar: dim
when off, lit with an accent ring when on. Replaces a stack of labelled
checkboxes that ate most of the filter panel's height — the explanations
moved into tooltips. */
.icon-toggles {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.icon-toggle {
position: relative;
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: var(--bg-elev);
color: var(--text);
opacity: 0.4;
cursor: pointer;
transition: opacity 0.1s, box-shadow 0.1s, color 0.1s;
}
.icon-toggle:hover {
opacity: 0.75;
}
.icon-toggle.on {
opacity: 1;
box-shadow: 0 0 0 2px var(--accent-hover);
}
/* Removing cards should not look like selecting them. */
.icon-toggle.exclude.on {
color: var(--danger);
box-shadow: 0 0 0 2px var(--danger);
}
/* Diagonal bar across an active exclusion, so "hidden" reads without the
tooltip. */
.icon-toggle-slash {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom right,
transparent calc(50% - 1px),
currentColor calc(50% - 1px),
currentColor calc(50% + 1px),
transparent calc(50% + 1px)
);
}
/* Separates the exclusions from the selecting filter beside them. */
.icon-toggles-sep {
width: 1px;
align-self: stretch;
margin: 2px 3px;
background: var(--border-soft);
}
.location-input {
padding: 4px 8px;
font-size: 13px;
max-width: 160px;
}
/* ============================================================
TagInput: chip-based multi-value filter with autocomplete
(type/subtype/keyword filters in the Library tab)
============================================================ */
.tag-input {
position: relative;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-height: 34px;
padding: 5px 8px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color 0.12s, box-shadow 0.12s;
}
.tag-chips:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--accent-subtle);
color: var(--accent-hover);
border-radius: 999px;
padding: 2px 4px 2px 10px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: inherit;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.25);
}
.tag-chip-input {
flex: 1;
min-width: 70px;
border: none;
background: none;
padding: 3px 2px;
font-size: 14px;
}
.tag-chip-input:focus {
outline: none;
border: none;
box-shadow: none;
}
.tag-suggestions {
position: absolute;
z-index: 50;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
}
/* ============================================================
Card images: thumbnails, hover preview, tiles
============================================================ */
.card-thumb {
width: 40px;
height: 56px;
object-fit: cover;
border-radius: 5px;
display: block;
background: var(--bg-elev);
cursor: zoom-in;
box-shadow: var(--shadow-sm);
transition: transform 0.1s, box-shadow 0.1s;
}
.card-thumb:hover {
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--accent-hover), var(--shadow-md);
}
.thumb-empty {
display: inline-flex;
width: 40px;
height: 56px;
align-items: center;
justify-content: center;
color: var(--faint);
border: 1px dashed var(--border);
border-radius: 5px;
font-size: 19px;
}
.hover-card {
position: fixed;
/* Above .dialog-backdrop (400) — cards inside a dialog (e.g. the
suggestions modal) can be hovered for a preview without it appearing
behind the dialog it's in. */
z-index: 450;
border-radius: 14px;
overflow: hidden;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.hover-card-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-modal-backdrop {
position: fixed;
inset: 0;
/* Above .dialog-backdrop (400) — a card can now be clicked to zoom from
inside a dialog (e.g. the sample hand modal), and the zoomed preview
must stay on top of the dialog it was opened from. */
z-index: 450;
display: flex;
align-items: center;
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;
animation: preview-fade-in 0.12s ease;
}
@keyframes preview-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* The info tile (see `.preview-info`) sits beside the card art whenever
there's room, and wraps onto its own line below when there isn't —
plain flex-wrap rather than a hand-tuned width breakpoint, so neither the
art nor the info tile ever has to shrink to make the other fit. Any
remaining vertical overflow (e.g. a double-faced card's full-size faces
plus a tall info tile on a short window) is handled by
`.preview-modal-backdrop`'s own scrolling, not by clipping content. */
.preview-modal {
position: relative;
cursor: default;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
max-width: 96vw;
}
.preview-modal-faces {
position: relative;
}
.preview-modal-img {
display: none;
max-width: min(720px, 90vw);
max-height: 90vh;
border-radius: 18px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.preview-modal-img.active {
display: block;
}
/* Horizontal cards (Battles/Planes/Phenomena, see `CardRef.horizontal`): the
stored image is portrait-shaped with the content itself rotated. The
wrapper's width/height (landscape) and the inner img's width/height
(the same box transposed, pre-rotation) are computed in JS —
`useRotatedPreviewBox` — and passed in as inline styles; this just
positions/rotates the img to fill the wrapper exactly. */
.preview-modal-img-rotated {
position: relative;
overflow: hidden;
/* Override `.preview-modal-img`'s portrait-oriented max-width/max-height —
they'd otherwise clamp this wrapper's JS-computed (correctly landscape)
inline width/height down, while the un-clamped rotated img inside stays
at its full computed size and overflows, getting cut off by the
`overflow: hidden` above instead of shrinking to fit. */
max-width: none;
max-height: none;
}
.preview-modal-rotate-img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
}
/* Wide enough to show a double-faced card's front and back side by side —
no flip needed. */
@media (min-width: 860px) {
.preview-modal-dfc .preview-modal-faces {
display: flex;
gap: 20px;
}
.preview-modal-dfc .preview-modal-img {
display: block;
max-width: min(430px, 43vw);
}
/* A horizontal front face (e.g. a transformed Battle) shown side-by-side
with its back face: keep using the JS-computed inline width/height
(see `.preview-modal-img-rotated` above) rather than the narrower DFC
column width, which would clamp the wrapper and crop the rotated image
the same way the base max-width/max-height would. */
.preview-modal-dfc .preview-modal-img-rotated {
max-width: none;
}
.preview-modal-dfc .preview-modal-flip {
display: none;
}
}
.preview-modal-close {
position: absolute;
top: -14px;
right: -14px;
}
.preview-modal-close .icon-btn {
box-shadow: var(--shadow-md);
}
.preview-modal-close .icon-btn:hover {
background: var(--danger);
color: #fff;
}
.preview-modal-flip {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
padding: 7px 16px;
border-radius: 999px;
background: rgba(20, 20, 24, 0.75);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.15);
font-size: 13px;
font-weight: 600;
backdrop-filter: blur(4px);
}
.preview-modal-flip:hover {
background: var(--accent);
border-color: var(--accent);
}
.preview-info {
width: min(320px, 90vw);
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-lg), var(--panel-highlight);
color: var(--text);
}
.preview-info-name {
font-size: 18px;
font-weight: 700;
line-height: 1.25;
color: var(--text-strong);
}
.preview-info-type {
font-size: 13px;
color: var(--muted);
}
.preview-info-row {
display: flex;
align-items: center;
gap: 10px;
}
.preview-info-mv {
font-size: 13px;
font-weight: 600;
}
.preview-info-price {
font-size: 16px;
font-weight: 700;
color: var(--gold);
}
.preview-info-section {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 10px;
border-top: 1px solid var(--border-soft);
}
.preview-info-label {
font-size: var(--fs-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--muted);
}
.preview-info-deck-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview-info-legal-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.legal-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
white-space: nowrap;
}
.legal-pill.legal-legal {
background: rgba(75, 176, 115, 0.16);
border-color: rgba(75, 176, 115, 0.4);
color: var(--success);
}
.legal-pill.legal-restricted {
background: rgba(224, 163, 74, 0.16);
border-color: rgba(224, 163, 74, 0.4);
color: var(--warn);
}
.legal-pill.legal-banned {
background: rgba(216, 80, 63, 0.16);
border-color: rgba(216, 80, 63, 0.4);
color: var(--danger);
text-decoration: line-through;
}
.legal-pill.legal-not_legal {
border-color: var(--border-soft);
color: var(--faint);
opacity: 0.55;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 16px;
}
.tile {
display: flex;
flex-direction: column;
gap: 7px;
}
.tile-img-wrap {
position: relative;
}
.tile-img {
width: 100%;
display: block;
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
cursor: zoom-in;
transition: transform var(--dur-fast), box-shadow var(--dur-fast);
}
.tile:hover .tile-img {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.tile-noimg {
aspect-ratio: 488 / 680;
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-panel);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 14px;
font-weight: 600;
color: var(--muted);
}
.tile-corner {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
/* The card art's own mana cost prints in this corner — only cover it while
the tile is actually being interacted with. */
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner,
.tile:focus-within .tile-corner {
opacity: 1;
}
.tile-corner-left {
position: absolute;
top: 8px;
left: 8px;
/* Same reasoning as .tile-corner: don't cover the card name unless the
tile is being interacted with — except once selected, where the
checkbox should stay visible so the selection is visible at a glance. */
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-left,
.tile:focus-within .tile-corner-left,
.tile-selected .tile-corner-left {
opacity: 1;
}
.tile-corner-bottom-right {
position: absolute;
bottom: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-right,
.tile:focus-within .tile-corner-bottom-right {
opacity: 1;
}
.tile-corner-bottom-left {
position: absolute;
bottom: 8px;
left: 8px;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-corner-bottom-left,
.tile:focus-within .tile-corner-bottom-left {
opacity: 1;
}
.tile-selected .tile-img {
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
}
.tile-select {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(2px);
}
.tile-badge {
background: rgba(0, 0, 0, 0.78);
color: #fff;
border-radius: 8px;
padding: 2px 9px;
font-size: 13px;
font-weight: 700;
backdrop-filter: blur(2px);
}
.tile-badge.warn {
background: var(--warn);
color: #241700;
}
.tile-footer {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* ============================================================
Toolbar controls: group-by + view toggle
============================================================ */
.group-by {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--muted);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 30px;
height: 30px;
padding: 0;
font-size: 15px;
}
.view-toggle {
display: inline-flex;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-toggle button {
border: none;
border-radius: 0;
background: transparent;
font-size: 13px;
padding: 5px 12px;
color: var(--muted);
}
.view-toggle button:hover {
background: rgba(255, 255, 255, 0.04);
}
.view-toggle button.active {
background: var(--accent);
color: #fff;
}
/* ============================================================
Deck metadata (format + commander)
============================================================ */
/* Two-pane deck screen: deck list + selected-deck detail. Stacks below the
tablet breakpoint (see the responsive section) so the list sits above the
detail on narrow/portrait viewports. */
.deck-layout {
display: flex;
gap: var(--space-5);
align-items: flex-start;
}
.deck-sidebar {
width: 240px;
flex-shrink: 0;
}
.deck-detail-pane {
flex: 1;
min-width: 0;
}
/* Surface from `.panel`; layout only here. */
.deck-meta {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-5);
}
/* Separates the deck-level info panels (commander, format, analysis,
legality) from the card-management controls + lists below. */
.deck-section-divider {
border: none;
border-top: 1px solid var(--border-soft);
margin: 4px 0 18px;
}
/* The four top-of-page info panels (commander, meta, analysis, legality).
Single column matches the existing top-to-bottom reading order on
narrow/portrait viewports; on wide viewports the compact panels move
into a side column so the analysis panel (the one panel that benefits
from extra width) gets the rest of the row instead of everything
stretching out with wasted horizontal space. grid-template-areas (not
flex wrapping) is what lets the two-column arrangement reorder visually
without touching DOM order. */
.deck-top-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"commander"
"meta"
"stats"
"legality";
gap: 16px;
margin-bottom: 16px;
}
.deck-top-commander {
grid-area: commander;
}
.deck-top-meta {
grid-area: meta;
}
.deck-top-stats {
grid-area: stats;
}
.deck-top-legality {
grid-area: legality;
}
@media (min-width: 1100px) {
.deck-top-grid {
grid-template-columns: minmax(360px, 560px) 1fr;
grid-template-areas:
"commander stats"
"meta stats"
"legality stats";
align-items: start;
}
}
.commander-box {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 280px;
flex: 1;
}
/* Prominent commander card display, above the fold on a Commander/Brawl deck.
Surface from `.panel`; layout only here. */
.commander-hero {
display: flex;
align-items: center;
gap: var(--space-4);
}
.commander-hero-img {
width: 84px;
height: 117px;
flex-shrink: 0;
object-fit: cover;
border-radius: 8px;
cursor: zoom-in;
}
.commander-hero-noimg {
width: 84px;
height: 117px;
flex-shrink: 0;
border: 1px dashed var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 8px;
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.commander-hero-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.commander-hero-label {
font-size: var(--fs-micro);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--tracking-label);
/* Quiet accent keeps a hint of brand on the hero without competing with
primary-action terracotta elsewhere. */
color: var(--accent-quiet);
}
.commander-hero-name {
font-size: var(--fs-display);
font-weight: 700;
color: var(--text-strong);
}
.commander-hero-type {
font-size: 12px;
color: var(--muted);
}
/* Per-deck analysis panel. Surface from `.panel`. */
.deck-stats-head {
margin-bottom: var(--space-3);
}
.deck-stats-body {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.deck-stats-numbers {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 220px;
}
.deck-stats-curve {
flex: 1;
min-width: 220px;
max-width: 420px;
display: flex;
flex-direction: column;
}
.deck-stats-curve-label {
font-size: var(--fs-micro);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
margin-bottom: var(--space-1);
}
/* ============================================================
Responsive groundwork
Desktop (the primary target) is unchanged above; these breakpoints keep
the layout usable as the window narrows and lay the foundation for a
future mobile port. Breakpoints: 1000 (tablet) and 720 (compact).
============================================================ */
/* Tablet: the deck two-pane stacks (list above detail). */
@media (max-width: 1000px) {
.deck-layout {
flex-direction: column;
}
.deck-sidebar {
width: 100%;
}
}
/* 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
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 {
display: none;
}
.bottom-nav {
display: flex;
}
.topbar {
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
comfortable size without inflating the compact mouse-driven desktop look. */
@media (pointer: coarse) {
button,
input,
select,
.nav-item {
min-height: var(--control-h);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
}
}