mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-05 20:57:16 +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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue