diff --git a/.env.example b/.env.example index 12da807..f8c35d7 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3133dcb..1448e2d 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Dockerfile b/Dockerfile index b7a22a6..e8c1aad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 8076e83..18cf97e 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -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=`. 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. diff --git a/code/web/app.py b/code/web/app.py index c227910..72b1156 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -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) diff --git a/code/web/routes/docs.py b/code/web/routes/docs.py new file mode 100644 index 0000000..d5662b2 --- /dev/null +++ b/code/web/routes/docs.py @@ -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") diff --git a/code/web/services/docs_service.py b/code/web/services/docs_service.py new file mode 100644 index 0000000..8682329 --- /dev/null +++ b/code/web/services/docs_service.py @@ -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
+ '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

, optional description

, and optional


that follows — + # the guide template renders both from metadata separately + html = re.sub(r'^\s*]*>.*?

\s*(?:

.*?

\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
 tags (shouldn't happen in prod)
+                html = f"
{content}
" + 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) diff --git a/code/web/static/styles.css b/code/web/static/styles.css index f0ca7d4..1bb94a2 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -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)); + } +} diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css index e855201..3c25c99 100644 --- a/code/web/static/tailwind.css +++ b/code/web/static/tailwind.css @@ -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; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 41b3faa..daf3caf 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -91,6 +91,7 @@ Finished Decks Themes {% if random_ui %}Random{% endif %} + Help {% if show_diagnostics %}Diagnostics{% endif %} {% if show_logs %}Logs{% endif %} @@ -557,5 +558,62 @@ document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); }); })(); + + + diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html index 1a4c31a..9187515 100644 --- a/code/web/templates/browse/cards/index.html +++ b/code/web/templates/browse/cards/index.html @@ -60,8 +60,10 @@
-

Card Browser

-

Browse all {{ total_cards }} cards with filters and search.

+ {# Error message #} {% if error %} diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index fad7013..176c8db 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -48,7 +48,7 @@ {% include "build/_new_deck_additional_themes.html" %} {% endif %}
-