Decks tab: a deck list page, with the builder on its own screen

The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.

A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.

Closes #9
Closes #11
Closes #12
This commit is contained in:
2026-10-25 15:04:41 -05:00
parent fae1aa2b05
commit d286d3420c
15 changed files with 2113 additions and 970 deletions

View File

@@ -1224,20 +1224,6 @@ h4 {
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)
============================================================ */
@@ -1312,7 +1298,8 @@ li:focus-within .row-actions {
gap: 6px;
margin-bottom: 14px;
}
.dialog-field input {
.dialog-field input,
.dialog-field select {
width: 100%;
}
.dialog-error {
@@ -1359,26 +1346,6 @@ li:focus-within .row-actions {
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
@@ -2613,25 +2580,412 @@ tbody tr:hover {
}
/* ============================================================
Deck metadata (format + commander)
Deck list (the Decks tab's front page)
============================================================ */
/* 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 {
.deck-gallery-toolbar {
display: flex;
gap: var(--space-5);
align-items: flex-start;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.deck-sidebar {
width: 240px;
.deck-gallery-toolbar h2 {
margin: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.deck-gallery-search {
width: 260px;
}
.deck-gallery-controls,
.deck-gallery-create {
display: flex;
align-items: center;
gap: var(--space-2);
}
.deck-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
padding: var(--space-8) var(--space-5);
}
.deck-gallery-empty h3 {
margin: 0;
border: none;
padding: 0;
}
.deck-gallery-empty .hint {
margin: 0 0 var(--space-3);
}
.deck-gallery-empty-icon svg {
width: 36px;
height: 36px;
color: var(--accent-quiet);
}
/* Tiles: an art banner with the deck's name on it, stats below. */
.deck-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.deck-tile {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
box-shadow: var(--panel-highlight);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.deck-tile:hover {
transform: translateY(-2px);
border-color: var(--accent-glow);
box-shadow: var(--panel-highlight), var(--shadow-md);
}
.deck-tile:focus-visible,
.deck-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The hover lift makes each tile its own stacking context, so the one whose
menu is open has to sit above its neighbours or they'd cover the menu. */
.deck-tile.menu-open,
.deck-row.menu-open {
z-index: 5;
}
.deck-tile-cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.deck-tile-art {
position: absolute;
inset: 0;
}
.deck-tile-art img,
.deck-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.deck-tile-art img {
transition: transform 0.4s ease;
}
.deck-tile:hover .deck-tile-art img {
transform: scale(1.04);
}
.deck-tile-art,
.deck-row-art {
display: flex;
align-items: center;
justify-content: center;
background: repeating-linear-gradient(-45deg, var(--bg-input) 0 10px, var(--bg-hover) 10px 20px);
}
.deck-art-empty {
font-size: 44px;
font-weight: 800;
color: var(--faint);
}
/* Darkens the top (for the badge + menu) and, more, the bottom (for the
name), so both stay legible over any art. */
.deck-tile-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(12, 10, 8, 0.45) 0%,
rgba(12, 10, 8, 0) 28%,
rgba(12, 10, 8, 0) 45%,
rgba(12, 10, 8, 0.92) 100%
);
}
.deck-tile-heading {
position: absolute;
left: var(--space-3);
right: var(--space-3);
bottom: var(--space-2);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-2);
}
.deck-tile-name {
font-size: 17px;
font-weight: 700;
line-height: 1.25;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.deck-tile-heading .pips {
flex-shrink: 0;
margin-bottom: 2px;
}
.deck-tile-cover .deck-legality {
position: absolute;
top: var(--space-2);
left: var(--space-2);
}
.deck-tile-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-3) var(--space-3);
}
.deck-tile-sub {
font-size: var(--fs-caption);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-tile-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
min-height: 22px;
}
.deck-tile-meta .deck-chip,
.deck-row-stats .deck-chip {
margin-right: 0;
}
.deck-tile-foot {
display: flex;
justify-content: space-between;
font-size: var(--fs-caption);
color: var(--muted);
}
.deck-tile-foot span:last-child,
.deck-row-cost {
color: var(--text);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.deck-size {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.deck-size-target {
color: var(--faint);
font-weight: 500;
}
.deck-size.done {
color: var(--success);
}
.deck-size.warn {
color: var(--warn);
}
.deck-owned-bar {
display: block;
height: 4px;
border-radius: var(--radius-pill);
background: var(--bg-input);
overflow: hidden;
}
.deck-owned-bar > span {
display: block;
height: 100%;
background: var(--success);
border-radius: inherit;
}
.deck-legality {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--fs-label);
font-weight: 700;
white-space: nowrap;
}
.deck-tile .deck-legality {
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
background: rgba(12, 10, 8, 0.72);
backdrop-filter: blur(2px);
}
.deck-legality.legal {
color: var(--success);
}
.deck-legality.illegal {
color: var(--warn);
}
/* The ⋯ menu: over the tile's top-right corner, or at a row's end. */
.deck-menu {
position: relative;
flex-shrink: 0;
}
.deck-detail-pane {
.deck-tile > .deck-menu {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.deck-menu-list {
left: auto;
right: 0;
min-width: 170px;
}
.deck-menu-list > button.danger-text {
color: var(--danger);
}
/* Where there's a hover, the tile's menu waits for it; on touch it's always
showing, since there's nothing to reveal it. */
@media (hover: hover) {
.deck-tile > .deck-menu {
opacity: 0;
transition: opacity 0.12s;
}
.deck-tile:hover > .deck-menu,
.deck-tile:focus-within > .deck-menu,
.deck-tile.menu-open > .deck-menu {
opacity: 1;
}
}
/* Rows: the compact alternative, more decks per screen. */
.deck-gallery-rows {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.deck-row {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-2);
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--panel-highlight);
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.deck-row:hover {
border-color: var(--accent-glow);
background: var(--bg-hover);
}
.deck-row-art {
width: 72px;
height: 52px;
flex-shrink: 0;
overflow: hidden;
border-radius: var(--radius-sm);
}
.deck-row-art .deck-art-empty {
font-size: 22px;
}
.deck-row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.deck-row-name {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.deck-row-name .card-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-row-name .pips {
flex-shrink: 0;
}
.deck-row-sub {
font-size: var(--fs-caption);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-row-stats {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
font-size: var(--fs-caption);
}
.deck-row-owned {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.deck-row-owned .deck-owned-bar {
width: 56px;
}
.deck-row-owned > span:last-child {
text-align: right;
}
/* Fixed widths, so each stat lines up in a column down the list. */
.deck-row-stats .deck-size {
min-width: 4.5em;
text-align: right;
}
.deck-row-owned > span:last-child {
min-width: 4ch;
}
.deck-row-cost {
min-width: 4.5em;
text-align: right;
}
.deck-row .deck-legality {
min-width: 1em;
font-size: var(--fs-body);
}
/* The builder's way back to the deck list. */
.deck-builder-back {
background: none;
border: none;
padding: 0;
margin-bottom: var(--space-2);
color: var(--muted);
font-size: 13px;
}
.deck-builder-back:hover {
color: var(--text-strong);
background: none;
}
.deck-builder-title {
gap: var(--space-1);
min-width: 0;
}
.deck-builder-title h2 {
margin-right: var(--space-2);
overflow-wrap: anywhere;
}
/* ============================================================
Deck metadata (format + commander)
============================================================ */
/* Surface from `.panel`; layout only here. */
.deck-meta {
@@ -2809,27 +3163,9 @@ tbody tr:hover {
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).
future mobile port. Breakpoint: 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). */
@@ -2871,6 +3207,41 @@ tbody tr:hover {
.deck-stats-curve {
min-width: 0;
}
/* Deck list toolbar: title and create buttons on top, then search, then
the filter/sort/view controls sharing a line. */
.deck-gallery-toolbar .spacer {
display: none;
}
.deck-gallery-toolbar h2 {
flex: 1;
}
.deck-gallery-create {
order: 1;
}
.deck-gallery-search {
order: 2;
width: 100%;
}
.deck-gallery-controls {
order: 3;
width: 100%;
}
.deck-gallery-controls select {
flex: 1;
min-width: 0;
}
/* Rows put their stats on a second line, under the name. */
.deck-row {
flex-wrap: wrap;
row-gap: var(--space-1);
}
.deck-row-stats {
order: 3;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--space-3);
padding-left: calc(72px + var(--space-3));
}
/* `.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
@@ -2880,20 +3251,6 @@ tbody tr:hover {
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