mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-04 12:17:17 +02:00
feat: web documentation portal with contextual help links and consistent page headers (#67)
This commit is contained in:
parent
46637cf27f
commit
13f6fa5dbf
44 changed files with 2232 additions and 140 deletions
|
|
@ -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)
|
||||
WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="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_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)
|
||||
|
|
|
|||
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -9,10 +9,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### 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
|
||||
_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
|
||||
- **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
|
||||
- **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
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY code/ ./code/
|
||||
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/
|
||||
# TypeScript sources are in code/web/static/ts/ from COPY code/
|
||||
|
||||
|
|
|
|||
|
|
@ -2,46 +2,24 @@
|
|||
|
||||
## [Unreleased]
|
||||
### 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
|
||||
_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
|
||||
- Bug fixes in `theme_preview.py` and `app.py` uncovered by the test suite.
|
||||
- Pydantic V2 deprecation warning resolved in `DeckExportRequest`.
|
||||
- **Bug: missing `idx` argument** in `project_detail()` call inside `theme_preview.py` caused theme preview pages to crash.
|
||||
- **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
|
||||
- 16 fragmented/stale test files consolidated or deleted; 7 permanently-skipped tests removed.
|
||||
|
||||
## [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 1–2 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_
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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 api as api_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_validation_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(card_browser_routes.router)
|
||||
app.include_router(compare_routes.router)
|
||||
app.include_router(docs_routes.router)
|
||||
app.include_router(api_routes.router)
|
||||
app.include_router(price_routes.router)
|
||||
|
||||
|
|
|
|||
115
code/web/routes/docs.py
Normal file
115
code/web/routes/docs.py
Normal 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")
|
||||
292
code/web/services/docs_service.py
Normal file
292
code/web/services/docs_service.py
Normal 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)
|
||||
|
|
@ -582,6 +582,10 @@ video {
|
|||
visibility: collapse;
|
||||
}
|
||||
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
|
@ -743,6 +747,10 @@ video {
|
|||
display: grid;
|
||||
}
|
||||
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -1061,6 +1069,10 @@ video {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
|
@ -1078,6 +1090,11 @@ video {
|
|||
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 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
|
|
@ -1622,6 +1639,25 @@ body.htmx-settling *{
|
|||
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 */
|
||||
|
||||
button{
|
||||
|
|
@ -4935,6 +4971,7 @@ img.lqip.loaded {
|
|||
}
|
||||
|
||||
/* Pool size badge for chip context (R21 M2) */
|
||||
|
||||
.badge-pool {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
|
|
@ -5605,6 +5642,71 @@ img.lqip.loaded {
|
|||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
|
@ -5834,31 +5936,6 @@ footer.site-footer {
|
|||
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
|
||||
============================================================ */
|
||||
|
|
@ -5890,6 +5967,7 @@ footer.site-footer {
|
|||
}
|
||||
|
||||
/* Tier badges on the pickups table */
|
||||
|
||||
.tier-badge {
|
||||
display: inline-block;
|
||||
padding: .1rem .5rem;
|
||||
|
|
@ -5916,6 +5994,7 @@ footer.site-footer {
|
|||
}
|
||||
|
||||
/* Inline price tooltip on card names */
|
||||
|
||||
.card-name-price-hover {
|
||||
cursor: default;
|
||||
position: relative;
|
||||
|
|
@ -5939,6 +6018,7 @@ footer.site-footer {
|
|||
}
|
||||
|
||||
/* Price overlay on card thumbnails (step5 tiles + deck summary thumbs) */
|
||||
|
||||
.card-price-overlay {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
|
|
@ -5955,9 +6035,13 @@ footer.site-footer {
|
|||
white-space: nowrap;
|
||||
line-height: 16px;
|
||||
}
|
||||
.card-price-overlay:empty { display: none; }
|
||||
|
||||
.card-price-overlay:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Inline price in deck summary list rows */
|
||||
|
||||
.card-price-inline {
|
||||
font-size: 11px;
|
||||
color: var(--muted, #94a3b8);
|
||||
|
|
@ -5965,17 +6049,23 @@ footer.site-footer {
|
|||
white-space: nowrap;
|
||||
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 */
|
||||
|
||||
.card-tile.over-budget {
|
||||
border-color: #f5c518 !important;
|
||||
box-shadow: inset 0 0 8px rgba(245, 197, 24, .25), 0 0 5px #f5c518 !important;
|
||||
}
|
||||
|
||||
.stack-card.over-budget {
|
||||
border-color: #f5c518 !important;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.55), 0 0 7px #f5c518 !important;
|
||||
}
|
||||
|
||||
.list-row.over-budget .name {
|
||||
background: rgba(245, 197, 24, .12);
|
||||
box-shadow: 0 0 0 1px #f5c518;
|
||||
|
|
@ -5983,6 +6073,7 @@ footer.site-footer {
|
|||
}
|
||||
|
||||
/* Budget price summary bar in deck summary */
|
||||
|
||||
.budget-price-bar {
|
||||
font-size: 13px;
|
||||
padding: .3rem .5rem;
|
||||
|
|
@ -5991,10 +6082,19 @@ footer.site-footer {
|
|||
border: 1px solid var(--border, #333);
|
||||
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 {
|
||||
border: 1px solid var(--border, #444);
|
||||
border-left: 4px solid #f5c518;
|
||||
|
|
@ -6002,6 +6102,7 @@ footer.site-footer {
|
|||
background: var(--panel, #1a1f2e);
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
.budget-review-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -6009,14 +6110,25 @@ footer.site-footer {
|
|||
gap: .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 {
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--bg, #141824);
|
||||
}
|
||||
|
||||
.budget-review-card-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -6024,9 +6136,22 @@ footer.site-footer {
|
|||
gap: .4rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.budget-review-card-name { font-weight: 600; }
|
||||
.budget-review-card-price { color: #f5c518; }
|
||||
.budget-review-alts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||
|
||||
.budget-review-card-name {
|
||||
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 {
|
||||
font-size: .8rem;
|
||||
padding: .2rem .5rem;
|
||||
|
|
@ -6038,18 +6163,64 @@ footer.site-footer {
|
|||
align-items: center;
|
||||
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 */
|
||||
.price-cat-section { margin: .6rem 0 .2rem 0; }
|
||||
.price-cat-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
height: 18px;
|
||||
|
|
@ -6058,12 +6229,18 @@ footer.site-footer {
|
|||
border: 1px solid var(--border, #333);
|
||||
background: var(--panel, #1a1f2e);
|
||||
}
|
||||
|
||||
.price-cat-seg {
|
||||
height: 100%;
|
||||
transition: opacity .15s;
|
||||
position: relative;
|
||||
}
|
||||
.price-cat-seg:hover { opacity: .75; cursor: default; }
|
||||
|
||||
.price-cat-seg:hover {
|
||||
opacity: .75;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.price-cat-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -6072,12 +6249,33 @@ footer.site-footer {
|
|||
font-size: 11px;
|
||||
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-hist-section { margin: .75rem 0 .2rem 0; }
|
||||
.price-hist-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; }
|
||||
.price-cat-legend-item {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
|
@ -6085,6 +6283,7 @@ footer.site-footer {
|
|||
height: 80px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.price-hist-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -6095,18 +6294,24 @@ footer.site-footer {
|
|||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.price-hist-column:hover { opacity: .8; }
|
||||
|
||||
.price-hist-column:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.price-hist-bar {
|
||||
width: 100%;
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.price-hist-xlabels {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.price-hist-xlabel {
|
||||
flex: 1;
|
||||
font-size: 10px;
|
||||
|
|
@ -6116,10 +6321,104 @@ footer.site-footer {
|
|||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.price-hist-count { font-size: 11px; color: var(--muted, #94a3b8); margin-top: .1rem; }
|
||||
|
||||
/* M9: 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; }
|
||||
.price-hist-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted, #94a3b8);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,22 @@ body.htmx-settling *{ transition-duration: 0s !important; }
|
|||
}
|
||||
.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 */
|
||||
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
|
||||
button:hover{ filter:brightness(1.05); }
|
||||
|
|
@ -3378,6 +3394,64 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
{% 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_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
|
|
@ -557,5 +558,62 @@
|
|||
document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); });
|
||||
})();
|
||||
</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 →</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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -60,8 +60,10 @@
|
|||
</style>
|
||||
|
||||
<section class="card-browser-container">
|
||||
<h3>Card Browser</h3>
|
||||
<p class="muted">Browse all {{ total_cards }} cards with filters and search.</p>
|
||||
<div class="page-header">
|
||||
<h2>All Cards</h2>
|
||||
<p class="muted">Browse all {{ total_cards }} cards with filters and search.</p>
|
||||
</div>
|
||||
|
||||
{# Error message #}
|
||||
{% if error %}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
{% include "build/_new_deck_additional_themes.html" %}
|
||||
{% endif %}
|
||||
<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">
|
||||
{% for b in brackets %}
|
||||
{% if not gc_commander or b.level >= 3 %}
|
||||
|
|
@ -89,12 +89,12 @@
|
|||
</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).">
|
||||
<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>
|
||||
<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.">
|
||||
<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 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 %} />
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
</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.">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
{% include "build/_new_deck_ideals.html" %}
|
||||
{% if allow_must_haves %}
|
||||
<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">
|
||||
<!-- Include Cards Column (Left, Green) -->
|
||||
<div>
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
{% include "build/_new_deck_skip_controls.html" %}
|
||||
{% if enable_budget_mode %}
|
||||
<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">
|
||||
<label class="block">
|
||||
<span>Total budget ($)</span>
|
||||
|
|
@ -270,6 +270,7 @@
|
|||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@
|
|||
|
||||
{# Always update the bracket dropdown on commander change; hide 1–2 only when gc_commander is 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">
|
||||
{% for b in brackets %}
|
||||
{% if not gc_commander or b.level >= 3 %}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
{% set partner_suggestions_has_hidden = partner_suggestions_has_hidden if partner_suggestions_has_hidden is defined else False %}
|
||||
{% if feature_available %}
|
||||
<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 %}
|
||||
<p class="muted" style="font-size:12px;">This commander doesn't support partner mechanics or backgrounds.</p>
|
||||
{% else %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% 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;">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
|
||||
{% block content %}
|
||||
<section class="commander-page">
|
||||
<header class="commander-hero">
|
||||
<div class="page-header">
|
||||
<h2>Commanders</h2>
|
||||
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.</p>
|
||||
</header>
|
||||
<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>.
|
||||
</p>
|
||||
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.
|
||||
<p class="muted" 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>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="commander-filter-form"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% 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;">
|
||||
<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 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<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 class="page-header">
|
||||
<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 %}
|
||||
<div class="error">{{ error }}</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
||||
<div class="page-header">
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<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;">
|
||||
<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>
|
||||
|
|
|
|||
588
code/web/templates/docs/guide.html
Normal file
588
code/web/templates/docs/guide.html
Normal 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 %}
|
||||
96
code/web/templates/docs/index.html
Normal file
96
code/web/templates/docs/index.html
Normal 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 %}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
{% 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('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 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 %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h3>Owned Cards Library</h3>
|
||||
<p class="muted">Upload .txt or .csv lists. We’ll extract names and keep a de-duplicated library for the web UI.</p>
|
||||
<div class="page-header">
|
||||
<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 %}
|
||||
<div class="error" style="margin:.5rem 0;">{{ error }}</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
background:#0f1115; border:1px solid var(--border); border-radius:10px;
|
||||
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;">
|
||||
<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>
|
||||
</div>
|
||||
<p class="muted" style="font-size:13px; margin:.25rem 0 .75rem;">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
{% block content %}
|
||||
{% set enable_ui = random_ui %}
|
||||
<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 %}
|
||||
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
|
||||
{% else %}
|
||||
|
|
@ -29,6 +31,7 @@
|
|||
<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">
|
||||
<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 →</a></p>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -47,8 +47,10 @@
|
|||
</style>
|
||||
|
||||
<section>
|
||||
<h2>Setup / Tagging</h2>
|
||||
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
|
||||
<div class="page-header">
|
||||
<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;">
|
||||
<summary>Current Status</summary>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Theme Catalog (Simple)</h2>
|
||||
<div class="page-header">
|
||||
<h2>Themes</h2>
|
||||
</div>
|
||||
<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>.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ services:
|
|||
SHOW_MISC_POOL: "0"
|
||||
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_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_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
|
||||
|
|
@ -271,5 +272,6 @@ services:
|
|||
# Mount code for hot-reload during development (templates, static files)
|
||||
- ${PWD}/code/web/templates:/app/code/web/templates
|
||||
- ${PWD}/code/web/static:/app/code/web/static
|
||||
- ${PWD}/docs:/app/docs
|
||||
working_dir: /app
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ services:
|
|||
SHOW_MISC_POOL: "0"
|
||||
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_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_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
|
||||
|
|
@ -269,5 +270,6 @@ services:
|
|||
- ${PWD}/card_files:/app/card_files
|
||||
- ${PWD}/config:/app/config
|
||||
- ${PWD}/owned_cards:/app/owned_cards
|
||||
- ${PWD}/docs:/app/docs
|
||||
working_dir: /app
|
||||
restart: "no"
|
||||
|
|
|
|||
|
|
@ -67,3 +67,10 @@ Access the compare view from **Finished Decks** to diff any two completed builds
|
|||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|----------|------------|----------|---------|
|
||||
| 1–3 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
|
||||
|
|
@ -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. |
|
||||
| `enforcement_mode` | `validate` \| `prefer` \| `strict` | How violations are handled during building. |
|
||||
| `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
|
||||
|
|
|
|||
|
|
@ -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?**
|
||||
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
|
||||
|
|
|
|||
255
docs/user_guides/build_wizard.md
Normal file
255
docs/user_guides/build_wizard.md
Normal 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 1–3 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 2–3 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 1–4**: 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**: 0–30 cards (default varies by commander)
|
||||
- **Lands**: 25–45 cards (default varies by commander speed)
|
||||
- **Basic Lands**: 0–40 cards (subset of total lands)
|
||||
- **Creatures**: 0–70 cards
|
||||
- **Removal**: 0–30 cards (spot removal)
|
||||
- **Wipes**: 0–15 cards (board wipes)
|
||||
- **Card Advantage**: 0–30 cards (draw/recursion)
|
||||
- **Protection**: 0–20 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 1–10 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 2–3 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
|
||||
|
|
@ -63,7 +63,9 @@ Must Include cards are inserted directly and are not subject to budget pool filt
|
|||
|
||||
## 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"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -71,3 +71,24 @@ Permalink URLs are self-contained — no server state is required. Anyone with t
|
|||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
|
|
|||
102
docs/user_guides/multi_copy.md
Normal file
102
docs/user_guides/multi_copy.md
Normal 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 (20–30 suggested) | artificer kindred, artifacts matter |
|
||||
| Dragon's Approach | R | None (20–30 suggested) | burn, spellslinger, storm, copy |
|
||||
| Hare Apparent | W | None (20–30 suggested) | rabbit kindred, tokens matter |
|
||||
| Nazgûl | B | **9** (printed cap) | wraith kindred, ring, amass |
|
||||
| Persistent Petitioners | U | None (20–30 suggested) | mill, advisor kindred, control |
|
||||
| Rat Colony | B | None (20–30 suggested) | rats, swarm, aristocrats |
|
||||
| Relentless Rats | B | None (20–30 suggested) | rats, swarm, aristocrats |
|
||||
| Seven Dwarves | R | **7** (printed cap) | dwarf kindred, treasure, equipment |
|
||||
| Shadowborn Apostle | B | None (20–30 suggested) | demon kindred, aristocrats, sacrifice |
|
||||
| Slime Against Humanity | G | None (20–30 suggested) | tokens, mill, graveyard, domain |
|
||||
| Tempest Hawk | W | None (20–30 suggested) | bird kindred, aggro |
|
||||
| Templar Knight | W | None (20–30 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 **20–30 copies**. This leaves room for a commander, support spells, and lands in a 100-card deck. The sweet spot is often 20–25 copies, which allows ~15–20 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
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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_SUGGESTIONS` | `0` | Show ranked partner suggestion chips in the web builder. |
|
||||
| `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
|
||||
|
|
|
|||
|
|
@ -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_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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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. |
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
### 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 0–100:
|
||||
|
||||
| Badge | Meaning |
|
||||
|-------|---------|
|
||||
| Excellent | Strong synergy, large pool, well-curated |
|
||||
| Good | Solid theme with reasonable card support |
|
||||
| Fair | Usable but limited pool or marginal synergy |
|
||||
| Poor | Sparse pool or weak theme coherence |
|
||||
| Factor | Max points | What it measures |
|
||||
|--------|-----------|------------------|
|
||||
| Card synergy quality | 30 | EDHREC rank and synergy data richness for the theme's example cards |
|
||||
| Uniqueness ratio | 40 | Fraction of theme cards that appear in fewer than 25% of all themes |
|
||||
| Description quality | 20 | Manual editorial description (10 pts), auto-generated rule (5 pts), generic (0 pts) |
|
||||
| Curation bonus | 10 | Theme has hand-curated synergy data |
|
||||
|
||||
| Badge | Score threshold | Meaning |
|
||||
|-------|----------------|---------|
|
||||
| Excellent | ≥ 75 / 100 | Strong synergy, distinctive card pool, well-curated |
|
||||
| Good | 60–74 | Solid theme with reasonable card support |
|
||||
| Fair | 40–59 | Usable but limited pool or marginal synergy |
|
||||
| Poor | < 40 | Sparse pool or weak theme coherence |
|
||||
|
||||
### Pool Size Badge (`SHOW_THEME_POOL_BADGES=1`)
|
||||
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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ pydantic>=2.5.0
|
|||
# YAML parsing for theme whitelist governance
|
||||
PyYAML>=6.0
|
||||
|
||||
# Markdown rendering for web-accessible documentation
|
||||
markdown>=3.5.0
|
||||
|
||||
# Development dependencies are in requirements-dev.txt
|
||||
Loading…
Add table
Add a link
Reference in a new issue