web(ui): move theme controls to sidebar bottom, tighten Test Hand fan arc, set desktop to 280x392; mobile banner cleanup; bump version to 2.2.10 and update compose APP_VERSION; cache-bust CSS

This commit is contained in:
matt 2025-09-11 14:54:35 -07:00
parent 07a92eb47f
commit f28f8e6b4f
9 changed files with 188 additions and 81 deletions

View file

@ -27,6 +27,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- (placeholder) no current unreleased land alternatives bugs logged
- Step 5 card grid scroll flicker at bottom: added overscroll containment and skip virtualization for small (<80 items) grids to prevent upward jump when reaching end
## [2.2.10] - 2025-09-11
### Changed
- Web UI: Test Hand uses a default fanned layout on desktop with tightened arc and 40% overlap; outer cards sit lower for a full-arc look
- Desktop Test Hand card size set to 280×392; responsive sizes refined at common breakpoints
- Theme controls moved from the top banner to the bottom of the left sidebar; sidebar made a flex column with the theme block anchored at the bottom
- Mobile banner simplified to show only Menu, title; spacing and gaps tuned to prevent overflow and wrapping
### Fixed
- Prevented mobile banner overflow by hiding non-essential items and relocating theme controls
- Ensured desktop sizing wins over previous inline styles by using global CSS overrides; cards no longer shrink due to flex
## [2.2.9] - 2025-09-10
### Added

View file

@ -52,6 +52,3 @@
- Deterministic toggle for land alternative randomization (e.g., `LAND_ALTS_DETERMINISTIC=1`).
- Unit tests focusing on edge-case mono-color filtering and theme weighting bounds.
- Potential adaptive virtualization row-height measurement per column for further smoothness (currently fixed estimate works acceptably).
---
Generated template ready for tagging release `${VERSION}` (update actual version number in CI/CD pipeline or tagging script).

View file

@ -83,7 +83,12 @@ body {
/* Top banner */
.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
.top-banner{ min-height: var(--banner-h); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; }
.top-banner .top-inner > div{ min-width:0; }
@media (max-width: 1100px){
.top-banner .top-inner{ grid-auto-rows:auto; }
.top-banner .top-inner select{ max-width:140px; }
}
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; }
.banner-status.busy{ color:#fbbf24; }
@ -105,6 +110,8 @@ body {
width: var(--sidebar-w);
z-index: 9; /* below the banner (z=10) */
box-shadow: 2px 0 10px rgba(0,0,0,.18);
display: flex;
flex-direction: column;
}
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
@ -120,13 +127,22 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
/* Mobile tweaks */
@media (max-width: 900px){
:root{ --sidebar-w: 240px; }
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem .5rem; }
.top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; }
.banner-status{ padding-left: .5rem; }
.layout{ grid-template-columns: 0 1fr; }
.sidebar{ transform: translateX(-100%); visibility: hidden; }
body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; }
body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; }
.content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
.top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); }
/* Spacing tweaks: tighter left, larger gaps between visible items */
.top-banner .top-inner > div{ gap: 25px !important; }
.top-banner .top-inner > div:first-child{ padding-left: 0 !important; }
/* Mobile: show only Menu, Title, and Theme selector */
#btn-open-permalink{ display:none !important; }
#banner-status{ display:none !important; }
#health-dot{ display:none !important; }
.top-banner #theme-reset{ display:none !important; }
}
/* Additional mobile spacing for bottom floating controls */
@ -149,6 +165,14 @@ body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
/* Sidebar theme controls anchored at bottom */
.sidebar .nav { flex: 1 1 auto; }
.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; }
.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; }
.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; }
.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); }
/* Simple two-column layout for inspect panel */
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
.two-col .grow { min-width: 0; }
@ -392,63 +416,50 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
width: 100% !important;
max-width: 100vw !important;
}
/* Test hand responsive adjustments */
#test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; }
/* Modal & form layout fixes (original block retained inside media query) */
/* Fix modal layout on mobile */
.modal {
padding: 10px !important;
box-sizing: border-box;
}
.modal-content {
width: 100% !important;
max-width: calc(100vw - 20px) !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
/* Force single column for include/exclude grid */
.include-exclude-grid {
display: flex !important;
flex-direction: column !important;
gap: 1rem !important;
}
.include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; }
/* Fix basics grid */
.basics-grid {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
.basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; }
/* Ensure all inputs and textareas fit properly */
.modal input,
.modal textarea,
.modal select {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
min-width: 0 !important;
}
.modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; }
/* Fix chips containers */
.modal [id$="_chips_container"] {
max-width: 100% !important;
overflow-x: hidden !important;
word-wrap: break-word !important;
}
.modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; }
/* Ensure fieldsets don't overflow */
.modal fieldset {
max-width: 100% !important;
box-sizing: border-box !important;
overflow-x: hidden !important;
}
.modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; }
/* Fix any inline styles that might cause overflow */
.modal fieldset > div,
.modal fieldset > div > div {
max-width: 100% !important;
overflow-x: hidden !important;
}
.modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; }
}
@media (max-width: 640px){
#test-hand{ --card-w: 150px !important; --card-h: 210px !important; }
/* Generic stack shrink */
.stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; }
}
@media (max-width: 560px){
#test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; }
#test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; }
#test-hand .stack-card{ flex:0 0 auto; }
.stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; }
}
@media (max-width: 480px) {
@ -508,3 +519,8 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
display: none !important; /* Hide separators on mobile */
}
}
/* Desktop sizing for Test Hand */
@media (min-width: 900px) {
#test-hand { --card-w: 280px !important; --card-h: 392px !important; }
}

View file

@ -30,7 +30,7 @@
}catch(_){ }
})();
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
@ -54,23 +54,9 @@
<div style="display:flex; align-items:center; gap:.5rem">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
<button type="button" class="btn" title="Open a saved permalink"
<button type="button" id="btn-open-permalink" class="btn" title="Open a saved permalink"
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
{% if enable_themes %}
<label style="margin:0 .5rem; align-items:flex-start; margin-left:auto">
<span class="muted" style="font-size:11px">Theme</span>
<select id="theme-select" aria-label="Theme selector">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="high-contrast">High contrast</option>
<option value="cb-friendly">Color-blind</option>
</select>
</label>
<button type="button" id="theme-reset" class="btn" title="Reset theme preference" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
Reset
</button>
{% endif %}
{# Theme controls moved to sidebar #}
</div>
</div>
</header>
@ -95,6 +81,21 @@
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
{% if enable_themes %}
<div class="sidebar-theme" role="group" aria-label="Theme">
<label class="sidebar-theme-label" for="theme-select">Theme</label>
<div class="sidebar-theme-row">
<select id="theme-select" aria-label="Theme selector">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="high-contrast">High contrast</option>
<option value="cb-friendly">Color-blind</option>
</select>
<button type="button" id="theme-reset" class="btn btn-ghost" title="Reset theme preference">Reset</button>
</div>
</div>
{% endif %}
</aside>
<main class="content" data-error-surface>
{% block content %}{% endblock %}

View file

@ -8,11 +8,13 @@
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
<div>
<div class="two-col two-col-left-rail" style="margin-top:.75rem;">
<aside class="card-preview">
{% if commander %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" width="320" />
</a>
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if csv_path %}
@ -27,13 +29,13 @@
<button type="submit">Download TXT</button>
</form>
{% endif %}
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
<form method="get" action="/decks" style="display:inline; margin:0;">
<button type="submit">Back to Finished Decks</button>
</form>
</div>
</div>
<div>
</aside>
<div class="grow">
{% if summary %}
{% if owned_set %}
{% set ns = namespace(owned=0, total=0) %}

View file

@ -338,12 +338,13 @@
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
<section style="margin-top:1rem;">
<h5>Test Hand</h5>
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
</h5>
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem;">
<button type="button" id="btn-new-hand">New Hand</button>
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
</div>
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
<div class="stack-wrap hand-row-overlap" id="test-hand">
<div class="stack-grid" id="test-hand-grid"></div>
</div>
<script>
@ -415,13 +416,30 @@
var grid = document.getElementById('test-hand-grid');
if (!grid) return;
grid.innerHTML = '';
hand.forEach(function(name){
var host = document.getElementById('test-hand');
if (host){ host.style.setProperty('--mid', (hand.length ? (hand.length - 1)/2 : 0)); host.style.setProperty('--count', hand.length); }
hand.forEach(function(name, idx){
if (!name) return;
var div = document.createElement('div');
div.className = 'stack-card';
if (GC_SET && GC_SET.has(name)) {
div.className += ' game-changer';
}
div.style.setProperty('--i', idx);
var mid = (hand.length - 1) / 2;
var diff = Math.abs(idx - mid);
var peakRaise = 22; // px raise at center (accentuate arc)
var dropPer = 5; // linear component per distance step
// Strengthen curve so the very outer cards sit lower
var outerExtra = 24; // quadratic downward px strongest at edges
var edgeBias = 8; // cubic bias for far edges
var norm = (mid > 0 ? diff / mid : 0); // 0..1
var curve = norm * norm * outerExtra; // quadratic easing
var curve3 = norm * norm * norm * edgeBias; // cubic accentuation
var y = (diff * dropPer) + curve + curve3 - peakRaise; // center negative (raised), edges positive (lower)
// Minor smoothing so second-from-edge isn't too low
if (mid >= 2 && Math.abs(diff - (mid - 1)) < 0.001) { y += 2; }
div.style.setProperty('--ty', y + 'px');
div.innerHTML = (
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
);
@ -431,9 +449,73 @@
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
var btn = document.getElementById('btn-new-hand');
if (btn) btn.addEventListener('click', newHand);
// Fan effect — desktop default (>=900px, hover-capable pointer)
var handEl = document.getElementById('test-hand');
(function(){
if(!handEl) return;
var onEnter = function(){ handEl.classList.add('fan'); };
var onLeave = function(){ handEl.classList.remove('fan'); };
var mq = window.matchMedia('(any-hover: hover) and (pointer: fine) and (min-width: 900px)');
function attach(){ handEl.addEventListener('mouseenter', onEnter); handEl.addEventListener('mouseleave', onLeave); }
function detach(){ handEl.removeEventListener('mouseenter', onEnter); handEl.removeEventListener('mouseleave', onLeave); }
// Desktop: fan is default; Mobile/tablet: no fan
function apply(){
if (mq.matches) {
detach();
handEl.classList.add('fan');
} else {
detach();
handEl.classList.remove('fan');
}
}
try {
if (typeof mq.addEventListener === 'function') mq.addEventListener('change', apply);
else if (typeof mq.addListener === 'function') mq.addListener(apply);
} catch(_) {}
apply();
})();
newHand();
})();
</script>
<style>
/* Base overlapping hand: 160px cards (same as deck thumbnails) */
#test-hand.hand-row-overlap{ padding-bottom:.9rem; --fan-gap:28px; --card-w:160px; --card-h:224px; }
#test-hand.hand-row-overlap .stack-grid{ display:flex !important; gap:0; overflow-x:auto; scrollbar-width:thin; }
#test-hand.hand-row-overlap .stack-card{ width:var(--card-w); height:var(--card-h); transition: transform .25s ease, margin-left .25s ease, width .25s ease, height .25s ease; flex: 0 0 auto; }
/* Dynamic overlap: show ~30% of next card */
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.7); }
#test-hand.hand-row-overlap .stack-card img{ width:var(--card-w); height:var(--card-h); display:block; }
#test-hand.hand-row-overlap .stack-card:hover{ z-index:999; transform:translateY(-4px); }
/* Desktop sizing for Test Hand */
@media (min-width:900px){
#test-hand.hand-row-overlap{ --card-w:280px; --card-h:392px; --fan-gap:40px; }
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.7); }
#test-hand.hand-row-overlap.fan{ --card-w:280px; --card-h:392px; }
}
/* Hover fan-out: spread cards horizontally, enlarge if not already large */
#test-hand.hand-row-overlap.fan{ --fan-overlap:0.40; --fan-gap:0px; }
#test-hand.hand-row-overlap.fan .stack-card + .stack-card{ margin-left:0; }
#test-hand.hand-row-overlap.fan .stack-grid{ justify-content:center; overflow:visible; padding-left:0; }
/* Fan transform now applies a 40% overlap (visible width ~60%) while keeping center aligned */
#test-hand.hand-row-overlap.fan .stack-card{ position:relative; transform: translateX(calc((var(--i) - var(--mid)) * (var(--fan-gap) - (var(--card-w) * var(--fan-overlap))))) translateY(var(--ty,0px)) rotate(calc((var(--i) - var(--mid)) * 4deg)); }
/* Responsive adjustments */
@media (max-width:900px){
#test-hand.hand-row-overlap.fan{ --card-w:240px; --card-h:336px; --fan-overlap:0.40; --fan-gap:0px; }
}
@media (max-width:640px){
#test-hand.hand-row-overlap{ --card-w:150px; --card-h:210px; }
#test-hand.hand-row-overlap.fan{ --card-w:200px; --card-h:280px; --fan-overlap:0.40; --fan-gap:0px; }
}
@media (min-width:640px) and (max-width:899px){
#test-hand.hand-row-overlap{ --card-w:160px; --card-h:224px; }
}
@media (max-width:480px){
#test-hand.hand-row-overlap{ --card-w:140px; --card-h:196px; }
#test-hand.hand-row-overlap .stack-card + .stack-card{ margin-left: calc(var(--card-w) * -0.65); }
#test-hand.hand-row-overlap.fan{ --card-w:180px; --card-h:252px; --fan-overlap:0.40; --fan-gap:0px; }
#test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; }
}
</style>
</section>
<style>
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }

View file

@ -27,12 +27,9 @@ services:
WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
# Compliance/exports
WEB_AUTO_ENFORCE: "0" # 1=auto-apply bracket enforcement and re-export
APP_VERSION: "v2.2.9" # Optional label shown in footer
# WEB_CUSTOM_EXPORT_BASE: "" # Optional custom export basename
# Misc land tuning (utility land selection Step 7)
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1

View file

@ -30,10 +30,10 @@ services:
WEB_TAG_PARALLEL: "1"
WEB_TAG_WORKERS: "4"
# Compliance/exports
WEB_AUTO_ENFORCE: "0"
APP_VERSION: "v2.2.9"
# WEB_CUSTOM_EXPORT_BASE: ""
# Compliance/exports
WEB_AUTO_ENFORCE: "0"
APP_VERSION: "v2.2.10"
# WEB_CUSTOM_EXPORT_BASE: ""
# Misc land tuning (utility land selection Step 7)
# MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off unless SHOW_DIAGNOSTICS=1

View file

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "2.2.9"
version = "2.2.10"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}