feat: web documentation portal with contextual help links and consistent page headers (#67)

This commit is contained in:
mwisnowski 2026-04-01 11:46:08 -07:00 committed by GitHub
parent 46637cf27f
commit 13f6fa5dbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2232 additions and 140 deletions

View file

@ -66,6 +66,7 @@ PRICE_LAZY_REFRESH=1 # dockerhub: PRICE_LAZY_REFRESH="1" (1=refres
PRICE_STALE_WARNING_HOURS=24 # dockerhub: PRICE_STALE_WARNING_HOURS="24" (hours before a cached price shows ⏱ stale indicator; 0=disable) PRICE_STALE_WARNING_HOURS=24 # dockerhub: PRICE_STALE_WARNING_HOURS="24" (hours before a cached price shows ⏱ stale indicator; 0=disable)
WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1" WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1"
ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1" ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1"
ENABLE_WEB_DOCS=1 # dockerhub: ENABLE_WEB_DOCS="1" (1=enable web-accessible documentation at /docs)
SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1" SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1"
SIMILARITY_CACHE_PATH="card_files/similarity_cache.parquet" # Path to Parquet cache file SIMILARITY_CACHE_PATH="card_files/similarity_cache.parquet" # Path to Parquet cache file
ENABLE_BATCH_BUILD=1 # dockerhub: ENABLE_BATCH_BUILD="1" (enable Build X and Compare feature) ENABLE_BATCH_BUILD=1 # dockerhub: ENABLE_BATCH_BUILD="1" (enable Build X and Compare feature)

View file

@ -9,10 +9,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
_No unreleased changes yet_ - **Web documentation portal**: All 13 user guides are now accessible at `/help` directly in the app — no need to navigate to GitHub. A guide index lists every guide with a description; each guide page renders full markdown with heading anchors for deep linking.
- **In-guide table of contents**: Each guide page displays a sidebar with an auto-generated "On This Page" section linking to all headings in the current guide. Collapses to a hamburger toggle on mobile.
- **Contextual help links**: Small help icons throughout the build wizard, bracket selector, owned cards mode, partner selection, and other UI areas link directly to the relevant guide section in a new tab — without interrupting the current workflow.
- **Documentation: Multi-Copy Package guide**: New dedicated guide covers all multi-copy card archetypes, count recommendations, exclusive groups, bracket interaction, and FAQ.
- **Documentation: See Also cross-links**: All 13 user guides end with a See Also section linking to related guides.
- **Documentation: FAQ sections**: FAQ sections added to 5 guides (Bracket Compliance, Include/Exclude, Locks/Replace/Permalinks, Owned Cards, Budget Mode).
- **Documentation: quality scoring and enforcement detail**: `theme_browser.md` documents the 4-factor badge scoring formula; `bracket_compliance.md` includes a full enforcement matrix.
- **Consistent page headers**: All pages now share a unified header style — same font size, description line, and separator — replacing the previous mix of different heading sizes and layouts.
- **"Help & Guides" button on home page**: Quick link to the documentation portal from the home page.
### Changed ### Changed
_No unreleased changes yet_ - **Docker: `docs/` volume mount added**: `docker-compose.yml` and `dockerhub-docker-compose.yml` now mount `./docs` so documentation edits reflect immediately without a container rebuild.
### Fixed ### Fixed
- **Bug: missing `idx` argument** in `project_detail()` call inside `theme_preview.py` caused theme preview pages to crash. - **Bug: missing `idx` argument** in `project_detail()` call inside `theme_preview.py` caused theme preview pages to crash.
@ -21,7 +29,7 @@ _No unreleased changes yet_
### Removed ### Removed
- **16 test files deleted**: 5 stale/broken tests and 11 single-test files merged into their domain equivalents to reduce fragmentation. - **16 test files deleted**: 5 stale/broken tests and 11 single-test files merged into their domain equivalents to reduce fragmentation.
- **7 permanently-skipped tests removed**: 3 obsolete M4-era `apply_combo_tags` tests (API changed), 2 obsolete M4-era commander catalog tests (parquet architecture), and 2 "run manually" performance tests that never ran in CI. - **7 permanently-skipped tests removed**: 3 obsolete `apply_combo_tags` tests (API changed), 2 obsolete commander catalog tests (parquet architecture), and 2 "run manually" performance tests that never ran in CI.
## [4.4.2] - 2026-03-26 ## [4.4.2] - 2026-03-26
### Added ### Added

View file

@ -37,6 +37,9 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY code/ ./code/ COPY code/ ./code/
COPY mypy.ini . COPY mypy.ini .
# Copy documentation for web-accessible docs feature
COPY docs/ ./docs/
# Tailwind source is already in code/web/static/tailwind.css from COPY code/ # Tailwind source is already in code/web/static/tailwind.css from COPY code/
# TypeScript sources are in code/web/static/ts/ from COPY code/ # TypeScript sources are in code/web/static/ts/ from COPY code/

View file

@ -2,46 +2,24 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
_No unreleased changes yet_ - **Web documentation portal**: All 13 user guides are now accessible at `/help` directly in the app — no need to navigate to GitHub. A guide index lists every guide with a description; each guide page renders full markdown with heading anchors for deep linking.
- **In-guide table of contents**: Each guide page displays a sidebar with an auto-generated "On This Page" section linking to all headings in the current guide. Collapses to a hamburger toggle on mobile.
- **Contextual help links**: Small help icons throughout the build wizard, bracket selector, owned cards mode, partner selection, and other UI areas link directly to the relevant guide section in a new tab — without interrupting the current workflow.
- **Documentation: Multi-Copy Package guide**: New dedicated guide covers all multi-copy card archetypes, count recommendations, exclusive groups, bracket interaction, and FAQ.
- **Documentation: See Also cross-links**: All 13 user guides end with a See Also section linking to related guides.
- **Documentation: FAQ sections**: FAQ sections added to 5 guides (Bracket Compliance, Include/Exclude, Locks/Replace/Permalinks, Owned Cards, Budget Mode).
- **Documentation: quality scoring and enforcement detail**: `theme_browser.md` documents the 4-factor badge scoring formula; `bracket_compliance.md` includes a full enforcement matrix.
- **Consistent page headers**: All pages now share a unified header style — same font size, description line, and separator — replacing the previous mix of different heading sizes and layouts.
- **"Help & Guides" button on home page**: Quick link to the documentation portal from the home page.
### Changed ### Changed
_No unreleased changes yet_ - **Docker: `docs/` volume mount added**: `docker-compose.yml` and `dockerhub-docker-compose.yml` now mount `./docs` so documentation edits reflect immediately without a container rebuild.
### Fixed ### Fixed
- Bug fixes in `theme_preview.py` and `app.py` uncovered by the test suite. - **Bug: missing `idx` argument** in `project_detail()` call inside `theme_preview.py` caused theme preview pages to crash.
- Pydantic V2 deprecation warning resolved in `DeckExportRequest`. - **Bug: `build_permalinks` router not mounted** in `app.py` caused all permalink-related endpoints to return 404.
- **Pydantic V2 deprecation warning** silenced: `DeckExportRequest` now uses `model_config = ConfigDict(...)` instead of the deprecated inner `class Config`.
### Removed ### Removed
- 16 fragmented/stale test files consolidated or deleted; 7 permanently-skipped tests removed. - **16 test files deleted**: 5 stale/broken tests and 11 single-test files merged into their domain equivalents to reduce fragmentation.
- **7 permanently-skipped tests removed**: 3 obsolete `apply_combo_tags` tests (API changed), 2 obsolete commander catalog tests (parquet architecture), and 2 "run manually" performance tests that never ran in CI.
## [4.4.2] - 2026-03-26
### Added
- **Community links**: GitHub, issue tracker, feature request, and DockerHub links in the footer and home page.
- **Feature request templates**: GitHub issue templates for General Theme Requests, Commander-Specific Theme Requests, and Other Feature Requests.
- **Feedback prompts**: Inline prompts on the Themes and Commanders pages linking to the relevant request templates.
### Added
- **Smart Land Bases checkbox**: The New Deck modal now has a **Smart Land Bases** checkbox in the Preferences section (checked by default). Enables or disables smart land analysis per-build without needing environment variables.
### Removed
- **`ENABLE_SMART_LANDS` environment variable**: Replaced by the per-build checkbox. Use `LAND_PROFILE` or `LAND_COUNT` for headless overrides.
## [4.3.1] - 2026-03-25
### Added
- **Smart Land Bases**: Land count and basic-to-dual ratio are now adjusted automatically based on the commander's speed and color-pip intensity. Controlled by `ENABLE_SMART_LANDS=1` (default on in Docker).
- **Speed detection**: Commander CMC determines a speed category applied as an offset to the user's configured ideal land count. Fast (CMC < 3) = 2 lands, mid = ±0, slow (CMC > 4) = +2 to +4 scaling with color count.
- **Profile selection**: Basics-heavy (~60% basics) for 12 color / low-pip decks; Balanced for moderate pip density; Fixing-heavy (minimal basics, more duals/fetches) for 3+ color or high-pip pools (≥15 double-pip or ≥3 triple-or-more-pip cards).
- **ETB tapped tolerance** is automatically tightened for fast decks and loosened for slow decks.
- **Budget override**: Low-budget 3+ color decks are pushed to basics-heavy automatically.
- **Slot earmarking**: Non-land ideal counts are scaled to fit within the remaining slots after the land target is set.
- **Backfill**: A final land step pads with basics if any land phase falls short.
- Override with `LAND_PROFILE=basics|mid|fixing` or `LAND_COUNT=<n>`. A **Smart Lands** notice in the Land Summary explains the chosen profile.
### Changed
_No changes_
### Fixed
_No changes_
### Removed
_No changes_

View file

@ -2342,6 +2342,7 @@ from .routes import card_browser as card_browser_routes # noqa: E402
from .routes import compare as compare_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402
from .routes import api as api_routes # noqa: E402 from .routes import api as api_routes # noqa: E402
from .routes import price as price_routes # noqa: E402 from .routes import price as price_routes # noqa: E402
from .routes import docs as docs_routes # noqa: E402
app.include_router(build_routes.router) app.include_router(build_routes.router)
app.include_router(build_validation_routes.router, prefix="/build") app.include_router(build_validation_routes.router, prefix="/build")
app.include_router(build_multicopy_routes.router, prefix="/build") app.include_router(build_multicopy_routes.router, prefix="/build")
@ -2364,6 +2365,7 @@ app.include_router(telemetry_routes.router)
app.include_router(cards_routes.router) app.include_router(cards_routes.router)
app.include_router(card_browser_routes.router) app.include_router(card_browser_routes.router)
app.include_router(compare_routes.router) app.include_router(compare_routes.router)
app.include_router(docs_routes.router)
app.include_router(api_routes.router) app.include_router(api_routes.router)
app.include_router(price_routes.router) app.include_router(price_routes.router)

115
code/web/routes/docs.py Normal file
View file

@ -0,0 +1,115 @@
"""Routes for user documentation viewer."""
from __future__ import annotations
import logging
import os
from typing import Optional
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from code.web.services.docs_service import DocsService, NotFoundError, ServiceError
from ..app import templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/help", tags=["help"])
# Initialize service
_docs_service = DocsService()
def _is_docs_enabled() -> bool:
"""Check if docs feature is enabled.
Returns:
True if ENABLE_WEB_DOCS=1
"""
return os.getenv("ENABLE_WEB_DOCS", "1") == "1"
@router.get("/", response_class=HTMLResponse, name="docs_index")
async def docs_index(request: Request):
"""Display documentation index page.
Lists all available user guides with titles and descriptions.
"""
if not _is_docs_enabled():
raise HTTPException(status_code=404, detail="Documentation not available")
try:
guides = _docs_service.list_guides()
return templates.TemplateResponse(
request,
"docs/index.html",
{
"guides": guides,
"page_title": "Documentation"
}
)
except ServiceError as e:
logger.error(f"Failed to load docs index: {e}")
raise HTTPException(status_code=500, detail="Failed to load documentation")
@router.get("/{guide_name}", response_class=HTMLResponse, name="docs_guide")
async def docs_guide(request: Request, guide_name: str, reload: Optional[bool] = False):
"""Display a specific documentation guide.
Args:
guide_name: Name of guide (without .md extension)
reload: Force reload from disk (admin/debug)
"""
if not _is_docs_enabled():
raise HTTPException(status_code=404, detail="Documentation not available")
try:
# Get metadata
metadata = _docs_service.get_metadata(guide_name)
# Get rendered content (HTML + TOC)
content = _docs_service.get_guide(guide_name, force_reload=bool(reload))
# Get all guides for sidebar navigation
all_guides = _docs_service.list_guides()
return templates.TemplateResponse(
request,
"docs/guide.html",
{
"guide_name": guide_name,
"guide_title": metadata.title,
"guide_description": metadata.description,
"html_content": content.html,
"toc_html": content.toc_html,
"all_guides": all_guides,
"page_title": metadata.title
}
)
except NotFoundError:
raise HTTPException(status_code=404, detail=f"Guide not found: {guide_name}")
except ServiceError as e:
logger.error(f"Failed to load guide {guide_name}: {e}")
raise HTTPException(status_code=500, detail="Failed to load guide")
@router.post("/invalidate", name="docs_invalidate")
async def invalidate_cache(guide_name: Optional[str] = None):
"""Invalidate documentation cache (admin/debug).
Args:
guide_name: Specific guide to invalidate (None = all)
"""
if not _is_docs_enabled():
raise HTTPException(status_code=404, detail="Documentation not available")
try:
_docs_service.invalidate_guide(guide_name)
return {"status": "ok", "invalidated": guide_name or "all"}
except Exception as e:
logger.error(f"Failed to invalidate cache: {e}")
raise HTTPException(status_code=500, detail="Cache invalidation failed")

View file

@ -0,0 +1,292 @@
"""Service for rendering and caching user documentation.
Follows the R9 BaseService pattern. Provides web-accessible documentation
from markdown files in docs/user_guides/.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
from code.web.services.base import CachedService, NotFoundError, ServiceError
logger = logging.getLogger(__name__)
# Try importing markdown with extensions
try:
import markdown
from markdown.extensions.fenced_code import FencedCodeExtension
from markdown.extensions.tables import TableExtension
from markdown.extensions.toc import TocExtension
MARKDOWN_AVAILABLE = True
except ImportError:
MARKDOWN_AVAILABLE = False
logger.warning("markdown library not available - docs service will be limited")
@dataclass
class GuideMetadata:
"""Metadata about a documentation guide."""
name: str
title: str
description: Optional[str]
file_path: Path
@dataclass
class GuideContent:
"""Rendered guide content with table of contents."""
html: str
toc_html: str # Table of contents HTML
class DocsService(CachedService[str, GuideContent]):
"""Service for rendering user documentation from markdown files.
Provides caching with TTL to avoid re-rendering unchanged files.
Supports GitHub-flavored markdown with syntax highlighting.
"""
def __init__(
self,
docs_dir: Optional[Path] = None,
ttl_seconds: int = 300, # 5 minute cache
max_size: int = 50
) -> None:
"""Initialize docs service.
Args:
docs_dir: Path to documentation directory (defaults to docs/user_guides)
ttl_seconds: Cache TTL in seconds
max_size: Maximum number of cached documents
"""
super().__init__(ttl_seconds=ttl_seconds, max_size=max_size)
if docs_dir is None:
# Default to docs/user_guides relative to project root
docs_dir = Path(__file__).parent.parent.parent.parent / "docs" / "user_guides"
self.docs_dir = docs_dir
self._validate()
def _validate(self) -> None:
"""Validate service configuration.
Raises:
ServiceError: If docs directory doesn't exist or markdown unavailable
"""
if not self.docs_dir.exists():
raise ServiceError(f"Documentation directory not found: {self.docs_dir}")
if not MARKDOWN_AVAILABLE:
logger.warning("Markdown library not available - install with: pip install markdown")
def _compute_value(self, guide_name: str) -> GuideContent:
"""Render markdown guide to HTML with table of contents.
Args:
guide_name: Name of guide (without .md extension)
Returns:
GuideContent with rendered HTML and TOC
Raises:
NotFoundError: If guide doesn't exist
ServiceError: If rendering fails
"""
file_path = self.docs_dir / f"{guide_name}.md"
if not file_path.exists():
raise NotFoundError(f"Guide not found: {guide_name}")
try:
# Read markdown content
content = file_path.read_text(encoding="utf-8")
# Render to HTML with GitHub-flavored extensions
if MARKDOWN_AVAILABLE:
md = markdown.Markdown(extensions=[
'fenced_code',
'tables',
'toc',
'nl2br', # Convert newlines to <br>
'sane_lists', # Better list handling
])
html = md.convert(content)
# Rewrite relative .md links to /help/ routes
# e.g. href="random_build.md" -> href="/help/random_build"
html = re.sub(
r'href="([^":/]+)\.md(?:#([^"]*))?"',
lambda m: f'href="/help/{m.group(1)}"' + (f'#{m.group(2)}' if m.group(2) else ''),
html
)
# Strip the first <h1>, optional description <p>, and optional <hr> that follows —
# the guide template renders both from metadata separately
html = re.sub(r'^\s*<h1[^>]*>.*?</h1>\s*(?:<p>.*?</p>\s*)?(?:<hr\s*/?>)?\s*', '', html, count=1, flags=re.DOTALL)
# Extract table of contents (generated by toc extension)
toc_html = getattr(md, 'toc', '')
else:
# Fallback: wrap in <pre> tags (shouldn't happen in prod)
html = f"<pre>{content}</pre>"
toc_html = ""
logger.warning(f"Markdown rendering unavailable for {guide_name}")
return GuideContent(html=html, toc_html=toc_html)
except Exception as e:
logger.error(f"Failed to render guide {guide_name}: {e}")
raise ServiceError(f"Failed to render guide: {e}")
def get_guide(self, guide_name: str, force_reload: bool = False) -> GuideContent:
"""Get rendered HTML and TOC for a guide.
Args:
guide_name: Name of guide (without .md extension)
force_reload: Force re-render even if cached
Returns:
GuideContent with HTML and TOC
Raises:
NotFoundError: If guide doesn't exist
ServiceError: If rendering fails
"""
return self.get(guide_name, force_recompute=force_reload)
def list_guides(self) -> List[GuideMetadata]:
"""List all available documentation guides.
Returns:
List of guide metadata sorted by logical workflow order
"""
# Define logical ordering for deckbuilding workflow
guide_order = [
"build_wizard",
"theme_browser",
"partner_mechanics",
"budget_mode",
"bracket_compliance",
"owned_cards",
"include_exclude",
"multi_copy",
"land_bases",
"quick_build_skip_controls",
"locks_replace_permalinks",
"random_build",
"batch_build_compare",
]
guides = []
for path in sorted(self.docs_dir.glob("*.md")):
name = path.stem
metadata = self._extract_metadata(path)
guides.append(GuideMetadata(
name=name,
title=metadata.get("title", self._title_from_name(name)),
description=metadata.get("description"),
file_path=path
))
# Sort by defined order (unrecognized guides at end, alphabetically)
def sort_key(g: GuideMetadata) -> tuple:
try:
return (0, guide_order.index(g.name))
except ValueError:
return (1, g.title.lower())
return sorted(guides, key=sort_key)
def get_metadata(self, guide_name: str) -> GuideMetadata:
"""Get metadata for a specific guide.
Args:
guide_name: Name of guide (without .md extension)
Returns:
Guide metadata
Raises:
NotFoundError: If guide doesn't exist
"""
file_path = self.docs_dir / f"{guide_name}.md"
if not file_path.exists():
raise NotFoundError(f"Guide not found: {guide_name}")
metadata = self._extract_metadata(file_path)
return GuideMetadata(
name=guide_name,
title=metadata.get("title", self._title_from_name(guide_name)),
description=metadata.get("description"),
file_path=file_path
)
def _extract_metadata(self, file_path: Path) -> Dict[str, str]:
"""Extract metadata from markdown file.
Looks for:
- Title: First H1 heading (# Title)
- Description: First paragraph after title
Args:
file_path: Path to markdown file
Returns:
Metadata dictionary
"""
try:
content = file_path.read_text(encoding="utf-8")
metadata = {}
# Extract title from first H1
title_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
if title_match:
metadata["title"] = title_match.group(1).strip()
# Extract description from first paragraph after title
# Look for non-empty line after title that's not a heading
lines = content.split('\n')
found_title = False
for line in lines:
line = line.strip()
if not found_title and line.startswith('# '):
found_title = True
continue
if found_title and line and not line.startswith('#'):
metadata["description"] = line[:200] # Limit description length
break
return metadata
except Exception as e:
logger.warning(f"Failed to extract metadata from {file_path}: {e}")
return {}
@staticmethod
def _title_from_name(name: str) -> str:
"""Convert file name to readable title.
Args:
name: File name (without extension)
Returns:
Human-readable title
"""
# Replace underscores with spaces and title case
return name.replace('_', ' ').title()
def invalidate_guide(self, guide_name: Optional[str] = None) -> None:
"""Invalidate cached guide(s).
Args:
guide_name: Guide to invalidate (None = invalidate all)
"""
self.invalidate(guide_name)

View file

@ -582,6 +582,10 @@ video {
visibility: collapse; visibility: collapse;
} }
.static {
position: static;
}
.fixed { .fixed {
position: fixed; position: fixed;
} }
@ -743,6 +747,10 @@ video {
display: grid; display: grid;
} }
.contents {
display: contents;
}
.hidden { .hidden {
display: none; display: none;
} }
@ -1061,6 +1069,10 @@ video {
text-transform: uppercase; text-transform: uppercase;
} }
.lowercase {
text-transform: lowercase;
}
.capitalize { .capitalize {
text-transform: capitalize; text-transform: capitalize;
} }
@ -1078,6 +1090,11 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity, 1)); color: rgb(229 231 235 / var(--tw-text-opacity, 1));
} }
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-600 { .text-gray-600 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1)); color: rgb(75 85 99 / var(--tw-text-opacity, 1));
@ -1622,6 +1639,25 @@ body.htmx-settling *{
max-width:200px; max-width:200px;
} }
/* Page header - consistent section heading across all pages */
.page-header {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.page-header > h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.page-header > p {
font-size: 0.9rem;
margin: 0;
}
/* Buttons, inputs */ /* Buttons, inputs */
button{ button{
@ -4935,6 +4971,7 @@ img.lqip.loaded {
} }
/* Pool size badge for chip context (R21 M2) */ /* Pool size badge for chip context (R21 M2) */
.badge-pool { .badge-pool {
font-size: 10px; font-size: 10px;
color: #6b7280; color: #6b7280;
@ -5605,6 +5642,71 @@ img.lqip.loaded {
border-radius: 10px; border-radius: 10px;
} }
/* Contextual help tooltips — shared fixed panel avoids overflow clipping */
.help-tip {
position: relative;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.help-tip-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 50%;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.help-tip-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.help-tip-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.help-tip-panel {
position: fixed;
display: none;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
width: 200px;
box-shadow: 0 4px 14px rgba(0,0,0,.35);
z-index: 9999;
white-space: normal;
text-align: left;
font-weight: normal;
}
.help-tip-panel a {
color: var(--primary);
font-size: 11px;
text-decoration: none;
display: block;
margin-top: 5px;
}
.help-tip-panel a:hover {
text-decoration: underline;
}
.pin-btn { .pin-btn {
position: absolute; position: absolute;
top: 4px; top: 4px;
@ -5834,31 +5936,6 @@ footer.site-footer {
flex-shrink: 0; flex-shrink: 0;
} }
.hover\:opacity-100:hover {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.dark\:border-gray-700 {
--tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
}
.dark\:bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
}
.dark\:text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
}
/* ============================================================ /* ============================================================
Budget Mode Badge, Tier Labels, Price Tooltip Budget Mode Badge, Tier Labels, Price Tooltip
============================================================ */ ============================================================ */
@ -5890,6 +5967,7 @@ footer.site-footer {
} }
/* Tier badges on the pickups table */ /* Tier badges on the pickups table */
.tier-badge { .tier-badge {
display: inline-block; display: inline-block;
padding: .1rem .5rem; padding: .1rem .5rem;
@ -5916,6 +5994,7 @@ footer.site-footer {
} }
/* Inline price tooltip on card names */ /* Inline price tooltip on card names */
.card-name-price-hover { .card-name-price-hover {
cursor: default; cursor: default;
position: relative; position: relative;
@ -5939,6 +6018,7 @@ footer.site-footer {
} }
/* Price overlay on card thumbnails (step5 tiles + deck summary thumbs) */ /* Price overlay on card thumbnails (step5 tiles + deck summary thumbs) */
.card-price-overlay { .card-price-overlay {
position: absolute; position: absolute;
top: 6px; top: 6px;
@ -5955,9 +6035,13 @@ footer.site-footer {
white-space: nowrap; white-space: nowrap;
line-height: 16px; line-height: 16px;
} }
.card-price-overlay:empty { display: none; }
.card-price-overlay:empty {
display: none;
}
/* Inline price in deck summary list rows */ /* Inline price in deck summary list rows */
.card-price-inline { .card-price-inline {
font-size: 11px; font-size: 11px;
color: var(--muted, #94a3b8); color: var(--muted, #94a3b8);
@ -5965,17 +6049,23 @@ footer.site-footer {
white-space: nowrap; white-space: nowrap;
padding: 0 2px; padding: 0 2px;
} }
.card-price-inline:empty { color: transparent; }
.card-price-inline:empty {
color: transparent;
}
/* Over-budget highlight — gold/amber, matching the locked card style */ /* Over-budget highlight — gold/amber, matching the locked card style */
.card-tile.over-budget { .card-tile.over-budget {
border-color: #f5c518 !important; border-color: #f5c518 !important;
box-shadow: inset 0 0 8px rgba(245, 197, 24, .25), 0 0 5px #f5c518 !important; box-shadow: inset 0 0 8px rgba(245, 197, 24, .25), 0 0 5px #f5c518 !important;
} }
.stack-card.over-budget { .stack-card.over-budget {
border-color: #f5c518 !important; border-color: #f5c518 !important;
box-shadow: 0 6px 18px rgba(0,0,0,.55), 0 0 7px #f5c518 !important; box-shadow: 0 6px 18px rgba(0,0,0,.55), 0 0 7px #f5c518 !important;
} }
.list-row.over-budget .name { .list-row.over-budget .name {
background: rgba(245, 197, 24, .12); background: rgba(245, 197, 24, .12);
box-shadow: 0 0 0 1px #f5c518; box-shadow: 0 0 0 1px #f5c518;
@ -5983,6 +6073,7 @@ footer.site-footer {
} }
/* Budget price summary bar in deck summary */ /* Budget price summary bar in deck summary */
.budget-price-bar { .budget-price-bar {
font-size: 13px; font-size: 13px;
padding: .3rem .5rem; padding: .3rem .5rem;
@ -5991,10 +6082,19 @@ footer.site-footer {
border: 1px solid var(--border, #333); border: 1px solid var(--border, #333);
background: var(--panel, #1a1f2e); background: var(--panel, #1a1f2e);
} }
.budget-price-bar.under { border-color: #34d399; color: #a7f3d0; }
.budget-price-bar.over { border-color: #f5c518; color: #fde68a; }
/* M5: Budget review panel */ .budget-price-bar.under {
border-color: #34d399;
color: #a7f3d0;
}
.budget-price-bar.over {
border-color: #f5c518;
color: #fde68a;
}
/* Budget review panel */
.budget-review-panel { .budget-review-panel {
border: 1px solid var(--border, #444); border: 1px solid var(--border, #444);
border-left: 4px solid #f5c518; border-left: 4px solid #f5c518;
@ -6002,6 +6102,7 @@ footer.site-footer {
background: var(--panel, #1a1f2e); background: var(--panel, #1a1f2e);
padding: .75rem 1rem; padding: .75rem 1rem;
} }
.budget-review-header { .budget-review-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -6009,14 +6110,25 @@ footer.site-footer {
gap: .5rem; gap: .5rem;
margin-bottom: .5rem; margin-bottom: .5rem;
} }
.budget-review-summary { flex: 1 1 auto; }
.budget-review-cards { display: flex; flex-direction: column; gap: .5rem; margin-top: .5rem; } .budget-review-summary {
flex: 1 1 auto;
}
.budget-review-cards {
display: flex;
flex-direction: column;
gap: .5rem;
margin-top: .5rem;
}
.budget-review-card-row { .budget-review-card-row {
border: 1px solid var(--border, #333); border: 1px solid var(--border, #333);
border-radius: 4px; border-radius: 4px;
padding: .4rem .6rem; padding: .4rem .6rem;
background: var(--bg, #141824); background: var(--bg, #141824);
} }
.budget-review-card-info { .budget-review-card-info {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -6024,9 +6136,22 @@ footer.site-footer {
gap: .4rem; gap: .4rem;
margin-bottom: .25rem; margin-bottom: .25rem;
} }
.budget-review-card-name { font-weight: 600; }
.budget-review-card-price { color: #f5c518; } .budget-review-card-name {
.budget-review-alts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; } font-weight: 600;
}
.budget-review-card-price {
color: #f5c518;
}
.budget-review-alts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem;
}
.btn-alt-swap { .btn-alt-swap {
font-size: .8rem; font-size: .8rem;
padding: .2rem .5rem; padding: .2rem .5rem;
@ -6038,18 +6163,64 @@ footer.site-footer {
align-items: center; align-items: center;
gap: .3rem; gap: .3rem;
} }
.btn-alt-swap:hover { background: var(--hover, #252d3d); }
.alt-price { color: #34d399; font-size: .75rem; }
.budget-review-no-alts { font-size: .8rem; }
.budget-review-subtitle { font-size: .85rem; margin-bottom: .5rem; }
.budget-review-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.chip-red { background: rgba(239,68,68,.15); color: #fca5a5; border-color: #ef4444; }
.chip-green { background: rgba(34,197,94,.15); color: #86efac; border-color: #22c55e; }
.chip-subtle { background: rgba(148,163,184,.08); color: var(--muted, #94a3b8); border-color: rgba(148,163,184,.2); font-size: .7rem; padding: 1px 6px; }
/* M8: Price category stacked bar */ .btn-alt-swap:hover {
.price-cat-section { margin: .6rem 0 .2rem 0; } background: var(--hover, #252d3d);
.price-cat-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; } }
.alt-price {
color: #34d399;
font-size: .75rem;
}
.budget-review-no-alts {
font-size: .8rem;
}
.budget-review-subtitle {
font-size: .85rem;
margin-bottom: .5rem;
}
.budget-review-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.chip-red {
background: rgba(239,68,68,.15);
color: #fca5a5;
border-color: #ef4444;
}
.chip-green {
background: rgba(34,197,94,.15);
color: #86efac;
border-color: #22c55e;
}
.chip-subtle {
background: rgba(148,163,184,.08);
color: var(--muted, #94a3b8);
border-color: rgba(148,163,184,.2);
font-size: .7rem;
padding: 1px 6px;
}
/* Price category stacked bar */
.price-cat-section {
margin: .6rem 0 .2rem 0;
}
.price-cat-heading {
font-size: 12px;
color: var(--muted, #94a3b8);
margin-bottom: .3rem;
font-weight: 600;
}
.price-cat-bar { .price-cat-bar {
display: flex; display: flex;
height: 18px; height: 18px;
@ -6058,12 +6229,18 @@ footer.site-footer {
border: 1px solid var(--border, #333); border: 1px solid var(--border, #333);
background: var(--panel, #1a1f2e); background: var(--panel, #1a1f2e);
} }
.price-cat-seg { .price-cat-seg {
height: 100%; height: 100%;
transition: opacity .15s; transition: opacity .15s;
position: relative; position: relative;
} }
.price-cat-seg:hover { opacity: .75; cursor: default; }
.price-cat-seg:hover {
opacity: .75;
cursor: default;
}
.price-cat-legend { .price-cat-legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -6072,12 +6249,33 @@ footer.site-footer {
font-size: 11px; font-size: 11px;
color: var(--muted, #94a3b8); color: var(--muted, #94a3b8);
} }
.price-cat-legend-item { display: flex; align-items: center; gap: .3rem; }
.price-cat-swatch { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
/* M8: Price histogram bars */ .price-cat-legend-item {
.price-hist-section { margin: .75rem 0 .2rem 0; } display: flex;
.price-hist-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; } align-items: center;
gap: .3rem;
}
.price-cat-swatch {
width: 9px;
height: 9px;
border-radius: 2px;
flex-shrink: 0;
}
/* Price histogram bars */
.price-hist-section {
margin: .75rem 0 .2rem 0;
}
.price-hist-heading {
font-size: 12px;
color: var(--muted, #94a3b8);
margin-bottom: .3rem;
font-weight: 600;
}
.price-hist-bars { .price-hist-bars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -6085,6 +6283,7 @@ footer.site-footer {
height: 80px; height: 80px;
margin-bottom: 0; margin-bottom: 0;
} }
.price-hist-column { .price-hist-column {
flex: 1; flex: 1;
display: flex; display: flex;
@ -6095,18 +6294,24 @@ footer.site-footer {
cursor: pointer; cursor: pointer;
transition: opacity .15s; transition: opacity .15s;
} }
.price-hist-column:hover { opacity: .8; }
.price-hist-column:hover {
opacity: .8;
}
.price-hist-bar { .price-hist-bar {
width: 100%; width: 100%;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
min-height: 2px; min-height: 2px;
} }
.price-hist-xlabels { .price-hist-xlabels {
display: flex; display: flex;
gap: 3px; gap: 3px;
margin-top: 2px; margin-top: 2px;
margin-bottom: .25rem; margin-bottom: .25rem;
} }
.price-hist-xlabel { .price-hist-xlabel {
flex: 1; flex: 1;
font-size: 10px; font-size: 10px;
@ -6116,10 +6321,104 @@ footer.site-footer {
word-break: break-all; word-break: break-all;
line-height: 1.2; line-height: 1.2;
} }
.price-hist-count { font-size: 11px; color: var(--muted, #94a3b8); margin-top: .1rem; }
/* M9: Stale price indicators */ .price-hist-count {
.stale-price-indicator { position: absolute; top: 4px; right: 4px; font-size: 10px; color: #f59e0b; cursor: default; pointer-events: auto; z-index: 2; } font-size: 11px;
.stale-price-badge { font-size: 10px; color: #f59e0b; margin-left: 2px; vertical-align: middle; cursor: default; } color: var(--muted, #94a3b8);
.stale-banner { background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.35); border-radius: 6px; padding: .4rem .75rem; font-size: 12px; color: #f59e0b; margin-bottom: .6rem; } margin-top: .1rem;
}
/* Stale price indicators */
.stale-price-indicator {
position: absolute;
top: 4px;
right: 4px;
font-size: 10px;
color: #f59e0b;
cursor: default;
pointer-events: auto;
z-index: 2;
}
.stale-price-badge {
font-size: 10px;
color: #f59e0b;
margin-left: 2px;
vertical-align: middle;
cursor: default;
}
.stale-banner {
background: rgba(245,158,11,.08);
border: 1px solid rgba(245,158,11,.35);
border-radius: 6px;
padding: .4rem .75rem;
font-size: 12px;
color: #f59e0b;
margin-bottom: .6rem;
}
/* Running budget chip */
.running-budget-chip {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .2rem .6rem;
border-radius: 999px;
font-size: .82rem;
font-weight: 600;
border: 1px solid var(--border, #444);
background: var(--panel, #1a1f2e);
color: var(--text, #e5e7eb);
white-space: nowrap;
}
/* Pickups table */
.pickups-table th,
.pickups-table td {
font-size: .92rem;
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
}
.hover\:opacity-100:hover {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.dark\:border-gray-700 {
--tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
}
.dark\:bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
}
.dark\:text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.dark\:hover\:text-gray-300:hover {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
}

View file

@ -196,6 +196,22 @@ body.htmx-settling *{ transition-duration: 0s !important; }
} }
.card-preview.card-sm{ max-width:200px; } .card-preview.card-sm{ max-width:200px; }
/* Page header - consistent section heading across all pages */
.page-header {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.page-header > h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.page-header > p {
font-size: 0.9rem;
margin: 0;
}
/* Buttons, inputs */ /* Buttons, inputs */
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
button:hover{ filter:brightness(1.05); } button:hover{ filter:brightness(1.05); }
@ -3378,6 +3394,64 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
border-radius: 10px; border-radius: 10px;
} }
/* Contextual help tooltips — shared fixed panel avoids overflow clipping */
.help-tip {
position: relative;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.help-tip-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: var(--text-muted);
background: none;
border: 1px solid var(--border);
border-radius: 50%;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.help-tip-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.help-tip-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.help-tip-panel {
position: fixed;
display: none;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
width: 200px;
box-shadow: 0 4px 14px rgba(0,0,0,.35);
z-index: 9999;
white-space: normal;
text-align: left;
font-weight: normal;
}
.help-tip-panel a {
color: var(--primary);
font-size: 11px;
text-decoration: none;
display: block;
margin-top: 5px;
}
.help-tip-panel a:hover {
text-decoration: underline;
}
.pin-btn { .pin-btn {
position: absolute; position: absolute;
top: 4px; top: 4px;

View file

@ -91,6 +91,7 @@
<a href="/decks">Finished Decks</a> <a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a> <a href="/themes/">Themes</a>
{% if random_ui %}<a href="/random">Random</a>{% endif %} {% if random_ui %}<a href="/random">Random</a>{% endif %}
<a href="/help">Help</a>
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %} {% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %} {% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav> </nav>
@ -557,5 +558,62 @@
document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); }); document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); });
})(); })();
</script> </script>
<!-- Shared help-tip panel: position:fixed to avoid overflow clipping in any container -->
<div id="g-help-tip" class="help-tip-panel" role="tooltip"></div>
<script>
(function() {
var P = null, hide = null, activeBtn = null;
function panel() { return P || (P = document.getElementById('g-help-tip')); }
function show(btn) {
clearTimeout(hide);
var t = btn.getAttribute('data-tip') || '';
var h = btn.getAttribute('data-tip-href') || '';
var p = panel();
p.innerHTML = t + (h ? '<a href="' + h + '" target="_blank" rel="noopener noreferrer">Full guide &rarr;</a>' : '');
p.style.display = 'block';
activeBtn = btn;
var r = btn.getBoundingClientRect();
var w = 200, m = 8;
var left = r.left + r.width / 2 - w / 2;
left = Math.max(m, Math.min(left, window.innerWidth - w - m));
p.style.left = left + 'px';
var ph = p.offsetHeight || 88;
p.style.top = r.top >= ph + 10 ? (r.top - ph - 6) + 'px' : (r.bottom + 6) + 'px';
}
function doHide(ms) {
clearTimeout(hide);
hide = setTimeout(function() { var p = panel(); p.style.display = 'none'; activeBtn = null; }, ms || 0);
}
// Desktop hover
document.addEventListener('mouseover', function(e) {
var b = e.target.closest && e.target.closest('.help-tip-btn');
if (b) { show(b); return; }
if (e.target.closest && e.target.closest('#g-help-tip')) clearTimeout(hide);
});
document.addEventListener('mouseout', function(e) {
var r = e.relatedTarget;
if (e.target.closest && e.target.closest('.help-tip-btn')) {
if (r && r.closest && r.closest('#g-help-tip')) return;
doHide(150);
}
if (e.target.closest && e.target.closest('#g-help-tip')) {
if (r && r.closest && r.closest('.help-tip-btn')) return;
doHide(150);
}
});
// Mobile tap
document.addEventListener('click', function(e) {
var b = e.target.closest && e.target.closest('.help-tip-btn');
if (b) {
if (panel().style.display === 'block' && activeBtn === b) { doHide(0); }
else { show(b); }
e.stopPropagation();
return;
}
if (e.target.closest && e.target.closest('#g-help-tip')) return;
doHide(0);
});
})();
</script>
</body> </body>
</html> </html>

View file

@ -60,8 +60,10 @@
</style> </style>
<section class="card-browser-container"> <section class="card-browser-container">
<h3>Card Browser</h3> <div class="page-header">
<p class="muted">Browse all {{ total_cards }} cards with filters and search.</p> <h2>All Cards</h2>
<p class="muted">Browse all {{ total_cards }} cards with filters and search.</p>
</div>
{# Error message #} {# Error message #}
{% if error %} {% if error %}

View file

@ -48,7 +48,7 @@
{% include "build/_new_deck_additional_themes.html" %} {% include "build/_new_deck_additional_themes.html" %}
{% endif %} {% endif %}
<div class="mt-2" id="newdeck-bracket-slot"> <div class="mt-2" id="newdeck-bracket-slot">
<label>Bracket <label><span style="display:inline-flex; align-items:center; gap:4px;">Bracket <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Restricts deck picks to the power-level rules for that bracket tier." data-tip-href="/help/bracket_compliance#bracket-tiers" aria-label="Bracket compliance help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></span>
<select name="bracket"> <select name="bracket">
{% for b in brackets %} {% for b in brackets %}
{% if not gc_commander or b.level >= 3 %} {% if not gc_commander or b.level >= 3 %}
@ -89,12 +89,12 @@
</div> </div>
<label for="pref-mc-chk" class="form-checkbox-label" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal)."> <label for="pref-mc-chk" class="form-checkbox-label" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" {% if form and form.enable_multicopy %}checked{% endif %} /> <input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" {% if form and form.enable_multicopy %}checked{% endif %} />
<span>Enable Multi-Copy package</span> <span>Enable Multi-Copy package <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Includes multiple copies of a single archetype card (tokens, slivers, etc.)." data-tip-href="/help/multi_copy" aria-label="Multi-Copy package help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></span>
</label> </label>
<div class="flex flex-col gap-2 mt-3"> <div class="flex flex-col gap-2">
<label for="use-owned-chk" class="form-checkbox-label" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped."> <label for="use-owned-chk" class="form-checkbox-label" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" {% if form and form.use_owned_only %}checked{% endif %} /> <input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" {% if form and form.use_owned_only %}checked{% endif %} />
<span>Use only owned cards</span> <span>Use only owned cards <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Limits card picks to your uploaded owned-card library only." data-tip-href="/help/owned_cards#build-modes" aria-label="Owned cards help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></span>
</label> </label>
<label for="prefer-owned-chk" class="form-checkbox-label" title="Still allow unowned cards, but rank owned cards higher when choosing picks."> <label for="prefer-owned-chk" class="form-checkbox-label" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" {% if form and form.prefer_owned %}checked{% endif %} /> <input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" {% if form and form.prefer_owned %}checked{% endif %} />
@ -106,7 +106,7 @@
</label> </label>
<label for="smart-lands-chk" class="form-checkbox-label" title="When enabled, the builder automatically adjusts the land count and mana-base profile based on your commander's speed and color complexity."> <label for="smart-lands-chk" class="form-checkbox-label" title="When enabled, the builder automatically adjusts the land count and mana-base profile based on your commander's speed and color complexity.">
<input type="checkbox" name="enable_smart_lands" id="smart-lands-chk" value="1" {% if form and form.enable_smart_lands %}checked{% endif %} /> <input type="checkbox" name="enable_smart_lands" id="smart-lands-chk" value="1" {% if form and form.enable_smart_lands %}checked{% endif %} />
<span>Smart Land Bases</span> <span>Smart Land Bases <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Auto-adjusts land count and mana profile based on commander speed and color complexity." data-tip-href="/help/land_bases#speed-categories-land-counts" aria-label="Smart Land Bases help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></span>
</label> </label>
</div> </div>
</div> </div>
@ -115,7 +115,7 @@
{% include "build/_new_deck_ideals.html" %} {% include "build/_new_deck_ideals.html" %}
{% if allow_must_haves %} {% if allow_must_haves %}
<fieldset> <fieldset>
<legend>Include/Exclude Cards</legend> <legend>Include/Exclude Cards <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Force specific cards into or out of your deck before the build runs." data-tip-href="/help/include_exclude#adding-cards" aria-label="Include/Exclude help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></legend>
<div class="include-exclude-grid"> <div class="include-exclude-grid">
<!-- Include Cards Column (Left, Green) --> <!-- Include Cards Column (Left, Green) -->
<div> <div>
@ -214,7 +214,7 @@
{% include "build/_new_deck_skip_controls.html" %} {% include "build/_new_deck_skip_controls.html" %}
{% if enable_budget_mode %} {% if enable_budget_mode %}
<fieldset> <fieldset>
<legend>Budget</legend> <legend>Budget <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Set deck and per-card price ceilings. Over-budget cards are flagged during the build." data-tip-href="/help/budget_mode#setting-a-budget" aria-label="Budget mode help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></legend>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<label class="block"> <label class="block">
<span>Total budget ($)</span> <span>Total budget ($)</span>
@ -270,6 +270,7 @@
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button> <button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<div class="modal-footer-left"> <div class="modal-footer-left">
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button> <button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
<span class="help-tip" style="margin-left:6px;"><button type="button" class="help-tip-btn" data-tip="Builds the complete deck in one step without step-by-step approval prompts." data-tip-href="/help/quick_build_skip_controls#quick-build" aria-label="Quick Build help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span>
<button type="submit" class="btn-continue" id="create-btn">Build Deck</button> <button type="submit" class="btn-continue" id="create-btn">Build Deck</button>
</div> </div>
</div> </div>

View file

@ -169,7 +169,7 @@
{# Always update the bracket dropdown on commander change; hide 12 only when gc_commander is true #} {# Always update the bracket dropdown on commander change; hide 12 only when gc_commander is true #}
<div id="newdeck-bracket-slot" hx-swap-oob="true"> <div id="newdeck-bracket-slot" hx-swap-oob="true">
<label>Bracket <label><span style="display:inline-flex; align-items:center; gap:4px;">Bracket <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Restricts deck picks to the power-level rules for that bracket tier." data-tip-href="/help/bracket_compliance#bracket-tiers" aria-label="Bracket compliance help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></span>
<select name="bracket"> <select name="bracket">
{% for b in brackets %} {% for b in brackets %}
{% if not gc_commander or b.level >= 3 %} {% if not gc_commander or b.level >= 3 %}

View file

@ -22,7 +22,7 @@
{% set partner_suggestions_has_hidden = partner_suggestions_has_hidden if partner_suggestions_has_hidden is defined else False %} {% set partner_suggestions_has_hidden = partner_suggestions_has_hidden if partner_suggestions_has_hidden is defined else False %}
{% if feature_available %} {% if feature_available %}
<fieldset> <fieldset>
<legend>Partner Mechanics</legend> <legend>Partner Mechanics <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Select a second commander via Partner, Friends Forever, or Choose a Background." data-tip-href="/help/partner_mechanics#selecting-a-partner-in-the-web-ui" aria-label="Partner Mechanics help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></legend>
{% if not partner_capable %} {% if not partner_capable %}
<p class="muted" style="font-size:12px;">This commander doesn't support partner mechanics or backgrounds.</p> <p class="muted" style="font-size:12px;">This commander doesn't support partner mechanics or backgrounds.</p>
{% else %} {% else %}

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block banner_subtitle %}Build a Deck{% endblock %} {% block banner_subtitle %}Build a Deck{% endblock %}
{% block content %} {% block content %}
<h2>Build a Deck</h2> <div class="page-header">
<h2>Build a Deck</h2>
</div>
<div style="margin:.25rem 0 1rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;"> <div style="margin:.25rem 0 1rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button> <button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
<span class="muted" style="margin-left:.25rem;">Quick-start wizard (name, commander, themes, ideals)</span> <span class="muted" style="margin-left:.25rem;">Quick-start wizard (name, commander, themes, ideals)</span>

View file

@ -3,13 +3,13 @@
{% block content %} {% block content %}
<section class="commander-page"> <section class="commander-page">
<header class="commander-hero"> <div class="page-header">
<h2>Commanders</h2> <h2>Commanders</h2>
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.</p> <p class="muted">Browse the catalog and jump straight into a build with your chosen leader.
</header> <p class="muted" style="font-size: .875rem;">
<p style="font-size: .875rem;"> Know of commander-specific themes or synergies we're missing? <a href="https://github.com/mwisnowski/mtg_python_deckbuilder/issues/new?template=commander-specific-theme-request.md" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: underline;">Submit a request here</a>.
Know of commander-specific themes or synergies we're missing? <a href="https://github.com/mwisnowski/mtg_python_deckbuilder/issues/new?template=commander-specific-theme-request.md" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: underline;">Submit a request here</a>. </p>
</p> </div>
<form <form
id="commander-filter-form" id="commander-filter-form"

View file

@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h2>Build from JSON</h2> <div class="page-header">
<h2>Build from JSON</h2>
<p class="muted">Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.</p>
</div>
<div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;"> <div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;">
<p class="muted" style="max-width: 70ch; margin:0;">
Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.
</p>
<div> <div>
<div style="display:flex; justify-content:space-between; align-items:center;"> <div style="display:flex; justify-content:space-between; align-items:center;">
<strong style="font-size:14px;">Example: {{ example_name }}</strong> <strong style="font-size:14px;">Example: {{ example_name }}</strong>

View file

@ -1,8 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block banner_subtitle %}Finished Decks{% endblock %} {% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %} {% block content %}
<h2 id="decks-heading">Finished Decks</h2> <div class="page-header">
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p> <h2 id="decks-heading">Finished Decks</h2>
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
</div>
{% if error %} {% if error %}
<div class="error">{{ error }}</div> <div class="error">{{ error }}</div>

View file

@ -1,8 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section> <section>
<h2>Diagnostics</h2> <div class="page-header">
<p class="muted">Use these tools to verify error handling surfaces.</p> <h2>Diagnostics</h2>
<p class="muted">Use these tools to verify error handling surfaces.</p>
</div>
<details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">System summary</summary> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">System summary</summary>
<div id="sysSummary" class="muted" style="margin-top:.5rem">Loading…</div> <div id="sysSummary" class="muted" style="margin-top:.5rem">Loading…</div>

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section> <section>
<h2>Logs</h2> <div class="page-header">
<h2>Logs</h2>
</div>
<form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;"> <form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;">
<label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label> <label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label>
<label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label> <label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label>

View file

@ -0,0 +1,588 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% block title %}{{ page_title }} - MTG Deckbuilder{% endblock %}
{% block content %}
<!-- Mobile guide nav toggle (must be outside sidebar due to CSS transform/fixed positioning) -->
<button class="docs-sidebar-toggle" id="docsMobileToggle" aria-label="Toggle guide navigation" aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="docs-layout">
<!-- Sidebar navigation -->
<aside class="docs-sidebar" id="docsSidebar">
<div class="docs-sidebar-header">
<h3>Documentation</h3>
{{ button('← All Guides', variant='ghost', href='/help', size='sm', classes='docs-back-link') }}
</div>
<!-- Table of contents for current guide -->
{% if toc_html %}
<div class="docs-toc">
<h4 class="docs-toc-title">On This Page</h4>
<div class="docs-toc-content">
{{ toc_html | safe }}
</div>
</div>
{% endif %}
<!-- All guides navigation (collapsible, collapsed by default if TOC present) -->
<details class="docs-all-guides" {% if not toc_html %}open{% endif %}>
<summary class="docs-all-guides-toggle">All Guides</summary>
<nav class="docs-nav" aria-label="Documentation navigation">
{% for guide in all_guides %}
<a
href="/help/{{ guide.name }}"
class="docs-nav-item {% if guide.name == guide_name %}active{% endif %}"
{% if guide.name == guide_name %}aria-current="page"{% endif %}
>
{{ guide.title }}
</a>
{% endfor %}
</nav>
</details>
</aside>
<!-- Main content -->
<main class="docs-content">
<article class="docs-article">
<!-- Guide header -->
<header class="docs-header">
<h1>{{ guide_title }}</h1>
{% if guide_description %}
<p class="docs-description">{{ guide_description }}</p>
{% endif %}
</header>
<!-- Rendered markdown content -->
<div class="docs-body markdown-content">
{{ html_content | safe }}
</div>
<!-- Footer navigation -->
<footer class="docs-footer">
{{ button('← Back to All Guides', variant='primary', href='/help', size='md') }}
</footer>
</article>
</main>
</div>
<style>
/* Layout */
.docs-layout {
display: grid;
grid-template-columns: minmax(280px, 320px) 1fr;
gap: 2rem;
max-width: 1400px;
margin: 0;
margin-right: auto;
padding: 2rem 1rem;
min-height: calc(100vh - 120px);
align-items: start;
}
/* Sidebar */
.docs-sidebar {
position: sticky;
top: 1rem;
height: auto;
max-height: none;
overflow-y: visible;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.docs-sidebar-header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.docs-sidebar-header h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: var(--text);
}
.docs-back-link {
width: 100%;
justify-content: flex-start;
}
/* Table of contents */
.docs-toc {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.docs-toc-title {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.docs-toc-content {
font-size: 0.9rem;
}
.docs-toc-content ul {
list-style: none;
margin: 0;
padding: 0;
}
.docs-toc-content li {
margin: 0;
padding: 0;
}
.docs-toc-content a {
display: block;
padding: 0.4rem 0.5rem;
color: var(--text-muted);
text-decoration: none;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
}
.docs-toc-content a:hover {
background: var(--surface-hover);
color: var(--text);
}
.docs-toc-content a:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Nested TOC items (H3, H4, etc.) */
.docs-toc-content ul ul {
padding-left: 0.75rem;
margin-top: 0.25rem;
}
/* All guides collapsible section */
.docs-all-guides {
margin-top: 1rem;
}
.docs-all-guides-toggle {
cursor: pointer;
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
user-select: none;
list-style: none;
border-bottom: 1px solid var(--border);
transition: color 0.2s;
}
.docs-all-guides-toggle:hover {
color: var(--text);
}
.docs-all-guides-toggle::-webkit-details-marker {
display: none;
}
.docs-all-guides-toggle::before {
content: '▼';
display: inline-block;
margin-right: 0.5rem;
font-size: 0.7rem;
transition: transform 0.2s;
}
.docs-all-guides:not([open]) .docs-all-guides-toggle::before {
transform: rotate(-90deg);
}
.docs-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.docs-nav-item {
padding: 0.5rem 0.75rem;
color: var(--text-muted);
text-decoration: none;
border-radius: 4px;
font-size: 0.95rem;
transition: background-color 0.2s, color 0.2s, transform 0.1s;
}
.docs-nav-item:hover {
background: var(--surface-hover);
color: var(--text);
transform: translateX(2px);
}
.docs-nav-item:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.docs-nav-item.active {
background: var(--primary);
color: white;
font-weight: 600;
}
.docs-nav-item.active:focus {
outline-color: var(--text);
}
/* Main content */
.docs-content {
min-width: 0; /* Prevent grid overflow */
}
.docs-article {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
}
.docs-header {
padding-bottom: 1.5rem;
border-bottom: 2px solid var(--border);
margin-bottom: 2rem;
}
.docs-header h1 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: var(--text);
}
.docs-description {
color: var(--text-muted);
margin: 0;
font-size: 1.05rem;
line-height: 1.6;
}
.docs-body {
line-height: 1.7;
color: var(--text);
}
.docs-footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
/* Markdown content styling */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.75em;
font-weight: 600;
line-height: 1.3;
color: var(--text);
scroll-margin-top: 68px; /* banner height (52px) + breathing room */
}
.markdown-content h1 { font-size: 1.8rem; border-bottom: 2px solid var(--border); padding-bottom: 0.3em; }
.markdown-content h2 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
.markdown-content h3 { font-size: 1.25rem; }
.markdown-content h4 { font-size: 1.1rem; }
.markdown-content h5 { font-size: 1rem; }
.markdown-content h6 { font-size: 0.95rem; color: var(--text-muted); }
.markdown-content p {
margin: 1em 0;
}
.markdown-content a {
color: var(--primary);
text-decoration: underline;
transition: color 0.2s;
}
.markdown-content a:hover {
color: var(--primary-hover);
}
.markdown-content ul,
.markdown-content ol {
margin: 1em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.5em 0;
}
.markdown-content code {
background: var(--surface-alt);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
color: var(--text);
border: 1px solid var(--border);
}
.markdown-content pre {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1em;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
border: none;
font-size: 0.9em;
display: block;
}
.markdown-content blockquote {
border-left: 4px solid var(--primary);
padding-left: 1em;
margin: 1em 0;
color: var(--text-muted);
font-style: italic;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
border: 1px solid var(--border);
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--border);
padding: 0.75em;
text-align: left;
}
.markdown-content th {
background: var(--surface-alt);
font-weight: 600;
}
.markdown-content tr:nth-child(even) {
background: var(--surface-hover);
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1em 0;
}
.markdown-content hr {
border: none;
border-top: 2px solid var(--border);
margin: 2em 0;
}
/* Mobile responsive */
/* Sidebar toggle tab (integrated into sidebar) */
.docs-sidebar-toggle {
display: none; /* Hidden on desktop */
}
@media (max-width: 968px) {
/* Toggle is a fixed sibling of sidebar - visible regardless of sidebar transform */
.docs-sidebar-toggle {
display: flex;
position: fixed;
top: 60px; /* Below banner */
left: 0;
z-index: 100;
background: var(--surface-sidebar);
color: var(--surface-sidebar-text);
border: 1px solid var(--border);
border-left: none;
border-radius: 0 6px 6px 0;
padding: 0.75rem 0.5rem;
cursor: pointer;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
transition: left 0.3s ease-in-out, border-radius 0.3s;
align-items: center;
justify-content: center;
}
.docs-sidebar-toggle:hover {
background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%);
}
/* When sidebar is open, shift toggle to be at edge of open sidebar */
.docs-sidebar-toggle.sidebar-open {
left: 280px;
border-radius: 0 6px 6px 0;
border-left: none;
}
.docs-layout {
grid-template-columns: 1fr;
padding: 1rem;
}
.docs-sidebar {
position: fixed;
top: 52px; /* Below top banner */
left: 0;
width: 280px;
height: calc(100vh - 52px);
z-index: 50;
max-height: none;
margin-bottom: 0;
border-radius: 0;
border-left: none;
border-top: none;
border-bottom: none;
background: var(--surface-sidebar);
color: var(--surface-sidebar-text);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
overflow-y: auto;
}
.docs-sidebar.mobile-open {
transform: translateX(0);
}
/* Backdrop when sidebar is open */
.docs-sidebar::before {
content: '';
position: fixed;
top: 52px;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
z-index: -1;
}
.docs-sidebar.mobile-open::before {
opacity: 1;
pointer-events: auto;
}
/* Sidebar content (not the toggle) */
.docs-sidebar-header,
.docs-toc,
.docs-all-guides {
position: relative;
z-index: 1;
}
.docs-article {
padding: 1.5rem;
}
.docs-header h1 {
font-size: 1.5rem;
}
}
@media (max-width: 640px) {
.docs-article {
padding: 1rem;
}
.markdown-content h1 { font-size: 1.5rem; }
.markdown-content h2 { font-size: 1.25rem; }
.markdown-content h3 { font-size: 1.1rem; }
}
</style>
<script>
// Smooth anchor scrolling
document.addEventListener('DOMContentLoaded', function() {
const links = document.querySelectorAll('.markdown-content a[href^="#"], .docs-toc-content a[href^="#"]');
links.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Close mobile sidebar after navigation
if (window.innerWidth <= 968) {
const sidebar = document.getElementById('docsSidebar');
const toggleButton = document.getElementById('docsMobileToggle');
if (sidebar && toggleButton) {
sidebar.classList.remove('mobile-open');
toggleButton.classList.remove('sidebar-open');
toggleButton.setAttribute('aria-expanded', 'false');
}
}
}
});
});
// Mobile sidebar toggle
const toggleButton = document.getElementById('docsMobileToggle');
const sidebar = document.getElementById('docsSidebar');
if (toggleButton && sidebar) {
toggleButton.addEventListener('click', function(e) {
e.stopPropagation();
const isOpen = sidebar.classList.toggle('mobile-open');
toggleButton.classList.toggle('sidebar-open', isOpen);
toggleButton.setAttribute('aria-expanded', isOpen);
});
// Close sidebar when clicking backdrop
sidebar.addEventListener('click', function(e) {
if (e.target === sidebar || e.target.classList.contains('docs-sidebar')) {
sidebar.classList.remove('mobile-open');
toggleButton.classList.remove('sidebar-open');
toggleButton.setAttribute('aria-expanded', 'false');
}
});
// Close sidebar on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && sidebar.classList.contains('mobile-open')) {
sidebar.classList.remove('mobile-open');
toggleButton.classList.remove('sidebar-open');
toggleButton.setAttribute('aria-expanded', 'false');
}
});
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,96 @@
{% extends "base.html" %}
{% from 'partials/_buttons.html' import button %}
{% from 'partials/_panels.html' import simple_panel %}
{% block title %}{{ page_title }} - MTG Deckbuilder{% endblock %}
{% block content %}
<div class="page-header">
<h2>Documentation</h2>
<p class="muted">User guides and feature documentation</p>
</div>
{% if guides %}
<div class="docs-index">
{% for guide in guides %}
<div class="doc-card">
<h3 class="doc-card-title">
<a href="/help/{{ guide.name }}">{{ guide.title }}</a>
</h3>
{% if guide.description %}
<p class="doc-card-description">{{ guide.description }}</p>
{% endif %}
<div class="doc-card-actions">
{{ button('Read Guide →', variant='ghost', href='/help/' + guide.name, size='sm') }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">
<p>No documentation guides available.</p>
</div>
{% endif %}
<style>
.docs-index {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
margin-top: 0;
}
.doc-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.doc-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.doc-card:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.doc-card-title {
margin: 0 0 0.75rem 0;
font-size: 1.25rem;
font-weight: 600;
}
.doc-card-title a {
color: var(--text);
text-decoration: none;
transition: color 0.2s;
}
.doc-card-title a:hover {
color: var(--primary);
}
.doc-card-description {
color: var(--text-muted);
margin: 0 0 1rem 0;
line-height: 1.5;
font-size: 0.95rem;
}
.doc-card-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.docs-index {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}

View file

@ -12,6 +12,7 @@
{% if show_commanders %}{{ button('Browse Commanders', variant='secondary', href='/commanders', classes='action-button home-button') }}{% endif %} {% if show_commanders %}{{ button('Browse Commanders', variant='secondary', href='/commanders', classes='action-button home-button') }}{% endif %}
{{ button('Finished Decks', variant='secondary', href='/decks', classes='action-button home-button') }} {{ button('Finished Decks', variant='secondary', href='/decks', classes='action-button home-button') }}
{{ button('Browse Themes', variant='secondary', href='/themes/', classes='action-button home-button') }} {{ button('Browse Themes', variant='secondary', href='/themes/', classes='action-button home-button') }}
{{ button('Help & Guides', variant='secondary', href='/help', classes='action-button home-button') }}
{% if random_ui %}{{ button('Random Build', variant='secondary', href='/random', classes='action-button home-button') }}{% endif %} {% if random_ui %}{{ button('Random Build', variant='secondary', href='/random', classes='action-button home-button') }}{% endif %}
{% if show_diagnostics %}{{ button('Diagnostics', variant='secondary', href='/diagnostics', classes='action-button home-button') }}{% endif %} {% if show_diagnostics %}{{ button('Diagnostics', variant='secondary', href='/diagnostics', classes='action-button home-button') }}{% endif %}
{% if show_logs %}{{ button('View Logs', variant='secondary', href='/logs', classes='action-button home-button') }}{% endif %} {% if show_logs %}{{ button('View Logs', variant='secondary', href='/logs', classes='action-button home-button') }}{% endif %}

View file

@ -1,8 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section> <section>
<h3>Owned Cards Library</h3> <div class="page-header">
<p class="muted">Upload .txt or .csv lists. Well extract names and keep a de-duplicated library for the web UI.</p> <h2>Owned Library</h2>
<p class="muted">Upload .txt or .csv lists. We'll extract names and keep a de-duplicated library for the web UI.</p>
</div>
{% if error %} {% if error %}
<div class="error" style="margin:.5rem 0;">{{ error }}</div> <div class="error" style="margin:.5rem 0;">{{ error }}</div>

View file

@ -10,7 +10,7 @@
background:#0f1115; border:1px solid var(--border); border-radius:10px; background:#0f1115; border:1px solid var(--border); border-radius:10px;
box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;"> box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;">
<div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-bottom:.75rem;"> <div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-bottom:.75rem;">
<h3 id="mc-include-title" style="margin:0; font-size:1rem;">Include multi-copy package?</h3> <h3 id="mc-include-title" style="margin:0; font-size:1rem;">Include multi-copy package? <span class="help-tip"><button type="button" class="help-tip-btn" data-tip="Configures a multi-copy package for archetypes like tokens, slivers, or relentless creatures." data-tip-href="/help/multi_copy" aria-label="Multi-Copy help"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" width="11" height="11"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7" x2="8" y2="10.5" stroke-linecap="round"/><circle cx="8" cy="4.5" r="0.75" fill="currentColor" stroke="none"/></svg></button></span></h3>
<button type="button" class="btn" aria-label="Close" onclick="_mcIncludeClose()">×</button> <button type="button" class="btn" aria-label="Close" onclick="_mcIncludeClose()">×</button>
</div> </div>
<p class="muted" style="font-size:13px; margin:.25rem 0 .75rem;"> <p class="muted" style="font-size:13px; margin:.25rem 0 .75rem;">

View file

@ -2,7 +2,9 @@
{% block content %} {% block content %}
{% set enable_ui = random_ui %} {% set enable_ui = random_ui %}
<section id="random-modes" aria-labelledby="random-heading"> <section id="random-modes" aria-labelledby="random-heading">
<h2 id="random-heading">Random Modes</h2> <div class="page-header">
<h2 id="random-heading">Random</h2>
</div>
{% if not enable_ui %} {% if not enable_ui %}
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div> <div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
{% else %} {% else %}
@ -29,6 +31,7 @@
<span id="theme-tooltip-text" class="sr-only">Explain theme fallback order</span> <span id="theme-tooltip-text" class="sr-only">Explain theme fallback order</span>
<div id="theme-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false"> <div id="theme-tooltip-panel" class="tooltip-panel" role="dialog" aria-modal="false">
<p>We attempt your Primary + Secondary + Tertiary first. If that has no hits, we relax to Primary + Secondary, then Primary + Tertiary, Primary only, synergy overlap, and finally the full pool.</p> <p>We attempt your Primary + Secondary + Tertiary first. If that has no hits, we relax to Primary + Secondary, then Primary + Tertiary, Primary only, synergy overlap, and finally the full pool.</p>
<p style="margin-top:6px;"><a href="/help/random_build#multi-theme-fallback-cascade" target="_blank" rel="noopener noreferrer" style="color:var(--primary,#6366f1); font-size:11px;">Full guide &rarr;</a></p>
</div> </div>
</span> </span>
</span> </span>

View file

@ -47,8 +47,10 @@
</style> </style>
<section> <section>
<h2>Setup / Tagging</h2> <div class="page-header">
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p> <h2>Setup / Tagging</h2>
<p class="muted">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
</div>
<details open style="margin-top:.5rem;"> <details open style="margin-top:.5rem;">
<summary>Current Status</summary> <summary>Current Status</summary>

View file

@ -1,6 +1,8 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<h2>Theme Catalog (Simple)</h2> <div class="page-header">
<h2>Themes</h2>
</div>
<p style="margin-bottom: 1rem; font-size: .875rem;"> <p style="margin-bottom: 1rem; font-size: .875rem;">
See a theme that's missing or might be set up wrong? <a href="https://github.com/mwisnowski/mtg_python_deckbuilder/issues/new?template=general-theme-request.md" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: underline;">Submit a request here</a>. See a theme that's missing or might be set up wrong? <a href="https://github.com/mwisnowski/mtg_python_deckbuilder/issues/new?template=general-theme-request.md" target="_blank" rel="noopener" style="color: var(--accent); text-decoration: underline;">Submit a request here</a>.
</p> </p>

View file

@ -40,6 +40,7 @@ services:
SHOW_MISC_POOL: "0" SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache) ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
ENABLE_WEB_DOCS: "1" # 1=enable web-accessible documentation at /docs
SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation
SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file
ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider
@ -271,5 +272,6 @@ services:
# Mount code for hot-reload during development (templates, static files) # Mount code for hot-reload during development (templates, static files)
- ${PWD}/code/web/templates:/app/code/web/templates - ${PWD}/code/web/templates:/app/code/web/templates
- ${PWD}/code/web/static:/app/code/web/static - ${PWD}/code/web/static:/app/code/web/static
- ${PWD}/docs:/app/docs
working_dir: /app working_dir: /app
restart: unless-stopped restart: unless-stopped

View file

@ -42,6 +42,7 @@ services:
SHOW_MISC_POOL: "0" SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache) ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
ENABLE_WEB_DOCS: "1" # 1=enable web-accessible documentation at /docs
SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation
SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file
ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider
@ -269,5 +270,6 @@ services:
- ${PWD}/card_files:/app/card_files - ${PWD}/card_files:/app/card_files
- ${PWD}/config:/app/config - ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards - ${PWD}/owned_cards:/app/owned_cards
- ${PWD}/docs:/app/docs
working_dir: /app working_dir: /app
restart: "no" restart: "no"

View file

@ -67,3 +67,10 @@ Access the compare view from **Finished Decks** to diff any two completed builds
| Variable | Default | Purpose | | Variable | Default | Purpose |
|----------|---------|---------| |----------|---------|---------|
| `ENABLE_BATCH_BUILD` | `1` | Show the build count slider in the New Deck modal. Set to `0` to hide and restrict to single builds. | | `ENABLE_BATCH_BUILD` | `1` | Show the build count slider in the New Deck modal. Set to `0` to hide and restrict to single builds. |
---
## See Also
- [Build Wizard](build_wizard.md) — step-by-step walkthrough of a single build
- [Quick Build & Skip Controls](quick_build_skip_controls.md) — automate individual stages to speed up batch runs

View file

@ -61,6 +61,15 @@ Set `enforcement_mode` in your JSON config to control how the builder handles br
} }
``` ```
### Enforcement Examples (Bracket 3 — Upgraded)
| Scenario | `validate` | `prefer` | `strict` |
|----------|------------|----------|---------|
| 13 Game Changers in pool | Proceeds; each flagged in report | Proceeds; included within 3-card cap | Proceeds; included within 3-card cap |
| 4+ Game Changers in pool | All flagged FAIL in report | Caps selection at 3; extras skipped | Build fails listing the violating cards |
| Mass land denial card | Flagged WARN/FAIL in report | Avoided if alternatives exist in pool | Build fails if card cannot be excluded |
| Must Include card violates bracket | Flagged in report; card stays | Flagged in report; card stays | Flagged in report; card stays (Must Include always wins) |
--- ---
## Rule Zero Notes ## Rule Zero Notes
@ -107,3 +116,27 @@ The Game Changers list and companion lists are static JSON files in `config/card
| `bracket` | `exhibition` \| `core` \| `upgraded` \| `optimized` \| `cedh` | Bracket selection. Defaults to `core` if unset. | | `bracket` | `exhibition` \| `core` \| `upgraded` \| `optimized` \| `cedh` | Bracket selection. Defaults to `core` if unset. |
| `enforcement_mode` | `validate` \| `prefer` \| `strict` | How violations are handled during building. | | `enforcement_mode` | `validate` \| `prefer` \| `strict` | How violations are handled during building. |
| `rule_zero_notes` | string | Optional table agreement notes included in the compliance report. | | `rule_zero_notes` | string | Optional table agreement notes included in the compliance report. |
---
## FAQ
**My deck passed Bracket 2 but the table says it feels more like Bracket 3 — why?**
The compliance check runs against the official card lists (Game Changers, extra turns, tutors, combos). Cards not on those lists are not flagged even if they're powerful in context. Use the compliance report as a starting point, then discuss with your table.
**I set `enforcement_mode: strict` but my Must Include card still violates the bracket.**
Must Include cards always bypass enforcement filtering — they are inserted directly before pool selection runs. The compliance report will still flag the violation. Adjust the Must Include list or the bracket to resolve it.
**Why does the compliance check flag a two-card combo I didn't intend?**
Combo detection runs against a known list of two-card infinite combinations. If your synergies happen to match a known combo pattern, they'll be flagged. The report is informational — no cards are removed automatically.
**Can I update the Game Changers list when WotC publishes new cards?**
Yes. Edit the JSON files in `config/card_lists/` (e.g., `game_changers.json`). Each file has a `source_url` field pointing to the canonical source. Restart the server after editing.
---
## See Also
- [Build Wizard](build_wizard.md) — step-by-step guide covering bracket selection in context
- [Include / Exclude Lists](include_exclude.md) — how Must Include cards interact with bracket enforcement
- [Partner Mechanics](partner_mechanics.md) — bracket implications when the commander is on the Game Changers list

View file

@ -87,3 +87,17 @@ No. The commander is never filtered by price — only cards drawn from the selec
**Can I use budget mode with Must Include cards?** **Can I use budget mode with Must Include cards?**
Yes. Must Include cards bypass the pool filter and are always added. They may appear as over-budget in the summary if they exceed the ceiling. Yes. Must Include cards bypass the pool filter and are always added. They may appear as over-budget in the summary if they exceed the ceiling.
**Why is the price shown different from what I see on a store site?**
Prices are sourced from Scryfall bulk data and cached locally. They reflect a recent market median, not real-time retail or buylist prices. Check the stale indicator (clock icon) in the summary for cache age.
**Can I set separate ceilings for different card types?**
Not directly — the per-card ceiling applies to the full pool. To approximate type-specific limits, use Must Exclude to block expensive cards in a category before the build runs.
---
## See Also
- [Build Wizard](build_wizard.md) — budget step in context of the full build workflow
- [Locks, Replace & Permalinks](locks_replace_permalinks.md) — use Replace to swap out over-budget cards after building
- [Land Bases](land_bases.md) — land counts and profile choices that affect overall deck cost

View file

@ -0,0 +1,255 @@
# Build a Deck - Step-by-Step Guide
Walk through the deck building wizard to create a Commander deck.
---
## Overview
The deck builder guides you through a multi-step modal wizard that helps you configure and build a complete Commander deck. Each step lets you customize your deck's strategy, budget, and power level before generating the final list.
Access the builder at `/build` or click **Build** in the sidebar.
---
## Step 1: Choose a Commander
Select your commander from the search field in the modal. Type the commander's name to see matches.
**Partner Commanders**: If your commander has Partner, Choose a Partner, or has a Background, you'll be prompted to select a second commander in a follow-up step.
**Learn more**: [Partner Mechanics Guide](partner_mechanics.md)
---
## Step 2: Select Primary Themes
Choose 13 core themes that define your deck's strategy by clicking theme chips in the modal. Themes are organized by pool size:
- **Recommended**: Themes that work well with your commander (marked with ★)
- **Pool Size Sections**: Themes grouped by available card count (Vast, Large, Moderate, Small, Tiny)
Each theme displays a badge showing the approximate number of cards available for that theme in your commander's colors.
**Tips**:
- Start with 23 themes for focused strategies
- Mix creature-tribal and mechanical themes for depth
- Larger pool sizes give the builder more card options
- Use AND mode for tighter synergy (cards match multiple themes)
- Use OR mode for broader pools (cards match any theme)
**Learn more**: [Theme Browser & Quality System](theme_browser.md)
---
## Step 3: Add Secondary/Synergy Themes (Optional)
After selecting your primary themes, you can add additional themes using the "Additional Themes" textbox:
- Enter theme names separated by commas
- Add utility packages (e.g., card draw, removal)
- Incorporate combo pieces or win conditions
**Multi-Theme Fallback**: If you select multiple themes, the builder uses a fallback cascade to find cards that synergize across themes. If no exact matches exist, it expands to broader combinations.
**Learn more**: [Random Build (Multi-Theme Cascade)](random_build.md#multi-theme-fallback-cascade)
---
## Step 4: Bracket Compliance
Set power-level restrictions with bracket policies (required, defaults to Bracket 3):
- **Bracket 14**: WOTC-defined power tiers
- **Enforcement Modes**: Advisory, Strict, or Custom
- **Banned Lists**: Automatically enforced per format rules
Enable bracket enforcement with `ENABLE_BRACKETS=1` (default: on).
**Learn more**: [Bracket Compliance Guide](bracket_compliance.md)
---
## Step 5: Preferences
Configure optional build behaviors and card priorities:
### Combo Preferences
- **Prioritize combos**: Automatically include combo pieces near the end of the build
- **Combo count**: How many combos to include (default: 2)
- **Balance**: Early game, late game, or mix
### Multi-Copy Package
- **Enable Multi-Copy package**: Include multiple copies of cards for token/tribal strategies
- Works with archetypes like Rat Colony, Relentless Rats, Dragon's Approach
- Automatically suggests Thrumming Stone synergy when applicable
- **Learn more**: [Multi-Copy Package](multi_copy.md)
### Owned Card Preferences
- **Use only owned cards**: Limit the pool to cards you already own
- **Prefer owned cards**: Still allow unowned cards, but rank owned cards higher
Upload your collection at `/owned` or before starting a build.
**Learn more**: [Owned Cards Guide](owned_cards.md)
### Land Base Options
- **Swap basics for MDFC lands**: Modal DFC lands replace matching basic lands automatically
- **Smart Land Bases**: Auto-adjust land count and mana curve based on commander speed and color complexity
**Learn more**: [Land Bases Guide](land_bases.md)
---
## Step 6: Ideal Counts
Set target counts for key card categories using sliders or number inputs:
- **Ramp**: 030 cards (default varies by commander)
- **Lands**: 2545 cards (default varies by commander speed)
- **Basic Lands**: 040 cards (subset of total lands)
- **Creatures**: 070 cards
- **Removal**: 030 cards (spot removal)
- **Wipes**: 015 cards (board wipes)
- **Card Advantage**: 030 cards (draw/recursion)
- **Protection**: 020 cards (counterspells, indestructible effects)
**Warning**: The builder validates your totals. If ideal counts exceed 99 cards, reduce totals to avoid build issues.
**Tips**:
- Start with default recommendations
- Adjust based on your deck's strategy
- Category totals may overlap (e.g., a creature that ramps counts in both)
---
## Step 7: Include/Exclude Specific Cards (Optional)
Fine-tune your deck with must-have or must-avoid lists:
- **Include List**: Guarantee specific cards in the deck (max 10)
- **Exclude List**: Prevent certain cards from being chosen (max 15)
- **File Upload**: Upload .txt files with one card name per line
- **Fuzzy Matching**: Card names are validated with approximate matching
Enable with `ALLOW_MUST_HAVES=true`.
**Learn more**: [Include/Exclude Cards Guide](include_exclude.md)
---
## Step 8: Budget Constraints (Optional)
Control deck cost with budget limits:
- **Total Budget ($)**: Set a deck cost ceiling — cards over budget will be flagged
- **Per-Card Ceiling ($)**: Flag individual cards above this price
- **Pool Filter Tolerance (%)**: Cards exceeding the per-card ceiling by more than this % are excluded from the card pool (default: 15%)
- Set to 0 to hard-cap at the ceiling exactly
Budget filtering uses cached Scryfall prices and respects owned card preferences.
Enable with `ENABLE_BUDGET_MODE=1`.
**Learn more**: [Budget Mode Guide](budget_mode.md)
---
## Step 9: Advanced Build Options
### Quick Build / Skip Controls
Skip building specific card types to speed up the process:
- Skip lands (use with external manabase tools)
- Skip ramp/removal/draw
- Customize card type distribution
**Learn more**: [Quick Build & Skip Controls](quick_build_skip_controls.md)
### Batch Build Mode
Generate multiple deck variations at once:
- Build 110 decks with the same configuration
- Compare results to see variance in card selection
Enable with `ENABLE_BATCH_BUILD=1`.
**Learn more**: [Batch Build & Compare](batch_build_compare.md)
---
## Step 10: Build & Review
Once configured, click **Build Deck** to generate your decklist. The builder will:
1. Select cards based on your themes and synergies
2. Apply budget and bracket constraints
3. Optimize the manabase for your color identity
4. Balance card types (creatures, spells, lands)
### After Building
The results page shows:
- **Full Decklist**: All 100 cards with images and prices
- **Summary Stats**: Mana curve, color distribution, card types
- **Export Options**: CSV, TXT, JSON, or Arena format
- **Deck Actions**: Lock cards, replace specific cards, rebuild with changes
**Lock & Replace**: Found a card you want to keep? Lock it, then replace others without losing your favorites.
**Learn more**: [Locks, Replace & Permalinks](locks_replace_permalinks.md)
---
## Alternative Build Modes
### Random Build Mode
Let the builder choose themes, budget, and configuration automatically with seeded randomization.
**Learn more**: [Random Build Guide](random_build.md)
### Batch Build & Compare
Generate multiple deck variations at once and compare strategies side-by-side.
**Learn more**: [Batch Build & Compare](batch_build_compare.md)
---
## Tips for Success
1. **Start Simple**: Choose 23 strong themes for your first build
2. **Check Quality Badges**: Higher quality themes have better curation
3. **Review Theme Pool Sizes**: Larger pools give the builder more options
4. **Use Permalinks**: Save your deck configurations for tweaking later
5. **Iterate**: Lock good cards and rebuild to refine your strategy
---
## Environment Variables
Key settings for the build wizard:
- `ENABLE_THEMES=1` - Enable theme selection (default: on)
- `ENABLE_BUDGET_MODE=1` - Enable budget constraints
- `ENABLE_BRACKETS=1` - Enable bracket compliance (default: on)
- `ALLOW_MUST_HAVES=true` - Enable include/exclude lists
- `THEME_MIN_CARDS=5` - Minimum cards per theme
---
## Need More Help?
- Browse other guides for detailed feature documentation
- Check diagnostics at `/diagnostics` for system status
- Visit the theme browser at `/themes` to explore available strategies
---
## See Also
- [Theme Browser](theme_browser.md) — explore and evaluate available themes before building
- [Partner Mechanics](partner_mechanics.md) — two-commander builds and color identity rules
- [Bracket Compliance](bracket_compliance.md) — power level tiers and enforcement modes
- [Budget Mode](budget_mode.md) — filter the card pool by per-card price ceiling
- [Multi-Copy Package](multi_copy.md) — build with many copies of a single archetype card
- [Random Build](random_build.md) — spin up a randomized deck with one click
- [Batch Build & Compare](batch_build_compare.md) — generate and compare multiple builds at once

View file

@ -63,7 +63,9 @@ Must Include cards are inserted directly and are not subject to budget pool filt
## Multi-Copy Archetypes ## Multi-Copy Archetypes
If a card is in Must Include and the builder detects it supports multi-copy (e.g., Relentless Rats), a count picker dialog appears. Set the desired copy count before confirming. Some cards have a printed exception allowing any number of copies in a Commander deck. When you add one of these cards to Must Include, a count picker dialog appears to set the desired copy count.
For the full list of supported archetypes, count caps, and detailed guidance see the [Multi-Copy Package](multi_copy.md) guide.
--- ---
@ -87,3 +89,25 @@ Set include and exclude lists in the JSON config. Environment variable overrides
"must_exclude": ["Demonic Tutor"] "must_exclude": ["Demonic Tutor"]
} }
``` ```
---
## FAQ
**Can I include a card that isn't in my commander's color identity?**
The builder will attempt to add it but will silently skip it if the card is outside the commander's color identity. Check the build summary for skipped cards.
**What happens if my Must Include list makes the deck exceed 100 cards?**
Must Include cards are inserted before pool selection fills the remaining slots. The total is always capped at 100 cards, with Must Includes taking priority over pool-selected cards.
**Do Must Include cards count against bracket compliance?**
Yes. A Must Include card that violates your bracket (e.g., a Game Changer at Bracket 2) will be flagged in the compliance report. The card stays in the deck regardless of enforcement mode.
---
## See Also
- [Build Wizard](build_wizard.md) — where Include/Exclude fits in the overall build flow
- [Multi-Copy Package](multi_copy.md) — dedicated guide for multi-copy archetype builds
- [Bracket Compliance](bracket_compliance.md) — how Must Include cards interact with bracket enforcement
- [Locks, Replace & Permalinks](locks_replace_permalinks.md) — lock cards in place and swap alternatives after building

View file

@ -113,3 +113,11 @@ A **backfill** step at the end of all land phases adds basics from the color ide
- Smart Lands only adjusts **counts** — the existing land-selection steps (duals, fetches, triples, ETB optimization, etc.) run unchanged on the updated targets. - Smart Lands only adjusts **counts** — the existing land-selection steps (duals, fetches, triples, ETB optimization, etc.) run unchanged on the updated targets.
- Colorless commanders fall back to `mid` profile with 35 lands (no color identity to analyze). - Colorless commanders fall back to `mid` profile with 35 lands (no color identity to analyze).
- If the analysis fails for any reason, it silently falls back to `mid` profile and fixed 35-land target — builds are never blocked. - If the analysis fails for any reason, it silently falls back to `mid` profile and fixed 35-land target — builds are never blocked.
---
## See Also
- [Build Wizard](build_wizard.md) — land base options in the context of the full build workflow
- [Budget Mode](budget_mode.md) — price filtering that affects which lands are available in the pool
- [Quick Build & Skip Controls](quick_build_skip_controls.md) — skip specific land stages if you want to handle lands manually

View file

@ -71,3 +71,24 @@ Permalink URLs are self-contained — no server state is required. Anyone with t
## Combos (Step 5 Panel) ## Combos (Step 5 Panel)
The Combos section in Step 5 lists known two-card combo pairs detected in the current deck. This is informational — no cards are added or removed automatically. Use locks to preserve combo pairs across rebuilds, or add individual combo cards to Must Include if you want them guaranteed. The Combos section in Step 5 lists known two-card combo pairs detected in the current deck. This is informational — no cards are added or removed automatically. Use locks to preserve combo pairs across rebuilds, or add individual combo cards to Must Include if you want them guaranteed.
---
## FAQ
**Can I share a permalink with someone who has a different card catalog version?**
Permalinks encode names and settings, not card data. As long as the commander and theme names are still valid in the recipient's catalog, the build will restore correctly. Cards that no longer exist in the catalog will be skipped.
**Are locks preserved when I export to CSV or TXT?**
Locks are session metadata and are stored in the summary JSON sidecar (`*.summary.json`) alongside the export. The CSV/TXT card list itself does not include lock state.
**What if I accidentally replace a card I wanted to keep?**
Replaced cards move out of the deck immediately. If you haven't rebuilt, you can replace again to bring a similar card back in. For guaranteed cards, use Must Include so they aren't displaced by Replace or Rebuild.
---
## See Also
- [Build Wizard](build_wizard.md) — locks and replace in the context of the Step 5 review
- [Include / Exclude Lists](include_exclude.md) — guarantee specific cards before the build runs instead of after
- [Owned Cards](owned_cards.md) — restrict Replace alternatives to your owned card library

View file

@ -0,0 +1,102 @@
# Multi-Copy Package
Build decks that run many copies of a single card-type — Relentless Rats, Shadowborn Apostles, and more.
---
## Overview
Some Magic cards have a printed exception allowing any number of copies in a Commander deck. The Multi-Copy Package feature lets you include a chosen archetype card at a configured count and builds the rest of the deck around it.
Enable with `ALLOW_MUST_HAVES=1` (default: on). The **Enable Multi-Copy package** checkbox appears in the New Deck modal preferences section.
---
## How It Works
1. Check **Enable Multi-Copy package** in the New Deck modal.
2. When you add an eligible card to Must Include, a **count picker dialog** appears. Set the number of copies.
3. The builder reserves those slots for the archetype card before filling the rest of the deck from the theme pool.
4. The multi-copy card is locked automatically and appears in Step 5 alongside the rest of the build.
The builder auto-suggests the dialog when your commander's theme tags match the archetype's trigger conditions. You can also manually add any eligible card via the quick-add input and set a count freely.
---
## Supported Archetypes
| Card | Color | Cap | Theme triggers |
|------|-------|-----|----------------|
| Cid, Timeless Artificer | W/U | None (2030 suggested) | artificer kindred, artifacts matter |
| Dragon's Approach | R | None (2030 suggested) | burn, spellslinger, storm, copy |
| Hare Apparent | W | None (2030 suggested) | rabbit kindred, tokens matter |
| Nazgûl | B | **9** (printed cap) | wraith kindred, ring, amass |
| Persistent Petitioners | U | None (2030 suggested) | mill, advisor kindred, control |
| Rat Colony | B | None (2030 suggested) | rats, swarm, aristocrats |
| Relentless Rats | B | None (2030 suggested) | rats, swarm, aristocrats |
| Seven Dwarves | R | **7** (printed cap) | dwarf kindred, treasure, equipment |
| Shadowborn Apostle | B | None (2030 suggested) | demon kindred, aristocrats, sacrifice |
| Slime Against Humanity | G | None (2030 suggested) | tokens, mill, graveyard, domain |
| Tempest Hawk | W | None (2030 suggested) | bird kindred, aggro |
| Templar Knight | W | None (2030 suggested) | human kindred, knight kindred |
Cards with a **printed cap** (Nazgûl: 9, Seven Dwarves: 7) cannot exceed their cap — the count picker enforces the maximum.
---
## Exclusive Groups
Rat Colony and Relentless Rats are mutually exclusive — they share the `rats` exclusive group. If both are added, a dialog will prompt you to choose one. Only one rat archetype can be active per build.
---
## Count Recommendations
For uncapped archetypes, the suggested range is **2030 copies**. This leaves room for a commander, support spells, and lands in a 100-card deck. The sweet spot is often 2025 copies, which allows ~1520 support cards and ~35 lands.
Archetypes with many synergy triggers (Shadowborn Apostle, Relentless Rats) can push toward 30 when the commander specifically supports the archetype.
---
## Bracket Interaction
High copy counts of powerful archetypes can affect bracket compliance:
- **Thrumming Stone** (which many of these archetypes synergise with) is a Game Changer — using it in Bracket 2 or below will trigger a compliance warning.
- The archetype cards themselves are generally not on the Game Changers list, but check the compliance report in Step 5 after building.
---
## Headless / CLI
Set the multi-copy card and count in your JSON config via the `must_include` list. The count picker is a UI affordance — in headless mode, add the card name the desired number of times:
```json
{
"must_include": [
"Relentless Rats", "Relentless Rats", "Relentless Rats",
"Relentless Rats", "Relentless Rats"
]
}
```
---
## FAQ
**Why doesn't the count picker appear when I add a multi-copy card?**
The auto-suggest only triggers when your commander's theme tags match the archetype's trigger conditions. If you don't see the dialog, try adding the card manually via the quick-add input — the dialog will appear for recognized archetype names regardless of commander.
**Can I run a multi-copy package with `owned_only` mode?**
Yes, but you need that many copies of the card in your owned card library. The builder will include the configured count regardless of owned status for Must Include cards — they bypass the owned filter.
**Will the multi-copy card count toward my ideal creature or spell count?**
Yes. Multi-copy creature archetypes (e.g., Relentless Rats) count toward the creature ideal, and non-creature archetypes (e.g., Dragon's Approach) count toward the spell ideal. Adjust your ideal counts in Step 6 accordingly.
---
## See Also
- [Include / Exclude Lists](include_exclude.md) — must-include and must-exclude cards in general
- [Bracket Compliance](bracket_compliance.md) — Thrumming Stone and archetype interaction with brackets
- [Build Wizard](build_wizard.md) — multi-copy package in the context of the full build flow

View file

@ -77,3 +77,27 @@ In headless mode, set the owned card mode via JSON config:
``` ```
Use `"prefer_owned": true` for soft weighting, `"owned_only": true` for hard filtering. The two are mutually exclusive; `owned_only` takes precedence if both are set. Use `"prefer_owned": true` for soft weighting, `"owned_only": true` for hard filtering. The two are mutually exclusive; `owned_only` takes precedence if both are set.
---
## FAQ
**My owned card file uploaded but no cards are being filtered — what's wrong?**
Check that the file is in a supported format (plain text, one card per line, or a standard CSV export). Card names must match the Scryfall canonical name exactly (e.g., "Sol Ring" not "sol ring"). Check the diagnostics page for parse errors.
**Can I use multiple owned card files?**
Yes. All files in the `owned_cards/` directory are merged automatically. If the same card appears in multiple files, it is counted once.
**Does `prefer_owned` guarantee my owned cards appear in the deck?**
No — `prefer_owned` adds a weight boost during pool selection, but owned cards compete with the rest of the pool. If you need a specific card guaranteed, add it to Must Include instead.
**What happens if I'm in `owned_only` mode and there aren't enough owned cards in a category?**
The builder fills the category with the cards available and may produce a deck with fewer cards than the ideal count for that slot. The build summary will note any under-filled categories.
---
## See Also
- [Build Wizard](build_wizard.md) — owned card preferences in the context of the full build flow
- [Locks, Replace & Permalinks](locks_replace_permalinks.md) — toggle owned-only filtering in the Replace alternatives panel
- [Budget Mode](budget_mode.md) — combine price limits with owned card filtering for tighter constraints

View file

@ -103,3 +103,11 @@ Exported configs (`HEADLESS_EXPORT_JSON=1`) include the resolved partner fields:
| `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background inputs in the web builder and headless runner. | | `ENABLE_PARTNER_MECHANICS` | `0` | Unlock partner/background inputs in the web builder and headless runner. |
| `ENABLE_PARTNER_SUGGESTIONS` | `0` | Show ranked partner suggestion chips in the web builder. | | `ENABLE_PARTNER_SUGGESTIONS` | `0` | Show ranked partner suggestion chips in the web builder. |
| `PARTNER_SUGGESTIONS_DATASET` | _(auto)_ | Override path to `partner_synergy.json` inside the container. | | `PARTNER_SUGGESTIONS_DATASET` | _(auto)_ | Override path to `partner_synergy.json` inside the container. |
---
## See Also
- [Build Wizard](build_wizard.md) — partner selection in the context of the full build flow
- [Bracket Compliance](bracket_compliance.md) — bracket implications when a commander is on the Game Changers list
- [Theme Browser](theme_browser.md) — find themes compatible with both commanders' color identity

View file

@ -106,3 +106,10 @@ WEB_IDEALS_UI=input
|----------|---------|---------| |----------|---------|---------|
| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). |
| `WEB_IDEALS_UI` | `slider` | Stage tuning interface: `slider` (range inputs) or `input` (text boxes). | | `WEB_IDEALS_UI` | `slider` | Stage tuning interface: `slider` (range inputs) or `input` (text boxes). |
---
## See Also
- [Build Wizard](build_wizard.md) — full walkthrough covering Quick Build and Skip Controls in context
- [Batch Build & Compare](batch_build_compare.md) — run multiple full builds in one session using Quick Build

View file

@ -32,6 +32,30 @@ If a specific theme combination cannot be satisfied (too few on-theme cards for
--- ---
## Multi-Theme Fallback Cascade
When you specify multiple themes (Primary + Secondary + Tertiary), the builder attempts to find commanders matching all themes using a **fallback cascade**. This ensures you get a valid commander even when exact combinations don't exist:
**Fallback Order** (AND logic):
1. **Primary + Secondary + Tertiary** — Exact match (all 3 themes)
2. **Primary + Secondary** — Drop tertiary theme
3. **Primary + Tertiary** — Drop secondary theme (treat tertiary as secondary)
4. **Primary only** — Use only the primary theme
5. **Synergy fallback** — Find commanders with theme tag overlap/token substring matches with Primary
6. **Full pool fallback** — Any commander (last resort)
**Fallback Notices:**
- **Combo Fallback** (blue/info): One or more themes were dropped (combinations 2-4). The builder found a commander but couldn't match all requested themes.
- **Synergy Fallback** (amber/warning): Exact theme match failed; using commanders with overlapping keywords/tokens from your primary theme.
- **Full Pool Fallback** (strong warning): No theme matches found; falling back to the entire commander pool. Your theme inputs were too restrictive.
**Example:**
- Request: `Primary: Aggro`, `Secondary: Tokens`, `Tertiary: Goblins`
- If no commander has all three tags, the builder tries `Aggro + Tokens`, then `Aggro + Goblins`, then `Aggro` only
- Fallback reason is displayed clearly in the result, explaining which combination was used
---
## Reproducible Builds (Seeds) ## Reproducible Builds (Seeds)
Set `RANDOM_SEED` to any integer or string to produce the same commander + theme combination every time: Set `RANDOM_SEED` to any integer or string to produce the same commander + theme combination every time:
@ -87,3 +111,11 @@ File path takes precedence over the inline `RANDOM_CONSTRAINTS` value.
| `RANDOM_TELEMETRY` | `0` | Enable lightweight timing and attempt count metrics. | | `RANDOM_TELEMETRY` | `0` | Enable lightweight timing and attempt count metrics. |
For rate limiting random endpoints see the [Docker guide](../../DOCKER.md) — Random rate limiting section. For rate limiting random endpoints see the [Docker guide](../../DOCKER.md) — Random rate limiting section.
---
## See Also
- [Theme Browser](theme_browser.md) — explore and evaluate themes before using them as random constraints
- [Quick Build & Skip Controls](quick_build_skip_controls.md) — combine with Quick Build for fully automated random decks
- [Build Wizard](build_wizard.md) — the standard build flow if you want more control over selections

View file

@ -17,14 +17,21 @@ Enable the theme selector and browser with `ENABLE_THEMES=1` (default: on).
Each theme card in the browser displays up to three badge types: Each theme card in the browser displays up to three badge types:
### Quality Badge (`SHOW_THEME_QUALITY_BADGES=1`) ### Quality Badge (`SHOW_THEME_QUALITY_BADGES=1`)
Editorial quality score based on synergy depth, card count, and thematic coherence. Assigned during catalog curation. Automatically computed score based on four factors, normalized to 0100:
| Badge | Meaning | | Factor | Max points | What it measures |
|-------|---------| |--------|-----------|------------------|
| Excellent | Strong synergy, large pool, well-curated | | Card synergy quality | 30 | EDHREC rank and synergy data richness for the theme's example cards |
| Good | Solid theme with reasonable card support | | Uniqueness ratio | 40 | Fraction of theme cards that appear in fewer than 25% of all themes |
| Fair | Usable but limited pool or marginal synergy | | Description quality | 20 | Manual editorial description (10 pts), auto-generated rule (5 pts), generic (0 pts) |
| Poor | Sparse pool or weak theme coherence | | Curation bonus | 10 | Theme has hand-curated synergy data |
| Badge | Score threshold | Meaning |
|-------|----------------|---------|
| Excellent | ≥ 75 / 100 | Strong synergy, distinctive card pool, well-curated |
| Good | 6074 | Solid theme with reasonable card support |
| Fair | 4059 | Usable but limited pool or marginal synergy |
| Poor | < 40 | Sparse pool or weak theme coherence |
### Pool Size Badge (`SHOW_THEME_POOL_BADGES=1`) ### Pool Size Badge (`SHOW_THEME_POOL_BADGES=1`)
Number of on-theme cards available in the catalog. Number of on-theme cards available in the catalog.
@ -102,3 +109,11 @@ docker compose run --rm --entrypoint bash web -lc "python -m code.scripts.build_
# Local: # Local:
python -m code.scripts.build_theme_catalog python -m code.scripts.build_theme_catalog
``` ```
---
## See Also
- [Build Wizard](build_wizard.md) — how themes are selected and used during the build workflow
- [Random Build](random_build.md) — use themes as constraints for randomized commander selection
- [Partner Mechanics](partner_mechanics.md) — finding themes that work across both commanders' color identity

View file

@ -20,4 +20,7 @@ pydantic>=2.5.0
# YAML parsing for theme whitelist governance # YAML parsing for theme whitelist governance
PyYAML>=6.0 PyYAML>=6.0
# Markdown rendering for web-accessible documentation
markdown>=3.5.0
# Development dependencies are in requirements-dev.txt # Development dependencies are in requirements-dev.txt