diff --git a/.claude/rules/distribution.md b/.claude/rules/distribution.md index 17dfcccf..9f787489 100644 --- a/.claude/rules/distribution.md +++ b/.claude/rules/distribution.md @@ -28,7 +28,7 @@ python3 build_dist.py --standalone --cpu-only # CPU-only standalone (~600 MB, - ``` - Output: `STAGING_DIR` constant in `build_dist.py` — defaults to `/mnt/Games/Scripts/Linux/Staging_Area/RetroDB`; override with the `RETRODB_STAGING_DIR` env var (`release.yml` does, to `/tmp/staging`). The default must name a mounted, writable drive: `build_standalone` calls `os.makedirs(STAGING_DIR)`, so a retired drive fails the documented build command with `PermissionError`. -- Filename: `RetroDB-v{VERSION}-{Platform}.zip` (source) or `RetroDB-v{VERSION}-{Platform}-Standalone.zip` +- Filename: `RetroDB-v{VERSION}-{Platform}.zip` (source) or `RetroDB-v{VERSION}-{Platform}-Standalone.zip`. The self-updater (PASS-59-169, `services/updater.py::asset_names`) finds a release's download by these names, its checksum in `SHA256SUMS.txt` / `.sha256`, and only sees a release once it is **published** — so publishing the draft is what offers the update to users. - What ships in a source ZIP: `collect_files` enumerates **`git ls-files`** — the allow-list `.gitignore` already maintains — and only then applies the deny-lists (Pass 59.13). Every generated artifact the ZIP needs (`main.min.css`, both bundles, `translations/**/*.mo`) is committed, so nothing shipped depends on the walk. Without git (a tarball, not a checkout) it falls back to the filesystem walk, which is why the deny-lists stay. They are `EXCLUDE_FILES`, `EXCLUDE_DIRS`, `EXCLUDE_EXTENSIONS` and the `INCLUDE_IMAGE_DIRS` whitelist in `build_dist.py`, and they remove the tracked-but-maintainer-only files. Between them this excludes user config (`config.py`, `data/{settings,scraper_settings,rom_tools_config,psn_tokens,xbox_tokens}.json`, `data/.secret_key`), the runtime DB (the whole `database/` dir — covers `database/roms.db`; `data/retrodb.db` is also listed as a legacy path, and all `.db*` / `.log` files are excluded by extension), and scraped media (`static/videos/`, plus every `static/images//` that is not in `INCLUDE_IMAGE_DIRS = {'hardware','ratings','systems','avatars'}`). The one exception is `INCLUDE_IMAGE_FILES`: the app's trophy and platform icons, which share `static/images/trophies/` with each user's downloaded PSN trophy art. They are named file by file in `build_dist.py`, `.gitignore` and `retrodb.spec`. `tests/test_pass59_packaging.py::TestReferencedImagesShip` fails if an image the templates or scripts name stops shipping. Per-platform: only that platform's start script (`start.sh` / `start.command` / `start.bat`). - Standalone build is driven by `retrodb.spec` (PyInstaller onedir mode). Spec whitelists static subdirs explicitly to avoid sweeping in scraped media; new pip deps that PyInstaller's static analyser can't follow (string-imported via `importlib`) must be added to `HIDDEN_IMPORTS` in the spec. - `--cpu-only` (Pass 46.3 Part 3) builds the standalone inside an isolated venv under `STAGING_DIR/.cpu-build-venv` (vanilla CPU `onnxruntime` from `requirements.txt` + PyInstaller), so it produces the ~600 MB CPU bundle even on the maintainer's AMD box where `onnxruntime-rocm` is installed system-wide (the two flavours share the `onnxruntime` import name and can't coexist). Artifacts get a `-CPU` filename suffix. Plain `--standalone` inherits whatever onnxruntime is in the current env (GPU/ROCm locally; CPU on a clean checkout, since `requirements.txt` pins vanilla). diff --git a/.gitignore b/.gitignore index b9c4dbdf..a3fa0b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ data/m3u_staging/ # HLTB dataset (large CSV, downloaded on demand) data/hltb_dataset.csv +# Self-updater state and work folder (PASS-59-169) +data/update_check.json +data/update_status.json +.update/ + # PSN credentials docs/psn-npsso.env diff --git a/CLAUDE.md b/CLAUDE.md index 62870a07..510e0615 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ genuinely diverges. ## Mandatory Workflow ### After Every Code Change -1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries 5 to 10 changelog items (user, 2026-10-23): release once the open entry reaches 10, or sooner at a natural stop once it has 5. While the version at the top of `data/changelog.yaml` has no `v` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way. +1. **Bundle changes into releases** (user, 2026-10-17): a release with one changelog item is too small. A release carries at least 5 changelog items, with no upper limit (user, 2026-10-27: the floor only stops a release going out with one item; an open entry may grow past 10). While the version at the top of `data/changelog.yaml` has no `v` git tag, it is still open: add the change to that entry and keep the version. Otherwise bump `config.py` AND `config.example.py` (`APP_VERSION` + `APP_LAST_UPDATE`) and open a new entry. Keep `APP_LAST_UPDATE` current either way. 2. Add or extend the changelog entry at top of `data/changelog.yaml`. For a real release, also translate the **recent** entry (current line onward; deep history stays English) into each per-locale `data/changelog..yaml` (the human-translation locales in `docs/specs/i18n.md` §9 — a different delivery path from the installed UI-catalog set `services/i18n.py::available_locales()` derives, §3). Repeat `version`, `date`, and every tag verbatim — the `/changelog` route swaps the whole entry in by version, so anything omitted is dropped, not inherited (Pass 43.6). Skip for internal/no-release bumps. 3. Rebuild CSS if any `static/css/**.css` changed: `python3 build_css.py` 4. Rebuild JS if any bundled `static/js/*.js` changed: `python3 build_js.py` @@ -114,6 +114,11 @@ has seen the raw output. Triage into new roadmap passes after. ### Multi-rating system (9 systems) Moved to `.claude/rules/ratings.md`, which loads when a matching file is read. Read it before touching ratings, the edit modals or the scrapers. +### Self-updater (PASS-59-169) +- The block at the top of `app.py` hands `--apply-update` to `updater_apply.py` before any other import; keep it first (the rest of `app.py` opens and migrates the database). `updater_apply.py` stays standard-library only: the standalone binary runs it inside its own bundle. +- Install runs the NEW version's helper, so a change to the plan file (`plan_version`) or to which paths an update may write (`updater_apply.PROTECTED`) must stay readable from the previous release's `services/updater.py`. +- The updater picks release assets by name (`RetroDB-v{V}-{Platform}.zip` + `SHA256SUMS.txt`, `RetroDB-v{V}-{Platform}[-CPU]-Standalone.zip` + `.sha256`); renaming them in `build_dist.py` / `release.yml` stops updates. Spec: `docs/specs/PASS-59-169-self-updater.md`. + ### Page wiring - Both `/games` (Library) and `/system/` use `AllGamesController` — keep them in sync. diff --git a/app.py b/app.py index 863dbe38..48715362 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,14 @@ # Features: ROM scanning, metadata scraping, beautiful cyberpunk UI # ============================================================================= +import sys +if len(sys.argv) > 1 and sys.argv[1] == '--apply-update': + # PASS-59-169 — the standalone binary is also the update helper. Hand + # over before anything else is imported: the rest of this module opens + # and migrates the database, which the helper must not do. + import updater_apply + sys.exit(updater_apply.main(sys.argv[1:])) + from werkzeug.exceptions import HTTPException from flask import Flask, render_template, request, redirect, url_for, jsonify, session, g, send_from_directory, abort import gzip @@ -342,6 +350,7 @@ def serve_static_image(filepath): from routes.launch import bp as launch_bp from routes.emulators import bp as emulators_bp from routes.launch_settings import bp as launch_settings_bp +from routes.updates import bp as updates_bp app.register_blueprint(clz_import_bp) app.register_blueprint(controllers_bp) @@ -365,6 +374,7 @@ def serve_static_image(filepath): app.register_blueprint(platform_import_bp) app.register_blueprint(steam_achievements_bp) app.register_blueprint(xbox_achievements_bp) +app.register_blueprint(updates_bp) app.register_blueprint(game_imports_bp) app.register_blueprint(launch_bp) app.register_blueprint(emulators_bp) @@ -1019,11 +1029,17 @@ def _get_collector_rank_safe(): trophies module / table isn't ready yet (e.g. first boot before the table has been populated). The sidebar badge reads this on every render, so it must never raise.""" + # inject_config runs for every render_template call in a request, so the + # rank is read once per request and reused. + if '_collector_rank' in g: + return g._collector_rank try: from routes.collector_trophies import get_current_rank - return get_current_rank() + rank = get_current_rank() except Exception: - return None + rank = None + g._collector_rank = rank + return rank # ============================================================================= @@ -1337,6 +1353,11 @@ def dashboard(): jtype = rj['job_type'] status = rj['status'] + # A job the user may neither resume nor dismiss is not offered: + # the banner would show buttons that only answer 403. + if not _may_manage_job(jtype, params): + continue + # Determine if this job had real progress (interrupted mid-run) # vs was just queued (no work done yet) has_progress = progress.get('current', 0) > 0 @@ -1409,6 +1430,16 @@ def dashboard(): } +def _may_manage_job(job_type, params): + """PASS-59-118 — resuming or dismissing a recoverable job needs the + rights starting it needed: the start route's role, and a per-user job + belongs to its user (admins excepted).""" + role = g.user['role'] + owner = (params or {}).get('user_id') + return (role in _RESUME_ROLES.get(job_type, ('admin',)) + and (owner is None or owner == g.user['id'] or role == 'admin')) + + @app.route('/api/jobs/resume/', methods=['POST']) @login_required @handle_api_errors @@ -1450,12 +1481,7 @@ def resume_job(job_id): if not handler or not hasattr(handler, 'resume_from_params'): return api_error(f"No handler for job type '{job['job_type']}'", 400) - # PASS-59-118 — resuming needs the rights starting needed: the start - # route's role, and a per-user job belongs to its user (admins excepted). - role = g.user['role'] - need = _RESUME_ROLES.get(job['job_type'], ('admin',)) - owner = (job.get('params') or {}).get('user_id') - if role not in need or (owner is not None and owner != g.user['id'] and role != 'admin'): + if not _may_manage_job(job['job_type'], job.get('params')): return api_error('You do not have permission to perform this action', 403) resumed = handler.resume_from_params(job['params'], progress=job.get('progress')) @@ -1471,8 +1497,19 @@ def resume_job(job_id): @login_required @handle_api_errors def dismiss_job_route(job_id): - """Dismiss a recoverable job so it no longer appears on the dashboard.""" + """Dismiss a recoverable job so it no longer appears on the dashboard. + + The same rights as resuming it: dismissing throws the job away. + """ from services.jobs.base import dismiss_job + row = query("SELECT job_type, params FROM job_queue WHERE id = ?", (job_id,), one=True) + if row: + try: + params = json.loads(row['params']) if row['params'] else {} + except (TypeError, ValueError): + params = {} + if not _may_manage_job(row['job_type'], params if isinstance(params, dict) else {}): + return api_error('You do not have permission to perform this action', 403) dismiss_job(job_id) return api_success() @@ -1890,12 +1927,16 @@ def should_open_browser(environ, frozen): The standalone bundle always does (only the server knows the resolved port, Pass 59.10); a source install when start.sh / start.command / - start.bat set RETRODB_OPEN_BROWSER=1. Never under LocalWebServerManager + start.bat set RETRODB_OPEN_BROWSER=1; RETRODB_OPEN_BROWSER=0 turns it off + for both (the update helper's restart, PASS-59-169). Never under LocalWebServerManager (LWSM_MANAGED=1), which has its own Open button. scripts/retrodb_launcher.py opens the browser itself. """ if environ.get('LWSM_MANAGED') == '1': return False + # '0' — the update helper restarting RetroDB: the open tab reloads itself. + if environ.get('RETRODB_OPEN_BROWSER') == '0': + return False return bool(frozen) or environ.get('RETRODB_OPEN_BROWSER') == '1' diff --git a/build_css.py b/build_css.py index cb4fd2be..2efd9759 100644 --- a/build_css.py +++ b/build_css.py @@ -51,6 +51,7 @@ 'features/filters.css', 'features/hltb.css', 'features/manual-reader.css', + 'features/updates.css', # 5. Pages 'pages/game-list.css', diff --git a/build_dist.py b/build_dist.py index 71ca97e3..da208a3b 100755 --- a/build_dist.py +++ b/build_dist.py @@ -68,6 +68,8 @@ 'data/settings.json', 'data/rom_tools_config.json', 'data/psn_tokens.json', + 'data/update_check.json', # self-updater state (PASS-59-169) + 'data/update_status.json', 'data/xbox_tokens.json', 'data/psn_tokens.json.migrated-015', 'data/xbox_tokens.json.migrated-015', @@ -84,6 +86,7 @@ '__pycache__', '.claude', '.git', '.vscode', '.idea', 'demo', 'dist', 'venv', '.venv', 'database', 'logs', # .gitkeep files added explicitly + '.update', # self-updater work folder (PASS-59-169) } # .db-shm joins .db-wal/.db-journal — WAL mode (v3.22.1) leaves all three behind. diff --git a/build_js.py b/build_js.py index 0caa1563..9df56f88 100644 --- a/build_js.py +++ b/build_js.py @@ -58,6 +58,7 @@ 'museum.js', # Page-specific 'versions.js', # Page-specific (game page + Regional Versions) 'official-names.js', # Page-specific (ROM Tools > Official Names) + 'updates.js', # Page-specific (Settings > Updates + dashboard banner) ] BUNDLES = [ diff --git a/config.example.py b/config.example.py index d191b709..a2c0d315 100644 --- a/config.example.py +++ b/config.example.py @@ -130,7 +130,7 @@ # Application metadata APP_NAME = "RetroDB" APP_VERSION = "3.33.0" -APP_LAST_UPDATE = "2026-10-23" +APP_LAST_UPDATE = "2026-10-27" APP_DESCRIPTION = "Retro Gaming ROM Library Manager" # Supported image extensions for boxart/screenshots diff --git a/data/changelog.yaml b/data/changelog.yaml index 039f79b1..52e72b7c 100644 --- a/data/changelog.yaml +++ b/data/changelog.yaml @@ -1,12 +1,42 @@ - version: 3.33.0 - date: 2026-10-23 + date: 2026-10-27 tags: + - type: feature + label: RetroDB updates itself - type: feature label: Get RetroArch from inside RetroDB - type: fix label: Next disk works for games played in RetroDB - body: | -
    + - type: fix + label: Game cards line up on a phone and after resizing the window + - type: fix + label: Scraped ratings and player counts are more accurate + - type: fix + label: ScreenScraper keeps its details when it is the main source + - type: fix + label: Clear scraped data puts the file name back as the title + - type: fix + label: RetroAchievements refresh for one console works again + - type: fix + label: Screenshot duplicate finder works in the downloaded app + - type: fix + label: ScreenScraper passwords are hidden, and the last admin stays admin + - type: fix + label: Interrupted jobs are only offered to people allowed to run them + - type: performance + label: Faster trophies, library imports and maintenance tools + body: | +
      +
    • RetroDB updates itself. When a new version is + out, admins see it on the dashboard and in Settings > System > + Updates, with what is new. Download fetches it and checks it is + exactly the published file; Install and restart backs up your + database, puts the new version in place and restarts. If the new + version does not start, RetroDB goes back to the old one by itself + and says why. Your games, settings, images and logs are not touched. + RetroDB looks for updates once a day; you can turn that off. A copy + run from git, or by a process manager, is told about updates but + not updated.
    • Get RetroArch from inside RetroDB. Settings > Emulators has a new Play in RetroDB section. Press Get RetroArch and RetroDB downloads the official RetroArch from libretro, checks @@ -20,6 +50,48 @@ own RetroArch with no setup. RetroArch listens for RetroDB's commands only while such a game runs, and on Windows and macOS while any game runs.
    • +
    • Game cards line up on a phone and after resizing the + window. On a phone the Library and console pages showed one + card and then a long empty gap; cards now stack one under another. + Making the window narrower or wider re-arranges the cards instead of + leaving them in the old columns.
    • +
    • Scraped ratings and player counts are more accurate. + Age ratings from IGDB for CERO, USK, GRAC, ClassInd and ACB are now + read correctly, so a game rated CERO Z or USK 18 gets that rating. + TheGamesDB, RAWG, IGDB and ES-DE no longer guess "1 player" when + they don't know; an ES-DE import no longer overwrites a player count + or game modes you set. TheGamesDB no longer saves a number in place + of a publisher or developer name after a failed lookup.
    • +
    • ScreenScraper keeps its details when it is the main + source. Its title, description, date, developer, publisher + and players are kept on every scrape, not only the first few + minutes after a search. Downloaded videos and manuals get a proper + file name.
    • +
    • Clear scraped data puts the file name back as the + title. It cleared the details but left the scraped + title.
    • +
    • RetroAchievements refresh for one console works + again. It failed with an error every time.
    • +
    • Screenshot duplicate finder works in the downloaded + app. It found no screenshots there, and deleting a + duplicate left the file on disk.
    • +
    • ScreenScraper passwords are hidden, and the last admin + stays admin. Settings now shows your ScreenScraper + passwords masked, like your other keys. The only admin account can + no longer be switched to editor or turned off, which would have + left nobody able to manage RetroDB.
    • +
    • Interrupted jobs are only offered to people allowed to run + them. The dashboard's interrupted-job banner now shows a + job only to someone allowed to start it, for example a bulk scrape + only to editors and admins. Other accounts no longer see it and can + no longer dismiss it.
    • +
    • Faster trophies, library imports and maintenance + tools. The Trophies page, Steam, Xbox and PlayStation + library imports, collector trophies, Calculate storage and the + unused-media scan all do far less work on a large library. Job + progress pop-ups keep updating when the server is busy, and the + HowLongToBeat and alternate-title jobs no longer hold up other + changes while they run.
    - version: 3.32.0 date: 2026-10-23 diff --git a/docs/design.md b/docs/design.md index c07f9008..a4c6aef1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -92,6 +92,12 @@ Themes are [`specs/themes.md`](specs/themes.md). **Translation.** Flask-Babel catalogs, plus whole per-locale files for the help manual and changelog. That is [`specs/i18n.md`](specs/i18n.md). +**Updates.** RetroDB checks GitHub Releases for a newer version, and an +admin installs it with one click: the download is checked against the +release's SHA-256, the database is backed up, and the new version's helper +swaps the files, restarts, and rolls back if the new version does not come +up. That is [`specs/PASS-59-169-self-updater.md`](specs/PASS-59-169-self-updater.md). + ### Subsystems without a spec Recorded under PASS-59-66 (2026-10-17) so the next audit does not decide this diff --git a/docs/specs/PASS-59-169-self-updater.md b/docs/specs/PASS-59-169-self-updater.md new file mode 100644 index 00000000..e857d3ff --- /dev/null +++ b/docs/specs/PASS-59-169-self-updater.md @@ -0,0 +1,211 @@ + +# PASS-59-169 — RetroDB updates itself + +**Status:** accepted (2026-10-27). +**Kind:** implement. +**Source:** ROADMAP PASS-59-169 (user request and decisions 2026-10-27: +check + one-click install, GitHub Releases, source and standalone installs; +no spec review, the implementation is the review). + +Layman: RetroDB notices when a new version is out, shows an admin what is +new, and installs it with one click. It downloads the release, checks the +download is exactly the published file, backs up your database, swaps the +program files, restarts, and goes back to the old version by itself if the +new one does not start. Your games, settings, images and logs are never +touched. + +## 1. Goal + +An admin sees "RetroDB X.Y.Z is available" on the dashboard and in +Settings > System > Updates, with the release notes. **Download** fetches and +verifies the release in the background; **Install and restart** applies it. +After the restart the page reloads on the new version, or, if the new +version did not come up, on the old one with the reason shown. Source +installs (unzipped `RetroDB-vX.Y.Z-.zip`) and standalone installs +(PyInstaller `RetroDB-vX.Y.Z-[-CPU]-Standalone.zip`) are both +covered, on Linux, Windows and macOS. + +## 2. Non-goals + +- No unattended install. Checking can be automatic; installing always takes + an admin's click (user, 2026-10-27). +- No downgrade and no pre-release channel: only GitHub's "latest" published, + non-draft, non-pre-release release is considered, and only when it is + newer than the running version. +- Git checkouts are never updated (`git pull` is the developer's tool), and + neither is an install run under LocalWebServerManager (`LWSM_MANAGED=1`), + whose manager restarts the process itself and would race the swap. Both + still see the notice and a download link. +- Signature verification of the cosign bundles `release.yml` attaches is + not in this pass (§7). + +## 3. Where updates come from + +`GET https://api.github.com/repos/milnet01/RetroDB/releases/latest` +(`services/updater.py::RELEASES_REPO`). Draft releases are invisible to this +endpoint, so the maintainer's existing "review the draft, then publish" step +is also the switch that offers an update to users. The tag is `vX.Y.Z`; +versions compare as integer tuples. + +The asset for this install: + +| Install | Asset | Checksum | +|---|---|---| +| source | `RetroDB-v{V}-{Platform}.zip` | its line in `SHA256SUMS.txt` | +| standalone | `RetroDB-v{V}-{Platform}-Standalone.zip`, else `RetroDB-v{V}-{Platform}-CPU-Standalone.zip` | `.sha256` | + +`{Platform}` is `Linux`, `macOS` or `Windows`. A download URL must start +with `https://github.com/milnet01/RetroDB/releases/download/`; GitHub then +redirects to its file host, which `requests` follows. A release without a +matching asset or checksum is shown as available but not installable. + +**Checking.** On demand (Check now) and, unless `update_check_auto` is off, +at most once every 24 hours, started in the background by an admin's +dashboard or Settings visit. The result is kept in memory and in +`data/update_check.json`, so a restart does not re-query. Unauthenticated +GitHub API use allows 60 requests an hour per address; one a day is far +below it. + +## 4. Download and verify (old version, background job) + +`services/updater.py::UpdateJob`, one at a time, admin only. + +1. Clear `/.update/download` and `.update/staging` from any + earlier attempt. +2. Stream the asset to `.update/download/`, refusing more than 3 GiB, and + hash it as it arrives. Fetch the checksum file (at most 64 KiB) and take + the line naming the asset. A mismatch deletes the file and stops. +3. Extract into `.update/staging/`. Every member name must stay inside the + staging folder (no absolute paths, no `..`), symlink members are refused, + and Unix permission bits from the zip are restored (`& 0o755`) so the + standalone binary, its libraries and `start.sh` stay executable. Total + unpacked size is capped at 8 GiB. +4. The zip holds one top folder (`RetroDB-v{V}` or + `RetroDB-v{V}[-CPU]-Standalone`); that folder is the new tree. It must + hold `app.py` and a `config.example.py` whose `APP_VERSION` is `{V}` + (source), or the `retrodb` / `retrodb.exe` program and `_internal/` + (standalone). +5. State becomes `ready`. Nothing in the install has changed yet. + +## 5. Install (old version hands over to the new version's helper) + +`POST /api/updates/install`, admin only, state `ready`: + +1. Back up the database with `services.database.backup_database` to + `database/backups/pre_update__to__.db`. Backups named + `pre_*` are never pruned. +2. Write `.update/plan.json` (§5.1). +3. Start the **new** version's helper, detached from this process: + source `python /updater_apply.py `, standalone + `/retrodb[.exe] --apply-update `. Running the new + version's helper means the update protocol is always the newest one, and + on Windows nothing needs to replace a running program. +4. Answer the request, then shut down exactly as `/api/shutdown` does + (drain jobs, SIGTERM). + +The browser shows "Restarting…" and polls `/health` until it reports a +version, then reloads. + +### 5.1 Plan file + +```json +{"plan_version": 1, "kind": "source|standalone", + "from_version": "3.33.0", "to_version": "3.34.0", + "install_dir": "...", "rollback_dir": "...", + "staging_root": "/.update/staging", + "staging_dir": "/", + "download_dir": "/.update/download", + "pid": 1234, "port": 5000, + "restart_argv": ["..."], "restart_cwd": "...", + "db_path": "...", "db_backup": "...", + "status_path": "/data/update_status.json", + "log_path": "/logs/update.log"} +``` + +### 5.2 The helper (`updater_apply.py`, standard library only) + +`app.py` hands `--apply-update` to it before importing anything else, so +the standalone binary can run it without starting a server or touching the +database. + +1. Wait up to 120 s for `pid` to exit. +2. Swap, recording every step in `rollback_dir`: + - **standalone:** move the old `_internal/` and every top-level file the + new tree also has (program, start script, `install-launcher.sh`, + `packaging/`) into `rollback_dir`, then copy the new ones in. Copied, + not moved: the helper is itself running from the staging tree. + - **source:** for every file in the new tree, move the existing file (if + any) into `rollback_dir`, then copy the new one in; remember files that + are new. Files the previous update installed (`.update/manifest.json`) + that the new version no longer has are moved to `rollback_dir` too. + Afterwards write the new manifest. + - **Never written, whatever the archive holds:** `config.py`, + `database/`, `logs/`, `.update/`, `data/settings.json`, + `data/scraper_settings.json`, `data/rom_tools_config.json`, + `data/psn_tokens.json`, `data/xbox_tokens.json`, `data/.secret_key`, + and every file under `static/images/` and `static/videos/` that the + new tree does not itself ship. +3. **source only:** when `requirements.lock` changed, run the new tree's + `installer_core.pip_install(select_pip_args(...))` with the install's own + Python. A failure rolls back. +4. Start `restart_argv` in `restart_cwd`, detached, with + `RETRODB_OPEN_BROWSER=0` (the open tab reloads itself), output appended + to `log_path`. +5. Poll `http://127.0.0.1:/health` for up to 180 s (the standalone + warms its upscaler first). Success is a 200 whose `version` is + `to_version`. +6. **Success:** write `status_path` `{"result": "updated", ...}`, delete the + download, and on Linux/macOS the staging tree. `rollback_dir` stays until + the next update, for a manual revert. +7. **Failure** (swap error, dependency install error, no healthy start): + stop the new process, put every recorded file back, remove files the + update added, restore the database from `db_backup` (removing `-wal` / + `-shm`), start the old version the same way, and write + `{"result": "rolled_back", "reason": ...}`. + +On the next status request the old/new app reads `status_path` once, +shows the outcome to the admin, and removes leftovers the helper could not +(the Windows staging tree) once the helper's process is gone. + +## 6. API (admin only) + +| Route | Does | +|---|---| +| `GET /api/updates/status` | current version, latest release (version, notes, page URL, published date), `available`, `can_install` + `reason`, job state and progress, last result. Starts a background check when the cached one is over 24 h old and `update_check_auto` is on. | +| `POST /api/updates/check` | check now | +| `POST /api/updates/download` | start §4 | +| `POST /api/updates/install` | §5 | + +Errors are the usual `error(...)` envelope (`docs/specs/api-contracts.md`). +Release notes are GitHub's release body, shown as plain text. + +## 7. Threat model + +What makes a download trustworthy here is HTTPS to GitHub plus the checksum +published with the release: a truncated, corrupted or CDN-substituted file +installs nothing. Someone who can publish a release on the repository can +also change its checksums, and could equally push code, so this adds no new +trust in anyone. Verifying the cosign keyless signatures `release.yml` +already attaches would also bind the file to the release workflow; it needs +the `sigstore` package and is a follow-up (roadmap). + +Only admins can check, download or install. The URL prefix check keeps the +updater on the RetroDB release host. Archive extraction refuses path escape +and symlinks. + +## 8. Settings + +`update_check_auto` (bool, default on): the daily background check. +Settings > System > Updates has the toggle. + +## 9. Verification + +- Unit: version comparison, asset choice per install kind and platform, + checksum parsing, URL prefix check, extraction refusing `..`, absolute + paths and symlinks and keeping executable bits, the protected-path list. +- Helper end to end with a fake install: a tiny server script stands in for + the app and answers `/health` with its version. Covers success, a new + version that never becomes healthy (rollback restores files, removes added + files, restores the database and restarts the old one), and the manifest + removing files a later version dropped. +- Routes: admin-only, state machine refusals. diff --git a/docs/specs/jobs.md b/docs/specs/jobs.md index f2c078bf..ed268bdf 100644 --- a/docs/specs/jobs.md +++ b/docs/specs/jobs.md @@ -318,8 +318,10 @@ Anything not persisted in the last progress-tick window (≤ 10 items or 2. `sweep_old_job_history()` — deletes terminal rows older than retention (§10). 3. The dashboard reads `get_recoverable_jobs()` and offers a banner. - `BulkScrapeJob` deduplicates by `(system_id, scrape_mode)` and - auto-dismisses losers (`base.py:606`). + `BulkScrapeJob` deduplicates single-system scrapes by + `(system_id, scrape_mode)` and auto-dismisses losers (`base.py:606`). + Multi-system scrapes (`system_id` None) are distinct selections and are + never deduplicated. 4. User clicks "Resume" → route calls `.resume_from_params(params, progress)` which restarts the worker at `progress['current']`. **Seven classes implement resume**: `BulkScrapeJob`, `RASyncJob`, `RARefreshJob`, diff --git a/install_gui.py b/install_gui.py index 576641cf..0909cdd7 100644 --- a/install_gui.py +++ b/install_gui.py @@ -324,12 +324,22 @@ def start_install(self): self.install_btn.configure(state='disabled', bg=TEXT_MUTED, cursor='arrow') - thread = threading.Thread(target=self._install_worker, daemon=True) + thread = threading.Thread(target=self._run_install_worker, daemon=True) thread.start() self._poll_queue() # ── Install worker (background thread) ─────────────────────────────── + def _run_install_worker(self): + """Run the worker so an unexpected error (an unwritable config copy or + directory, say) still reaches the UI. Uncaught, it ended the thread + before 'done' was queued, and the window sat with Install disabled.""" + try: + self._install_worker() + except Exception as e: + self._log(f'Installer error: {e}', 'error') + self._finish(False) + def _install_worker(self): base_dir = self.base_dir errors = [] diff --git a/messages.pot b/messages.pot index 0593f8a9..b180cf59 100644 --- a/messages.pot +++ b/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-23 15:00+0200\n" +"POT-Creation-Date: 2026-10-27 13:06+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: app.py:680 +#: app.py:690 #, python-format msgid "" "Upload exceeds the %(total)s MB limit. Each image may be up to %(image)s " @@ -25,20 +25,20 @@ msgid "" "can raise the total limit via RETRODB_MAX_UPLOAD_MB." msgstr "" -#: app.py:711 +#: app.py:721 msgid "Too many requests. Please wait and try again." msgstr "" -#: app.py:820 +#: app.py:830 msgid "Setup required" msgstr "" -#: app.py:841 +#: app.py:851 msgid "Password change required" msgstr "" -#: app.py:1668 routes/auth.py:202 routes/auth.py:251 routes/auth.py:436 -#: routes/auth.py:481 templates/force_change_password.html:49 +#: app.py:1705 routes/auth.py:202 routes/auth.py:262 routes/auth.py:447 +#: routes/auth.py:492 templates/force_change_password.html:49 #: templates/setup.html:469 msgid "Password must be at least 12 characters" msgstr "" @@ -55,9 +55,9 @@ msgstr "" #: routes/bonus_discs.py:265 routes/bulk_scrape.py:46 routes/collections.py:276 #: routes/collections.py:531 routes/emulators.py:271 routes/emulators.py:285 #: routes/games.py:348 routes/games.py:808 routes/games.py:931 -#: routes/games.py:987 routes/games_ai.py:46 routes/games_media.py:37 -#: routes/games_media.py:85 routes/games_media.py:146 routes/launch.py:240 -#: routes/reports.py:693 routes/reports.py:818 routes/trophies.py:1278 +#: routes/games.py:987 routes/games_ai.py:47 routes/games_media.py:37 +#: routes/games_media.py:85 routes/games_media.py:148 routes/launch.py:240 +#: routes/reports.py:693 routes/reports.py:822 routes/trophies.py:1291 #: services/hltb_service.py:125 msgid "Game not found" msgstr "" @@ -82,8 +82,8 @@ msgstr "" msgid "Could not fetch achievement data" msgstr "" -#: routes/achievements.py:294 routes/controllers.py:412 -#: routes/controllers.py:462 routes/museum.py:309 routes/museum.py:381 +#: routes/achievements.py:294 routes/controllers.py:413 +#: routes/controllers.py:463 routes/museum.py:309 routes/museum.py:381 #: routes/ra_sync.py:98 routes/systems.py:220 msgid "System not found" msgstr "" @@ -97,8 +97,8 @@ msgstr "" msgid "Sync already in progress for %(name)s" msgstr "" -#: routes/achievements.py:367 routes/platform_import.py:326 -#: routes/platform_import.py:693 +#: routes/achievements.py:367 routes/platform_import.py:334 +#: routes/platform_import.py:702 msgid "That job belongs to another user" msgstr "" @@ -114,7 +114,7 @@ msgstr "" msgid "No user selected" msgstr "" -#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:276 +#: routes/auth.py:68 routes/auth.py:228 routes/auth.py:287 msgid "User not found" msgstr "" @@ -146,70 +146,74 @@ msgstr "" msgid "Username already exists" msgstr "" -#: routes/auth.py:280 +#: routes/auth.py:239 +msgid "The last admin must stay an active admin" +msgstr "" + +#: routes/auth.py:291 msgid "Cannot delete your own account" msgstr "" -#: routes/auth.py:285 +#: routes/auth.py:296 msgid "Cannot delete the last admin user" msgstr "" -#: routes/auth.py:343 routes/auth.py:412 routes/auth.py:468 +#: routes/auth.py:354 routes/auth.py:423 routes/auth.py:479 msgid "Not logged in" msgstr "" -#: routes/auth.py:380 +#: routes/auth.py:391 msgid "Invalid locale" msgstr "" -#: routes/auth.py:385 +#: routes/auth.py:396 msgid "Invalid theme" msgstr "" -#: routes/auth.py:391 +#: routes/auth.py:402 #, python-format msgid "Invalid RPCS3 trophy folder: %(reason)s" msgstr "" -#: routes/auth.py:424 +#: routes/auth.py:435 msgid "Too many attempts. Please try again later." msgstr "" -#: routes/auth.py:431 routes/auth.py:477 +#: routes/auth.py:442 routes/auth.py:488 msgid "New password is required" msgstr "" -#: routes/auth.py:442 +#: routes/auth.py:453 msgid "Current password is incorrect" msgstr "" -#: routes/auth.py:471 +#: routes/auth.py:482 msgid "Password change not required" msgstr "" -#: routes/auth.py:523 +#: routes/auth.py:534 msgid "No file provided" msgstr "" -#: routes/auth.py:527 routes/museum.py:660 +#: routes/auth.py:538 routes/museum.py:660 msgid "No file selected" msgstr "" -#: routes/auth.py:532 +#: routes/auth.py:543 msgid "Invalid file type. Allowed: jpg, png, gif, webp" msgstr "" -#: routes/auth.py:537 +#: routes/auth.py:548 msgid "File too large. Maximum size is 2MB" msgstr "" -#: routes/auth.py:555 +#: routes/auth.py:566 msgid "" "Avatar uploads require Pillow; please ask the operator to install the " "project requirements." msgstr "" -#: routes/auth.py:571 routes/museum.py:692 +#: routes/auth.py:582 routes/museum.py:692 msgid "Invalid image file" msgstr "" @@ -226,17 +230,17 @@ msgid "Bonus disc and parent game must be in the same system" msgstr "" #: routes/bonus_discs.py:211 routes/bonus_discs.py:229 routes/games.py:1245 -#: routes/games.py:1275 routes/platform_import.py:289 -#: routes/platform_import.py:658 routes/platform_import.py:1024 +#: routes/games.py:1275 routes/platform_import.py:297 +#: routes/platform_import.py:667 routes/platform_import.py:1032 #: routes/scrape_logs.py:84 routes/scrape_logs.py:168 routes/scrape_logs.py:181 -#: routes/scrape_logs.py:219 routes/scraper.py:200 routes/scraper.py:528 -#: routes/scraper.py:917 routes/settings.py:317 routes/settings.py:341 +#: routes/scrape_logs.py:219 routes/scraper.py:202 routes/scraper.py:530 +#: routes/scraper.py:919 routes/settings.py:317 routes/settings.py:341 #: routes/settings.py:456 routes/settings.py:533 routes/settings.py:547 #: routes/settings.py:591 routes/settings.py:607 routes/settings.py:643 -#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:466 -#: routes/trophies.py:498 routes/trophies.py:694 routes/trophies.py:742 -#: routes/trophies.py:1564 routes/trophies.py:1588 routes/trophies.py:1633 -#: routes/trophies.py:1661 routes/trophies.py:1703 routes/trophies.py:1770 +#: routes/steam_achievements.py:138 routes/systems.py:286 routes/systems.py:467 +#: routes/trophies.py:508 routes/trophies.py:706 routes/trophies.py:754 +#: routes/trophies.py:1582 routes/trophies.py:1606 routes/trophies.py:1651 +#: routes/trophies.py:1679 routes/trophies.py:1721 routes/trophies.py:1788 #: routes/xbox_achievements.py:157 services/api_helpers.py:46 msgid "An internal error occurred" msgstr "" @@ -245,7 +249,7 @@ msgstr "" msgid "Missing bonus_id or parent_id" msgstr "" -#: routes/bonus_discs.py:310 routes/reports.py:806 +#: routes/bonus_discs.py:310 routes/reports.py:810 msgid "Missing game_id" msgstr "" @@ -366,22 +370,22 @@ msgstr "" msgid "Wishlist item not found" msgstr "" -#: routes/controllers.py:225 routes/controllers.py:287 +#: routes/controllers.py:226 routes/controllers.py:288 msgid "Controller name is required" msgstr "" -#: routes/controllers.py:230 +#: routes/controllers.py:231 #, python-format msgid "Controller \"%(name)s\" already exists" msgstr "" -#: routes/controllers.py:292 routes/controllers.py:334 -#: routes/controllers.py:419 routes/controllers.py:468 routes/museum.py:577 +#: routes/controllers.py:293 routes/controllers.py:335 +#: routes/controllers.py:420 routes/controllers.py:469 routes/museum.py:577 #: routes/museum.py:653 routes/museum.py:723 msgid "Controller not found" msgstr "" -#: routes/controllers.py:297 +#: routes/controllers.py:298 #, python-format msgid "Another controller named \"%(name)s\" already exists" msgstr "" @@ -462,9 +466,9 @@ msgstr "" msgid "No fields to update" msgstr "" -#: routes/games.py:1064 routes/platform_import.py:212 -#: routes/platform_import.py:572 routes/platform_import.py:928 -#: routes/trophies.py:1852 +#: routes/games.py:1064 routes/platform_import.py:220 +#: routes/platform_import.py:581 routes/platform_import.py:936 +#: routes/trophies.py:1870 msgid "No games selected" msgstr "" @@ -492,11 +496,11 @@ msgstr "" msgid "Invalid filter type" msgstr "" -#: routes/games_ai.py:83 +#: routes/games_ai.py:84 msgid "AI returned no data. Check your API key and provider settings." msgstr "" -#: routes/games_ai.py:300 +#: routes/games_ai.py:305 msgid "AI scraper module not available" msgstr "" @@ -514,7 +518,7 @@ msgstr "" msgid "ROM file not found on disk: %(path)s" msgstr "" -#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:892 +#: routes/games_media.py:105 routes/reports.py:721 routes/reports.py:896 msgid "Destination is outside the configured ROM root" msgstr "" @@ -523,23 +527,23 @@ msgstr "" msgid "Cannot rename: %(reason)s" msgstr "" -#: routes/games_media.py:112 routes/reports.py:729 routes/reports.py:896 +#: routes/games_media.py:114 routes/reports.py:731 routes/reports.py:902 msgid "A file with that name already exists" msgstr "" -#: routes/games_media.py:119 +#: routes/games_media.py:121 msgid "Failed to rename file" msgstr "" -#: routes/games_media.py:139 +#: routes/games_media.py:141 msgid "No screenshot specified" msgstr "" -#: routes/games_media.py:142 +#: routes/games_media.py:144 msgid "Invalid screenshot filename" msgstr "" -#: routes/games_media.py:152 +#: routes/games_media.py:154 msgid "Screenshot not found" msgstr "" @@ -614,11 +618,11 @@ msgstr "" msgid "Next disk inserted" msgstr "" -#: routes/maintenance.py:260 +#: routes/maintenance.py:263 msgid "Unknown image type" msgstr "" -#: routes/museum.py:385 routes/scraper.py:770 +#: routes/museum.py:385 routes/scraper.py:772 msgid "No AI provider configured" msgstr "" @@ -677,7 +681,7 @@ msgstr "" msgid "Image file not found" msgstr "" -#: routes/museum.py:740 routes/museum.py:744 services/js_i18n_strings.py:512 +#: routes/museum.py:740 routes/museum.py:744 services/js_i18n_strings.py:536 msgid "Background removal failed" msgstr "" @@ -697,52 +701,52 @@ msgstr "" msgid "No official names run to cancel." msgstr "" -#: routes/platform_import.py:146 +#: routes/platform_import.py:153 msgid "Steam API key not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:148 +#: routes/platform_import.py:155 msgid "Steam ID not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:154 +#: routes/platform_import.py:161 #, python-format msgid "" "Could not resolve Steam vanity URL \"%(steam_id)s\". Use your 17-digit " "Steam ID instead." msgstr "" -#: routes/platform_import.py:160 +#: routes/platform_import.py:167 msgid "" "No games found. Check that your Steam profile and game details are set to" " Public." msgstr "" -#: routes/platform_import.py:217 +#: routes/platform_import.py:225 msgid "Could not create Windows system entry" msgstr "" -#: routes/platform_import.py:344 +#: routes/platform_import.py:352 msgid "Xbox Client ID not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:482 +#: routes/platform_import.py:490 msgid "Xbox credentials not configured. Add them in Settings → API Keys." msgstr "" -#: routes/platform_import.py:486 +#: routes/platform_import.py:494 msgid "Xbox authentication failed. Please re-connect your Xbox account." msgstr "" -#: routes/platform_import.py:490 +#: routes/platform_import.py:498 msgid "No games found in Xbox title history." msgstr "" -#: routes/platform_import.py:768 +#: routes/platform_import.py:777 msgid "PSN NPSSO not configured. Add it in Settings → API Keys." msgstr "" -#: routes/platform_import.py:795 +#: routes/platform_import.py:804 msgid "Failed to fetch PSN game list. Try syncing trophies first." msgstr "" @@ -751,11 +755,11 @@ msgstr "" msgid "System %(name)s does not support RetroAchievements" msgstr "" -#: routes/reports.py:413 +#: routes/reports.py:427 msgid "Unknown system" msgstr "" -#: routes/reports.py:434 routes/reports.py:599 +#: routes/reports.py:448 routes/reports.py:599 msgid "ROM path not configured. Go to Settings → Paths to set it." msgstr "" @@ -763,8 +767,8 @@ msgstr "" msgid "Missing required parameters" msgstr "" -#: routes/reports.py:605 routes/tools.py:503 routes/tools.py:560 -#: routes/tools.py:605 +#: routes/reports.py:605 routes/tools.py:511 routes/tools.py:568 +#: routes/tools.py:613 msgid "Invalid path" msgstr "" @@ -777,31 +781,31 @@ msgstr "" msgid "Missing game_id or new_name" msgstr "" -#: routes/reports.py:697 routes/reports.py:822 +#: routes/reports.py:697 routes/reports.py:826 msgid "No ROM path set" msgstr "" -#: routes/reports.py:712 routes/reports.py:827 +#: routes/reports.py:712 routes/reports.py:831 msgid "ROM file not found" msgstr "" -#: routes/reports.py:738 +#: routes/reports.py:740 msgid "M3U files must keep the .m3u extension" msgstr "" -#: routes/reports.py:751 routes/reports.py:908 +#: routes/reports.py:755 routes/reports.py:916 msgid "A folder with the new name already exists" msgstr "" -#: routes/reports.py:831 +#: routes/reports.py:835 msgid "No scraped title available" msgstr "" -#: routes/reports.py:849 +#: routes/reports.py:853 msgid "Title is empty after sanitization" msgstr "" -#: routes/reports.py:872 +#: routes/reports.py:876 msgid "Generated filename contains invalid characters" msgstr "" @@ -810,7 +814,7 @@ msgstr "" msgid "Invalid log file" msgstr "" -#: routes/scrape_logs.py:101 routes/tools.py:506 +#: routes/scrape_logs.py:101 routes/tools.py:514 msgid "File not found" msgstr "" @@ -819,56 +823,56 @@ msgstr "" msgid "Log file not found" msgstr "" -#: routes/scraper.py:215 +#: routes/scraper.py:217 #, python-format msgid "invalid scraper settings: %(reason)s" msgstr "" -#: routes/scraper.py:276 +#: routes/scraper.py:278 #, python-format msgid "invalid api keys: %(reason)s" msgstr "" -#: routes/scraper.py:548 +#: routes/scraper.py:550 msgid "No TGDB API key configured" msgstr "" -#: routes/scraper.py:655 +#: routes/scraper.py:657 msgid "No ScreenScraper credentials configured" msgstr "" -#: routes/scraper.py:691 +#: routes/scraper.py:693 msgid "Invalid JSON response from ScreenScraper" msgstr "" -#: routes/scraper.py:694 +#: routes/scraper.py:696 msgid "Error parsing API response" msgstr "" -#: routes/scraper.py:696 +#: routes/scraper.py:698 #, python-format msgid "ScreenScraper answered HTTP %(code)s" msgstr "" -#: routes/scraper.py:711 +#: routes/scraper.py:713 msgid "No RAWG API key configured" msgstr "" -#: routes/scraper.py:816 +#: routes/scraper.py:818 #, python-format msgid "Unknown AI provider: %(provider)s" msgstr "" -#: routes/scraper.py:819 +#: routes/scraper.py:821 msgid "No response from AI provider" msgstr "" -#: routes/scraper.py:910 +#: routes/scraper.py:912 #, python-format msgid "Connection error: %(error)s" msgstr "" -#: routes/scraper.py:913 +#: routes/scraper.py:915 #, python-format msgid "Allowance check not supported for %(scraper)s" msgstr "" @@ -899,7 +903,7 @@ msgid "Invalid %(field)s: %(result)s" msgstr "" #: routes/settings.py:529 routes/settings.py:588 routes/settings.py:640 -#: routes/tools.py:289 +#: routes/tools.py:290 services/js_i18n_strings.py:649 msgid "Failed to save settings" msgstr "" @@ -907,12 +911,12 @@ msgstr "" msgid "Request body must be a JSON object" msgstr "" -#: routes/settings.py:567 routes/tools.py:281 +#: routes/settings.py:567 routes/tools.py:282 #, python-format msgid "Unknown setting key: %(key)s" msgstr "" -#: routes/settings.py:573 routes/tools.py:284 +#: routes/settings.py:573 routes/tools.py:285 #, python-format msgid "Invalid value for '%(key)s': %(reason)s" msgstr "" @@ -950,149 +954,157 @@ msgstr "" msgid "ES-DE scraper not available" msgstr "" -#: routes/tools.py:271 +#: routes/tools.py:272 msgid "Administrator privileges required" msgstr "" -#: routes/tools.py:275 +#: routes/tools.py:276 msgid "Settings payload must be an object" msgstr "" -#: routes/tools.py:324 routes/tools.py:965 +#: routes/tools.py:325 routes/tools.py:973 #, python-format msgid "Path does not exist: %(path)s" msgstr "" -#: routes/tools.py:354 +#: routes/tools.py:362 msgid "Permission denied" msgstr "" -#: routes/tools.py:382 +#: routes/tools.py:390 msgid "Task not found" msgstr "" -#: routes/tools.py:405 routes/tools.py:425 +#: routes/tools.py:413 routes/tools.py:433 msgid "Task not found or not running" msgstr "" -#: routes/tools.py:445 +#: routes/tools.py:453 msgid "Task not found or not paused" msgstr "" -#: routes/tools.py:462 routes/tools.py:667 routes/tools.py:838 -#: routes/tools.py:954 +#: routes/tools.py:470 routes/tools.py:675 routes/tools.py:846 +#: routes/tools.py:962 msgid "Invalid scan path" msgstr "" -#: routes/tools.py:500 routes/tools.py:557 routes/tools.py:602 +#: routes/tools.py:508 routes/tools.py:565 routes/tools.py:610 msgid "No path provided" msgstr "" -#: routes/tools.py:528 +#: routes/tools.py:536 msgid "No archive path provided" msgstr "" -#: routes/tools.py:531 +#: routes/tools.py:539 msgid "Invalid archive path" msgstr "" -#: routes/tools.py:534 +#: routes/tools.py:542 msgid "No files to remove" msgstr "" -#: routes/tools.py:635 +#: routes/tools.py:643 msgid "No paths provided" msgstr "" -#: routes/tools.py:641 +#: routes/tools.py:649 #, python-format msgid "Invalid path: %(path)s" msgstr "" -#: routes/tools.py:716 +#: routes/tools.py:724 msgid "chdman not found. Please install MAME tools." msgstr "" -#: routes/tools.py:861 +#: routes/tools.py:869 msgid "chdman not found" msgstr "" -#: routes/tools.py:959 +#: routes/tools.py:967 #, python-format msgid "Unknown duplicate method: %(method)s" msgstr "" -#: routes/tools.py:1167 +#: routes/tools.py:1175 msgid "These scan results have expired. Scan again, then delete." msgstr "" -#: routes/tools.py:1172 +#: routes/tools.py:1180 msgid "Keep at least one copy of each game. Nothing was deleted." msgstr "" -#: routes/trophies.py:387 +#: routes/trophies.py:396 msgid "" "Please configure your RPCS3 Trophy Path in your profile settings to view " "trophies" msgstr "" -#: routes/trophies.py:418 +#: routes/trophies.py:428 msgid "Trophy set not found" msgstr "" -#: routes/trophies.py:462 +#: routes/trophies.py:472 msgid "RPCS3_TROPHY_PATH not configured" msgstr "" -#: routes/trophies.py:465 +#: routes/trophies.py:475 #, python-format msgid "Trophy path not found: %(path)s" msgstr "" -#: routes/trophies.py:755 routes/trophies.py:1686 +#: routes/trophies.py:767 routes/trophies.py:1704 msgid "PSN game not found" msgstr "" -#: routes/trophies.py:1046 +#: routes/trophies.py:1058 msgid "Sync already in progress" msgstr "" -#: routes/trophies.py:1296 +#: routes/trophies.py:1309 msgid "Game not found in your PSN trophy list" msgstr "" -#: routes/trophies.py:1561 +#: routes/trophies.py:1579 msgid "Game not found on PSN" msgstr "" -#: routes/trophies.py:1576 routes/trophies.py:1649 +#: routes/trophies.py:1594 routes/trophies.py:1667 msgid "Missing npwr_id" msgstr "" -#: routes/trophies.py:1674 +#: routes/trophies.py:1692 msgid "Missing npwr_id or group_id" msgstr "" -#: routes/trophies.py:1677 +#: routes/trophies.py:1695 msgid "Name cannot be empty" msgstr "" -#: routes/trophies.py:1715 +#: routes/trophies.py:1733 msgid "PSNAWP library not installed. Run: pip install psnawp" msgstr "" -#: routes/trophies.py:1809 +#: routes/trophies.py:1827 msgid "No NPSSO token provided" msgstr "" -#: routes/trophies.py:1834 +#: routes/trophies.py:1852 msgid "Could not connect to PSN" msgstr "" -#: routes/trophies.py:1861 +#: routes/trophies.py:1879 msgid "PSN NPSSO not configured" msgstr "" +#: routes/updates.py:48 +msgid "No newer version is available" +msgstr "" + +#: routes/updates.py:52 routes/updates.py:64 +msgid "This update cannot be installed here" +msgstr "" + #: routes/versions.py:23 msgid "Pick at least two games." msgstr "" @@ -1479,19 +1491,19 @@ msgstr "" msgid "Turn-Based" msgstr "" -#: services/js_i18n_strings.py:475 +#: services/js_i18n_strings.py:498 msgid "(Paused)" msgstr "" -#: services/js_i18n_strings.py:476 +#: services/js_i18n_strings.py:499 msgid "(none)" msgstr "" -#: services/js_i18n_strings.py:477 +#: services/js_i18n_strings.py:500 msgid "(not set)" msgstr "" -#: services/js_i18n_strings.py:478 templates/_bulk_edit_modal.html:25 +#: services/js_i18n_strings.py:501 templates/_bulk_edit_modal.html:25 #: templates/_bulk_edit_modal.html:38 templates/_bulk_edit_modal.html:83 #: templates/_bulk_edit_modal.html:95 templates/_bulk_edit_modal.html:120 #: templates/_bulk_edit_modal.html:137 templates/_bulk_edit_modal.html:153 @@ -1499,99 +1511,103 @@ msgstr "" msgid "-- Don't change --" msgstr "" -#: services/js_i18n_strings.py:479 +#: services/js_i18n_strings.py:502 msgid "-- Select log file --" msgstr "" -#: services/js_i18n_strings.py:480 +#: services/js_i18n_strings.py:503 #, python-brace-format msgid "... and {n} more" msgstr "" -#: services/js_i18n_strings.py:481 templates/_bulk_edit_modal.html:30 +#: services/js_i18n_strings.py:504 templates/_bulk_edit_modal.html:30 #: templates/base.html:1014 msgid "100%" msgstr "" -#: services/js_i18n_strings.py:482 templates/_settings_tabs/account.html:273 +#: services/js_i18n_strings.py:505 templates/_settings_tabs/account.html:273 #: templates/archive_scanner.html:726 templates/multi_disc_organizer.html:217 #: templates/reports.html:369 templates/reports.html:450 msgid "Actions" msgstr "" -#: services/js_i18n_strings.py:483 +#: services/js_i18n_strings.py:506 msgid "Added to queue - will start when current operation completes" msgstr "" -#: services/js_i18n_strings.py:484 +#: services/js_i18n_strings.py:507 msgid "All ROMs accounted for!" msgstr "" -#: services/js_i18n_strings.py:485 templates/logs.html:764 +#: services/js_i18n_strings.py:508 templates/logs.html:764 msgid "All Sources" msgstr "" -#: services/js_i18n_strings.py:486 templates/all_games.html:42 +#: services/js_i18n_strings.py:509 templates/all_games.html:42 #: templates/reports.html:328 templates/reports.html:407 #: templates/reports.html:480 msgid "All Systems" msgstr "" -#: services/js_i18n_strings.py:487 +#: services/js_i18n_strings.py:510 msgid "Already Syncing" msgstr "" -#: services/js_i18n_strings.py:488 +#: services/js_i18n_strings.py:511 +msgid "An update is already being downloaded." +msgstr "" + +#: services/js_i18n_strings.py:512 #, python-brace-format msgid "Applied when you press Play, in the {emulator} settings for this game." msgstr "" -#: services/js_i18n_strings.py:489 +#: services/js_i18n_strings.py:513 #, python-brace-format msgid "Applied {n} update(s)." msgstr "" -#: services/js_i18n_strings.py:490 templates/_bulk_edit_modal.html:193 +#: services/js_i18n_strings.py:514 templates/_bulk_edit_modal.html:193 msgid "Apply Changes" msgstr "" -#: services/js_i18n_strings.py:491 +#: services/js_i18n_strings.py:515 msgid "Apply Normalization" msgstr "" -#: services/js_i18n_strings.py:492 +#: services/js_i18n_strings.py:516 #, python-brace-format msgid "Apply failed: {error}" msgstr "" -#: services/js_i18n_strings.py:493 +#: services/js_i18n_strings.py:517 #, python-brace-format msgid "Apply {n} Selected" msgstr "" -#: services/js_i18n_strings.py:494 +#: services/js_i18n_strings.py:518 #, python-brace-format msgid "Apply {n} normalization rule?" msgstr "" -#: services/js_i18n_strings.py:495 +#: services/js_i18n_strings.py:519 #, python-brace-format msgid "Apply {n} normalization rules?" msgstr "" -#: services/js_i18n_strings.py:496 +#: services/js_i18n_strings.py:520 msgid "Applying Metadata" msgstr "" -#: services/js_i18n_strings.py:497 +#: services/js_i18n_strings.py:521 msgid "Applying..." msgstr "" -#: services/js_i18n_strings.py:498 +#: services/js_i18n_strings.py:522 msgid "Applying…" msgstr "" -#: services/js_i18n_strings.py:499 +#: services/js_i18n_strings.py:523 msgid "" "Are you sure you want to cancel the bulk scrape?\n" "\n" @@ -1600,87 +1616,87 @@ msgid "" "during that window." msgstr "" -#: services/js_i18n_strings.py:500 +#: services/js_i18n_strings.py:524 msgid "Are you sure you want to cancel?" msgstr "" -#: services/js_i18n_strings.py:501 +#: services/js_i18n_strings.py:525 #, python-brace-format msgid "Are you sure you want to delete \"{filename}\"?" msgstr "" -#: services/js_i18n_strings.py:502 +#: services/js_i18n_strings.py:526 msgid "" "Are you sure you want to reset all metadata for this game? This will " "delete boxart and screenshots." msgstr "" -#: services/js_i18n_strings.py:503 +#: services/js_i18n_strings.py:527 msgid "Are you sure you want to restart the server?" msgstr "" -#: services/js_i18n_strings.py:504 +#: services/js_i18n_strings.py:528 msgid "" "Are you sure you want to shut down the server? You will need to start it " "again manually." msgstr "" -#: services/js_i18n_strings.py:505 templates/settings.html:67 +#: services/js_i18n_strings.py:529 templates/settings.html:67 msgid "Are you sure?" msgstr "" -#: services/js_i18n_strings.py:506 +#: services/js_i18n_strings.py:530 msgid "As the core sets it" msgstr "" -#: services/js_i18n_strings.py:507 +#: services/js_i18n_strings.py:531 msgid "Asking AI…" msgstr "" -#: services/js_i18n_strings.py:508 +#: services/js_i18n_strings.py:532 msgid "Asking the AI. This can take up to a minute." msgstr "" -#: services/js_i18n_strings.py:509 +#: services/js_i18n_strings.py:533 #, python-brace-format msgid "" "Asking the AI: game {current} of {total}. Names suggested: {proposals}. " "{system}" msgstr "" -#: services/js_i18n_strings.py:510 +#: services/js_i18n_strings.py:534 msgid "Aspect ratio" msgstr "" -#: services/js_i18n_strings.py:511 +#: services/js_i18n_strings.py:535 msgid "BIOS" msgstr "" -#: services/js_i18n_strings.py:513 +#: services/js_i18n_strings.py:537 msgid "Background removed" msgstr "" -#: services/js_i18n_strings.py:514 +#: services/js_i18n_strings.py:538 msgid "Bulk fetch failed" msgstr "" -#: services/js_i18n_strings.py:515 +#: services/js_i18n_strings.py:539 msgid "Bulk fetch request failed" msgstr "" -#: services/js_i18n_strings.py:516 +#: services/js_i18n_strings.py:540 msgid "Bulk scraping complete!" msgstr "" -#: services/js_i18n_strings.py:517 +#: services/js_i18n_strings.py:541 msgid "CLZ Imports" msgstr "" -#: services/js_i18n_strings.py:518 +#: services/js_i18n_strings.py:542 msgid "CRT screen" msgstr "" -#: services/js_i18n_strings.py:519 templates/_bulk_edit_modal.html:191 +#: services/js_i18n_strings.py:543 templates/_bulk_edit_modal.html:191 #: templates/_bulk_scrape_modal.html:49 templates/_modals/edit_modal.html:12 #: templates/_modals/rename_modal.html:19 #: templates/_settings_tabs/account.html:72 @@ -1701,85 +1717,85 @@ msgstr "" msgid "Cancel" msgstr "" -#: services/js_i18n_strings.py:520 +#: services/js_i18n_strings.py:544 msgid "Cancel Bulk Scrape" msgstr "" -#: services/js_i18n_strings.py:521 +#: services/js_i18n_strings.py:545 msgid "Cancel failed" msgstr "" -#: services/js_i18n_strings.py:522 +#: services/js_i18n_strings.py:546 msgid "Cancel request failed" msgstr "" -#: services/js_i18n_strings.py:523 +#: services/js_i18n_strings.py:547 msgid "Cancellation requested" msgstr "" -#: services/js_i18n_strings.py:524 +#: services/js_i18n_strings.py:548 msgid "Cancelled" msgstr "" -#: services/js_i18n_strings.py:525 +#: services/js_i18n_strings.py:549 msgid "Cancelled by user" msgstr "" -#: services/js_i18n_strings.py:526 +#: services/js_i18n_strings.py:550 msgid "Checking RetroAchievements…" msgstr "" -#: services/js_i18n_strings.py:527 +#: services/js_i18n_strings.py:551 msgid "Clean Missing ROMs" msgstr "" -#: services/js_i18n_strings.py:528 +#: services/js_i18n_strings.py:552 msgid "Clear CLZ Imports" msgstr "" -#: services/js_i18n_strings.py:529 +#: services/js_i18n_strings.py:553 msgid "Clear HLTB data for this game?" msgstr "" -#: services/js_i18n_strings.py:530 templates/achievements_system.html:109 +#: services/js_i18n_strings.py:554 templates/achievements_system.html:109 msgid "Clear RA Data" msgstr "" -#: services/js_i18n_strings.py:531 +#: services/js_i18n_strings.py:555 msgid "Clear RetroAchievements Data" msgstr "" -#: services/js_i18n_strings.py:532 +#: services/js_i18n_strings.py:556 #, python-brace-format msgid "Cleared RA data for {count} games in {system}" msgstr "" -#: services/js_i18n_strings.py:533 +#: services/js_i18n_strings.py:557 #, python-brace-format msgid "Cleared RA data for {n} games" msgstr "" -#: services/js_i18n_strings.py:534 +#: services/js_i18n_strings.py:558 #, python-brace-format msgid "Cleared RA data for {n} games in {system}" msgstr "" -#: services/js_i18n_strings.py:535 +#: services/js_i18n_strings.py:559 #, python-brace-format msgid "" "Cleared RetroAchievements data for {n} games. Run \"Refresh " "RetroAchievements\" to re-scan with updated matching." msgstr "" -#: services/js_i18n_strings.py:536 +#: services/js_i18n_strings.py:560 msgid "Clearing..." msgstr "" -#: services/js_i18n_strings.py:537 +#: services/js_i18n_strings.py:561 msgid "Click again to remove" msgstr "" -#: services/js_i18n_strings.py:538 templates/_bulk_edit_modal.html:8 +#: services/js_i18n_strings.py:562 templates/_bulk_edit_modal.html:8 #: templates/_bulk_scrape_modal.html:8 #: templates/_modals/boxart_zoom_modal.html:3 #: templates/_modals/manual_reader_modal.html:11 @@ -1788,44 +1804,44 @@ msgstr "" #: templates/archive_scanner.html:749 templates/archive_scanner.html:757 #: templates/base.html:249 templates/base.html:332 templates/base.html:349 #: templates/base.html:695 templates/base.html:971 templates/base.html:1365 -#: templates/reports.html:472 templates/systems.html:121 -#: templates/wishlist.html:56 +#: templates/dashboard.html:37 templates/reports.html:472 +#: templates/systems.html:121 templates/wishlist.html:56 msgid "Close" msgstr "" -#: services/js_i18n_strings.py:539 +#: services/js_i18n_strings.py:563 msgid "Close keyboard shortcuts" msgstr "" -#: services/js_i18n_strings.py:540 +#: services/js_i18n_strings.py:564 msgid "Close modal / cancel" msgstr "" -#: services/js_i18n_strings.py:541 templates/dashboard.html:179 +#: services/js_i18n_strings.py:565 templates/dashboard.html:188 #: templates/setup.html:20 msgid "Complete" msgstr "" -#: services/js_i18n_strings.py:542 +#: services/js_i18n_strings.py:566 msgid "Complete!" msgstr "" -#: services/js_i18n_strings.py:543 templates/_bulk_edit_modal.html:29 +#: services/js_i18n_strings.py:567 templates/_bulk_edit_modal.html:29 #: templates/analytics.html:1664 templates/base.html:1013 -#: templates/dashboard.html:497 templates/game_detail.html:222 +#: templates/dashboard.html:506 templates/game_detail.html:222 msgid "Completed" msgstr "" -#: services/js_i18n_strings.py:544 templates/_bulk_edit_modal.html:23 +#: services/js_i18n_strings.py:568 templates/_bulk_edit_modal.html:23 #: templates/analytics.html:137 templates/base.html:1008 msgid "Completion Status" msgstr "" -#: services/js_i18n_strings.py:545 +#: services/js_i18n_strings.py:569 msgid "Completion status updated" msgstr "" -#: services/js_i18n_strings.py:546 templates/achievement_game.html:172 +#: services/js_i18n_strings.py:570 templates/achievement_game.html:172 #: templates/achievement_game.html:197 templates/achievement_game.html:211 #: templates/base.html:1050 templates/game_detail.html:512 #: templates/game_detail.html:537 templates/game_detail.html:551 @@ -1838,395 +1854,403 @@ msgstr "" msgid "Completionist" msgstr "" -#: services/js_i18n_strings.py:547 templates/settings.html:66 +#: services/js_i18n_strings.py:571 templates/settings.html:66 msgid "Confirm Action" msgstr "" -#: services/js_i18n_strings.py:548 +#: services/js_i18n_strings.py:572 msgid "Confirm Bulk Edit" msgstr "" -#: services/js_i18n_strings.py:549 +#: services/js_i18n_strings.py:573 msgid "Content generated successfully" msgstr "" -#: services/js_i18n_strings.py:550 +#: services/js_i18n_strings.py:574 msgid "Controller" msgstr "" -#: services/js_i18n_strings.py:551 +#: services/js_i18n_strings.py:575 msgid "Controller image saved" msgstr "" -#: services/js_i18n_strings.py:552 +#: services/js_i18n_strings.py:576 msgid "Controller image uploaded" msgstr "" -#: services/js_i18n_strings.py:553 +#: services/js_i18n_strings.py:577 msgid "Core" msgstr "" -#: services/js_i18n_strings.py:554 +#: services/js_i18n_strings.py:578 msgid "Could not accept these names." msgstr "" -#: services/js_i18n_strings.py:555 +#: services/js_i18n_strings.py:579 msgid "Could not apply metadata." msgstr "" -#: services/js_i18n_strings.py:556 +#: services/js_i18n_strings.py:580 +msgid "Could not check for updates." +msgstr "" + +#: services/js_i18n_strings.py:581 #, python-brace-format msgid "" "Could not download the {system} core. Check the internet connection and " "try again." msgstr "" -#: services/js_i18n_strings.py:557 +#: services/js_i18n_strings.py:582 msgid "Could not link these versions." msgstr "" -#: services/js_i18n_strings.py:558 +#: services/js_i18n_strings.py:583 msgid "Could not load suggestions." msgstr "" -#: services/js_i18n_strings.py:559 +#: services/js_i18n_strings.py:584 msgid "Could not load the suggested names." msgstr "" -#: services/js_i18n_strings.py:560 +#: services/js_i18n_strings.py:585 msgid "Could not open this manual. Use \"Open PDF in a new tab\" instead." msgstr "" -#: services/js_i18n_strings.py:561 +#: services/js_i18n_strings.py:586 msgid "Could not read the run status." msgstr "" -#: services/js_i18n_strings.py:562 +#: services/js_i18n_strings.py:587 msgid "Could not reconnect to server. Please refresh manually." msgstr "" -#: services/js_i18n_strings.py:563 +#: services/js_i18n_strings.py:588 msgid "Could not reject these names." msgstr "" -#: services/js_i18n_strings.py:564 +#: services/js_i18n_strings.py:589 msgid "Could not save that setting." msgstr "" -#: services/js_i18n_strings.py:565 +#: services/js_i18n_strings.py:590 msgid "Could not save that." msgstr "" -#: services/js_i18n_strings.py:566 +#: services/js_i18n_strings.py:591 msgid "Could not show this page of the manual." msgstr "" -#: services/js_i18n_strings.py:567 +#: services/js_i18n_strings.py:592 msgid "Could not start the run." msgstr "" -#: services/js_i18n_strings.py:568 +#: services/js_i18n_strings.py:593 msgid "Could not stop the run." msgstr "" -#: services/js_i18n_strings.py:569 +#: services/js_i18n_strings.py:594 msgid "Could not stop the running instance." msgstr "" -#: services/js_i18n_strings.py:570 +#: services/js_i18n_strings.py:595 msgid "Could not unlink this version." msgstr "" -#: services/js_i18n_strings.py:571 +#: services/js_i18n_strings.py:596 msgid "Could not write the summary." msgstr "" -#: services/js_i18n_strings.py:572 +#: services/js_i18n_strings.py:597 msgid "Currently set" msgstr "" -#: services/js_i18n_strings.py:573 +#: services/js_i18n_strings.py:598 msgid "Default" msgstr "" -#: services/js_i18n_strings.py:574 +#: services/js_i18n_strings.py:599 #, python-brace-format msgid "Default emulator for {system}" msgstr "" -#: services/js_i18n_strings.py:575 +#: services/js_i18n_strings.py:600 msgid "Delete Log File" msgstr "" -#: services/js_i18n_strings.py:576 +#: services/js_i18n_strings.py:601 #, python-brace-format msgid "Detect failed: {error}" msgstr "" -#: services/js_i18n_strings.py:577 +#: services/js_i18n_strings.py:602 msgid "Detected path" msgstr "" -#: services/js_i18n_strings.py:578 +#: services/js_i18n_strings.py:603 msgid "Detecting…" msgstr "" -#: services/js_i18n_strings.py:579 templates/_bulk_scrape_modal.html:69 +#: services/js_i18n_strings.py:604 templates/_bulk_scrape_modal.html:69 #: templates/all_games.html:223 templates/psn_trophies.html:247 #: templates/psn_trophies.html:307 templates/reports.html:525 #: templates/system_games.html:203 msgid "Done" msgstr "" -#: services/js_i18n_strings.py:580 +#: services/js_i18n_strings.py:605 #, python-brace-format msgid "Done. Games asked: {total}. Names suggested: {proposals}." msgstr "" -#: services/js_i18n_strings.py:581 +#: services/js_i18n_strings.py:606 +msgid "Download the update first." +msgstr "" + +#: services/js_i18n_strings.py:607 msgid "Downloading 3D box art…" msgstr "" -#: services/js_i18n_strings.py:582 +#: services/js_i18n_strings.py:608 msgid "Downloading RetroArch… This can take a few minutes." msgstr "" -#: services/js_i18n_strings.py:583 +#: services/js_i18n_strings.py:609 msgid "Downloading box art…" msgstr "" -#: services/js_i18n_strings.py:584 +#: services/js_i18n_strings.py:610 msgid "Downloading fan art…" msgstr "" -#: services/js_i18n_strings.py:585 +#: services/js_i18n_strings.py:611 msgid "Downloading manual…" msgstr "" -#: services/js_i18n_strings.py:586 +#: services/js_i18n_strings.py:612 msgid "Downloading media…" msgstr "" -#: services/js_i18n_strings.py:587 +#: services/js_i18n_strings.py:613 msgid "Downloading screenshots…" msgstr "" -#: services/js_i18n_strings.py:588 +#: services/js_i18n_strings.py:614 #, python-brace-format msgid "Downloading the {system} core…" msgstr "" -#: services/js_i18n_strings.py:589 +#: services/js_i18n_strings.py:615 msgid "Downloading video…" msgstr "" -#: services/js_i18n_strings.py:590 +#: services/js_i18n_strings.py:616 #, python-brace-format msgid "Downloading {core} ({done} of {total})…" msgstr "" -#: services/js_i18n_strings.py:591 templates/_settings_tabs/account.html:318 +#: services/js_i18n_strings.py:617 templates/_settings_tabs/account.html:318 #: templates/base.html:969 templates/game_detail.html:105 #: templates/wishlist.html:641 msgid "Edit" msgstr "" -#: services/js_i18n_strings.py:592 +#: services/js_i18n_strings.py:618 msgid "Edit game" msgstr "" -#: services/js_i18n_strings.py:593 +#: services/js_i18n_strings.py:619 #, python-brace-format msgid "Edit: {title}" msgstr "" -#: services/js_i18n_strings.py:594 +#: services/js_i18n_strings.py:620 msgid "Emulator" msgstr "" -#: services/js_i18n_strings.py:595 +#: services/js_i18n_strings.py:621 msgid "Enter one folder path." msgstr "" -#: services/js_i18n_strings.py:596 +#: services/js_i18n_strings.py:622 msgid "Error" msgstr "" -#: services/js_i18n_strings.py:597 +#: services/js_i18n_strings.py:623 msgid "Error clearing HLTB data" msgstr "" -#: services/js_i18n_strings.py:598 +#: services/js_i18n_strings.py:624 msgid "Error connecting to HowLongToBeat" msgstr "" -#: services/js_i18n_strings.py:599 +#: services/js_i18n_strings.py:625 msgid "Error deleting log file" msgstr "" -#: services/js_i18n_strings.py:600 +#: services/js_i18n_strings.py:626 msgid "Error loading file" msgstr "" -#: services/js_i18n_strings.py:601 +#: services/js_i18n_strings.py:627 msgid "Error loading log file" msgstr "" -#: services/js_i18n_strings.py:602 +#: services/js_i18n_strings.py:628 msgid "Error looking up HLTB" msgstr "" -#: services/js_i18n_strings.py:603 +#: services/js_i18n_strings.py:629 msgid "Error refreshing RetroAchievements: " msgstr "" -#: services/js_i18n_strings.py:604 +#: services/js_i18n_strings.py:630 msgid "Error saving HLTB data" msgstr "" -#: services/js_i18n_strings.py:605 +#: services/js_i18n_strings.py:631 msgid "Error saving game: " msgstr "" -#: services/js_i18n_strings.py:606 +#: services/js_i18n_strings.py:632 #, python-brace-format msgid "Error scanning library: {error}" msgstr "" -#: services/js_i18n_strings.py:607 +#: services/js_i18n_strings.py:633 msgid "Error updating completion status" msgstr "" -#: services/js_i18n_strings.py:608 +#: services/js_i18n_strings.py:634 msgid "Error updating status" msgstr "" -#: services/js_i18n_strings.py:609 +#: services/js_i18n_strings.py:635 msgid "Europe" msgstr "" -#: services/js_i18n_strings.py:610 templates/_modals/edit_modal.html:223 +#: services/js_i18n_strings.py:636 templates/_modals/edit_modal.html:223 #: templates/base.html:1241 msgid "Exclusive" msgstr "" -#: services/js_i18n_strings.py:611 +#: services/js_i18n_strings.py:637 msgid "Failed to apply bulk edit. Check console for details." msgstr "" -#: services/js_i18n_strings.py:612 +#: services/js_i18n_strings.py:638 msgid "Failed to apply normalization" msgstr "" -#: services/js_i18n_strings.py:613 +#: services/js_i18n_strings.py:639 msgid "Failed to clean missing ROMs" msgstr "" -#: services/js_i18n_strings.py:614 +#: services/js_i18n_strings.py:640 msgid "Failed to clear CLZ imports" msgstr "" -#: services/js_i18n_strings.py:615 +#: services/js_i18n_strings.py:641 msgid "Failed to clear HLTB: " msgstr "" -#: services/js_i18n_strings.py:616 +#: services/js_i18n_strings.py:642 msgid "Failed to clear RA data" msgstr "" -#: services/js_i18n_strings.py:617 +#: services/js_i18n_strings.py:643 msgid "Failed to delete" msgstr "" -#: services/js_i18n_strings.py:618 +#: services/js_i18n_strings.py:644 msgid "Failed to fetch controller image" msgstr "" -#: services/js_i18n_strings.py:619 +#: services/js_i18n_strings.py:645 msgid "Failed to fetch image" msgstr "" -#: services/js_i18n_strings.py:620 +#: services/js_i18n_strings.py:646 msgid "Failed to load game" msgstr "" -#: services/js_i18n_strings.py:621 +#: services/js_i18n_strings.py:647 msgid "Failed to load normalization data" msgstr "" -#: services/js_i18n_strings.py:622 +#: services/js_i18n_strings.py:648 msgid "Failed to save HLTB: " msgstr "" -#: services/js_i18n_strings.py:623 +#: services/js_i18n_strings.py:650 msgid "Failed to save: " msgstr "" -#: services/js_i18n_strings.py:624 +#: services/js_i18n_strings.py:651 msgid "Failed to start RetroAchievements refresh" msgstr "" -#: services/js_i18n_strings.py:625 +#: services/js_i18n_strings.py:652 msgid "Failed to start bulk scrape" msgstr "" -#: services/js_i18n_strings.py:626 +#: services/js_i18n_strings.py:653 msgid "Failed to start generation" msgstr "" -#: services/js_i18n_strings.py:627 +#: services/js_i18n_strings.py:654 msgid "Failed to update completion status: " msgstr "" -#: services/js_i18n_strings.py:628 +#: services/js_i18n_strings.py:655 #, python-brace-format msgid "Failed to update status: {error}" msgstr "" -#: services/js_i18n_strings.py:629 +#: services/js_i18n_strings.py:656 #, python-brace-format msgid "Failed: {error}" msgstr "" -#: services/js_i18n_strings.py:630 +#: services/js_i18n_strings.py:657 msgid "Fetch All Controller Images" msgstr "" -#: services/js_i18n_strings.py:631 +#: services/js_i18n_strings.py:658 #, python-brace-format msgid "Fetching from {source}…" msgstr "" -#: services/js_i18n_strings.py:632 +#: services/js_i18n_strings.py:659 #, python-brace-format msgid "Fetching images: {done} of {total}" msgstr "" -#: services/js_i18n_strings.py:633 +#: services/js_i18n_strings.py:660 msgid "Focus search box" msgstr "" -#: services/js_i18n_strings.py:634 templates/logs.html:743 +#: services/js_i18n_strings.py:661 templates/logs.html:743 msgid "From" msgstr "" -#: services/js_i18n_strings.py:635 +#: services/js_i18n_strings.py:662 msgid "From the system setting" msgstr "" -#: services/js_i18n_strings.py:636 +#: services/js_i18n_strings.py:663 msgid "Fullscreen" msgstr "" -#: services/js_i18n_strings.py:637 +#: services/js_i18n_strings.py:664 msgid "Game Page" msgstr "" -#: services/js_i18n_strings.py:638 +#: services/js_i18n_strings.py:665 msgid "Game details updated successfully." msgstr "" -#: services/js_i18n_strings.py:639 templates/_settings_tabs/data.html:186 +#: services/js_i18n_strings.py:666 templates/_settings_tabs/data.html:186 #: templates/achievements_system.html:119 templates/analytics.html:571 #: templates/analytics.html:1584 templates/analytics.html:1625 #: templates/analytics.html:1947 templates/analytics.html:2122 @@ -2236,207 +2260,215 @@ msgstr "" msgid "Games" msgstr "" -#: services/js_i18n_strings.py:640 +#: services/js_i18n_strings.py:667 msgid "Generate All Museum Content" msgstr "" -#: services/js_i18n_strings.py:641 templates/museum_system.html:45 +#: services/js_i18n_strings.py:668 templates/museum_system.html:45 msgid "Generate Content" msgstr "" -#: services/js_i18n_strings.py:642 templates/museum.html:28 +#: services/js_i18n_strings.py:669 templates/museum.html:28 msgid "Generating content..." msgstr "" -#: services/js_i18n_strings.py:643 +#: services/js_i18n_strings.py:670 msgid "Generating..." msgstr "" -#: services/js_i18n_strings.py:644 +#: services/js_i18n_strings.py:671 msgid "Generation cancelled" msgstr "" -#: services/js_i18n_strings.py:645 +#: services/js_i18n_strings.py:672 #, python-brace-format msgid "" "Generation complete: {generated} generated, {skipped} skipped, {failed} " "failed" msgstr "" -#: services/js_i18n_strings.py:646 +#: services/js_i18n_strings.py:673 msgid "Generation failed" msgstr "" -#: services/js_i18n_strings.py:647 +#: services/js_i18n_strings.py:674 msgid "Generation request failed" msgstr "" -#: services/js_i18n_strings.py:648 +#: services/js_i18n_strings.py:675 msgid "Go to Analytics" msgstr "" -#: services/js_i18n_strings.py:649 +#: services/js_i18n_strings.py:676 msgid "Go to Changelog" msgstr "" -#: services/js_i18n_strings.py:650 +#: services/js_i18n_strings.py:677 msgid "Go to Dashboard" msgstr "" -#: services/js_i18n_strings.py:651 +#: services/js_i18n_strings.py:678 msgid "Go to Help" msgstr "" -#: services/js_i18n_strings.py:652 +#: services/js_i18n_strings.py:679 msgid "Go to Library" msgstr "" -#: services/js_i18n_strings.py:653 +#: services/js_i18n_strings.py:680 msgid "Go to Settings" msgstr "" -#: services/js_i18n_strings.py:654 +#: services/js_i18n_strings.py:681 msgid "Go to Systems" msgstr "" -#: services/js_i18n_strings.py:655 +#: services/js_i18n_strings.py:682 msgid "HLTB data cleared" msgstr "" -#: services/js_i18n_strings.py:656 +#: services/js_i18n_strings.py:683 msgid "HLTB data saved" msgstr "" -#: services/js_i18n_strings.py:657 +#: services/js_i18n_strings.py:684 msgid "HLTB lookup cancelled" msgstr "" -#: services/js_i18n_strings.py:658 +#: services/js_i18n_strings.py:685 msgid "Handheld LCD" msgstr "" -#: services/js_i18n_strings.py:659 templates/all_games.html:99 +#: services/js_i18n_strings.py:686 templates/all_games.html:99 #: templates/system_games.html:90 msgid "Has Achievements" msgstr "" -#: services/js_i18n_strings.py:660 templates/logs.html:719 +#: services/js_i18n_strings.py:687 templates/logs.html:719 msgid "Image Resize" msgstr "" -#: services/js_i18n_strings.py:661 +#: services/js_i18n_strings.py:688 msgid "Image fetch cancelled." msgstr "" -#: services/js_i18n_strings.py:662 +#: services/js_i18n_strings.py:689 msgid "Image must be under 10MB" msgstr "" -#: services/js_i18n_strings.py:663 templates/_bulk_edit_modal.html:27 +#: services/js_i18n_strings.py:690 templates/_bulk_edit_modal.html:27 #: templates/achievements_system.html:151 #: templates/achievements_system.html:170 templates/analytics.html:1664 -#: templates/base.html:1011 templates/dashboard.html:353 -#: templates/dashboard.html:493 templates/game_detail.html:220 +#: templates/base.html:1011 templates/dashboard.html:362 +#: templates/dashboard.html:502 templates/game_detail.html:220 #: templates/steam_achievements.html:77 templates/xbox_achievements.html:77 msgid "In Progress" msgstr "" -#: services/js_i18n_strings.py:664 +#: services/js_i18n_strings.py:691 msgid "Initializing bulk scrape..." msgstr "" -#: services/js_i18n_strings.py:665 templates/_bulk_scrape_modal.html:23 +#: services/js_i18n_strings.py:692 templates/_bulk_scrape_modal.html:23 #: templates/duplicate_finder.html:250 templates/screenshot_dedup.html:276 msgid "Initializing..." msgstr "" -#: services/js_i18n_strings.py:666 +#: services/js_i18n_strings.py:693 msgid "Input lag reduction" msgstr "" -#: services/js_i18n_strings.py:667 +#: services/js_i18n_strings.py:694 +msgid "Install update" +msgstr "" + +#: services/js_i18n_strings.py:695 #, python-brace-format msgid "Installed {n} cores." msgstr "" -#: services/js_i18n_strings.py:668 +#: services/js_i18n_strings.py:696 #, python-brace-format msgid "Installed {n} cores. Failed: {failed}" msgstr "" -#: services/js_i18n_strings.py:669 +#: services/js_i18n_strings.py:697 +msgid "Installing the update and restarting RetroDB…" +msgstr "" + +#: services/js_i18n_strings.py:698 msgid "Integer scaling" msgstr "" -#: services/js_i18n_strings.py:670 +#: services/js_i18n_strings.py:699 msgid "Japan" msgstr "" -#: services/js_i18n_strings.py:671 +#: services/js_i18n_strings.py:700 msgid "Just synced" msgstr "" -#: services/js_i18n_strings.py:672 +#: services/js_i18n_strings.py:701 msgid "Keyboard Shortcuts" msgstr "" -#: services/js_i18n_strings.py:673 +#: services/js_i18n_strings.py:702 msgid "Kill" msgstr "" -#: services/js_i18n_strings.py:674 +#: services/js_i18n_strings.py:703 #, python-brace-format msgid "Launch failed: {error}" msgstr "" -#: services/js_i18n_strings.py:675 +#: services/js_i18n_strings.py:704 #, python-brace-format msgid "Launched (pid {pid})" msgstr "" -#: services/js_i18n_strings.py:676 +#: services/js_i18n_strings.py:705 msgid "Launched (replaced previous instance)" msgstr "" -#: services/js_i18n_strings.py:677 +#: services/js_i18n_strings.py:706 msgid "Launching…" msgstr "" -#: services/js_i18n_strings.py:678 +#: services/js_i18n_strings.py:707 #, python-brace-format msgid "Library scan complete! Found {n} new games." msgstr "" -#: services/js_i18n_strings.py:679 +#: services/js_i18n_strings.py:708 msgid "Link" msgstr "" -#: services/js_i18n_strings.py:680 +#: services/js_i18n_strings.py:709 msgid "Link ticked games" msgstr "" -#: services/js_i18n_strings.py:681 +#: services/js_i18n_strings.py:710 msgid "Loading log file..." msgstr "" -#: services/js_i18n_strings.py:682 +#: services/js_i18n_strings.py:711 msgid "Loading the manual…" msgstr "" -#: services/js_i18n_strings.py:683 templates/settings_emulators.html:132 +#: services/js_i18n_strings.py:712 templates/settings_emulators.html:132 #: templates/settings_emulators.html:202 msgid "Loading…" msgstr "" -#: services/js_i18n_strings.py:684 +#: services/js_i18n_strings.py:713 msgid "Log file deleted" msgstr "" -#: services/js_i18n_strings.py:685 +#: services/js_i18n_strings.py:714 msgid "Looking for versions…" msgstr "" -#: services/js_i18n_strings.py:686 templates/achievement_game.html:168 +#: services/js_i18n_strings.py:715 templates/achievement_game.html:168 #: templates/achievement_game.html:197 templates/achievement_game.html:207 #: templates/base.html:1046 templates/game_detail.html:508 #: templates/game_detail.html:537 templates/game_detail.html:547 @@ -2449,7 +2481,7 @@ msgstr "" msgid "Main + Extras" msgstr "" -#: services/js_i18n_strings.py:687 templates/achievement_game.html:164 +#: services/js_i18n_strings.py:716 templates/achievement_game.html:164 #: templates/achievement_game.html:197 templates/achievement_game.html:203 #: templates/base.html:1042 templates/game_detail.html:504 #: templates/game_detail.html:537 templates/game_detail.html:543 @@ -2462,312 +2494,316 @@ msgstr "" msgid "Main Story" msgstr "" -#: services/js_i18n_strings.py:688 +#: services/js_i18n_strings.py:717 msgid "Marked as not versions." msgstr "" -#: services/js_i18n_strings.py:689 +#: services/js_i18n_strings.py:718 msgid "Match found - please confirm to save" msgstr "" -#: services/js_i18n_strings.py:690 +#: services/js_i18n_strings.py:719 msgid "" "Matched using this alternate/regional title — confirm it refers to the " "same game before saving." msgstr "" -#: services/js_i18n_strings.py:691 +#: services/js_i18n_strings.py:720 #, python-brace-format msgid "" "Matched via alternate title \"{term}\" — HLTB shows it as \"{match}\". " "Confirm before saving." msgstr "" -#: services/js_i18n_strings.py:692 +#: services/js_i18n_strings.py:721 #, python-brace-format msgid "Missing: {files}" msgstr "" -#: services/js_i18n_strings.py:693 +#: services/js_i18n_strings.py:722 msgid "Multi-System" msgstr "" -#: services/js_i18n_strings.py:694 +#: services/js_i18n_strings.py:723 msgid "Multi-platform" msgstr "" -#: services/js_i18n_strings.py:695 +#: services/js_i18n_strings.py:724 msgid "Museum generation started" msgstr "" -#: services/js_i18n_strings.py:696 +#: services/js_i18n_strings.py:725 msgid "NOT" msgstr "" -#: services/js_i18n_strings.py:697 +#: services/js_i18n_strings.py:726 #, python-brace-format msgid "Names changed: {accepted}." msgstr "" -#: services/js_i18n_strings.py:698 +#: services/js_i18n_strings.py:727 #, python-brace-format msgid "" "Names changed: {accepted}. Skipped because their title changed since: " "{skipped}." msgstr "" -#: services/js_i18n_strings.py:699 +#: services/js_i18n_strings.py:728 #, python-brace-format msgid "Names rejected: {rejected}. They will not be suggested again." msgstr "" -#: services/js_i18n_strings.py:700 +#: services/js_i18n_strings.py:729 msgid "Navigation" msgstr "" -#: services/js_i18n_strings.py:701 +#: services/js_i18n_strings.py:730 msgid "Network error" msgstr "" -#: services/js_i18n_strings.py:702 +#: services/js_i18n_strings.py:731 msgid "Network error starting bulk scrape" msgstr "" -#: services/js_i18n_strings.py:703 +#: services/js_i18n_strings.py:732 msgid "Next disk" msgstr "" -#: services/js_i18n_strings.py:704 +#: services/js_i18n_strings.py:733 msgid "Next disk failed" msgstr "" -#: services/js_i18n_strings.py:705 templates/_bulk_edit_modal.html:171 +#: services/js_i18n_strings.py:734 templates/_bulk_edit_modal.html:171 #: templates/_modals/edit_modal.html:143 templates/game_detail.html:666 #: templates/game_detail.html:734 msgid "No" msgstr "" -#: services/js_i18n_strings.py:706 +#: services/js_i18n_strings.py:735 msgid "No (already set)" msgstr "" -#: services/js_i18n_strings.py:707 +#: services/js_i18n_strings.py:736 msgid "No CLZ Import games found" msgstr "" -#: services/js_i18n_strings.py:708 +#: services/js_i18n_strings.py:737 msgid "No Changes" msgstr "" -#: services/js_i18n_strings.py:709 +#: services/js_i18n_strings.py:738 msgid "No Games Selected" msgstr "" -#: services/js_i18n_strings.py:710 +#: services/js_i18n_strings.py:739 msgid "No HLTB data found" msgstr "" -#: services/js_i18n_strings.py:711 +#: services/js_i18n_strings.py:740 msgid "No achievements found" msgstr "" -#: services/js_i18n_strings.py:712 templates/achievement_game.html:72 +#: services/js_i18n_strings.py:741 templates/achievement_game.html:72 #: templates/steam_achievement_game.html:69 #: templates/xbox_achievement_game.html:59 msgid "No description available." msgstr "" -#: services/js_i18n_strings.py:713 +#: services/js_i18n_strings.py:742 msgid "No emulator AppImages found in the scan paths." msgstr "" -#: services/js_i18n_strings.py:714 +#: services/js_i18n_strings.py:743 msgid "No emulators registered." msgstr "" -#: services/js_i18n_strings.py:715 +#: services/js_i18n_strings.py:744 msgid "No folders yet." msgstr "" -#: services/js_i18n_strings.py:716 +#: services/js_i18n_strings.py:745 msgid "No games or systems match" msgstr "" -#: services/js_i18n_strings.py:717 +#: services/js_i18n_strings.py:746 msgid "No log files in this category" msgstr "" -#: services/js_i18n_strings.py:718 +#: services/js_i18n_strings.py:747 msgid "No log lines match current filters" msgstr "" -#: services/js_i18n_strings.py:719 +#: services/js_i18n_strings.py:748 msgid "No mappings yet. Add an emulator folder above." msgstr "" -#: services/js_i18n_strings.py:720 +#: services/js_i18n_strings.py:749 msgid "No matching games on this system." msgstr "" -#: services/js_i18n_strings.py:721 +#: services/js_i18n_strings.py:750 msgid "No missing ROM files found." msgstr "" -#: services/js_i18n_strings.py:722 +#: services/js_i18n_strings.py:751 +msgid "No newer version is available." +msgstr "" + +#: services/js_i18n_strings.py:752 msgid "No options available" msgstr "" -#: services/js_i18n_strings.py:723 +#: services/js_i18n_strings.py:753 msgid "No ratings available" msgstr "" -#: services/js_i18n_strings.py:724 +#: services/js_i18n_strings.py:754 msgid "No results" msgstr "" -#: services/js_i18n_strings.py:725 +#: services/js_i18n_strings.py:755 msgid "No results found" msgstr "" -#: services/js_i18n_strings.py:726 +#: services/js_i18n_strings.py:756 msgid "No results found on HowLongToBeat" msgstr "" -#: services/js_i18n_strings.py:727 +#: services/js_i18n_strings.py:757 msgid "No sessions match current filters" msgstr "" -#: services/js_i18n_strings.py:728 +#: services/js_i18n_strings.py:758 msgid "No suggested names waiting. Press Ask the AI to look for some." msgstr "" -#: services/js_i18n_strings.py:729 +#: services/js_i18n_strings.py:759 msgid "No suggestions. Run Backfill Alternate Titles in Maintenance to find more." msgstr "" -#: services/js_i18n_strings.py:730 templates/game_detail.html:814 +#: services/js_i18n_strings.py:760 templates/game_detail.html:814 msgid "None" msgstr "" -#: services/js_i18n_strings.py:731 +#: services/js_i18n_strings.py:761 #, python-brace-format msgid "Normalization applied: {games} games updated, {rules} rules saved" msgstr "" -#: services/js_i18n_strings.py:732 +#: services/js_i18n_strings.py:762 msgid "Not versions" msgstr "" -#: services/js_i18n_strings.py:733 templates/base.html:1400 +#: services/js_i18n_strings.py:763 templates/base.html:1400 msgid "Now playing" msgstr "" -#: services/js_i18n_strings.py:734 templates/base.html:339 +#: services/js_i18n_strings.py:764 templates/base.html:339 msgid "OK" msgstr "" -#: services/js_i18n_strings.py:735 +#: services/js_i18n_strings.py:765 msgid "Off" msgstr "" -#: services/js_i18n_strings.py:736 +#: services/js_i18n_strings.py:766 msgid "On" msgstr "" -#: services/js_i18n_strings.py:737 +#: services/js_i18n_strings.py:767 msgid "Operation completed" msgstr "" -#: services/js_i18n_strings.py:738 +#: services/js_i18n_strings.py:768 msgid "Operation failed" msgstr "" -#: services/js_i18n_strings.py:739 +#: services/js_i18n_strings.py:769 msgid "Other" msgstr "" -#: services/js_i18n_strings.py:740 +#: services/js_i18n_strings.py:770 msgid "PSN Imports" msgstr "" -#: services/js_i18n_strings.py:741 +#: services/js_i18n_strings.py:771 msgid "Page:" msgstr "" -#: services/js_i18n_strings.py:742 +#: services/js_i18n_strings.py:772 msgid "Part of a Series" msgstr "" -#: services/js_i18n_strings.py:743 templates/_bulk_scrape_modal.html:46 +#: services/js_i18n_strings.py:773 templates/_bulk_scrape_modal.html:46 #: templates/archive_scanner.html:658 templates/archive_scanner.html:795 #: templates/psn_trophies.html:294 msgid "Pause" msgstr "" -#: services/js_i18n_strings.py:744 +#: services/js_i18n_strings.py:774 msgid "Paused - Click Resume to continue" msgstr "" -#: services/js_i18n_strings.py:745 +#: services/js_i18n_strings.py:775 msgid "Platform not found" msgstr "" -#: services/js_i18n_strings.py:746 templates/game_detail.html:85 +#: services/js_i18n_strings.py:776 templates/game_detail.html:85 msgid "Play" msgstr "" -#: services/js_i18n_strings.py:747 templates/_bulk_edit_modal.html:28 +#: services/js_i18n_strings.py:777 templates/_bulk_edit_modal.html:28 #: templates/analytics.html:1664 templates/base.html:1012 -#: templates/dashboard.html:495 templates/game_detail.html:221 +#: templates/dashboard.html:504 templates/game_detail.html:221 msgid "Played" msgstr "" -#: services/js_i18n_strings.py:748 +#: services/js_i18n_strings.py:778 msgid "Please change at least one field before applying." msgstr "" -#: services/js_i18n_strings.py:749 +#: services/js_i18n_strings.py:779 msgid "Please enter a game title" msgstr "" -#: services/js_i18n_strings.py:750 +#: services/js_i18n_strings.py:780 msgid "Please select at least one game to edit." msgstr "" -#: services/js_i18n_strings.py:751 +#: services/js_i18n_strings.py:781 msgid "Please select at least one game to scrape." msgstr "" -#: services/js_i18n_strings.py:752 +#: services/js_i18n_strings.py:782 msgid "Pre-session log entries" msgstr "" -#: services/js_i18n_strings.py:753 +#: services/js_i18n_strings.py:783 msgid "Processing images…" msgstr "" -#: services/js_i18n_strings.py:754 templates/game_imports.html:418 +#: services/js_i18n_strings.py:784 templates/game_imports.html:418 msgid "Processing..." msgstr "" -#: services/js_i18n_strings.py:755 +#: services/js_i18n_strings.py:785 #, python-brace-format msgid "Processing: {file}" msgstr "" -#: services/js_i18n_strings.py:756 +#: services/js_i18n_strings.py:786 msgid "Queued" msgstr "" -#: services/js_i18n_strings.py:757 +#: services/js_i18n_strings.py:787 msgid "RA Data Cleared" msgstr "" -#: services/js_i18n_strings.py:758 +#: services/js_i18n_strings.py:788 msgid "ROM Files" msgstr "" -#: services/js_i18n_strings.py:759 templates/_macros/nav_tabs.html:34 +#: services/js_i18n_strings.py:789 templates/_macros/nav_tabs.html:34 #: templates/archive_scanner.html:561 templates/base.html:174 #: templates/chd_converter.html:64 templates/chd_verify.html:80 #: templates/duplicate_finder.html:166 templates/logs.html:713 @@ -2777,406 +2813,427 @@ msgstr "" msgid "ROM Tools" msgstr "" -#: services/js_i18n_strings.py:760 +#: services/js_i18n_strings.py:790 msgid "Ready" msgstr "" -#: services/js_i18n_strings.py:761 +#: services/js_i18n_strings.py:791 msgid "Refresh RetroAchievements" msgstr "" -#: services/js_i18n_strings.py:762 +#: services/js_i18n_strings.py:792 #, python-brace-format msgid "Refresh: {system}" msgstr "" -#: services/js_i18n_strings.py:763 +#: services/js_i18n_strings.py:793 #, python-brace-format msgid "Refreshing RA for {system}..." msgstr "" -#: services/js_i18n_strings.py:764 +#: services/js_i18n_strings.py:794 msgid "Regional name matches" msgstr "" -#: services/js_i18n_strings.py:765 templates/compare_games.html:24 +#: services/js_i18n_strings.py:795 templates/compare_games.html:24 #: templates/compare_games.html:40 msgid "Remove" msgstr "" -#: services/js_i18n_strings.py:766 +#: services/js_i18n_strings.py:796 msgid "Remove background from the uploaded image?" msgstr "" -#: services/js_i18n_strings.py:767 +#: services/js_i18n_strings.py:797 msgid "Remove from queue" msgstr "" -#: services/js_i18n_strings.py:768 +#: services/js_i18n_strings.py:798 #, python-brace-format msgid "Removed {n} CLZ Import games" msgstr "" -#: services/js_i18n_strings.py:769 +#: services/js_i18n_strings.py:799 #, python-brace-format msgid "Removed {n} CLZ Import games:" msgstr "" -#: services/js_i18n_strings.py:770 +#: services/js_i18n_strings.py:800 #, python-brace-format msgid "Removed {n} games with missing ROMs" msgstr "" -#: services/js_i18n_strings.py:771 +#: services/js_i18n_strings.py:801 #, python-brace-format msgid "Removed {n} games:" msgstr "" -#: services/js_i18n_strings.py:772 +#: services/js_i18n_strings.py:802 msgid "Removing background…" msgstr "" -#: services/js_i18n_strings.py:773 +#: services/js_i18n_strings.py:803 msgid "Removing..." msgstr "" -#: services/js_i18n_strings.py:774 templates/_bulk_edit_modal.html:42 +#: services/js_i18n_strings.py:804 templates/_bulk_edit_modal.html:42 #: templates/_bulk_edit_modal.html:56 templates/_bulk_edit_modal.html:70 #: templates/_bulk_edit_modal.html:124 templates/_bulk_edit_modal.html:140 #: templates/_bulk_edit_modal.html:156 templates/_bulk_edit_modal.html:180 msgid "Replace" msgstr "" -#: services/js_i18n_strings.py:775 templates/logs.html:716 +#: services/js_i18n_strings.py:805 templates/logs.html:716 msgid "Reports" msgstr "" -#: services/js_i18n_strings.py:776 +#: services/js_i18n_strings.py:806 msgid "Resolution scale" msgstr "" -#: services/js_i18n_strings.py:777 +#: services/js_i18n_strings.py:807 msgid "Restarting server..." msgstr "" -#: services/js_i18n_strings.py:778 +#: services/js_i18n_strings.py:808 msgid "Resume" msgstr "" -#: services/js_i18n_strings.py:779 +#: services/js_i18n_strings.py:809 msgid "RetroArch is installed." msgstr "" -#: services/js_i18n_strings.py:780 +#: services/js_i18n_strings.py:810 msgid "RetroArch was not installed." msgstr "" -#: services/js_i18n_strings.py:781 +#: services/js_i18n_strings.py:811 #, python-brace-format msgid "RetroArch was not installed: {error}" msgstr "" -#: services/js_i18n_strings.py:782 +#: services/js_i18n_strings.py:812 msgid "RetroDB default" msgstr "" -#: services/js_i18n_strings.py:783 +#: services/js_i18n_strings.py:813 +msgid "RetroDB has not come back yet. See logs/update.log, then reload this page." +msgstr "" + +#: services/js_i18n_strings.py:814 +msgid "" +"RetroDB is run by a process manager here. Download the release and " +"install it yourself." +msgstr "" + +#: services/js_i18n_strings.py:815 +msgid "" +"RetroDB will back up its database, install the update and restart. Anyone" +" using it will be disconnected for a minute or two." +msgstr "" + +#: services/js_i18n_strings.py:816 +#, python-brace-format +msgid "RetroDB {version} is available." +msgstr "" + +#: services/js_i18n_strings.py:817 msgid "Run-ahead, 1 frame" msgstr "" -#: services/js_i18n_strings.py:784 +#: services/js_i18n_strings.py:818 msgid "Running" msgstr "" -#: services/js_i18n_strings.py:785 +#: services/js_i18n_strings.py:819 msgid "Running..." msgstr "" -#: services/js_i18n_strings.py:786 +#: services/js_i18n_strings.py:820 msgid "Same IGDB game" msgstr "" -#: services/js_i18n_strings.py:787 +#: services/js_i18n_strings.py:821 msgid "Same ScreenScraper game" msgstr "" -#: services/js_i18n_strings.py:788 +#: services/js_i18n_strings.py:822 msgid "Same title, different region" msgstr "" -#: services/js_i18n_strings.py:789 +#: services/js_i18n_strings.py:823 msgid "Save Failed" msgstr "" -#: services/js_i18n_strings.py:790 +#: services/js_i18n_strings.py:824 #, python-brace-format msgid "Save failed: {error}" msgstr "" -#: services/js_i18n_strings.py:791 +#: services/js_i18n_strings.py:825 msgid "Saved" msgstr "" -#: services/js_i18n_strings.py:792 +#: services/js_i18n_strings.py:826 msgid "Saved." msgstr "" -#: services/js_i18n_strings.py:793 +#: services/js_i18n_strings.py:827 msgid "Saved. It applies the next time you press Play." msgstr "" -#: services/js_i18n_strings.py:794 +#: services/js_i18n_strings.py:828 msgid "Saving to the database…" msgstr "" -#: services/js_i18n_strings.py:795 +#: services/js_i18n_strings.py:829 msgid "Saving..." msgstr "" -#: services/js_i18n_strings.py:796 +#: services/js_i18n_strings.py:830 msgid "Saving…" msgstr "" -#: services/js_i18n_strings.py:797 +#: services/js_i18n_strings.py:831 #, python-brace-format msgid "Scan failed (HTTP {status})" msgstr "" -#: services/js_i18n_strings.py:798 +#: services/js_i18n_strings.py:832 #, python-brace-format msgid "Scan failed: {error}" msgstr "" -#: services/js_i18n_strings.py:799 +#: services/js_i18n_strings.py:833 #, python-brace-format msgid "Scanned {paths} paths · found {found} matches." msgstr "" -#: services/js_i18n_strings.py:800 templates/multi_disc_organizer.html:204 +#: services/js_i18n_strings.py:834 templates/multi_disc_organizer.html:204 msgid "Scanning..." msgstr "" -#: services/js_i18n_strings.py:801 +#: services/js_i18n_strings.py:835 msgid "Scanning…" msgstr "" -#: services/js_i18n_strings.py:802 +#: services/js_i18n_strings.py:836 msgid "Scrape game" msgstr "" -#: services/js_i18n_strings.py:803 +#: services/js_i18n_strings.py:837 #, python-brace-format msgid "Scraped 0/{total} - 0%" msgstr "" -#: services/js_i18n_strings.py:804 +#: services/js_i18n_strings.py:838 #, python-brace-format msgid "Scraped {processed} / {total} - {percent}%" msgstr "" -#: services/js_i18n_strings.py:805 templates/logs.html:710 +#: services/js_i18n_strings.py:839 templates/logs.html:710 #: templates/wishlist.html:507 msgid "Scraping" msgstr "" -#: services/js_i18n_strings.py:806 +#: services/js_i18n_strings.py:840 msgid "Screen effect" msgstr "" -#: services/js_i18n_strings.py:807 templates/logs.html:781 +#: services/js_i18n_strings.py:841 templates/logs.html:781 #: templates/museum_system.html:197 templates/museum_system.html:204 #: templates/psn_trophy_detail.html:320 msgid "Search" msgstr "" -#: services/js_i18n_strings.py:808 +#: services/js_i18n_strings.py:842 msgid "Search failed." msgstr "" -#: services/js_i18n_strings.py:809 +#: services/js_i18n_strings.py:843 msgid "Search failed. Try again." msgstr "" -#: services/js_i18n_strings.py:810 +#: services/js_i18n_strings.py:844 msgid "Searching & removing background..." msgstr "" -#: services/js_i18n_strings.py:811 +#: services/js_i18n_strings.py:845 #, python-brace-format msgid "Searching {source}…" msgstr "" -#: services/js_i18n_strings.py:812 +#: services/js_i18n_strings.py:846 #, python-brace-format msgid "See all {n} results" msgstr "" -#: services/js_i18n_strings.py:813 +#: services/js_i18n_strings.py:847 msgid "Select Developer" msgstr "" -#: services/js_i18n_strings.py:814 +#: services/js_i18n_strings.py:848 msgid "Select Dimension" msgstr "" -#: services/js_i18n_strings.py:815 +#: services/js_i18n_strings.py:849 #: templates/_modals/select_filter_modal.html:15 msgid "Select Filter" msgstr "" -#: services/js_i18n_strings.py:816 +#: services/js_i18n_strings.py:850 msgid "Select Genre" msgstr "" -#: services/js_i18n_strings.py:817 +#: services/js_i18n_strings.py:851 msgid "Select Perspective" msgstr "" -#: services/js_i18n_strings.py:818 +#: services/js_i18n_strings.py:852 msgid "Select Play Mode" msgstr "" -#: services/js_i18n_strings.py:819 +#: services/js_i18n_strings.py:853 msgid "Select Publisher" msgstr "" -#: services/js_i18n_strings.py:820 +#: services/js_i18n_strings.py:854 msgid "Select Rating" msgstr "" -#: services/js_i18n_strings.py:821 +#: services/js_i18n_strings.py:855 msgid "Select Series" msgstr "" -#: services/js_i18n_strings.py:822 +#: services/js_i18n_strings.py:856 msgid "Select Source" msgstr "" -#: services/js_i18n_strings.py:823 +#: services/js_i18n_strings.py:857 #, python-brace-format msgid "Selected {count} games across all pages" msgstr "" -#: services/js_i18n_strings.py:824 +#: services/js_i18n_strings.py:858 #, python-brace-format msgid "Selected {count} unscraped games across all pages" msgstr "" -#: services/js_i18n_strings.py:825 +#: services/js_i18n_strings.py:859 msgid "Server restarted successfully!" msgstr "" -#: services/js_i18n_strings.py:826 +#: services/js_i18n_strings.py:860 msgid "Server stopped. You can close this tab." msgstr "" -#: services/js_i18n_strings.py:827 +#: services/js_i18n_strings.py:861 msgid "Set for this game" msgstr "" -#: services/js_i18n_strings.py:828 +#: services/js_i18n_strings.py:862 msgid "Set inside the emulator; RetroDB leaves it alone" msgstr "" -#: services/js_i18n_strings.py:829 +#: services/js_i18n_strings.py:863 msgid "Set per system, in Settings > Emulators" msgstr "" -#: services/js_i18n_strings.py:830 templates/_macros/nav_tabs.html:44 +#: services/js_i18n_strings.py:864 templates/_macros/nav_tabs.html:44 #: templates/base.html:186 templates/rom_tools_hub.html:119 #: templates/settings.html:5 templates/settings.html:12 #: templates/settings_emulators.html:4 msgid "Settings" msgstr "" -#: services/js_i18n_strings.py:831 +#: services/js_i18n_strings.py:865 msgid "Show keyboard shortcuts" msgstr "" -#: services/js_i18n_strings.py:832 +#: services/js_i18n_strings.py:866 msgid "Showing Bonus Discs" msgstr "" -#: services/js_i18n_strings.py:833 +#: services/js_i18n_strings.py:867 msgid "Shutting down server..." msgstr "" -#: services/js_i18n_strings.py:834 templates/_modals/edit_modal.html:50 +#: services/js_i18n_strings.py:868 templates/_modals/edit_modal.html:50 #: templates/base.html:1098 msgid "Standalone" msgstr "" -#: services/js_i18n_strings.py:835 +#: services/js_i18n_strings.py:869 msgid "Starting..." msgstr "" -#: services/js_i18n_strings.py:836 +#: services/js_i18n_strings.py:870 msgid "Starting…" msgstr "" -#: services/js_i18n_strings.py:837 +#: services/js_i18n_strings.py:871 msgid "Steam Imports" msgstr "" -#: services/js_i18n_strings.py:838 +#: services/js_i18n_strings.py:872 #, python-brace-format msgid "Stopped. Names suggested: {proposals}." msgstr "" -#: services/js_i18n_strings.py:839 +#: services/js_i18n_strings.py:873 #, python-brace-format msgid "Successfully updated {count} games" msgstr "" -#: services/js_i18n_strings.py:840 +#: services/js_i18n_strings.py:874 #, python-brace-format msgid "Suggested names waiting: {count}. Untick any you do not want, then accept." msgstr "" -#: services/js_i18n_strings.py:841 +#: services/js_i18n_strings.py:875 msgid "Summary written." msgstr "" -#: services/js_i18n_strings.py:842 +#: services/js_i18n_strings.py:876 msgid "Sync In Progress" msgstr "" -#: services/js_i18n_strings.py:843 templates/achievements_system.html:105 +#: services/js_i18n_strings.py:877 templates/achievements_system.html:105 msgid "Sync Progress" msgstr "" -#: services/js_i18n_strings.py:844 +#: services/js_i18n_strings.py:878 msgid "Sync Queued" msgstr "" -#: services/js_i18n_strings.py:845 +#: services/js_i18n_strings.py:879 msgid "Sync complete! Refresh page to see updated progress." msgstr "" -#: services/js_i18n_strings.py:846 +#: services/js_i18n_strings.py:880 msgid "Sync failed" msgstr "" -#: services/js_i18n_strings.py:847 +#: services/js_i18n_strings.py:881 #, python-brace-format msgid "Sync: {system}" msgstr "" -#: services/js_i18n_strings.py:848 +#: services/js_i18n_strings.py:882 msgid "Syncing..." msgstr "" -#: services/js_i18n_strings.py:849 +#: services/js_i18n_strings.py:883 #, python-brace-format msgid "Syncing: {processed} / {total}" msgstr "" -#: services/js_i18n_strings.py:850 +#: services/js_i18n_strings.py:884 #: templates/_settings_tabs/customization.html:299 #: templates/achievement_game.html:228 templates/analytics.html:569 #: templates/analytics.html:603 templates/compare_games.html:69 -#: templates/dashboard.html:321 templates/dashboard.html:380 -#: templates/dashboard.html:457 templates/dashboard.html:523 +#: templates/dashboard.html:330 templates/dashboard.html:389 +#: templates/dashboard.html:466 templates/dashboard.html:532 #: templates/logs.html:722 templates/multi_disc_organizer.html:213 #: templates/official_names_review.html:84 templates/reports.html:360 #: templates/reports.html:435 templates/settings.html:41 @@ -3185,7 +3242,7 @@ msgstr "" msgid "System" msgstr "" -#: services/js_i18n_strings.py:851 +#: services/js_i18n_strings.py:885 #: templates/_settings_tabs/customization.html:153 #: templates/_settings_tabs/customization.html:234 #: templates/_settings_tabs/data.html:19 templates/achievements_system.html:49 @@ -3194,125 +3251,157 @@ msgstr "" msgid "Systems" msgstr "" -#: services/js_i18n_strings.py:852 +#: services/js_i18n_strings.py:886 msgid "The core installed but the game still cannot start." msgstr "" -#: services/js_i18n_strings.py:853 +#: services/js_i18n_strings.py:887 +#, python-brace-format +msgid "The download failed: {error}" +msgstr "" + +#: services/js_i18n_strings.py:888 #, python-brace-format msgid "The run stopped: {error}. Names already suggested are kept." msgstr "" -#: services/js_i18n_strings.py:854 +#: services/js_i18n_strings.py:889 +msgid "The update could not be started. See logs/update.log." +msgstr "" + +#: services/js_i18n_strings.py:890 +msgid "The update is downloaded and checked." +msgstr "" + +#: services/js_i18n_strings.py:891 +#, python-brace-format +msgid "" +"The update to {to} did not work, so RetroDB went back to {from}. Reason: " +"{reason}" +msgstr "" + +#: services/js_i18n_strings.py:892 +msgid "This copy of RetroDB is a git checkout. Update it with git pull." +msgstr "" + +#: services/js_i18n_strings.py:893 msgid "This game is already running. Kill the existing process and relaunch?" msgstr "" -#: services/js_i18n_strings.py:855 +#: services/js_i18n_strings.py:894 +msgid "This release has no download for this computer yet." +msgstr "" + +#: services/js_i18n_strings.py:895 msgid "This system is already being synced." msgstr "" -#: services/js_i18n_strings.py:856 +#: services/js_i18n_strings.py:896 +msgid "This update cannot be installed here." +msgstr "" + +#: services/js_i18n_strings.py:897 msgid "" "This will clear ALL RetroAchievements game IDs and progress data from " "your database. You will need to run \"Refresh RetroAchievements\" again " "afterwards to re-scan your games. Continue?" msgstr "" -#: services/js_i18n_strings.py:857 +#: services/js_i18n_strings.py:898 #, python-brace-format msgid "" "This will clear RetroAchievements game IDs and progress data for " "\"{system}\". Continue?" msgstr "" -#: services/js_i18n_strings.py:858 +#: services/js_i18n_strings.py:899 #, python-brace-format msgid "This will remove \"{system}\" from the Achievements page. Continue?" msgstr "" -#: services/js_i18n_strings.py:859 +#: services/js_i18n_strings.py:900 msgid "" "This will remove ALL games imported via CLZ Games Import from the " "database. This cannot be undone. Continue?" msgstr "" -#: services/js_i18n_strings.py:860 +#: services/js_i18n_strings.py:901 msgid "" "This will remove database entries for ROM files that no longer exist on " "disk. This cannot be undone. Continue?" msgstr "" -#: services/js_i18n_strings.py:861 +#: services/js_i18n_strings.py:902 msgid "" "This will scan all games and update their RetroAchievements status. This " "may take several minutes for large collections. Continue?" msgstr "" -#: services/js_i18n_strings.py:862 +#: services/js_i18n_strings.py:903 msgid "" "This will search for and download images for all controllers without " "images. Continue?" msgstr "" -#: services/js_i18n_strings.py:863 +#: services/js_i18n_strings.py:904 msgid "" "This will use your configured AI provider to generate history and top " "games for all systems. This may take several minutes and use API credits." " Continue?" msgstr "" -#: services/js_i18n_strings.py:864 +#: services/js_i18n_strings.py:905 msgid "Tick at least one name." msgstr "" -#: services/js_i18n_strings.py:865 +#: services/js_i18n_strings.py:906 msgid "Tick at least two games." msgstr "" -#: services/js_i18n_strings.py:866 templates/logs.html:745 +#: services/js_i18n_strings.py:907 templates/logs.html:745 msgid "To" msgstr "" -#: services/js_i18n_strings.py:867 +#: services/js_i18n_strings.py:908 msgid "Try adjusting your search or filters" msgstr "" -#: services/js_i18n_strings.py:868 +#: services/js_i18n_strings.py:909 msgid "USA" msgstr "" -#: services/js_i18n_strings.py:869 +#: services/js_i18n_strings.py:910 msgid "Unexpected error" msgstr "" -#: services/js_i18n_strings.py:870 +#: services/js_i18n_strings.py:911 msgid "Unhandled rejection" msgstr "" -#: services/js_i18n_strings.py:871 templates/analytics.html:583 +#: services/js_i18n_strings.py:912 templates/analytics.html:583 #: templates/base.html:1198 templates/local_trophy_detail.html:220 #: templates/local_trophy_detail.html:224 msgid "Unknown" msgstr "" -#: services/js_i18n_strings.py:872 +#: services/js_i18n_strings.py:913 msgid "Unknown error" msgstr "" -#: services/js_i18n_strings.py:873 +#: services/js_i18n_strings.py:914 msgid "Unknown error occurred." msgstr "" -#: services/js_i18n_strings.py:874 templates/all_games.html:102 +#: services/js_i18n_strings.py:915 templates/all_games.html:102 #: templates/system_games.html:93 templates/wishlist.html:506 msgid "Unscraped" msgstr "" -#: services/js_i18n_strings.py:875 +#: services/js_i18n_strings.py:916 msgid "Update Failed" msgstr "" -#: services/js_i18n_strings.py:876 +#: services/js_i18n_strings.py:917 #, python-brace-format msgid "" "Update {count} game(s)?\n" @@ -3320,248 +3409,272 @@ msgid "" "Fields: {fields}" msgstr "" -#: services/js_i18n_strings.py:877 templates/museum_system.html:198 +#: services/js_i18n_strings.py:918 +#, python-brace-format +msgid "Updated from {from} to {to}." +msgstr "" + +#: services/js_i18n_strings.py:919 templates/museum_system.html:198 #: templates/museum_system.html:205 msgid "Upload" msgstr "" -#: services/js_i18n_strings.py:878 +#: services/js_i18n_strings.py:920 msgid "Upload Controller Image" msgstr "" -#: services/js_i18n_strings.py:879 +#: services/js_i18n_strings.py:921 msgid "Upload failed" msgstr "" -#: services/js_i18n_strings.py:880 +#: services/js_i18n_strings.py:922 msgid "Upload replacement image" msgstr "" -#: services/js_i18n_strings.py:881 +#: services/js_i18n_strings.py:923 msgid "Uploading & removing background…" msgstr "" -#: services/js_i18n_strings.py:882 +#: services/js_i18n_strings.py:924 msgid "Uploading…" msgstr "" -#: services/js_i18n_strings.py:883 +#: services/js_i18n_strings.py:925 msgid "Use the system setting" msgstr "" -#: services/js_i18n_strings.py:884 +#: services/js_i18n_strings.py:926 #, python-brace-format msgid "" "Used for every {system} game unless the game sets its own. Applied when " "you press Play." msgstr "" -#: services/js_i18n_strings.py:885 +#: services/js_i18n_strings.py:927 msgid "Version unlinked." msgstr "" -#: services/js_i18n_strings.py:886 +#: services/js_i18n_strings.py:928 +#, python-brace-format +msgid "Version {version} is available." +msgstr "" + +#: services/js_i18n_strings.py:929 msgid "Versions linked." msgstr "" -#: services/js_i18n_strings.py:887 +#: services/js_i18n_strings.py:930 +#, python-brace-format +msgid "What is new in {version}" +msgstr "" + +#: services/js_i18n_strings.py:931 +#, python-brace-format +msgid "What is new in {version} ({date})" +msgstr "" + +#: services/js_i18n_strings.py:932 msgid "Will change?" msgstr "" -#: services/js_i18n_strings.py:888 +#: services/js_i18n_strings.py:933 msgid "Works" msgstr "" -#: services/js_i18n_strings.py:889 +#: services/js_i18n_strings.py:934 msgid "World" msgstr "" -#: services/js_i18n_strings.py:890 templates/game_detail.html:755 +#: services/js_i18n_strings.py:935 templates/game_detail.html:755 msgid "Write again with AI" msgstr "" -#: services/js_i18n_strings.py:891 +#: services/js_i18n_strings.py:936 msgid "Xbox Imports" msgstr "" -#: services/js_i18n_strings.py:892 templates/_bulk_edit_modal.html:170 +#: services/js_i18n_strings.py:937 templates/_bulk_edit_modal.html:170 #: templates/_modals/edit_modal.html:143 templates/game_detail.html:609 #: templates/game_detail.html:666 templates/game_detail.html:734 msgid "Yes" msgstr "" -#: services/js_i18n_strings.py:893 +#: services/js_i18n_strings.py:938 +msgid "You have the latest version." +msgstr "" + +#: services/js_i18n_strings.py:939 #, python-brace-format msgid "" "Your bulk scrape has been queued (position {position}). It will start " "automatically when the current scrape finishes." msgstr "" -#: services/js_i18n_strings.py:894 +#: services/js_i18n_strings.py:940 msgid "found" msgstr "" -#: services/js_i18n_strings.py:895 +#: services/js_i18n_strings.py:941 msgid "in queue" msgstr "" -#: services/js_i18n_strings.py:896 +#: services/js_i18n_strings.py:942 msgid "system" msgstr "" -#: services/js_i18n_strings.py:897 +#: services/js_i18n_strings.py:943 msgid "unknown" msgstr "" -#: services/js_i18n_strings.py:898 +#: services/js_i18n_strings.py:944 #, python-brace-format msgid "{confidence}% match" msgstr "" -#: services/js_i18n_strings.py:899 +#: services/js_i18n_strings.py:945 #, python-brace-format msgid "{count} Achievements" msgstr "" -#: services/js_i18n_strings.py:900 +#: services/js_i18n_strings.py:946 #, python-brace-format msgid "{count} regional versions" msgstr "" -#: services/js_i18n_strings.py:901 +#: services/js_i18n_strings.py:947 #, python-brace-format msgid "{count} suggested groups. Untick any game that does not belong, then link." msgstr "" -#: services/js_i18n_strings.py:902 +#: services/js_i18n_strings.py:948 #, python-brace-format msgid "{fetched} fetched, {skipped} skipped, {failed} failed" msgstr "" -#: services/js_i18n_strings.py:903 +#: services/js_i18n_strings.py:949 #, python-brace-format msgid "{job} Queued (#{position})" msgstr "" -#: services/js_i18n_strings.py:904 +#: services/js_i18n_strings.py:950 #, python-brace-format msgid "{job} cancelled" msgstr "" -#: services/js_i18n_strings.py:905 +#: services/js_i18n_strings.py:951 #, python-brace-format msgid "{job} completed ({n} processed)" msgstr "" -#: services/js_i18n_strings.py:906 +#: services/js_i18n_strings.py:952 #, python-brace-format msgid "{job} failed: {error}" msgstr "" -#: services/js_i18n_strings.py:907 +#: services/js_i18n_strings.py:953 #, python-brace-format msgid "{job}: {current} of {total} ({percent}%)" msgstr "" -#: services/js_i18n_strings.py:908 +#: services/js_i18n_strings.py:954 #, python-brace-format msgid "{n} Player(s)" msgstr "" -#: services/js_i18n_strings.py:909 +#: services/js_i18n_strings.py:955 #, python-brace-format msgid "{n} cores" msgstr "" -#: services/js_i18n_strings.py:910 +#: services/js_i18n_strings.py:956 #, python-brace-format msgid "{n} games" msgstr "" -#: services/js_i18n_strings.py:911 +#: services/js_i18n_strings.py:957 #, python-brace-format msgid "{n} lines" msgstr "" -#: services/js_i18n_strings.py:912 +#: services/js_i18n_strings.py:958 #, python-brace-format msgid "{n} results" msgstr "" -#: services/js_i18n_strings.py:913 +#: services/js_i18n_strings.py:959 #, python-brace-format msgid "{points} pts" msgstr "" -#: services/js_i18n_strings.py:914 +#: services/js_i18n_strings.py:960 #, python-brace-format msgid "{system} has been added to the sync queue." msgstr "" -#: services/js_i18n_strings.py:915 +#: services/js_i18n_strings.py:961 #, python-brace-format msgid "{system} · {file} · old name from: {region}" msgstr "" -#: services/js_i18n_strings.py:916 +#: services/js_i18n_strings.py:962 #, python-brace-format msgid "{title} ({count} versions)" msgstr "" -#: services/js_i18n_strings.py:917 +#: services/js_i18n_strings.py:963 msgid "⏳ Searching..." msgstr "" -#: services/js_i18n_strings.py:918 +#: services/js_i18n_strings.py:964 #, python-brace-format msgid "⚠️ Cancel {job}" msgstr "" -#: services/js_i18n_strings.py:919 +#: services/js_i18n_strings.py:965 msgid "⚠️ Confirm" msgstr "" -#: services/js_i18n_strings.py:920 +#: services/js_i18n_strings.py:966 msgid "⚡ via" msgstr "" -#: services/js_i18n_strings.py:921 +#: services/js_i18n_strings.py:967 msgid "✕ Clear" msgstr "" -#: services/js_i18n_strings.py:922 +#: services/js_i18n_strings.py:968 msgid "🏆 Refresh RetroAchievements" msgstr "" -#: services/js_i18n_strings.py:923 +#: services/js_i18n_strings.py:969 msgid "📋 Clear CLZ Imports" msgstr "" -#: services/js_i18n_strings.py:924 +#: services/js_i18n_strings.py:970 msgid "🔄 Restart Server" msgstr "" -#: services/js_i18n_strings.py:925 +#: services/js_i18n_strings.py:971 msgid "🔍 Lookup HLTB" msgstr "" -#: services/js_i18n_strings.py:926 +#: services/js_i18n_strings.py:972 msgid "🗑️ Clear HLTB Data" msgstr "" -#: services/js_i18n_strings.py:927 +#: services/js_i18n_strings.py:973 msgid "🗑️ Clear RetroAchievements Data" msgstr "" -#: services/js_i18n_strings.py:928 +#: services/js_i18n_strings.py:974 msgid "🗑️ Reset Metadata" msgstr "" -#: services/js_i18n_strings.py:929 +#: services/js_i18n_strings.py:975 msgid "🛑 Shut Down Server" msgstr "" -#: services/js_i18n_strings.py:930 +#: services/js_i18n_strings.py:976 msgid "🧹 Clean Missing ROMs" msgstr "" @@ -3615,7 +3728,7 @@ msgstr "" #: templates/_bulk_edit_modal.html:26 templates/achievements_system.html:171 #: templates/analytics.html:1664 templates/base.html:1010 -#: templates/dashboard.html:491 templates/game_detail.html:219 +#: templates/dashboard.html:500 templates/game_detail.html:219 #: templates/steam_achievements.html:78 templates/xbox_achievements.html:78 msgid "Not Started" msgstr "" @@ -3625,7 +3738,7 @@ msgstr "" #: templates/achievement_game.html:241 templates/all_games.html:72 #: templates/analytics.html:481 templates/analytics.html:2428 #: templates/base.html:940 templates/base.html:1148 -#: templates/compare_games.html:73 templates/dashboard.html:381 +#: templates/compare_games.html:73 templates/dashboard.html:390 #: templates/game_detail.html:265 templates/local_trophy_detail.html:99 #: templates/psn_trophy_detail.html:94 templates/psn_trophy_detail.html:258 #: templates/steam_achievement_game.html:178 templates/system_games.html:63 @@ -3918,8 +4031,8 @@ msgstr "" #: templates/_macros/nav_tabs.html:24 templates/achievement_game.html:265 #: templates/achievements_system.html:126 templates/analytics.html:17 -#: templates/base.html:131 templates/dashboard.html:123 -#: templates/dashboard.html:209 templates/steam_achievement_game.html:190 +#: templates/base.html:131 templates/dashboard.html:132 +#: templates/dashboard.html:218 templates/steam_achievement_game.html:190 #: templates/xbox_achievement_game.html:177 templates/xbox_achievements.html:46 msgid "Achievements" msgstr "" @@ -3995,7 +4108,7 @@ msgstr "" #: templates/_settings_tabs/data.html:23 templates/achievements_system.html:42 #: templates/analytics.html:71 templates/analytics.html:2535 -#: templates/dashboard.html:155 templates/steam_achievements.html:39 +#: templates/dashboard.html:164 templates/steam_achievements.html:39 #: templates/xbox_achievements.html:39 msgid "Total Games" msgstr "" @@ -4209,21 +4322,21 @@ msgstr "" msgid "No games match these filters" msgstr "" -#: templates/all_games.html:171 templates/dashboard.html:84 +#: templates/all_games.html:171 templates/dashboard.html:93 msgid "Your library is empty" msgstr "" -#: templates/all_games.html:173 templates/dashboard.html:86 +#: templates/all_games.html:173 templates/dashboard.html:95 msgid "Scan your ROM folder to add your games." msgstr "" #: templates/_settings_tabs/library.html:42 templates/all_games.html:176 -#: templates/dashboard.html:74 templates/dashboard.html:90 +#: templates/dashboard.html:83 templates/dashboard.html:99 #: templates/systems.html:111 msgid "Scan Library" msgstr "" -#: templates/all_games.html:179 templates/dashboard.html:95 +#: templates/all_games.html:179 templates/dashboard.html:104 msgid "Ask an admin to scan the ROM folder." msgstr "" @@ -4297,8 +4410,8 @@ msgstr "" msgid "Gameplay" msgstr "" -#: templates/analytics.html:14 templates/dashboard.html:224 -#: templates/dashboard.html:322 templates/game_imports.html:277 +#: templates/analytics.html:14 templates/dashboard.html:233 +#: templates/dashboard.html:331 templates/game_imports.html:277 msgid "Playtime" msgstr "" @@ -4370,15 +4483,15 @@ msgstr "" msgid "Refresh" msgstr "" -#: templates/analytics.html:78 templates/dashboard.html:146 +#: templates/analytics.html:78 templates/dashboard.html:155 msgid "Total Systems" msgstr "" -#: templates/analytics.html:85 templates/dashboard.html:191 +#: templates/analytics.html:85 templates/dashboard.html:200 msgid "Total Storage" msgstr "" -#: templates/analytics.html:92 templates/dashboard.html:200 +#: templates/analytics.html:92 templates/dashboard.html:209 msgid "Completion Rate" msgstr "" @@ -4567,9 +4680,9 @@ msgid "Description" msgstr "" #: templates/analytics.html:480 templates/analytics.html:2428 -#: templates/dashboard.html:121 templates/dashboard.html:319 -#: templates/dashboard.html:378 templates/dashboard.html:455 -#: templates/dashboard.html:521 +#: templates/dashboard.html:130 templates/dashboard.html:328 +#: templates/dashboard.html:387 templates/dashboard.html:464 +#: templates/dashboard.html:530 msgid "Boxart" msgstr "" @@ -4617,7 +4730,7 @@ msgstr "" msgid "Storage by System" msgstr "" -#: templates/analytics.html:563 templates/dashboard.html:273 +#: templates/analytics.html:563 templates/dashboard.html:282 msgid "Top Systems by Game Count" msgstr "" @@ -4954,7 +5067,7 @@ msgstr "" msgid "Login" msgstr "" -#: templates/base.html:128 templates/dashboard.html:61 +#: templates/base.html:128 templates/dashboard.html:70 msgid "Dashboard" msgstr "" @@ -5200,9 +5313,9 @@ msgid "No playtime data" msgstr "" #: templates/_modals/edit_modal.html:28 templates/_modals/filter_modal.html:13 -#: templates/base.html:1077 templates/dashboard.html:320 -#: templates/dashboard.html:379 templates/dashboard.html:456 -#: templates/dashboard.html:522 templates/game_detail.html:710 +#: templates/base.html:1077 templates/dashboard.html:329 +#: templates/dashboard.html:388 templates/dashboard.html:465 +#: templates/dashboard.html:531 templates/game_detail.html:710 msgid "Title" msgstr "" @@ -5552,7 +5665,7 @@ msgid "Source Folder" msgstr "" #: templates/chd_converter.html:92 templates/chd_verify.html:108 -#: templates/dashboard.html:250 templates/settings_emulators.html:103 +#: templates/dashboard.html:259 templates/settings_emulators.html:103 msgid "Scan" msgstr "" @@ -5837,7 +5950,7 @@ msgid "ROM Path" msgstr "" #: templates/compare_games.html:95 templates/dashboard.html:20 -#: templates/dashboard.html:431 templates/dashboard.html:555 +#: templates/dashboard.html:440 templates/dashboard.html:564 #: templates/wishlist.html:508 msgid "Scraped" msgstr "" @@ -5858,8 +5971,8 @@ msgstr "" msgid "Viewed" msgstr "" -#: templates/dashboard.html:21 templates/dashboard.html:383 -#: templates/dashboard.html:524 templates/game_imports.html:224 +#: templates/dashboard.html:21 templates/dashboard.html:392 +#: templates/dashboard.html:533 templates/game_imports.html:224 #: templates/game_imports.html:277 templates/game_imports.html:340 #: templates/game_imports.html:403 msgid "Status" @@ -5873,232 +5986,236 @@ msgstr "" msgid "Check Settings →" msgstr "" -#: templates/dashboard.html:37 +#: templates/dashboard.html:36 +msgid "See what is new" +msgstr "" + +#: templates/dashboard.html:46 #, python-format msgid "%(count)s interrupted job found" msgid_plural "%(count)s interrupted jobs found" msgstr[0] "" msgstr[1] "" -#: templates/dashboard.html:38 +#: templates/dashboard.html:47 msgid "Resume All" msgstr "" -#: templates/dashboard.html:39 +#: templates/dashboard.html:48 msgid "Dismiss All" msgstr "" -#: templates/dashboard.html:51 templates/dashboard.html:1426 +#: templates/dashboard.html:60 templates/dashboard.html:1438 msgid "Dismiss" msgstr "" -#: templates/dashboard.html:62 +#: templates/dashboard.html:71 msgid "Overview of your retro gaming collection" msgstr "" -#: templates/dashboard.html:66 +#: templates/dashboard.html:75 msgid "Pick a random game" msgstr "" -#: templates/dashboard.html:68 +#: templates/dashboard.html:77 msgid "Random Game" msgstr "" -#: templates/dashboard.html:92 +#: templates/dashboard.html:101 msgid "Set ROM folder" msgstr "" -#: templates/dashboard.html:111 +#: templates/dashboard.html:120 msgid "Collection Health" msgstr "" -#: templates/dashboard.html:112 +#: templates/dashboard.html:121 msgid "Metadata completeness across your library" msgstr "" -#: templates/dashboard.html:115 +#: templates/dashboard.html:124 msgid "Excellent" msgstr "" -#: templates/dashboard.html:115 +#: templates/dashboard.html:124 msgid "Good" msgstr "" -#: templates/dashboard.html:115 +#: templates/dashboard.html:124 msgid "Fair" msgstr "" -#: templates/dashboard.html:115 +#: templates/dashboard.html:124 msgid "Needs Work" msgstr "" -#: templates/dashboard.html:122 +#: templates/dashboard.html:131 msgid "Descriptions" msgstr "" -#: templates/dashboard.html:150 +#: templates/dashboard.html:159 msgid "Gaming platforms" msgstr "" -#: templates/dashboard.html:159 +#: templates/dashboard.html:168 msgid "ROMs in library" msgstr "" -#: templates/_settings_tabs/data.html:31 templates/dashboard.html:164 +#: templates/_settings_tabs/data.html:31 templates/dashboard.html:173 msgid "Missing Metadata" msgstr "" -#: templates/dashboard.html:170 +#: templates/dashboard.html:179 msgid "Action required" msgstr "" -#: templates/dashboard.html:172 +#: templates/dashboard.html:181 msgid "All games scraped" msgstr "" -#: templates/dashboard.html:183 +#: templates/dashboard.html:192 msgid "Games with metadata" msgstr "" -#: templates/dashboard.html:195 +#: templates/dashboard.html:204 msgid "ROM library size" msgstr "" -#: templates/dashboard.html:204 +#: templates/dashboard.html:213 #, python-format msgid "%(n)s game beaten" msgid_plural "%(n)s games beaten" msgstr[0] "" msgstr[1] "" -#: templates/dashboard.html:215 +#: templates/dashboard.html:224 #, python-format msgid "of %(n)s available" msgstr "" -#: templates/dashboard.html:217 +#: templates/dashboard.html:226 msgid "RetroAchievements earned" msgstr "" -#: templates/dashboard.html:228 +#: templates/dashboard.html:237 msgid "Estimated total (HLTB)" msgstr "" -#: templates/dashboard.html:236 +#: templates/dashboard.html:245 msgid "Quick Fixes" msgstr "" -#: templates/dashboard.html:243 +#: templates/dashboard.html:252 msgid "Games missing box art" msgstr "" -#: templates/dashboard.html:251 +#: templates/dashboard.html:260 msgid "Find duplicate ROMs" msgstr "" -#: templates/dashboard.html:258 +#: templates/dashboard.html:267 msgid "Check" msgstr "" -#: templates/dashboard.html:259 +#: templates/dashboard.html:268 msgid "Broken ROM paths" msgstr "" -#: templates/dashboard.html:274 +#: templates/dashboard.html:283 msgid "View All Systems →" msgstr "" -#: templates/dashboard.html:301 +#: templates/dashboard.html:310 #, python-format msgid "%(pct)s%% scraped" msgstr "" -#: templates/dashboard.html:312 +#: templates/dashboard.html:321 msgid "Continue Playing" msgstr "" -#: templates/dashboard.html:370 +#: templates/dashboard.html:379 msgid "Recently Added" msgstr "" -#: templates/dashboard.html:371 +#: templates/dashboard.html:380 msgid "View All Games →" msgstr "" -#: templates/dashboard.html:382 templates/reports.html:447 +#: templates/dashboard.html:391 templates/reports.html:447 msgid "Year" msgstr "" -#: templates/dashboard.html:433 +#: templates/dashboard.html:442 msgid "Missing" msgstr "" -#: templates/dashboard.html:448 +#: templates/dashboard.html:457 msgid "Recently Viewed" msgstr "" -#: templates/dashboard.html:458 +#: templates/dashboard.html:467 msgid "Completion" msgstr "" -#: templates/dashboard.html:514 +#: templates/dashboard.html:523 msgid "Recently Scraped" msgstr "" -#: templates/dashboard.html:568 +#: templates/dashboard.html:577 msgid "System Status" msgstr "" -#: templates/dashboard.html:574 +#: templates/dashboard.html:583 msgid "Database Service" msgstr "" -#: templates/dashboard.html:576 +#: templates/dashboard.html:585 msgid "Online" msgstr "" -#: templates/dashboard.html:582 +#: templates/dashboard.html:591 msgid "ES-DE Local Scraper" msgstr "" -#: templates/dashboard.html:584 +#: templates/dashboard.html:593 msgid "Available" msgstr "" -#: templates/dashboard.html:593 templates/dashboard.html:603 -#: templates/dashboard.html:623 templates/dashboard.html:633 +#: templates/dashboard.html:602 templates/dashboard.html:612 +#: templates/dashboard.html:632 templates/dashboard.html:642 #: templates/game_imports.html:368 msgid "Checking..." msgstr "" -#: templates/dashboard.html:593 templates/dashboard.html:603 -#: templates/dashboard.html:623 templates/dashboard.html:633 +#: templates/dashboard.html:602 templates/dashboard.html:612 +#: templates/dashboard.html:632 templates/dashboard.html:642 msgid "Disabled" msgstr "" -#: templates/dashboard.html:593 templates/dashboard.html:603 -#: templates/dashboard.html:613 templates/dashboard.html:623 -#: templates/dashboard.html:633 +#: templates/dashboard.html:602 templates/dashboard.html:612 +#: templates/dashboard.html:622 templates/dashboard.html:632 +#: templates/dashboard.html:642 msgid "Not Configured" msgstr "" -#: templates/_settings_tabs/account.html:297 templates/dashboard.html:613 +#: templates/_settings_tabs/account.html:297 templates/dashboard.html:622 msgid "Configured" msgstr "" -#: templates/dashboard.html:1409 +#: templates/dashboard.html:1421 msgid "" "Your PlayStation Network session has expired — re-link it to keep trophy " "and library sync working." msgstr "" -#: templates/dashboard.html:1412 +#: templates/dashboard.html:1424 msgid "" "Your PlayStation Network session expires soon — re-link it to avoid an " "interruption." msgstr "" -#: templates/dashboard.html:1425 +#: templates/dashboard.html:1437 msgid "Re-link now" msgstr "" @@ -7998,11 +8115,11 @@ msgstr "" msgid "Customization" msgstr "" -#: templates/settings.html:1404 +#: templates/settings.html:1405 msgid "Failed to save language" msgstr "" -#: templates/settings.html:1410 +#: templates/settings.html:1411 msgid "Network error saving language" msgstr "" @@ -9745,31 +9862,61 @@ msgstr "" msgid "Default Region" msgstr "" -#: templates/_settings_tabs/system.html:182 +#: templates/_settings_tabs/system.html:7 +#: templates/_settings_tabs/system.html:174 +msgid "Updates" +msgstr "" + +#: templates/_settings_tabs/system.html:177 +#, python-format +msgid "This is RetroDB %(version)s." +msgstr "" + +#: templates/_settings_tabs/system.html:183 +msgid "Release page" +msgstr "" + +#: templates/_settings_tabs/system.html:190 +msgid "Check for updates" +msgstr "" + +#: templates/_settings_tabs/system.html:191 +msgid "Download update" +msgstr "" + +#: templates/_settings_tabs/system.html:192 +msgid "Install and restart" +msgstr "" + +#: templates/_settings_tabs/system.html:196 +msgid "Check for updates once a day" +msgstr "" + +#: templates/_settings_tabs/system.html:213 msgid "Shut Down Server" msgstr "" -#: templates/_settings_tabs/system.html:185 +#: templates/_settings_tabs/system.html:216 msgid "" "Restart the Flask server (useful after configuration changes), or shut it" " down completely" msgstr "" -#: templates/_settings_tabs/system.html:444 +#: templates/_settings_tabs/system.html:475 msgid "Support Development" msgstr "" -#: templates/_settings_tabs/system.html:448 +#: templates/_settings_tabs/system.html:479 msgid "" "RetroDB is free and open source. If it saves you time, consider " "sponsoring — every bit helps keep solo development sustainable." msgstr "" -#: templates/_settings_tabs/system.html:452 +#: templates/_settings_tabs/system.html:483 msgid "Sponsor on GitHub" msgstr "" -#: templates/_settings_tabs/system.html:456 +#: templates/_settings_tabs/system.html:487 msgid "Support on Patreon" msgstr "" diff --git a/retrodb.spec b/retrodb.spec index 1efa1d32..b5060d27 100644 --- a/retrodb.spec +++ b/retrodb.spec @@ -62,6 +62,9 @@ _RUNTIME_HIDDEN = [ # default analyser may not follow the dynamic command-resolution path. 'waitress', 'waitress.runner', + # PASS-59-169 — the update helper; app.py imports it only under + # --apply-update, so name it rather than rely on the analyser. + 'updater_apply', # PSNAWP uses pyrate-limiter via a string config knob; explicit. 'pyrate_limiter', # PIL plugin auto-discovery (formats are loaded lazily by name). diff --git a/roadmap.md b/roadmap.md index cd22b1c8..7baed25d 100644 --- a/roadmap.md +++ b/roadmap.md @@ -9699,6 +9699,46 @@ were corrected in `2836bc3` and are not repeated here. - **Verify**: a fresh Get RetroArch installs the new version; `ss -ulnp` shows the player's port on 127.0.0.1 only; Next disk works. +--- + +#### Pass 59.169 RetroDB updates itself from GitHub Releases (FEATURE, L). +- **Status**: implemented in 3.33.0, not yet tried against a real + published release. +- **Asked** (user, 2026-10-27): a self-updater. Decided the same day: + check plus one-click install (never unattended), GitHub Releases as + the source, source and standalone installs both, spec then straight + to code with the code as the review. +- **Spec**: `docs/specs/PASS-59-169-self-updater.md`. +- **Shape**: `services/updater.py` checks + `releases/latest` (drafts stay invisible until published, so + publishing is the switch), downloads the asset for this install, + checks it against the release's SHA-256 file and unpacks it safely. + Install backs up the database and starts the NEW version's helper + (`updater_apply.py`, or `retrodb --apply-update`), which swaps files + after RetroDB exits, restarts, waits for `/health` to report the new + version and otherwise rolls files and database back. Settings > + System > Updates and an admin dashboard banner (`static/js/updates.js`). +- **Verify (still to do on real releases)**: publish a release after + this one ships, then update a source install and a standalone on + Linux and Windows from it; break one (a release whose program exits + at once) and see the rollback bring the old version back. + +--- + +#### Pass 59.170 Verify release signatures before an update installs (SECURITY, M). +- **Status**: todo +- **Why**: PASS-59-169 checks downloads against the SHA-256 file + published in the same release, which catches corruption and a + swapped file on the way, but not a release whose files and checksums + were both replaced. `release.yml` already signs every zip with cosign + (keyless, GitHub OIDC); checking that signature would tie a download + to the repository's own release workflow. +- **Do**: verify the `.cosign.bundle` beside each asset (the `sigstore` + package, identity = this repository's `release.yml` on a `v*` tag) + before unpacking; the standalone `-Standalone.zip` assets need + signing in the `build-standalone` job first. +- **Verify**: a tampered zip with a matching checksum file is refused. + ## Done index Compact one-liner per landed pass. Detail lives in git history diff --git a/routes/auth.py b/routes/auth.py index ef132778..e5018401 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -227,6 +227,17 @@ def api_update_user(user_id): if not user: return error(_('User not found'), code=404) + # The last active admin cannot be demoted or deactivated: nobody would be + # left who can manage users or settings (the delete route guards the same). + if user['role'] == 'admin' and user['is_active']: + demoted = 'role' in data and data['role'] in VALID_ROLES and data['role'] != 'admin' + deactivated = 'is_active' in data and not data['is_active'] + if demoted or deactivated: + admin_count = query("SELECT COUNT(*) as count FROM users " + "WHERE role = 'admin' AND is_active = 1", one=True) + if admin_count['count'] <= 1: + return error(_('The last admin must stay an active admin'), code=403) + updates = [] params = [] diff --git a/routes/collector_trophies.py b/routes/collector_trophies.py index 0344b4c9..65103f1e 100644 --- a/routes/collector_trophies.py +++ b/routes/collector_trophies.py @@ -20,7 +20,7 @@ from services.api_helpers import handle_api_errors, success from services.auth import login_required, permission_required -from services.database import query, execute +from services.database import get_request_db, query from services.formatters import get_manufacturer logger = logging.getLogger(__name__) @@ -470,6 +470,17 @@ def _refresh_trophies(user_id=None): newly_earned = 0 total_earned = 0 + # One read of the caller's rows and one transaction for the writes; this + # was a SELECT plus a committed write per trophy (~140 round trips). + existing_rows = { + r['id']: r['earned_at'] for r in query( + "SELECT id, earned_at FROM collector_trophies WHERE user_id = ?", + (user_id,), + ) + } + updates = [] + inserts = [] + for trophy_def in TROPHY_DEFINITIONS: trophy_id = trophy_def['id'] stat_key = trophy_def['stat'] @@ -478,65 +489,47 @@ def _refresh_trophies(user_id=None): progress = min(current_value, threshold) met = current_value >= threshold + definition = (trophy_def['name'], trophy_def['description'], + trophy_def['icon'], trophy_def['tier'], + trophy_def['category'], threshold) - existing = query( - "SELECT earned_at FROM collector_trophies WHERE id = ? AND user_id = ?", - (trophy_id, user_id), one=True - ) - - if existing is not None: - already_earned = existing['earned_at'] is not None + if trophy_id in existing_rows: + already_earned = existing_rows[trophy_id] is not None + if met: + total_earned += 1 + # Stamp earned_at the first time the threshold is met; otherwise + # keep it. Definition fields are re-written every refresh so edits + # (name, description, icon, tier, threshold) propagate without a + # manual DELETE FROM collector_trophies. + earned_at = now if met and not already_earned else existing_rows[trophy_id] if met and not already_earned: - execute( - """UPDATE collector_trophies - SET progress = ?, earned_at = ?, - name = ?, description = ?, icon = ?, tier = ?, - category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, now, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) newly_earned += 1 - total_earned += 1 - elif met and already_earned: - # Re-write fields every refresh so definition edits (name, - # description, icon, tier, threshold) propagate without a - # manual DELETE FROM collector_trophies. - execute( - """UPDATE collector_trophies - SET progress = ?, name = ?, description = ?, icon = ?, - tier = ?, category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) - total_earned += 1 - else: - execute( - """UPDATE collector_trophies - SET progress = ?, name = ?, description = ?, icon = ?, - tier = ?, category = ?, threshold = ? - WHERE id = ? AND user_id = ?""", - (progress, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, trophy_id, user_id) - ) + updates.append((progress, earned_at, *definition, trophy_id, user_id)) else: earned_at = now if met else None if met: newly_earned += 1 total_earned += 1 - execute( + inserts.append((trophy_id, user_id, *definition[:5], threshold, + earned_at, progress)) + + conn = get_request_db() + with conn: + if updates: + conn.executemany( + """UPDATE collector_trophies + SET progress = ?, earned_at = ?, + name = ?, description = ?, icon = ?, tier = ?, + category = ?, threshold = ? + WHERE id = ? AND user_id = ?""", + updates, + ) + if inserts: + conn.executemany( """INSERT INTO collector_trophies (id, user_id, name, description, icon, tier, category, threshold, earned_at, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - trophy_id, user_id, trophy_def['name'], trophy_def['description'], - trophy_def['icon'], trophy_def['tier'], trophy_def['category'], - threshold, earned_at, progress, - ) + inserts, ) # Compute the rank after refresh so the API response reflects the new state diff --git a/routes/controllers.py b/routes/controllers.py index 54ac11be..32cf50e1 100644 --- a/routes/controllers.py +++ b/routes/controllers.py @@ -155,19 +155,20 @@ def api_get_all_controllers(): ORDER BY c.manufacturer COLLATE NOCASE, c.name COLLATE NOCASE """) - # For each controller, get all associated systems + # Every controller's systems in one query, not one query per controller. + systems_by_controller = {} + for row in query(""" + SELECT sc.controller_id, s.id, s.name + FROM systems s + JOIN system_controllers sc ON s.id = sc.system_id + ORDER BY s.name COLLATE NOCASE + """): + systems_by_controller.setdefault(row['controller_id'], []).append(row) + result = [] for c in controllers: ctrl = dict(c) - - # Get systems from junction table - systems = query(""" - SELECT s.id, s.name - FROM systems s - JOIN system_controllers sc ON s.id = sc.system_id - WHERE sc.controller_id = ? - ORDER BY s.name COLLATE NOCASE - """, (ctrl['id'],)) + systems = systems_by_controller.get(ctrl['id'], []) ctrl['system_ids'] = [s['id'] for s in systems] ctrl['system_names'] = [s['name'] for s in systems] @@ -558,12 +559,18 @@ def api_assign_default_controllers(): 'xbox360': ['Xbox 360'], } + patterns_longest_first = sorted(controller_mappings, key=len, reverse=True) + for system in systems: folder_lower = system['folder'].lower().replace('-', '').replace('_', '').replace(' ', '') - # Try to find a matching controller + # Try to find a matching controller. Longest pattern first: a bare + # substring scan in dict order typed 'snes' as 'nes', 'gba' as 'gb', + # 'wiiu' as 'wii' and 'xbox360' as 'xbox' (same bug Pass 48.5 fixed in + # systems.update_system_types). matched_controller = None - for folder_pattern, controller_names in controller_mappings.items(): + for folder_pattern in patterns_longest_first: + controller_names = controller_mappings[folder_pattern] if folder_pattern in folder_lower: # Search for any matching controller for ctrl_name in controller_names: diff --git a/routes/games_ai.py b/routes/games_ai.py index f421c1a9..c1fd9e92 100644 --- a/routes/games_ai.py +++ b/routes/games_ai.py @@ -17,6 +17,7 @@ from services.api_helpers import handle_api_errors, success, error from services.auth import editor_required from services.database import query, execute +from services.game_query import invalidate_filter_cache from services.game_metadata_service import cross_map_ratings from services.game_utils import ( generate_sort_title, infer_rating_from_content, @@ -242,6 +243,10 @@ def api_game_ai_fill(game_id): if sets: assignments = ', '.join(f'{col} = ?' for col in sets) execute(f"UPDATE games SET {assignments} WHERE id = ?", (*sets.values(), game_id)) + # AI fill writes genre/developer/publisher/franchise/modes/ratings, + # all of which feed the Library filter dropdowns — same cache + # discipline as the edit paths in routes/games.py. + invalidate_filter_cache() invalidate_analytics_cache() verify = query("SELECT esrb_rating, edition FROM games WHERE id = ?", (game_id,), one=True) diff --git a/routes/games_media.py b/routes/games_media.py index ad4d6741..d9e5cc0d 100644 --- a/routes/games_media.py +++ b/routes/games_media.py @@ -108,7 +108,9 @@ def api_rename_rom(game_id): if problem: return error(_('Cannot rename: %(reason)s') % {'reason': problem}, 400) - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) try: diff --git a/routes/games_search.py b/routes/games_search.py index 5b709dd5..63d9ebee 100644 --- a/routes/games_search.py +++ b/routes/games_search.py @@ -82,7 +82,9 @@ def api_search_games(): def api_local_search_games(): """Search local game library by title. Used by lists, compare, etc.""" q = request.args.get('q', '').strip() - limit = min(request.args.get('limit', 20, type=int), 50) + # Floor at 1: SQLite reads a negative LIMIT as "no limit", so + # ?limit=-1 returned the whole library. + limit = min(max(request.args.get('limit', 20, type=int), 1), 50) if not q or len(q) < 2: return success(games=[]) @@ -119,9 +121,9 @@ def api_similar_games(game_id): matches = query(""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.franchise LIKE ? AND g.id != ? AND g.is_bonus_disc = 0 + WHERE g.franchise LIKE ? ESCAPE '\\' AND g.id != ? AND g.is_bonus_disc = 0 ORDER BY g.title LIMIT 5 - """, [f'%{franchise}%', game_id]) + """, [f'%{escape_like(franchise)}%', game_id]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': f'Same franchise: {franchise}'}) @@ -135,9 +137,10 @@ def api_similar_games(game_id): matches = query(""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.genre LIKE ? AND g.developer LIKE ? AND g.id != ? AND g.is_bonus_disc = 0 + WHERE g.genre LIKE ? ESCAPE '\\' AND g.developer LIKE ? ESCAPE '\\' + AND g.id != ? AND g.is_bonus_disc = 0 ORDER BY g.title LIMIT 5 - """, [f'%{genres[0]}%', f'%{devs[0]}%', game_id]) + """, [f'%{escape_like(genres[0])}%', f'%{escape_like(devs[0])}%', game_id]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': 'Same genre & developer'}) @@ -154,9 +157,9 @@ def api_similar_games(game_id): matches = query(f""" SELECT g.id, g.title, g.boxart, s.name AS system_name FROM games g JOIN systems s ON g.system_id = s.id - WHERE g.genre LIKE ? AND g.system_id = ? AND g.id NOT IN ({placeholders}) AND g.is_bonus_disc = 0 + WHERE g.genre LIKE ? ESCAPE '\\' AND g.system_id = ? AND g.id NOT IN ({placeholders}) AND g.is_bonus_disc = 0 ORDER BY RANDOM() LIMIT ? - """, [f'%{genres[0]}%', game['system_id']] + seen_ids_list + [8 - len(similar)]) + """, [f'%{escape_like(genres[0])}%', game['system_id']] + seen_ids_list + [8 - len(similar)]) for m in matches: if m['id'] not in seen_ids: similar.append({'id': m['id'], 'title': m['title'], 'boxart': m['boxart'], 'system_name': m['system_name'], 'reason': 'Similar genre on same system'}) diff --git a/routes/maintenance.py b/routes/maintenance.py index d4db172e..e70062e0 100644 --- a/routes/maintenance.py +++ b/routes/maintenance.py @@ -224,10 +224,13 @@ def api_database_optimize(): start_time = time.time() conn = get_db() - conn.execute("VACUUM") - conn.execute("ANALYZE") - conn.execute("PRAGMA optimize") - conn.close() + try: + conn.execute("VACUUM") + conn.execute("ANALYZE") + conn.execute("PRAGMA optimize") + finally: + # VACUUM raises "database is locked" while a job writes; close anyway. + conn.close() duration = time.time() - start_time size_after = os.path.getsize(db_path) diff --git a/routes/platform_import.py b/routes/platform_import.py index 2e80761b..d6e66fd8 100644 --- a/routes/platform_import.py +++ b/routes/platform_import.py @@ -73,36 +73,43 @@ def _ensure_system_exists(folder, name=None): return None -def _check_duplicate(title, system_id, platform_field=None, platform_value=None): - """Check if a game already exists by platform ID or normalized title. +_PLATFORM_ID_FIELDS = ('steam_app_id', 'xbox_title_id', 'psn_npwr_id') - Args: - title: Game title to check - system_id: System ID to check against - platform_field: Column name for platform-specific ID (e.g. 'steam_app_id') - platform_value: Value of the platform-specific ID - Returns: - int or None: Existing game_id if found, else None - """ - # 1. Exact platform ID match - if platform_field and platform_value: - allowed_fields = ('steam_app_id', 'xbox_title_id', 'psn_npwr_id') - if platform_field not in allowed_fields: - return None - row = query(f"SELECT id FROM games WHERE {platform_field} = ?", (platform_value,), one=True) - if row: - return row['id'] +class _DuplicateIndex: + """Duplicate lookup for a whole fetched library, built with one query. - # 2. Normalized title match on same system - norm = normalize_title(title) - if norm and system_id: - games = query("SELECT id, title FROM games WHERE system_id = ?", (system_id,)) - for game in games: - if normalize_title(game['title']) == norm: - return game['id'] + Checking each fetched title with its own queries re-read and + re-normalised every game of the target system per title, so a + 1 000-game Steam library against a 1 000-game Windows system cost a + million title normalisations and 2 000 queries. - return None + `find()` answers as the per-title check did: the platform-ID match + anywhere in the library first, then a normalised-title match among the + games of the given system. The first row in rowid order wins. + """ + + def __init__(self, platform_field): + if platform_field not in _PLATFORM_ID_FIELDS: + raise ValueError(f"unknown platform field {platform_field!r}") + self._by_platform_id = {} + self._by_title = {} + for row in query(f"SELECT id, title, system_id, {platform_field} AS pid " + f"FROM games ORDER BY id"): + if row['pid']: + self._by_platform_id.setdefault(row['pid'], row['id']) + norm = normalize_title(row['title']) + if norm: + self._by_title.setdefault((norm, row['system_id']), row['id']) + + def find(self, title, system_id, platform_value=None): + """Existing game_id for this title / platform ID, else None.""" + if platform_value and platform_value in self._by_platform_id: + return self._by_platform_id[platform_value] + norm = normalize_title(title) + if norm and system_id: + return self._by_title.get((norm, system_id)) + return None # ============================================================================= @@ -167,6 +174,7 @@ def api_steam_fetch_library(): system_id = system['id'] if system else None # Check duplicates for each game + dupes = _DuplicateIndex('steam_app_id') result_games = [] for game in games: app_id = str(game.get('appid', '')) @@ -174,7 +182,7 @@ def api_steam_fetch_library(): playtime = game.get('playtime_forever', 0) last_played = game.get('rtime_last_played', 0) - existing_id = _check_duplicate(name, system_id, 'steam_app_id', app_id) + existing_id = dupes.find(name, system_id, app_id) result_games.append({ 'appid': app_id, @@ -501,6 +509,7 @@ def api_xbox_fetch_library(): if windows_system: xbox_systems['windows'] = windows_system + dupes = _DuplicateIndex('xbox_title_id') result_games = [] for title in titles: name = title.get('name', '') @@ -517,7 +526,7 @@ def api_xbox_fetch_library(): system_id = system['id'] if system else None # Check duplicates - existing_id = _check_duplicate(name, system_id, 'xbox_title_id', title_id) + existing_id = dupes.find(name, system_id, title_id) # Extract achievement summary from title data achievement = title.get('achievement', {}) @@ -833,6 +842,7 @@ def _build_psn_library_response(games_data, from_api=False): if system: psn_systems[platform_str] = system + dupes = _DuplicateIndex('psn_npwr_id') result_games = [] for game in games_data: if from_api: @@ -858,9 +868,7 @@ def _build_psn_library_response(games_data, from_api=False): system_id = system['id'] if system else None # Check duplicates - existing_id = linked_game_id or _check_duplicate( - name, system_id, 'psn_npwr_id', npwr_id - ) + existing_id = linked_game_id or dupes.find(name, system_id, npwr_id) result_games.append({ 'npwr_id': npwr_id, diff --git a/routes/reports.py b/routes/reports.py index cba49bb8..da8698b9 100644 --- a/routes/reports.py +++ b/routes/reports.py @@ -10,7 +10,6 @@ import os import shutil import re -import glob import logging # Import configuration @@ -387,6 +386,21 @@ def regions_match(file_region, db_region): # API ROUTES - Multi-Disc Scan # ============================================================================= +def _disc_files_in(folder, extensions): + """Top-level files in `folder` whose extension is one of `extensions`, + matched case-insensitively, each listed once. + + The old per-extension glob of `*.bin` plus `*.BIN` listed every file + twice where the filesystem ignores case (Windows, macOS), so one + "Game (Disc 1).bin" looked like a two-disc set. + """ + wanted = {e.lower() for e in extensions} + with os.scandir(folder) as entries: + return [e.path for e in entries + if not e.name.startswith('.') and e.is_file() + and os.path.splitext(e.name)[1].lower() in wanted] + + @reports_bp.route('/api/reports/multidisc-scan', methods=['POST']) @login_required @handle_api_errors @@ -441,15 +455,7 @@ def api_reports_multidisc_scan(): scanned_systems.append(system) # Find all disc files in this system folder (top level only - not in subdirectories) - disc_files = [] - for ext in disc_extensions: - # Only look at top level files - found = glob.glob(os.path.join(system_path, f'*{ext}')) - disc_files.extend(found) - # Also check case-insensitive - if ext != ext.upper(): - found_upper = glob.glob(os.path.join(system_path, f'*{ext.upper()}')) - disc_files.extend(found_upper) + disc_files = _disc_files_in(system_path, disc_extensions) logger.info(f"Multi-disc scan: Found {len(disc_files)} top-level disc files in {system}") @@ -463,13 +469,7 @@ def api_reports_multidisc_scan(): m3u_exists = os.path.exists(os.path.join(system_path, f"{subfolder}.m3u")) # Count disc files in this subfolder - subfolder_disc_files = [] - for ext in disc_extensions: - found = glob.glob(os.path.join(subfolder_path, f'*{ext}')) - subfolder_disc_files.extend(found) - if ext != ext.upper(): - found_upper = glob.glob(os.path.join(subfolder_path, f'*{ext.upper()}')) - subfolder_disc_files.extend(found_upper) + subfolder_disc_files = _disc_files_in(subfolder_path, disc_extensions) # Check if any files look like multi-disc has_disc_indicator = False @@ -725,7 +725,9 @@ def api_reports_rename_rom(): return error(_('Cannot rename: %(reason)s') % {'reason': problem}, 400) # Check if new name already exists - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) # Check if this is an M3U file @@ -747,7 +749,9 @@ def api_reports_rename_rom(): # Check if associated folder exists if os.path.isdir(old_folder): # Check if new folder name already exists - if os.path.exists(new_folder) and new_folder != old_folder: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_folder) and not os.path.samefile(new_folder, old_folder): return error(_('A folder with the new name already exists'), 400) # Rename the folder first @@ -892,7 +896,9 @@ def api_reports_rename_to_scraped(): return error(_('Destination is outside the configured ROM root'), 400) # Check if new name already exists - if os.path.exists(new_path) and new_path != old_path: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_path) and not os.path.samefile(new_path, old_path): return error(_('A file with that name already exists'), 400) # Handle M3U files specially - need to rename folder and update M3U contents @@ -904,7 +910,9 @@ def api_reports_rename_to_scraped(): # Check if associated folder exists if os.path.isdir(old_folder): # Check if new folder name already exists - if os.path.exists(new_folder) and new_folder != old_folder: + # samefile, not !=: on a case-insensitive filesystem (Windows, + # macOS) a case-only rename finds its own source "existing". + if os.path.exists(new_folder) and not os.path.samefile(new_folder, old_folder): return error(_('A folder with the new name already exists'), 400) # Rename the folder first diff --git a/routes/scraper.py b/routes/scraper.py index 642b9bad..3b0bb512 100644 --- a/routes/scraper.py +++ b/routes/scraper.py @@ -72,6 +72,8 @@ 'ai_claude_api_key', 'steam_api_key', 'xbox_client_secret', + 'screenscraper_password', + 'screenscraper_devpassword', }) MASKED_SENTINEL_PREFIX = '***' diff --git a/routes/systems.py b/routes/systems.py index 38b574cf..96a2de48 100644 --- a/routes/systems.py +++ b/routes/systems.py @@ -446,16 +446,17 @@ def api_calculate_storage(): """Calculate and update file sizes for all games""" try: games = query("SELECT id, rom_path FROM games WHERE file_size IS NULL OR file_size = 0") - updated = 0 - + # One batched write (one commit) instead of a commit per game. + sizes = [] for game in games: if game['rom_path'] and os.path.exists(game['rom_path']): try: - size = os.path.getsize(game['rom_path']) - execute("UPDATE games SET file_size = ? WHERE id = ?", (size, game['id'])) - updated += 1 + sizes.append((os.path.getsize(game['rom_path']), game['id'])) except Exception as e: logger.warning(f"Could not get size for {game['rom_path']}: {e}") + if sizes: + execute_many("UPDATE games SET file_size = ? WHERE id = ?", sizes) + updated = len(sizes) return success( updated=updated, diff --git a/routes/tools.py b/routes/tools.py index a9e9144a..4bd9fa64 100644 --- a/routes/tools.py +++ b/routes/tools.py @@ -60,7 +60,8 @@ def _cleanup_completed_tasks(): """Remove completed/failed/cancelled tasks older than 30 minutes.""" now = datetime.now() to_remove = [] - for task_id, task in _rom_tool_tasks.items(): + # Snapshot: request threads may add tasks while this iterates. + for task_id, task in list(_rom_tool_tasks.items()): status = task.get('status') if status in ('completed', 'failed', 'cancelled'): end_time = task.get('end_time') @@ -70,7 +71,7 @@ def _cleanup_completed_tasks(): # No end_time recorded — stamp it now so it gets cleaned up next cycle task['end_time'] = now for task_id in to_remove: - del _rom_tool_tasks[task_id] + _rom_tool_tasks.pop(task_id, None) def _wait_if_paused(task): @@ -337,9 +338,16 @@ def api_rom_tools_browse_folders(): for item in sorted(os.listdir(current_path)): item_path = os.path.join(current_path, item) if os.path.isdir(item_path) and not item.startswith('.'): + # One scandir pass (d_type, no per-entry stat on most + # filesystems) instead of two listdir + isfile/isdir walks. + file_count = subfolder_count = 0 try: - file_count = len([f for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f))]) - subfolder_count = len([f for f in os.listdir(item_path) if os.path.isdir(os.path.join(item_path, f))]) + with os.scandir(item_path) as entries: + for entry in entries: + if entry.is_file(): + file_count += 1 + elif entry.is_dir(): + subfolder_count += 1 except PermissionError: file_count = 0 subfolder_count = 0 @@ -1253,7 +1261,9 @@ def _visual_group(file_data, threshold): def _run_screenshot_dedup_scan(task, method, threshold): """Background task: scan games for duplicate screenshots""" - screenshots_dir = os.path.join(config.STATIC_PATH, 'images', 'screenshots') + # Scraped media lives under IMAGE_PATH (the writable data root), not the + # read-only bundle STATIC_PATH — they differ in a frozen build. + screenshots_dir = os.path.join(config.IMAGE_PATH, 'screenshots') games = query(""" SELECT g.id, g.title, g.screenshots, s.name as system_name @@ -1415,7 +1425,9 @@ def api_screenshot_dedup_delete(): data = request.get_json() or {} deletions = data.get('deletions', []) # [{game_id, filename}, ...] - screenshots_dir = os.path.join(config.STATIC_PATH, 'images', 'screenshots') + # Scraped media lives under IMAGE_PATH (the writable data root), not the + # read-only bundle STATIC_PATH — they differ in a frozen build. + screenshots_dir = os.path.join(config.IMAGE_PATH, 'screenshots') results = {'deleted': 0, 'failed': 0, 'freed_space': 0, 'db_updated': 0} # Group by game_id diff --git a/routes/trophies.py b/routes/trophies.py index 78b302d6..2e5d2048 100644 --- a/routes/trophies.py +++ b/routes/trophies.py @@ -12,7 +12,7 @@ import threading import config -from services.database import query, execute +from services.database import query, execute, execute_many from services.api_helpers import success, error from services.auth import login_required, admin_required, editor_required, get_user_settings from services.jobs.base import _download_psn_trophy_image as download_psn_trophy_image @@ -326,8 +326,31 @@ def get_trophy_data(trophy_path=None): return {}, {'P': (0, 0), 'G': (0, 0), 'S': (0, 0), 'B': (0, 0)} -def find_matching_game(trophy_title): - """Find a matching game in RetroDB by title (local RPCS3 trophies = PS3)""" +_PS_FOLDERS_SQL = "('ps3', 'ps4', 'ps5', 'psvita', 'psx', 'ps2')" + + +def _ps_match_candidates(): + """Every PlayStation-family game with its cleaned title, read once. + + `find_matching_game` runs once per trophy set on /trophies; reading and + re-cleaning the PlayStation games inside it cost two full queries and + every title cleaned twice per set. + """ + rows = query(f""" + SELECT g.*, s.name AS system_name, s.folder + FROM games g + JOIN systems s ON g.system_id = s.id + WHERE s.folder IN {_PS_FOLDERS_SQL} + """) + return [(row, _clean_title_for_matching(row['title'])) for row in rows] + + +def find_matching_game(trophy_title, candidates=None): + """Find a matching game in RetroDB by title (local RPCS3 trophies = PS3). + + `candidates` is `_ps_match_candidates()`; pass it when matching many + titles so the games are read once. + """ # Title aliases for common abbreviated names in trophy data title_aliases = { 'MGSV:TPP': 'Metal Gear Solid V: The Phantom Pain', @@ -342,37 +365,23 @@ def find_matching_game(trophy_title): search_title = title_aliases.get(trophy_title, trophy_title) clean_title = _clean_title_for_matching(search_title) - # Pass 1: Exact cleaned match on PS3 only - ps3_games = query(""" - SELECT g.*, s.name AS system_name, s.folder - FROM games g - JOIN systems s ON g.system_id = s.id - WHERE s.folder = 'ps3' - """) + if candidates is None: + candidates = _ps_match_candidates() - if ps3_games: - for game in ps3_games: - if _clean_title_for_matching(game['title']) == clean_title: - return dict(game) + # Pass 1: Exact cleaned match on PS3 only + for game, db_clean in candidates: + if game['folder'] == 'ps3' and db_clean == clean_title: + return dict(game) # Pass 2: Exact cleaned match across all PlayStation systems - all_ps_games = query(""" - SELECT g.*, s.name AS system_name, s.folder - FROM games g - JOIN systems s ON g.system_id = s.id - WHERE s.folder IN ('ps3', 'ps4', 'ps5', 'psvita', 'psx', 'ps2') - """) - - if all_ps_games: - for game in all_ps_games: - if _clean_title_for_matching(game['title']) == clean_title: - return dict(game) + for game, db_clean in candidates: + if db_clean == clean_title: + return dict(game) - # Pass 3: Substring match (one contains the other) - for game in all_ps_games: - db_clean = _clean_title_for_matching(game['title']) - if clean_title and db_clean and (clean_title in db_clean or db_clean in clean_title): - return dict(game) + # Pass 3: Substring match (one contains the other) + for game, db_clean in candidates: + if clean_title and db_clean and (clean_title in db_clean or db_clean in clean_title): + return dict(game) return None @@ -395,8 +404,9 @@ def trophies(): # Match trophy sets with RetroDB games games_data = {} + candidates = _ps_match_candidates() if trophy_sets else [] for npwr_id, ts in trophy_sets.items(): - game = find_matching_game(ts.title) + game = find_matching_game(ts.title, candidates) games_data[npwr_id] = game # Sort trophy sets alphabetically by title @@ -604,8 +614,10 @@ def psn_trophies(): def api_psn_games(): """Paginated PSN games API for the PSN trophies page""" try: - page = request.args.get('page', 1, type=int) - per_page = min(request.args.get('per_page', 60, type=int), 200) + # Clamped: per_page=0 divided by zero below (500), and a page < 1 + # produced a negative OFFSET. + page = max(request.args.get('page', 1, type=int), 1) + per_page = max(min(request.args.get('per_page', 60, type=int), 200), 1) search = request.args.get('search', '').strip() letter = request.args.get('letter', '').strip() sort = request.args.get('sort', 'name-asc').strip() @@ -836,11 +848,11 @@ def trophy_sort_key(t): for t in trophies_list ) locked_count = sum(1 for t in trophies_list if not bool(t.get('earned'))) - logger.info(f"PSN Trophy Detail - platinum_earned: {platinum_earned}, total: {len(trophies_list)}, locked: {locked_count}") + logger.debug(f"PSN Trophy Detail - platinum_earned: {platinum_earned}, total: {len(trophies_list)}, locked: {locked_count}") # Debug: log first few trophies to understand data format for i, t in enumerate(trophies_list[:5]): - logger.info(f" Trophy {i}: type={t.get('trophy_type')!r}, earned={t.get('earned')!r}, group_id={t.get('group_id')!r}, group_name={t.get('group_name')!r}") + logger.debug(f" Trophy {i}: type={t.get('trophy_type')!r}, earned={t.get('earned')!r}, group_id={t.get('group_id')!r}, group_name={t.get('group_name')!r}") for t in trophies_list: if not bool(t.get('earned')): @@ -908,10 +920,10 @@ def trophy_sort_key(t): (platinum_earned and len(locked_trophies) > 0) ) - logger.info(f"PSN Trophy Detail - has_dlc: {has_dlc}, groups_list: {len(groups_list)}, dlc_inferred in locked: {'dlc_inferred' in locked_by_group}") - logger.info(f" locked_by_group keys: {list(locked_by_group.keys())}") + logger.debug(f"PSN Trophy Detail - has_dlc: {has_dlc}, groups_list: {len(groups_list)}, dlc_inferred in locked: {'dlc_inferred' in locked_by_group}") + logger.debug(f" locked_by_group keys: {list(locked_by_group.keys())}") for gid, gdata in locked_by_group.items(): - logger.info(f" Group '{gid}': name='{gdata['name']}', count={len(gdata['trophies'])}") + logger.debug(f" Group '{gid}': name='{gdata['name']}', count={len(gdata['trophies'])}") # Get HLTB data from linked game (shared data source) hltb_data = None @@ -1156,11 +1168,13 @@ def _run_psn_full_sync(psnawp, user_id): # sync threads have no request context, so `g` would surface # only as a typing/IDE hazard, but the shadow is fragile under # future refactors that move this body into a request handler. + # Each title is cleaned once here, not once per trophy title in + # the loop below (that was trophy titles x PS games cleanings). + all_ps_games = [(ps_game['id'], ps_game['folder'], + _clean_title_for_matching(ps_game['title'])) + for ps_game in all_ps_games] for ps_game in all_ps_games: - folder = ps_game['folder'] - if folder not in ps_by_folder: - ps_by_folder[folder] = [] - ps_by_folder[folder].append(ps_game) + ps_by_folder.setdefault(ps_game[1], []).append(ps_game) platform_map = {'PS3': 'ps3', 'PS4': 'ps4', 'PS5': 'ps5', 'PSVITA': 'psvita', 'PS Vita': 'psvita'} @@ -1186,22 +1200,21 @@ def _run_psn_full_sync(psnawp, user_id): # shadowing flask.g (mirrors the loop above and # the existing_groups comprehension at :1384). if sf and sf in ps_by_folder: - for ps_game in ps_by_folder[sf]: - if _clean_title_for_matching(ps_game['title']) == clean_title: - linked_game_id = ps_game['id'] + for game_id, _folder, db_clean in ps_by_folder[sf]: + if db_clean == clean_title: + linked_game_id = game_id break if not linked_game_id: - for ps_game in all_ps_games: - if _clean_title_for_matching(ps_game['title']) == clean_title: - linked_game_id = ps_game['id'] + for game_id, _folder, db_clean in all_ps_games: + if db_clean == clean_title: + linked_game_id = game_id break if not linked_game_id: - for ps_game in all_ps_games: - db_clean = _clean_title_for_matching(ps_game['title']) + for game_id, _folder, db_clean in all_ps_games: if db_clean and (clean_title in db_clean or db_clean in clean_title): - linked_game_id = ps_game['id'] + linked_game_id = game_id break _upsert_psn_title(conn, user_id, title, platform, icon_url, linked_game_id) @@ -1391,7 +1404,10 @@ def api_psn_sync_game(npwr_id): """, (psn_game['id'],)) # Pass 41.8 — `tg` instead of `g` to avoid shadowing flask.g. custom_group_names = {tg['group_id']: tg['group_name'] for tg in existing_groups} if existing_groups else {} - + + # Rows are written in one batch after the loop: one commit per + # trophy made a 100-trophy game 100 separate transactions. + trophy_rows = [] for trophy in trophies: try: # Extract trophy data @@ -1400,7 +1416,7 @@ def api_psn_sync_game(npwr_id): continue # Debug: log trophy attributes for first trophy - if trophies_synced == 0: + if not trophy_rows: logger.info(f"Trophy object type: {type(trophy)}") logger.info(f"Trophy attributes: {[a for a in dir(trophy) if not a.startswith('_')]}") @@ -1408,7 +1424,7 @@ def api_psn_sync_game(npwr_id): group_id = getattr(trophy, 'trophy_group_id', None) or 'default' # Debug: log group info for first 5 trophies - if trophies_synced < 5: + if len(trophy_rows) < 5: api_name = group_names_from_api.get(group_id, 'NOT_FOUND') logger.info(f"Trophy {trophy_id}: group_id={group_id!r}, api_name={api_name!r}") @@ -1471,7 +1487,7 @@ def api_psn_sync_game(npwr_id): earned = False # Debug log for first few trophies - show trophy type details - if trophies_synced < 3: + if len(trophy_rows) < 3: logger.info(f"Trophy {trophy_id}: name='{trophy_name}', raw_type={type(trophy_type_raw).__name__}:{trophy_type_raw}, str={trophy_type_str}, letter={trophy_type}, earned={earned}") earned_date = None @@ -1495,24 +1511,7 @@ def api_psn_sync_game(npwr_id): # direct filtering. The psn_game_id already cascades owner # via psn_games.user_id, so user_id on psn_trophies is a # denormalised lookup aid. - execute(""" - INSERT INTO psn_trophies ( - psn_game_id, user_id, trophy_id, group_id, group_name, - name, description, trophy_type, icon_url, - rarity, rarity_label, earned, earned_date - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(psn_game_id, trophy_id) DO UPDATE SET - group_id = excluded.group_id, - group_name = excluded.group_name, - name = excluded.name, - description = excluded.description, - trophy_type = excluded.trophy_type, - icon_url = excluded.icon_url, - rarity = excluded.rarity, - rarity_label = excluded.rarity_label, - earned = excluded.earned, - earned_date = excluded.earned_date - """, ( + trophy_rows.append(( psn_game['id'], user_id, trophy_id, @@ -1528,12 +1527,31 @@ def api_psn_sync_game(npwr_id): earned_date )) - trophies_synced += 1 - except Exception as te: logger.warning(f"Error syncing trophy: {te}") continue + if trophy_rows: + execute_many(""" + INSERT INTO psn_trophies ( + psn_game_id, user_id, trophy_id, group_id, group_name, + name, description, trophy_type, icon_url, + rarity, rarity_label, earned, earned_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(psn_game_id, trophy_id) DO UPDATE SET + group_id = excluded.group_id, + group_name = excluded.group_name, + name = excluded.name, + description = excluded.description, + trophy_type = excluded.trophy_type, + icon_url = excluded.icon_url, + rarity = excluded.rarity, + rarity_label = excluded.rarity_label, + earned = excluded.earned, + earned_date = excluded.earned_date + """, trophy_rows) + trophies_synced = len(trophy_rows) + # Update first and last trophy dates if we found any if first_trophy_date or last_trophy_date: execute(""" diff --git a/routes/updates.py b/routes/updates.py new file mode 100644 index 00000000..92edc420 --- /dev/null +++ b/routes/updates.py @@ -0,0 +1,73 @@ +# ============================================================================= +# RETRODB - Self-updater routes (PASS-59-169) +# ============================================================================= +# Admin-only. The page code maps the `reason` codes to its own +# text, so these replies carry codes, not sentences. +# +# GET /api/updates/status current + latest version, job, last result +# POST /api/updates/check ask GitHub now +# POST /api/updates/download download + verify the latest release +# POST /api/updates/install back up, hand over to the helper, restart +# POST /api/updates/dismiss forget the last install's outcome +# +# Contract: docs/specs/PASS-59-169-self-updater.md §6. +# ============================================================================= + +import config +from flask import Blueprint +from flask_babel import gettext as _ + +from services import updater +from services.api_helpers import error, handle_api_errors, success +from services.auth import admin_required + +bp = Blueprint('updates', __name__) + + +@bp.route('/api/updates/status') +@admin_required +@handle_api_errors +def api_update_status(): + return success(**updater.status()) + + +@bp.route('/api/updates/check', methods=['POST']) +@admin_required +@handle_api_errors +def api_update_check(): + updater.check(force=True) + return success(**updater.status()) + + +@bp.route('/api/updates/download', methods=['POST']) +@admin_required +@handle_api_errors +def api_update_download(): + st = updater.status() + if not st['available']: + return error(_('No newer version is available'), 409, reason='not_available') + cached = updater.check() + result = updater.job.start(cached['release']) + if not result['success']: + return error(_('This update cannot be installed here'), 409, reason=result['error']) + return success(**updater.status()) + + +@bp.route('/api/updates/install', methods=['POST']) +@admin_required +@handle_api_errors +def api_update_install(): + from server_port import resolve_server_port + port = resolve_server_port(default=config.SERVER_PORT, use_saved=True) + result = updater.install(port) + if not result['success']: + return error(_('This update cannot be installed here'), 409, reason=result['error']) + return success() + + +@bp.route('/api/updates/dismiss', methods=['POST']) +@admin_required +@handle_api_errors +def api_update_dismiss(): + updater.dismiss_result() + return success() diff --git a/scraper/hybrid_scraper.py b/scraper/hybrid_scraper.py index 9e7ca784..1c8dac3b 100644 --- a/scraper/hybrid_scraper.py +++ b/scraper/hybrid_scraper.py @@ -258,6 +258,7 @@ def fetch_igdb_extended(game_id): fields name, first_release_date, involved_companies.company.name, involved_companies.developer, involved_companies.publisher, genres.name, age_ratings.category, age_ratings.rating, + age_ratings.organization.name, age_ratings.rating_category.rating, game_modes.name, player_perspectives.name, multiplayer_modes.offlinemax, multiplayer_modes.onlinemax, multiplayer_modes.campaigncoop, multiplayer_modes.splitscreen, diff --git a/scraper/metadata_merger.py b/scraper/metadata_merger.py index 8cc4ed0a..98b452c5 100644 --- a/scraper/metadata_merger.py +++ b/scraper/metadata_merger.py @@ -165,6 +165,23 @@ def _download_and_finalize(url, local_path, image_type, *, timeout=15): return True +# File extensions a ScreenScraper media URL may name in its path. Its media +# URLs are usually `.../mediaJeu.php?devid=...&media=box-2D(us)`, so the text +# after the last dot is "php?..." (query string, account credentials and all), +# not a file type. +_MEDIA_EXTS = frozenset({ + 'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mkv', 'avi', + 'mov', 'pdf', 'cbz', 'zip', +}) + + +def _url_ext(url, default): + """The media file extension (no dot) a URL's path names, else `default`.""" + from urllib.parse import urlparse + ext = os.path.splitext(urlparse(url).path)[1].lstrip('.').lower() + return ext if ext in _MEDIA_EXTS else default + + # Bounded concurrency for a game's screenshot downloads. ES-DE fetches a game's # media files all at once (Scraper.cpp / MediaDownloadHandle) rather than # one-by-one; this is the Python analogue. Capped so a burst never floods a @@ -400,6 +417,83 @@ def _tgdb_ss_job(url, suffix): result['filled_fields'].append('franchise (TGDB)') +# ============================================================================= +# IGDB AGE RATINGS +# ============================================================================= +# IGDB's legacy `category` enum names the board (1=ESRB, 2=PEGI, 3=CERO, 4=USK, +# 5=GRAC, 6=CLASS_IND, 7=ACB); its `rating` enum is ONE global list across all +# boards: 1-5 PEGI, 6-12 ESRB, 13-17 CERO, 18-22 USK, 23-27 GRAC, 28-33 +# CLASS_IND, 34-39 ACB. IGDB is replacing both with `organization` and +# `rating_category` objects (fetched as organization.name and +# rating_category.rating), so a record may carry only the new shape. +_IGDB_RATING_BOARDS = { + 1: 'esrb_rating', 2: 'pegi_rating', 3: 'cero_rating', 4: 'usk_rating', + 5: 'grac_rating', 6: 'classind_rating', 7: 'acb_rating', +} +_IGDB_ORG_BOARDS = { + 'ESRB': 'esrb_rating', 'PEGI': 'pegi_rating', 'CERO': 'cero_rating', + 'USK': 'usk_rating', 'GRAC': 'grac_rating', 'CLASS_IND': 'classind_rating', + 'CLASSIND': 'classind_rating', 'ACB': 'acb_rating', +} +_IGDB_RATING_ENUM = { + 1: 'PEGI 3', 2: 'PEGI 7', 3: 'PEGI 12', 4: 'PEGI 16', 5: 'PEGI 18', + 6: 'RP', 7: 'EC', 8: 'E', 9: 'E10+', 10: 'T', 11: 'M', 12: 'AO', + 13: 'A', 14: 'B', 15: 'C', 16: 'D', 17: 'Z', + 18: '0', 19: '6', 20: '12', 21: '16', 22: '18', + 23: 'ALL', 24: '12', 25: '15', 26: '18', + 28: 'L', 29: '10', 30: '12', 31: '14', 32: '16', 33: '18', + 34: 'G', 35: 'PG', 36: 'M', 37: 'MA15+', 38: 'R18+', +} +# Which enum values belong to which board, so a mismatched pair is ignored. +_IGDB_RATING_ENUM_RANGE = { + 'pegi_rating': range(1, 6), 'esrb_rating': range(6, 13), + 'cero_rating': range(13, 18), 'usk_rating': range(18, 23), + 'grac_rating': range(23, 28), 'classind_rating': range(28, 34), + 'acb_rating': range(34, 40), +} +_IGDB_RATING_WORDS = { + 'THREE': '3', 'SEVEN': '7', 'TEN': '10', 'TWELVE': '12', 'FOURTEEN': '14', + 'FIFTEEN': '15', 'SIXTEEN': '16', 'EIGHTEEN': '18', +} +# rating_category.rating text (after word → number and '+' removal) → value. +_IGDB_RATING_TEXT = { + 'esrb_rating': {'RP': 'RP', 'EC': 'EC', 'E': 'E', 'E10': 'E10+', 'T': 'T', + 'M': 'M', 'AO': 'AO'}, + 'pegi_rating': {n: f'PEGI {n}' for n in ('3', '7', '12', '16', '18')}, + 'cero_rating': {v: v for v in ('A', 'B', 'C', 'D', 'Z')}, + 'usk_rating': {v: v for v in ('0', '6', '12', '16', '18')}, + 'grac_rating': {'ALL': 'ALL', '12': '12', '15': '15', '18': '18'}, + 'classind_rating': {v: v for v in ('L', '10', '12', '14', '16', '18')}, + 'acb_rating': {'G': 'G', 'PG': 'PG', 'M': 'M', 'MA15': 'MA15+', 'R18': 'R18+'}, +} + + +def _igdb_age_rating(rating): + """(metadata field, value) for one IGDB age_ratings entry, or (None, None).""" + if not isinstance(rating, dict): + return None, None + field = _IGDB_RATING_BOARDS.get(rating.get('category')) + if not field: + org = rating.get('organization') + org_name = org.get('name') if isinstance(org, dict) else None + field = _IGDB_ORG_BOARDS.get(str(org_name or '').strip().upper()) + if not field: + return None, None + + val = rating.get('rating') + if val in _IGDB_RATING_ENUM_RANGE[field]: + return field, _IGDB_RATING_ENUM.get(val) + + rc = rating.get('rating_category') + text = rc.get('rating') if isinstance(rc, dict) else None + if not text: + return field, None + token = re.sub(r'^(?:ESRB|PEGI|CERO|USK|GRAC|CLASS_?IND|ACB)[\s_]*', '', + str(text).strip().upper()).replace('+', '').strip() + token = _IGDB_RATING_WORDS.get(token, token) + return field, _IGDB_RATING_TEXT[field].get(token) + + # ============================================================================= # IGDB METADATA APPLICATION # ============================================================================= @@ -486,30 +580,20 @@ def apply_igdb_to_metadata(metadata, igdb_data, db_game_id, result, fill_only=Fa if not metadata['players']: multi = igdb_data.get('multiplayer_modes', []) if multi: - max_p = max((m.get('offlinemax', 1) for m in multi), default=1) - metadata['players'] = max_p - result['filled_fields'].append('players (IGDB)') - - # Age ratings — IGDB categories: 1=ESRB, 2=PEGI, 3=CERO, 4=USK, 5=GRAC, 6=CLASS_IND, 7=ACB - _igdb_rating_maps = { - 1: ('esrb_rating', {6: 'RP', 7: 'EC', 8: 'E', 9: 'E10+', 10: 'T', 11: 'M', 12: 'AO'}), - 2: ('pegi_rating', {1: 'PEGI 3', 2: 'PEGI 7', 3: 'PEGI 12', 4: 'PEGI 16', 5: 'PEGI 18'}), - 3: ('cero_rating', {1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'Z'}), - 4: ('usk_rating', {1: '0', 2: '6', 3: '12', 4: '16', 5: '18'}), - 5: ('grac_rating', {1: 'ALL', 2: '12', 3: '15', 4: '18'}), - 6: ('classind_rating', {1: 'L', 2: '10', 3: '12', 4: '14', 5: '16', 6: '18'}), - 7: ('acb_rating', {1: 'G', 2: 'PG', 3: 'M', 4: 'MA15+', 5: 'R18+'}), - } - for rating in igdb_data.get('age_ratings', []): - cat = rating.get('category') - val = rating.get('rating') - if cat in _igdb_rating_maps: - field, vmap = _igdb_rating_maps[cat] - if not metadata.get(field): - mapped = vmap.get(val) - if mapped: - metadata[field] = mapped - result['filled_fields'].append(f'{field} (IGDB)') + # `or 0`: IGDB may send an explicit null, which max() cannot + # compare with an int. No offline count leaves the field open. + max_p = max(((m.get('offlinemax') or 0) for m in multi if isinstance(m, dict)), + default=0) + if max_p: + metadata['players'] = max_p + result['filled_fields'].append('players (IGDB)') + + # Age ratings — see _igdb_age_rating for the two IGDB shapes. + for rating in igdb_data.get('age_ratings') or []: + field, mapped = _igdb_age_rating(rating) + if field and mapped and not metadata.get(field): + metadata[field] = mapped + result['filled_fields'].append(f'{field} (IGDB)') # Download cover (only if not already present) if not metadata['boxart']: @@ -787,8 +871,12 @@ def get_localized(items, lang='en'): return items[0].get('text', '') return None + # `ss_data` is either a raw jeu record (search cache) or the flat dict + # scrape_screenscraper.parse_game_data returns (ROM lookup on a cache + # miss). Each text field below reads the raw key first, then the flat one. + # Title - always set when primary source (fill_only=False), matching IGDB/RAWG - title = get_localized(ss_data.get('noms')) + title = get_localized(ss_data.get('noms')) or ss_data.get('title') if title and (not metadata['title'] or not fill_only): metadata['title'] = normalize_title(title) result['filled_fields'].append('title (ScreenScraper)') @@ -817,30 +905,30 @@ def get_localized(items, lang='en'): result['filled_fields'].append(f'alternate_titles +{added} (ScreenScraper)') # Description - only fill if empty - desc = get_localized(ss_data.get('synopsis')) + desc = get_localized(ss_data.get('synopsis')) or ss_data.get('description') if desc and not metadata['description']: metadata['description'] = desc result['filled_fields'].append('description (ScreenScraper)') # Release date - only fill if empty - dates = ss_data.get('dates', []) + dates = ss_data.get('dates') or ss_data.get('release_date') if dates and not metadata['release_date']: - date_str = get_localized(dates) + date_str = dates if isinstance(dates, str) else get_localized(dates) if date_str: metadata['release_date'] = date_str[:10] if len(date_str) >= 10 else date_str result['filled_fields'].append('release_date (ScreenScraper)') # Developer/Publisher - only fill if empty - if ss_data.get('developpeur') and not metadata['developer']: - dev = ss_data['developpeur'] + if (ss_data.get('developpeur') or ss_data.get('developer')) and not metadata['developer']: + dev = ss_data.get('developpeur') or ss_data['developer'] if isinstance(dev, dict): metadata['developer'] = dev.get('text', '') else: metadata['developer'] = str(dev) result['filled_fields'].append('developer (ScreenScraper)') - if ss_data.get('editeur') and not metadata['publisher']: - pub = ss_data['editeur'] + if (ss_data.get('editeur') or ss_data.get('publisher')) and not metadata['publisher']: + pub = ss_data.get('editeur') or ss_data['publisher'] if isinstance(pub, dict): metadata['publisher'] = pub.get('text', '') else: @@ -851,17 +939,20 @@ def get_localized(items, lang='en'): genres = ss_data.get('genres', []) if genres and not metadata['genre']: genre_names = [] - for g in genres: - name = get_localized(g.get('noms', [])) - if name: - genre_names.append(name) + if isinstance(genres, str): + genre_names.append(genres) + else: + for g in genres: + name = get_localized(g.get('noms', [])) + if name: + genre_names.append(name) if genre_names: metadata['genre'] = normalize_genre(', '.join(genre_names)) result['filled_fields'].append('genre (ScreenScraper)') # Players - only fill if empty - if ss_data.get('joueurs') and not metadata['players']: - players = ss_data['joueurs'] + if (ss_data.get('joueurs') or ss_data.get('players')) and not metadata['players']: + players = ss_data.get('joueurs') or ss_data['players'] if isinstance(players, dict): metadata['players'] = players.get('text', '') else: @@ -1050,7 +1141,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'png' else: - ext = boxart_url.rsplit('.', 1)[-1] if '.' in boxart_url else 'png' + ext = _url_ext(boxart_url, 'png') if boxart_url: filename = f"{db_game_id}_ss_boxart{preferred_image_extension('boxart', '.' + ext)}" @@ -1073,7 +1164,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'png' else: - ext = boxart_3d_url.rsplit('.', 1)[-1] if '.' in boxart_3d_url else 'png' + ext = _url_ext(boxart_3d_url, 'png') if boxart_3d_url: filename = f"{db_game_id}_ss_boxart3d{preferred_image_extension('boxart_3d', '.' + ext)}" @@ -1095,7 +1186,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): _ss_candidates = [] if parsed_media.get('screenshot'): _u = parsed_media['screenshot'] - _ss_candidates.append((_u, _u.rsplit('.', 1)[-1] if '.' in _u else 'png')) + _ss_candidates.append((_u, _url_ext(_u, 'png'))) for m in medias: if m.get('type') == 'ss' and m.get('url'): _ss_candidates.append((m['url'], m.get('format', 'png'))) @@ -1135,7 +1226,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'jpg' else: - ext = fanart_url.rsplit('.', 1)[-1] if '.' in fanart_url else 'jpg' + ext = _url_ext(fanart_url, 'jpg') if fanart_url: filename = f"{db_game_id}_ss_fanart{preferred_image_extension('fanart', '.' + ext)}" @@ -1157,7 +1248,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'mp4' else: - ext = video_url.rsplit('.', 1)[-1] if '.' in video_url else 'mp4' + ext = _url_ext(video_url, 'mp4') if video_url: filename = f"{db_game_id}_ss_video.{ext}" @@ -1180,7 +1271,7 @@ def _download_ss_media(url, dest_path, timeout=15, image_type=None): else: ext = 'pdf' else: - ext = manual_url.rsplit('.', 1)[-1] if '.' in manual_url else 'pdf' + ext = _url_ext(manual_url, 'pdf') if manual_url: filename = f"{db_game_id}_ss_manual.{ext}" diff --git a/scraper/retroachievements.py b/scraper/retroachievements.py index de29c852..320f8091 100644 --- a/scraper/retroachievements.py +++ b/scraper/retroachievements.py @@ -170,10 +170,12 @@ def normalize_title(title): title = re.sub(r'\s*[\(\[].*?[\)\]]\s*', '', title) # Normalize punctuation - replace with space title = re.sub(r"[:\-\u2013\u2014/&]", ' ', title) + # Normalize apostrophe variants to standard - before the strip below, + # which would otherwise delete a curly one ("Kirby’s" -> "kirbys" + # while "Kirby's" -> "kirby's", so the two never matched) + title = re.sub(r"[\u2019\u2018\u02BC\u02BB`\u00B4\u2032\u02B9]", "'", title) # Remove remaining special chars except apostrophes title = re.sub(r"[^\w\s']", '', title) - # Normalize apostrophe variants to standard - title = re.sub(r"[\u2019\u2018\u02BC\u02BB`\u00B4\u2032\u02B9]", "'", title) # Collapse whitespace title = re.sub(r'\s+', ' ', title).strip() return title @@ -189,12 +191,16 @@ def get_significant_words(title): return set(w for w in words if w not in stopwords and (len(w) > 1 or w.isdigit())) - def calculate_similarity(search_title, ra_title): + # The search side is the same for every RA title on the console (often + # 2,000+), so normalize it once rather than per comparison. + norm_search = normalize_title(game_name) + search_words = get_significant_words(game_name) + + def calculate_similarity(ra_title): """ - Calculate match score between search title and RA title + Calculate match score between the search title and an RA title Returns a score from 0-100 """ - norm_search = normalize_title(search_title) norm_ra = normalize_title(ra_title) # Exact match after normalization = perfect score @@ -202,7 +208,6 @@ def calculate_similarity(search_title, ra_title): return 100 # Word-based comparison - search_words = get_significant_words(search_title) ra_words = get_significant_words(ra_title) if not search_words or not ra_words: @@ -285,7 +290,7 @@ def calculate_similarity(search_title, ra_title): for game in games: ra_title = game.get('Title', '') - score = calculate_similarity(game_name, ra_title) + score = calculate_similarity(ra_title) if score > best_score: best_score = score diff --git a/scraper/scan_roms.py b/scraper/scan_roms.py index 146e9088..93f27423 100644 --- a/scraper/scan_roms.py +++ b/scraper/scan_roms.py @@ -16,6 +16,7 @@ from config import SYSTEM_NAME_MAP from scraper.base_scraper import get_scraper_conn from services.rom_scanner import RomPathNotConfigured +from services.game_utils import find_image_file logger = logging.getLogger(__name__) @@ -146,11 +147,11 @@ def scan_roms(): # Get system name from mapping or generate from folder name system_name = SYSTEM_NAME_MAP.get(folder, folder.replace('_', ' ').title()) - # Check for system logo (folder.png in static/images/systems/) - logo_filename = f"{folder}.png" - logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - 'static', 'images', 'systems', logo_filename) - system_logo = logo_filename if os.path.exists(logo_path) else None + # System logo in static/images/systems/, any supported extension: a + # PNG-only check set the logo of every SVG-only system (genesis, ...) + # to NULL on each scan. + system_logo, _ = find_image_file( + os.path.join(config.STATIC_PATH, 'images', 'systems'), folder) # Insert or update system c.execute("INSERT OR IGNORE INTO systems (name, folder, logo) VALUES (?, ?, ?)", @@ -164,8 +165,9 @@ def scan_roms(): system_id = result[0] - # Update system name and logo - c.execute("UPDATE systems SET name = ?, logo = ? WHERE folder = ?", + # Update system name and logo (a logo already set is kept when none + # is found on disk) + c.execute("UPDATE systems SET name = ?, logo = COALESCE(?, logo) WHERE folder = ?", (system_name, system_logo, folder)) # Get supported extensions from systeminfo.txt diff --git a/scraper/scrape_esde.py b/scraper/scrape_esde.py index 6a251922..6fa98980 100644 --- a/scraper/scrape_esde.py +++ b/scraper/scrape_esde.py @@ -655,48 +655,41 @@ def fetch_esde_game_details(game_path, system_folder=None): for i, g in enumerate(games[:5]): logger.info(f" [{i}] {g.get('path', 'NO PATH')}") - # Try to match the game by path + # Try to match the game by path. A full-path match wins over a bare + # filename match: games kept in their own folders can share a filename + # ("./Game A/disc1.chd", "./Game B/disc1.chd"), and returning the first + # filename hit picked whichever came first in the gamelist. + search_normalized = decoded_game_path.replace('./', '').strip() + search_filename = os.path.basename(decoded_game_path) + match = None + filename_match = None for game in games: - stored_path = game.get('path', '') - - # Normalize paths for comparison + stored_path = game.get('path') or '' stored_normalized = stored_path.replace('./', '').strip() - search_normalized = decoded_game_path.replace('./', '').strip() - - # Also try with just the filename - stored_filename = os.path.basename(stored_path) - search_filename = os.path.basename(decoded_game_path) - - # Try multiple matching strategies - matched = False - if stored_path == decoded_game_path: - logger.info(f"Found exact match for path: {decoded_game_path}") - matched = True - elif stored_normalized == search_normalized: - logger.info(f"Found match after normalizing: {search_normalized}") - matched = True - elif stored_filename == search_filename: - logger.info(f"Found match by filename: {search_filename}") - matched = True - elif stored_normalized.lower() == search_normalized.lower(): - logger.info(f"Found case-insensitive match: {search_normalized}") - matched = True - - if not matched: - continue - - # Found a match - format and return + if (stored_path == decoded_game_path + or stored_normalized.lower() == search_normalized.lower()): + logger.info(f"Found path match: {decoded_game_path}") + match = game + break + if filename_match is None and os.path.basename(stored_path) == search_filename: + filename_match = game + if match is None and filename_match is not None: + logger.info(f"Found match by filename: {search_filename}") + match = filename_match + + if match is not None: + game = match release_date = game.get('releasedate', '') if release_date and len(release_date) >= 8: release_date = f"{release_date[:4]}-{release_date[4:6]}-{release_date[6:8]}" - + return { 'name': game.get('name', ''), 'description': game.get('desc', ''), 'developer': game.get('developer', ''), 'publisher': game.get('publisher', ''), 'genre': game.get('genre', ''), - 'players': game.get('players', '1'), + 'players': game.get('players', ''), 'release_date': release_date, 'rating': game.get('rating', ''), 'image_path': game.get('image', ''), @@ -704,7 +697,7 @@ def fetch_esde_game_details(game_path, system_folder=None): 'video_path': game.get('video', ''), 'esde_data': game } - + logger.error(f"No matching game found for path: {game_path} (decoded: {decoded_game_path}) in {system_folder}") return None @@ -932,7 +925,10 @@ def copy_media(src_path, dest_folder, prefix): from services.normalization import normalize_genre from services.game_utils import normalize_players_value genre = normalize_genre(esde_data.get('genre', '') or game_data.get('genre', '')) - players = normalize_players_value(esde_data.get('players', '1') or game_data.get('players', '1')) + # No default: a gamelist row without must leave the column + # alone. A '1' default wrote players=1 / modes='Single-Player' through + # COALESCE over curated values on every ES-DE import (Pass 40.6 class). + players = normalize_players_value(esde_data.get('players') or game_data.get('players')) # Format release date release_date = esde_data.get('releasedate', '') or game_data.get('release_date', '') @@ -1142,8 +1138,9 @@ def copy_media(src_path, dest_folder, prefix): # Extract region from ROM filename region = extract_region_from_filename(rom_filename) - # Derive game modes from players - modes = derive_game_modes(players) + # Derive game modes from players (only when the gamelist gave a count) + from services.normalization import modes_from_player_count + modes = modes_from_player_count(players) or None # Pass 48.3 — only flip `scraped = 1` when ES-DE actually filled at least # one metadata/media field. `region` is excluded from the test: it's diff --git a/scraper/scrape_rawg.py b/scraper/scrape_rawg.py index abb92de3..23458a33 100644 --- a/scraper/scrape_rawg.py +++ b/scraper/scrape_rawg.py @@ -268,8 +268,10 @@ def get_game_details(game_id): if screenshots_data and 'results' in screenshots_data: screenshot_urls = [s.get('image') for s in screenshots_data['results'][:5] if s.get('image')] - # Determine player count from tags - players = 1 + # Determine player count from tags. None when no tag says, so the + # fill-only merge leaves `players` open for a source that knows + # (a default of 1 filled it with a guess no later source could replace). + players = None if game.get('tags'): for tag in game['tags']: tag_name = tag.get('name', '').lower() diff --git a/scraper/scrape_thegamesdb.py b/scraper/scrape_thegamesdb.py index 854a58c3..b07b06f4 100644 --- a/scraper/scrape_thegamesdb.py +++ b/scraper/scrape_thegamesdb.py @@ -135,6 +135,7 @@ def clear_caches(): _publishers_cache = None _developers_cache = None _genres_cache = None + _lookup_failed_at.clear() logger.info("TGDB lookup caches cleared") @@ -142,78 +143,72 @@ def clear_caches(): # CACHED LOOKUP TABLES # ============================================================================= -def _get_publishers(): - """Fetch and cache all publisher names (single API call, reused across games).""" - global _publishers_cache - if _publishers_cache is not None: - return _publishers_cache +# A failed lookup-table fetch is retried after this many seconds rather than +# cached as an empty table for the life of the process. +_LOOKUP_RETRY_SECONDS = 300 +_lookup_failed_at = {} - try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Publishers", params={}, timeout=15) - if response and response.status_code == 200: - data = response.json() - _publishers_cache = data.get('data', {}).get('publishers', {}) - logger.info(f"Cached {len(_publishers_cache)} TGDB publishers") - return _publishers_cache - except Exception as e: - logger.warning(f"Failed to fetch publishers: {e}") - - _publishers_cache = {} - return _publishers_cache +def _get_lookup(kind): + """Fetch and cache one TGDB lookup table ('publishers', 'developers' or + 'genres') — a single API call, reused across games. -def _get_developers(): - """Fetch and cache all developer names (single API call, reused across games).""" - global _developers_cache - if _developers_cache is not None: - return _developers_cache + A failed fetch returns {} without caching it, so one transient error does + not leave every later game in the process with unresolved IDs; retries + are spaced by _LOOKUP_RETRY_SECONDS. + """ + cache_name = f'_{kind}_cache' + cached = globals()[cache_name] + if cached is not None: + return cached + if time.time() - _lookup_failed_at.get(kind, 0) < _LOOKUP_RETRY_SECONDS: + return {} try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Developers", params={}, timeout=15) + response = _tgdb_request(f"{TGDB_API_BASE}/v1/{kind.capitalize()}", params={}, timeout=15) if response and response.status_code == 200: - data = response.json() - _developers_cache = data.get('data', {}).get('developers', {}) - logger.info(f"Cached {len(_developers_cache)} TGDB developers") - return _developers_cache + table = response.json().get('data', {}).get(kind, {}) + if isinstance(table, dict) and table: + globals()[cache_name] = table + logger.info(f"Cached {len(table)} TGDB {kind}") + return table except Exception as e: - logger.warning(f"Failed to fetch developers: {e}") + logger.warning(f"Failed to fetch {kind}: {e}") - _developers_cache = {} - return _developers_cache + _lookup_failed_at[kind] = time.time() + return {} -def _get_genres(): - """Fetch and cache all genre names (single API call, reused across games).""" - global _genres_cache - if _genres_cache is not None: - return _genres_cache +def _get_publishers(): + """Cached TGDB publisher table.""" + return _get_lookup('publishers') + + +def _get_developers(): + """Cached TGDB developer table.""" + return _get_lookup('developers') - try: - response = _tgdb_request(f"{TGDB_API_BASE}/v1/Genres", params={}, timeout=15) - if response and response.status_code == 200: - data = response.json() - _genres_cache = data.get('data', {}).get('genres', {}) - logger.info(f"Cached {len(_genres_cache)} TGDB genres") - return _genres_cache - except Exception as e: - logger.warning(f"Failed to fetch genres: {e}") - _genres_cache = {} - return _genres_cache +def _get_genres(): + """Cached TGDB genre table.""" + return _get_lookup('genres') def _resolve_names(ids, cache_fn, entity_key='name'): - """Resolve a list of entity IDs to names using a cached lookup table.""" + """Resolve a list of entity IDs to names using a cached lookup table. + + An ID the table does not name is left out: writing the bare number as a + publisher/developer name would fill the field with junk that no later + source can replace (fill-only). + """ if not ids: return '' lookup = cache_fn() names = [] for eid in ids: - info = lookup.get(str(eid), {}) - if info: - names.append(info.get(entity_key, str(eid))) - else: - names.append(str(eid)) + info = lookup.get(str(eid)) + if isinstance(info, dict) and info.get(entity_key): + names.append(info[entity_key]) return ', '.join(names) @@ -224,13 +219,16 @@ def _resolve_genre_names(genre_ids): lookup = _get_genres() names = [] for gid in genre_ids: - info = lookup.get(str(gid), {}) - if info: - names.append(info.get('name', str(gid))) - elif isinstance(gid, int) and gid in TGDB_GENRE_MAP: - names.append(TGDB_GENRE_MAP[gid]) - else: - names.append(str(gid)) + info = lookup.get(str(gid)) + if isinstance(info, dict) and info.get('name'): + names.append(info['name']) + continue + try: + fallback = TGDB_GENRE_MAP.get(int(gid)) + except (TypeError, ValueError): + fallback = None + if fallback: + names.append(fallback) return names @@ -442,7 +440,7 @@ def fetch_game_details(game_id): 'publisher': publisher_str, 'developer': developer_str, 'genres': genre_names, - 'players': game.get('players', 1), + 'players': game.get('players'), 'rating': game.get('rating', ''), 'summary': game.get('overview', ''), 'boxart_url': boxart_url, diff --git a/scripts/install_launcher.py b/scripts/install_launcher.py index 1958b688..a1b15902 100644 --- a/scripts/install_launcher.py +++ b/scripts/install_launcher.py @@ -12,6 +12,24 @@ ROOT = Path(__file__).resolve().parent.parent +# Characters that make an Exec argument need quoting (Desktop Entry spec, +# "The Exec key"). +_EXEC_RESERVED = set(' \t\n"\'\\><~|&;$*?#()`') + + +def exec_arg(arg: str) -> str: + """Quote one Exec argument per the Desktop Entry spec, so an install + path with a space (~/My Games/RetroDB) is not split into two arguments. + Inside quotes, `"`, `` ` ``, `$` and `\\` are backslash-escaped; the key + file's own string escaping then doubles every backslash; `%` is a field + code prefix and is written `%%`.""" + arg = arg.replace('%', '%%') + if not any(c in _EXEC_RESERVED for c in arg): + return arg + for ch in ('\\', '"', '`', '$'): + arg = arg.replace(ch, '\\' + ch) + return '"' + arg.replace('\\', '\\\\') + '"' + def main() -> None: if sys.platform != 'linux': @@ -28,7 +46,7 @@ def main() -> None: shutil.copyfile(src_icon, dst_icon) launcher = ROOT / 'scripts' / 'retrodb_launcher.py' - exec_line = f'{sys.executable} {launcher}' + exec_line = f'{exec_arg(sys.executable)} {exec_arg(str(launcher))}' template = (ROOT / 'packaging' / 'RetroDB.desktop').read_text() desktop = template.replace('__EXEC__', exec_line).replace('__ICON__', str(dst_icon)) diff --git a/services/analytics.py b/services/analytics.py index 814173b3..fbc8571f 100644 --- a/services/analytics.py +++ b/services/analytics.py @@ -31,7 +31,13 @@ def _get_analytics_stats(): FROM games GROUP BY completion_status """) - completion_map = {row['completion_status'] or 'not_started': row['count'] for row in completion_counts} + # NULL / '' statuses count as not started. Summed, not assigned: they + # group apart from 'not_started', and a dict comprehension let the + # later group overwrite the earlier one's count. + completion_map = {} + for row in completion_counts: + status = row['completion_status'] or 'not_started' + completion_map[status] = completion_map.get(status, 0) + row['count'] completed = completion_map.get('completed', 0) + completion_map.get('100_percent', 0) completion_rate = round((completed / total_games * 100) if total_games > 0 else 0, 1) @@ -43,7 +49,7 @@ def _get_analytics_stats(): } completion_data = [ - completion_map.get('not_started', 0) + completion_map.get(None, 0), + completion_map.get('not_started', 0), completion_map.get('in_progress', 0), completion_map.get('played', 0), completion_map.get('completed', 0), diff --git a/services/database.py b/services/database.py index d5839943..e651267b 100644 --- a/services/database.py +++ b/services/database.py @@ -128,6 +128,17 @@ def get_request_db(): return g.db +def _borrow_db(): + """(connection, owned) for one helper call. Outside an app context + get_request_db() hands out a fresh connection nobody else holds; the + helper must close it (`owned`), or every background-job query leaves an + unclosed connection for the garbage collector (a ResourceWarning on + Python 3.13+).""" + if has_app_context(): + return get_request_db(), False + return get_db(), True + + def query(sql, args=(), one=False): """ Execute a SELECT query and return results as dictionaries. @@ -155,11 +166,14 @@ def query(sql, args=(), one=False): games = query("SELECT * FROM games WHERE system_id = ? AND title LIKE ?", (system_id, f"%{search}%")) """ - conn = get_request_db() - _t0 = time.perf_counter() - cur = conn.execute(sql, args) - rows = cur.fetchall() - _log_if_slow(sql, args, _t0) + conn, owned = _borrow_db() + try: + _t0 = time.perf_counter() + rows = conn.execute(sql, args).fetchall() + _log_if_slow(sql, args, _t0) + finally: + if owned: + conn.close() # Convert sqlite3.Row objects to dicts so .get() works if one: @@ -196,7 +210,7 @@ def execute(sql, args=()): # Delete a game execute("DELETE FROM games WHERE id = ?", (game_id,)) """ - conn = get_request_db() + conn, owned = _borrow_db() _t0 = time.perf_counter() try: cur = conn.execute(sql, args) @@ -213,6 +227,9 @@ def execute(sql, args=()): except Exception: pass raise + finally: + if owned: + conn.close() _log_if_slow(sql, args, _t0) return cur.lastrowid @@ -243,7 +260,7 @@ def execute_many(sql, args_list): games_data ) """ - conn = get_request_db() + conn, owned = _borrow_db() _t0 = time.perf_counter() try: cur = conn.executemany(sql, args_list) @@ -257,6 +274,9 @@ def execute_many(sql, args_list): except Exception: pass raise + finally: + if owned: + conn.close() _log_if_slow(sql, args_list, _t0) return cur.rowcount @@ -282,7 +302,7 @@ def execute_script(sql_script): CREATE INDEX IF NOT EXISTS idx_new ON new_table(id); ''') """ - conn = get_request_db() + conn, owned = _borrow_db() try: conn.executescript(sql_script) conn.commit() @@ -298,6 +318,9 @@ def execute_script(sql_script): except Exception: pass raise + finally: + if owned: + conn.close() def _fsync_path(path): diff --git a/services/game_cleanup.py b/services/game_cleanup.py index 2d7d0c2d..bd882eb1 100644 --- a/services/game_cleanup.py +++ b/services/game_cleanup.py @@ -212,6 +212,9 @@ def clear_scraped_data(system_id=None, delete_images=False): try: for game_id in game_ids_to_reset: reset_game_title_from_filename(game_id, conn) + # A caller-supplied connection is never committed by the reset + # itself; without this the close() rolled every title back. + conn.commit() finally: conn.close() diff --git a/services/image_utils.py b/services/image_utils.py index b601d29d..58fc8a35 100644 --- a/services/image_utils.py +++ b/services/image_utils.py @@ -1026,7 +1026,9 @@ def _save_image(img, path): else: _atomic_save(img, path, 'WEBP', quality=85) elif ext in ('.jpg', '.jpeg'): - if img.mode == 'RGBA': + # JPEG holds only RGB / L / CMYK; a palette or alpha PNG (P, LA, + # PA, RGBA...) otherwise raises "cannot write mode P as JPEG". + if img.mode not in ('RGB', 'L', 'CMYK'): img = img.convert('RGB') _atomic_save(img, path, 'JPEG', quality=90) elif ext == '.png': diff --git a/services/jobs/alt_titles_backfill.py b/services/jobs/alt_titles_backfill.py index 968ef9e3..19334953 100644 --- a/services/jobs/alt_titles_backfill.py +++ b/services/jobs/alt_titles_backfill.py @@ -186,6 +186,13 @@ def _worker(self, only_empty): last_persist = time.time() try: + # Commit the previous game's writes before the network + # search: an open write transaction holds SQLite's one + # writer lock, and batching 25 games kept it across 25 + # scraper round trips, stalling every other writer. + if write_conn.in_transaction: + _commit_with_retry(write_conn) + existing = [] if game.get('alternate_titles'): try: @@ -274,9 +281,6 @@ def _worker(self, only_empty): self.updated_count += 1 self.alts_added += added - if (i + 1) % 25 == 0: - _commit_with_retry(write_conn) - except Exception as e: logger.warning(f"Alt-titles backfill: failed for game {game['id']} ({game['title']}): {e}") with self._lock: diff --git a/services/jobs/base.py b/services/jobs/base.py index 490d3c55..5d5bf07a 100644 --- a/services/jobs/base.py +++ b/services/jobs/base.py @@ -627,13 +627,16 @@ def get_recoverable_jobs(): }) # Deduplicate: for bulk_scrape, keep only the best record per - # (system_id, scrape_mode) — prefer most progress, then newest + # (system_id, scrape_mode) — prefer most progress, then newest. + # A multi-system scrape (system_id None) is a hand-picked game + # selection, so two of them are different work, not duplicates: + # keying them all to (None, mode) auto-dismissed all but one. best = {} # key -> job dismiss_ids = [] jobs = [] for job in all_jobs: - if job['job_type'] == 'bulk_scrape': + if job['job_type'] == 'bulk_scrape' and job['params'].get('system_id') is not None: sid = job['params'].get('system_id') mode = job['params'].get('scrape_mode', 'fill_missing') key = (sid, mode) diff --git a/services/jobs/bulk_scrape.py b/services/jobs/bulk_scrape.py index 22a64235..2269b9b8 100644 --- a/services/jobs/bulk_scrape.py +++ b/services/jobs/bulk_scrape.py @@ -413,6 +413,11 @@ def swap_with_running(self, job_id): logger.info(f"Swapping running job {self.job_id} with queued job {job_id}") + # The promoted job leaves the queue here, not via _start_next_queued, + # so drop its 'queued' job_queue row too — left behind, a restart + # turned it into a recoverable "interrupted" scrape that had already run. + remove_queued_job(new_running_job.get('db_queue_id')) + # Wait for the old worker to actually exit its current iteration # before we mutate state for the new job. A bare `time.sleep(0.5)` # used to race here: the worker could wake after reset() and treat @@ -495,6 +500,9 @@ def demote_running(self): logger.info(f"Demoting running job {self.job_id}, promoting {new_running_job['job_id']}") + # As in swap_with_running: the promoted job's 'queued' row goes too. + remove_queued_job(new_running_job.get('db_queue_id')) + # Same race as swap_with_running — wait for the worker to exit # before we mutate state for the new job. if old_thread is not None and old_thread.is_alive(): @@ -764,12 +772,14 @@ def _run_scrape(self): except sqlite3.Error as e: logger.warning(f"Pre-fetch failed, will fall back to per-game queries: {e}") - # Load scraper settings once before the loop - scraper_settings = load_scraper_settings() - enabled_scrapers = scraper_settings.get('enabled', {}) - match_settings = get_match_settings() - try: + # Load scraper settings once before the loop. Inside the try: a + # raise here used to kill the thread with `running` still True, + # the job_queue row 'running' and the singleton lock held. + scraper_settings = load_scraper_settings() + enabled_scrapers = scraper_settings.get('enabled', {}) + match_settings = get_match_settings() + for i, game_id in enumerate(_game_ids): # Check for cancel with self._lock: diff --git a/services/jobs/hltb_bulk.py b/services/jobs/hltb_bulk.py index 93991a2b..0c619f29 100644 --- a/services/jobs/hltb_bulk.py +++ b/services/jobs/hltb_bulk.py @@ -230,6 +230,13 @@ def _worker(self): last_persist = time.time() try: + # Commit the previous game's write before the network + # lookup: an open write transaction holds SQLite's one + # writer lock, and batching 25 games kept it across 25 + # HLTB round trips, stalling every other writer. + if write_conn.in_transaction: + _commit_with_retry(write_conn) + alt_titles = _extract_alt_titles_list(game.get('alternate_titles')) result = lookup_playtime( @@ -312,9 +319,6 @@ def _worker(self): with self._lock: self.queued_count += 1 - if (i + 1) % 25 == 0: - _commit_with_retry(write_conn) - except Exception as e: logger.warning(f"HLTB bulk: failed for game {game['id']} ({game['title']}): {e}") with self._lock: diff --git a/services/jobs/museum_images.py b/services/jobs/museum_images.py index aec9f0c4..a4ee5d3a 100644 --- a/services/jobs/museum_images.py +++ b/services/jobs/museum_images.py @@ -94,12 +94,15 @@ def cancel(self): return {'success': False, 'error': 'No running image fetch to cancel'} def _worker(self, system_id): - # Lazy import: the fetch helpers live with the Museum routes. - from routes.museum import ( - _fetch_and_process_image, _get_removebg_key, _propagate_controller_image, - ) - conn = _get_conn() + # The import and connect sit inside the try, so a failure in either + # still ends the job instead of leaving it `running` with the lock held. + conn = None try: + # Lazy import: the fetch helpers live with the Museum routes. + from routes.museum import ( + _fetch_and_process_image, _get_removebg_key, _propagate_controller_image, + ) + conn = _get_conn() controllers = conn.execute( """SELECT c.id, c.name, c.manufacturer, c.image FROM controllers c JOIN system_controllers sc ON c.id = sc.controller_id @@ -146,7 +149,8 @@ def _worker(self, system_id): with self._lock: self.error_message = str(e) finally: - conn.close() + if conn is not None: + conn.close() with self._lock: self.completed = True self.running = False diff --git a/services/jobs/player_cores.py b/services/jobs/player_cores.py index 4d952236..caf09949 100644 --- a/services/jobs/player_cores.py +++ b/services/jobs/player_cores.py @@ -94,6 +94,7 @@ def _worker(self): self.queue, self.running, self.current_item = [], False, '' return self._singleton_fd = fd + drained = False try: with self.app.app_context(): while True: @@ -120,10 +121,20 @@ def _worker(self): logger.warning("player core %s not installed: %s", core, e) with self._lock: self.errors[core] = str(e) + drained = True finally: - with self._lock: - self.running, self.current_item = False, '' release_singleton_fd(self) + # A request() between the empty queue above and here saw + # `running` and only queued its core; run it rather than strand + # it. The lock is released first so the new worker can take it. + with self._lock: + again = drained and bool(self.queue) + self.current_item = '' + if not again: + self.running = False + if again: + self._thread = threading.Thread(target=self._worker, name='player-cores', daemon=True) + self._thread.start() def _shaders_for(self, player_cores, system_id): """The first install for a system whose chosen shader is not `none` diff --git a/services/jobs/ra_refresh.py b/services/jobs/ra_refresh.py index f294ddba..7f4671bf 100644 --- a/services/jobs/ra_refresh.py +++ b/services/jobs/ra_refresh.py @@ -71,8 +71,9 @@ def get_status(self): 'error': self.error_message } - def start(self, system_id=None): - """Start RA refresh scan""" + def start(self, system_id=None, system_name=None): + """Start RA refresh scan. `system_name` is shown until the worker + reads the name from the DB (routes/ra_sync.py passes it).""" with self._lock: if self.running: return {'success': False, 'error': 'Refresh already running'} @@ -88,6 +89,7 @@ def start(self, system_id=None): self._singleton_fd = singleton_fd self.job_id = f"ra_refresh_{int(time.time())}" self.system_id = system_id + self.system_name = system_name self.running = True # Start background thread diff --git a/services/js_i18n_strings.py b/services/js_i18n_strings.py index d07c1559..1916bbf1 100644 --- a/services/js_i18n_strings.py +++ b/services/js_i18n_strings.py @@ -23,6 +23,7 @@ 'All Sources', 'All Systems', 'Already Syncing', + 'An update is already being downloaded.', 'Applied when you press Play, in the {emulator} settings for this game.', 'Applied {n} update(s).', 'Apply Changes', @@ -91,6 +92,7 @@ 'Core', 'Could not accept these names.', 'Could not apply metadata.', + 'Could not check for updates.', 'Could not download the {system} core. Check the internet connection and try again.', 'Could not link these versions.', 'Could not load suggestions.', @@ -116,6 +118,7 @@ 'Detecting…', 'Done', 'Done. Games asked: {total}. Names suggested: {proposals}.', + 'Download the update first.', 'Downloading 3D box art…', 'Downloading RetroArch… This can take a few minutes.', 'Downloading box art…', @@ -158,6 +161,7 @@ 'Failed to load game', 'Failed to load normalization data', 'Failed to save HLTB: ', + 'Failed to save settings', 'Failed to save: ', 'Failed to start RetroAchievements refresh', 'Failed to start bulk scrape', @@ -202,8 +206,10 @@ 'Initializing bulk scrape...', 'Initializing...', 'Input lag reduction', + 'Install update', 'Installed {n} cores.', 'Installed {n} cores. Failed: {failed}', + 'Installing the update and restarting RetroDB…', 'Integer scaling', 'Japan', 'Just synced', @@ -257,6 +263,7 @@ 'No mappings yet. Add an emulator folder above.', 'No matching games on this system.', 'No missing ROM files found.', + 'No newer version is available.', 'No options available', 'No ratings available', 'No results', @@ -318,6 +325,10 @@ 'RetroArch was not installed.', 'RetroArch was not installed: {error}', 'RetroDB default', + 'RetroDB has not come back yet. See logs/update.log, then reload this page.', + 'RetroDB is run by a process manager here. Download the release and install it yourself.', + 'RetroDB will back up its database, install the update and restart. Anyone using it will be disconnected for a minute or two.', + 'RetroDB {version} is available.', 'Run-ahead, 1 frame', 'Running', 'Running...', @@ -388,9 +399,16 @@ 'System', 'Systems', 'The core installed but the game still cannot start.', + 'The download failed: {error}', 'The run stopped: {error}. Names already suggested are kept.', + 'The update could not be started. See logs/update.log.', + 'The update is downloaded and checked.', + 'The update to {to} did not work, so RetroDB went back to {from}. Reason: {reason}', + 'This copy of RetroDB is a git checkout. Update it with git pull.', 'This game is already running. Kill the existing process and relaunch?', + 'This release has no download for this computer yet.', 'This system is already being synced.', + 'This update cannot be installed here.', 'This will clear ALL RetroAchievements game IDs and progress data from your database. You will need to run "Refresh RetroAchievements" again afterwards to re-scan your games. Continue?', 'This will clear RetroAchievements game IDs and progress data for "{system}". Continue?', 'This will remove "{system}" from the Achievements page. Continue?', @@ -412,6 +430,7 @@ 'Unscraped', 'Update Failed', 'Update {count} game(s)?\n\nFields: {fields}', + 'Updated from {from} to {to}.', 'Upload', 'Upload Controller Image', 'Upload failed', @@ -421,13 +440,17 @@ 'Use the system setting', 'Used for every {system} game unless the game sets its own. Applied when you press Play.', 'Version unlinked.', + 'Version {version} is available.', 'Versions linked.', + 'What is new in {version}', + 'What is new in {version} ({date})', 'Will change?', 'Works', 'World', 'Write again with AI', 'Xbox Imports', 'Yes', + 'You have the latest version.', 'Your bulk scrape has been queued (position {position}). It will start automatically when the current scrape finishes.', 'found', 'in queue', @@ -485,6 +508,7 @@ _('All Sources'), _('All Systems'), _('Already Syncing'), + _('An update is already being downloaded.'), _('Applied when you press Play, in the {emulator} settings for this game.'), _('Applied {n} update(s).'), _('Apply Changes'), @@ -553,6 +577,7 @@ _('Core'), _('Could not accept these names.'), _('Could not apply metadata.'), + _('Could not check for updates.'), _('Could not download the {system} core. Check the internet connection and try again.'), _('Could not link these versions.'), _('Could not load suggestions.'), @@ -578,6 +603,7 @@ _('Detecting…'), _('Done'), _('Done. Games asked: {total}. Names suggested: {proposals}.'), + _('Download the update first.'), _('Downloading 3D box art…'), _('Downloading RetroArch… This can take a few minutes.'), _('Downloading box art…'), @@ -620,6 +646,7 @@ _('Failed to load game'), _('Failed to load normalization data'), _('Failed to save HLTB: '), + _('Failed to save settings'), _('Failed to save: '), _('Failed to start RetroAchievements refresh'), _('Failed to start bulk scrape'), @@ -664,8 +691,10 @@ _('Initializing bulk scrape...'), _('Initializing...'), _('Input lag reduction'), + _('Install update'), _('Installed {n} cores.'), _('Installed {n} cores. Failed: {failed}'), + _('Installing the update and restarting RetroDB…'), _('Integer scaling'), _('Japan'), _('Just synced'), @@ -719,6 +748,7 @@ _('No mappings yet. Add an emulator folder above.'), _('No matching games on this system.'), _('No missing ROM files found.'), + _('No newer version is available.'), _('No options available'), _('No ratings available'), _('No results'), @@ -780,6 +810,10 @@ _('RetroArch was not installed.'), _('RetroArch was not installed: {error}'), _('RetroDB default'), + _('RetroDB has not come back yet. See logs/update.log, then reload this page.'), + _('RetroDB is run by a process manager here. Download the release and install it yourself.'), + _('RetroDB will back up its database, install the update and restart. Anyone using it will be disconnected for a minute or two.'), + _('RetroDB {version} is available.'), _('Run-ahead, 1 frame'), _('Running'), _('Running...'), @@ -850,9 +884,16 @@ _('System'), _('Systems'), _('The core installed but the game still cannot start.'), + _('The download failed: {error}'), _('The run stopped: {error}. Names already suggested are kept.'), + _('The update could not be started. See logs/update.log.'), + _('The update is downloaded and checked.'), + _('The update to {to} did not work, so RetroDB went back to {from}. Reason: {reason}'), + _('This copy of RetroDB is a git checkout. Update it with git pull.'), _('This game is already running. Kill the existing process and relaunch?'), + _('This release has no download for this computer yet.'), _('This system is already being synced.'), + _('This update cannot be installed here.'), _('This will clear ALL RetroAchievements game IDs and progress data from your database. You will need to run "Refresh RetroAchievements" again afterwards to re-scan your games. Continue?'), _('This will clear RetroAchievements game IDs and progress data for "{system}". Continue?'), _('This will remove "{system}" from the Achievements page. Continue?'), @@ -874,6 +915,7 @@ _('Unscraped'), _('Update Failed'), _('Update {count} game(s)?\n\nFields: {fields}'), + _('Updated from {from} to {to}.'), _('Upload'), _('Upload Controller Image'), _('Upload failed'), @@ -883,13 +925,17 @@ _('Use the system setting'), _('Used for every {system} game unless the game sets its own. Applied when you press Play.'), _('Version unlinked.'), + _('Version {version} is available.'), _('Versions linked.'), + _('What is new in {version}'), + _('What is new in {version} ({date})'), _('Will change?'), _('Works'), _('World'), _('Write again with AI'), _('Xbox Imports'), _('Yes'), + _('You have the latest version.'), _('Your bulk scrape has been queued (position {position}). It will start automatically when the current scrape finishes.'), _('found'), _('in queue'), diff --git a/services/media_cleanup.py b/services/media_cleanup.py index a622616b..2abc3982 100644 --- a/services/media_cleanup.py +++ b/services/media_cleanup.py @@ -182,6 +182,9 @@ def find_orphaned_media(games): game_ids = {g['id'] for g in games} referenced_files = _collect_referenced_files(games) + # Pass 48.2 matches orphans on basename; a set keeps that one lookup per + # file instead of a walk over every reference (orphans x references). + referenced_basenames = {os.path.basename(ref) for ref in referenced_files} # Pass 48.4 — each entry carries the base its rel_path is computed # against, instead of inferring it from a ``'static' in dir_path`` @@ -238,17 +241,14 @@ def find_orphaned_media(games): rel_path = os.path.relpath(filepath, rel_base) if filename in referenced_files or rel_path in referenced_files: is_orphaned = False - else: - # Pass 48.2 — match on basename, not a substring. A DB - # value may store a path (e.g. ``images/boxart/42.png``); - # comparing basenames keeps that protected without the old - # ``filename in ref`` test also sparing unrelated refs that - # merely contain the name as a substring (e.g. ``42.png`` - # inside ``442.png`` or ``42.png.bak``). - for ref in referenced_files: - if os.path.basename(ref) == filename: - is_orphaned = False - break + # Pass 48.2 — match on basename, not a substring. A DB + # value may store a path (e.g. ``images/boxart/42.png``); + # comparing basenames keeps that protected without the old + # ``filename in ref`` test also sparing unrelated refs that + # merely contain the name as a substring (e.g. ``42.png`` + # inside ``442.png`` or ``42.png.bak``). + elif filename in referenced_basenames: + is_orphaned = False if is_orphaned: try: diff --git a/services/rom_scanner.py b/services/rom_scanner.py index 00a7e28e..837dabbe 100644 --- a/services/rom_scanner.py +++ b/services/rom_scanner.py @@ -138,7 +138,9 @@ def run_inline_scan(): logger.warning(f"System not found for folder: {folder}") continue system_id = row[0] - c.execute("UPDATE systems SET name = ?, logo = ? WHERE folder = ?", + # Keep a logo already set when none is bundled (an imported ES-DE + # logo lives under IMAGE_PATH, not STATIC_PATH). + c.execute("UPDATE systems SET name = ?, logo = COALESCE(?, logo) WHERE folder = ?", (system_name, system_logo, folder)) extensions = parse_systeminfo(system_path) diff --git a/services/settings_validators.py b/services/settings_validators.py index cf40d991..4b20f02b 100644 --- a/services/settings_validators.py +++ b/services/settings_validators.py @@ -240,6 +240,7 @@ def _launch_required_permission_validator(value): 'default_sort': _string_validator(max_len=64), 'default_sort_order': _enum_validator(_ALLOWED_SORT_ORDERS, 'default_sort_order'), 'show_unscraped_first': _bool_validator, + 'update_check_auto': _bool_validator, 'preferred_boxart': _enum_validator(_ALLOWED_BOXART_MODES, 'preferred_boxart'), 'preferred_rating_system': _enum_validator(_ALLOWED_RATING_SYSTEMS, 'preferred_rating_system'), 'enable_animations': _bool_validator, diff --git a/services/updater.py b/services/updater.py new file mode 100644 index 00000000..48a04564 --- /dev/null +++ b/services/updater.py @@ -0,0 +1,554 @@ +# ============================================================================= +# RETRODB - Self-updater (PASS-59-169) +# ============================================================================= +# Checks GitHub Releases for a newer RetroDB, downloads and verifies the +# release for this install, and hands over to the new version's helper +# (updater_apply.py), which swaps the files, restarts and rolls back on +# failure. Admin-only; installing always takes a click. +# +# status() what the Settings card and dashboard banner show +# check(force) query GitHub (cached for CHECK_INTERVAL) +# job.start() download + verify + unpack in the background +# install() back up the DB, write the plan, start the helper +# +# Contract: docs/specs/PASS-59-169-self-updater.md. +# ============================================================================= + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +import shutil +import stat +import subprocess +import sys +import threading +import time +import zipfile +from datetime import datetime, timezone +from typing import Optional + +import requests + +import config + +logger = logging.getLogger(__name__) + +RELEASES_REPO = 'milnet01/RetroDB' +LATEST_URL = f'https://api.github.com/repos/{RELEASES_REPO}/releases/latest' +DOWNLOAD_PREFIX = f'https://github.com/{RELEASES_REPO}/releases/download/' +CHECK_INTERVAL = 24 * 3600 +MAX_DOWNLOAD = 3 * 1024 ** 3 +MAX_UNPACKED = 8 * 1024 ** 3 +MAX_CHECKSUM_FILE = 64 * 1024 +_CHUNK = 1024 * 1024 + + +class UpdateError(Exception): + pass + + +# ----------------------------------------------------------------------------- +# Paths +# ----------------------------------------------------------------------------- + +def _base(): + return config.BASE_DIR + + +def update_dir(): + return os.path.join(_base(), '.update') + + +def _paths(): + root = update_dir() + return { + 'root': root, + 'download': os.path.join(root, 'download'), + 'staging': os.path.join(root, 'staging'), + 'plan': os.path.join(root, 'plan.json'), + 'check': os.path.join(_base(), 'data', 'update_check.json'), + 'status': os.path.join(_base(), 'data', 'update_status.json'), + 'log': os.path.join(_base(), 'logs', 'update.log'), + } + + +# ----------------------------------------------------------------------------- +# Versions, install kind, asset choice +# ----------------------------------------------------------------------------- + +def parse_version(text) -> Optional[tuple]: + """'v3.33.0' / '3.33.0' -> (3, 33, 0); None when it is not X.Y.Z.""" + m = re.fullmatch(r'v?(\d+)\.(\d+)\.(\d+)', str(text or '').strip()) + return tuple(int(p) for p in m.groups()) if m else None + + +def is_newer(latest, current) -> bool: + a, b = parse_version(latest), parse_version(current) + return a is not None and b is not None and a > b + + +def host_platform() -> Optional[str]: + if sys.platform.startswith('linux'): + return 'Linux' + if sys.platform == 'darwin': + return 'macOS' + if sys.platform == 'win32': + return 'Windows' + return None + + +def install_kind() -> tuple: + """(kind, reason): kind is 'source' / 'standalone', or None with the + reason one-click install is not offered here.""" + if os.environ.get('LWSM_MANAGED') == '1': + return None, 'managed' + if getattr(sys, 'frozen', False): + return 'standalone', None + if os.path.exists(os.path.join(_base(), '.git')): + return None, 'git' + return 'source', None + + +def asset_names(version, kind, platform) -> list: + """Release asset names for this install, most preferred first.""" + if kind == 'source': + return [f'RetroDB-v{version}-{platform}.zip'] + return [f'RetroDB-v{version}-{platform}-Standalone.zip', + f'RetroDB-v{version}-{platform}-CPU-Standalone.zip'] + + +def pick_asset(release, kind, platform) -> Optional[dict]: + """{'name', 'url', 'size', 'checksum_url'} for this install, or None.""" + if not kind or not platform: + return None + assets = {a.get('name'): a for a in release.get('assets') or [] if isinstance(a, dict)} + # A GitHub release (`tag_name`) or this module's cached summary (`tag`). + version = '.'.join(map(str, parse_version(release.get('tag_name') or release.get('tag')) or ())) + for name in asset_names(version, kind, platform): + asset = assets.get(name) + if not asset: + continue + checksum = assets.get('SHA256SUMS.txt' if kind == 'source' else f'{name}.sha256') + if not checksum: + continue + url, sum_url = asset.get('browser_download_url'), checksum.get('browser_download_url') + if not (_allowed_url(url) and _allowed_url(sum_url)): + continue + return {'name': name, 'url': url, 'size': asset.get('size') or 0, + 'checksum_url': sum_url} + return None + + +def _allowed_url(url) -> bool: + return isinstance(url, str) and url.startswith(DOWNLOAD_PREFIX) and '..' not in url + + +def checksum_for(text, name) -> Optional[str]: + """The SHA-256 a `sha256sum`-style file gives for `name`. A one-line + file naming no file (a bare hash) counts for any name.""" + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + for line in lines: + parts = line.split() + digest = parts[0].lower() + if not re.fullmatch(r'[0-9a-f]{64}', digest): + continue + if len(parts) == 1 and len(lines) == 1: + return digest + if len(parts) >= 2 and os.path.basename(parts[-1].lstrip('*')) == name: + return digest + return None + + +# ----------------------------------------------------------------------------- +# Checking +# ----------------------------------------------------------------------------- + +_check_lock = threading.Lock() +_check_cache = None # {'checked_at': ts, 'release': {...} | None, 'error': str | None} + + +def _http_get(url, **kwargs): + """The one network call; tests replace it.""" + headers = {'User-Agent': f'RetroDB/{config.APP_VERSION}', + 'Accept': 'application/vnd.github+json'} + return requests.get(url, headers=headers, timeout=kwargs.pop('timeout', 15), **kwargs) + + +def _load_check(): + global _check_cache + if _check_cache is None: + try: + with open(_paths()['check'], encoding='utf-8') as f: + _check_cache = json.load(f) + except (OSError, ValueError): + _check_cache = {} + return _check_cache + + +def _save_check(data): + global _check_cache + _check_cache = data + try: + from services.atomic_io import atomic_write_json + atomic_write_json(_paths()['check'], data) + except Exception as e: + logger.debug(f"could not save update check: {e}") + + +def _summarise(release) -> dict: + return { + 'version': '.'.join(map(str, parse_version(release.get('tag_name')))), + 'tag': release.get('tag_name'), + 'notes': (release.get('body') or '')[:20000], + 'url': release.get('html_url') if str(release.get('html_url') or '').startswith( + f'https://github.com/{RELEASES_REPO}/') else None, + 'published_at': release.get('published_at'), + 'assets': [{'name': a.get('name'), 'browser_download_url': a.get('browser_download_url'), + 'size': a.get('size')} + for a in release.get('assets') or [] if isinstance(a, dict)], + } + + +def check(force=False) -> dict: + """Ask GitHub for the latest release; cached for CHECK_INTERVAL.""" + with _check_lock: + cached = _load_check() + if not force and cached and time.time() - cached.get('checked_at', 0) < CHECK_INTERVAL: + return cached + data = {'checked_at': time.time(), 'release': None, 'error': None} + try: + r = _http_get(LATEST_URL) + if r.status_code == 404: + data['error'] = 'no published release' + elif r.status_code != 200: + data['error'] = f'GitHub answered {r.status_code}' + else: + release = r.json() + if parse_version(release.get('tag_name')) and not release.get('draft') \ + and not release.get('prerelease'): + data['release'] = _summarise(release) + else: + data['error'] = 'latest release has no X.Y.Z tag' + except (requests.RequestException, ValueError) as e: + data['error'] = 'could not reach GitHub' + logger.info(f"update check failed: {e}") + _save_check(data) + return data + + +def _background_check_if_due(): + try: + import settings_manager + if not settings_manager.get_setting('update_check_auto', True): + return + except Exception: + pass + cached = _load_check() + if cached and time.time() - cached.get('checked_at', 0) < CHECK_INTERVAL: + return + if _check_lock.locked(): + return + threading.Thread(target=check, name='update-check', daemon=True).start() + + +# ----------------------------------------------------------------------------- +# Download, verify, unpack +# ----------------------------------------------------------------------------- + +def safe_extract(zip_path, dest) -> None: + """Unpack `zip_path` into `dest`: no member may escape `dest`, symlinks + are refused, Unix permission bits are kept. Raises UpdateError.""" + dest_real = os.path.realpath(dest) + total = 0 + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + name = info.filename + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + raise UpdateError(f'the archive holds a link: {name}') + if name.startswith(('/', '\\')) or re.match(r'^[A-Za-z]:', name): + raise UpdateError(f'the archive holds an absolute path: {name}') + target = os.path.realpath(os.path.join(dest_real, name)) + if target != dest_real and not target.startswith(dest_real + os.sep): + raise UpdateError(f'the archive path leaves its folder: {name}') + total += info.file_size + if total > MAX_UNPACKED: + raise UpdateError('the archive unpacks larger than 8 GiB') + for info in zf.infolist(): + path = zf.extract(info, dest_real) + mode = (info.external_attr >> 16) & 0o777 + if mode and not info.is_dir() and os.name != 'nt': + os.chmod(path, (mode & 0o755) | stat.S_IRUSR | stat.S_IWUSR) + + +def _read_app_version(tree) -> Optional[str]: + try: + with open(os.path.join(tree, 'config.example.py'), encoding='utf-8') as f: + m = re.search(r'^APP_VERSION\s*=\s*["\']([^"\']+)["\']', f.read(), re.M) + return m.group(1) if m else None + except OSError: + return None + + +def program_name() -> str: + return 'retrodb.exe' if sys.platform == 'win32' else 'retrodb' + + +def validate_tree(tree, kind, version) -> None: + if kind == 'source': + if not os.path.isfile(os.path.join(tree, 'app.py')) or \ + not os.path.isfile(os.path.join(tree, 'updater_apply.py')): + raise UpdateError('the download is not a RetroDB source release') + if _read_app_version(tree) != version: + raise UpdateError('the download is not the expected version') + else: + if not os.path.isfile(os.path.join(tree, program_name())) or \ + not os.path.isdir(os.path.join(tree, '_internal')): + raise UpdateError('the download is not a RetroDB standalone release') + + +class UpdateJob: + """Download, verify and unpack one release. One at a time.""" + + def __init__(self): + self._lock = threading.Lock() + self.reset() + + def reset(self): + self.state = 'idle' # idle | downloading | ready | installing | error + self.version = None + self.kind = None + self.asset = None + self.done_bytes = 0 + self.total_bytes = 0 + self.error = None + self.tree = None + + def snapshot(self) -> dict: + with self._lock: + return {'state': self.state, 'version': self.version, + 'done_bytes': self.done_bytes, 'total_bytes': self.total_bytes, + 'error': self.error} + + def start(self, release) -> dict: + kind, reason = install_kind() + asset = pick_asset(release, kind, host_platform()) if kind else None + if not asset: + return {'success': False, 'error': reason or 'no_asset'} + with self._lock: + if self.state in ('downloading', 'installing'): + return {'success': False, 'error': 'busy'} + self.reset() + self.state, self.version, self.kind = 'downloading', release['version'], kind + self.asset, self.total_bytes = asset, asset['size'] + threading.Thread(target=self._run, name='update-download', daemon=True).start() + return {'success': True} + + def _run(self): + p = _paths() + try: + for d in (p['download'], p['staging']): + shutil.rmtree(d, ignore_errors=True) + os.makedirs(d, exist_ok=True) + sums = self._fetch_checksums() + expected = checksum_for(sums, self.asset['name']) + if not expected: + raise UpdateError('the release lists no checksum for this download') + archive = os.path.join(p['download'], self.asset['name']) + if self._download(archive) != expected: + os.remove(archive) + raise UpdateError('the download failed its checksum') + safe_extract(archive, p['staging']) + tops = [n for n in os.listdir(p['staging']) if not n.startswith('.')] + if len(tops) != 1: + raise UpdateError('the download is not a RetroDB release') + tree = os.path.join(p['staging'], tops[0]) + validate_tree(tree, self.kind, self.version) + with self._lock: + self.tree, self.state = tree, 'ready' + logger.info(f"update {self.version} downloaded and verified") + except (UpdateError, OSError, zipfile.BadZipFile, requests.RequestException) as e: + logger.warning(f"update download failed: {e}") + with self._lock: + self.state, self.error = 'error', str(e) + + def _fetch_checksums(self) -> str: + r = _http_get(self.asset['checksum_url'], stream=True) + try: + if r.status_code != 200: + raise UpdateError(f'HTTP {r.status_code} for the checksum file') + body = b'' + for chunk in r.iter_content(8192): + body += chunk + if len(body) > MAX_CHECKSUM_FILE: + raise UpdateError('the checksum file is too large') + return body.decode('utf-8', 'replace') + finally: + r.close() + + def _download(self, dest) -> str: + r = _http_get(self.asset['url'], stream=True, timeout=60) + digest = hashlib.sha256() + try: + if r.status_code != 200: + raise UpdateError(f'HTTP {r.status_code} for the download') + with open(dest, 'wb') as f: + for chunk in r.iter_content(_CHUNK): + with self._lock: + self.done_bytes += len(chunk) + if self.done_bytes > MAX_DOWNLOAD: + raise UpdateError('the download is larger than 3 GiB') + digest.update(chunk) + f.write(chunk) + finally: + r.close() + return digest.hexdigest() + + +job = UpdateJob() + + +# ----------------------------------------------------------------------------- +# Install: hand over to the new version's helper +# ----------------------------------------------------------------------------- + +def _restart_argv() -> tuple: + if getattr(sys, 'frozen', False): + return [sys.executable] + sys.argv[1:], os.path.dirname(sys.executable) + return [sys.executable] + sys.argv, os.getcwd() + + +def _install_dir(kind) -> str: + return os.path.dirname(sys.executable) if kind == 'standalone' else _base() + + +def build_plan(tree, kind, version, port, db_backup) -> dict: + p = _paths() + argv, cwd = _restart_argv() + return { + 'plan_version': 1, 'kind': kind, + 'from_version': config.APP_VERSION, 'to_version': version, + 'install_dir': _install_dir(kind), + 'staging_root': p['staging'], 'staging_dir': tree, + 'download_dir': p['download'], + 'rollback_dir': os.path.join(p['root'], f'rollback-{config.APP_VERSION}'), + 'pid': os.getpid(), 'port': port, + 'restart_argv': argv, 'restart_cwd': cwd, + 'db_path': config.DB_PATH, 'db_backup': db_backup, + 'status_path': p['status'], 'log_path': p['log'], + } + + +def helper_argv(tree, kind, plan_path) -> list: + if kind == 'standalone': + return [os.path.join(tree, program_name()), '--apply-update', plan_path] + return [sys.executable, os.path.join(tree, 'updater_apply.py'), plan_path] + + +def _backup_database(version) -> str: + from services.database import backup_database + backup_dir = os.path.join(os.path.dirname(config.DB_PATH), 'backups') + os.makedirs(backup_dir, exist_ok=True) + stamp = datetime.now().strftime('%Y%m%d_%H%M%S') + path = os.path.join(backup_dir, f'pre_update_{config.APP_VERSION}_to_{version}_{stamp}.db') + backup_database(config.DB_PATH, path) + return path + + +def _spawn(argv, cwd): + import updater_apply + return updater_apply.spawn_detached(argv, cwd, _paths()['log']) + + +def _shutdown_soon(): + """As /api/shutdown: let the reply flush, drain jobs, SIGTERM.""" + import signal + + def go(): + time.sleep(1) + os.kill(os.getpid(), signal.SIGTERM) + threading.Thread(target=go, daemon=True).start() + + +def install(port) -> dict: + """Start the update that `job` has ready. Returns a result dict.""" + with job._lock: + if job.state != 'ready': + return {'success': False, 'error': 'not_ready'} + job.state = 'installing' + tree, kind, version = job.tree, job.kind, job.version + try: + db_backup = _backup_database(version) + plan = build_plan(tree, kind, version, port, db_backup) + plan_path = _paths()['plan'] + with open(plan_path, 'w', encoding='utf-8') as f: + json.dump(plan, f) + try: + os.remove(_paths()['status']) + except OSError: + pass + _spawn(helper_argv(tree, kind, plan_path), tree) + except Exception as e: + logger.error(f"update install could not start: {e}", exc_info=True) + with job._lock: + job.state, job.error = 'error', str(e) + return {'success': False, 'error': 'start_failed'} + logger.info(f"updating to {version}: helper started, shutting down") + _shutdown_soon() + return {'success': True} + + +# ----------------------------------------------------------------------------- +# Status +# ----------------------------------------------------------------------------- + +def last_result() -> Optional[dict]: + """The helper's outcome file, after cleaning what the helper could not.""" + try: + with open(_paths()['status'], encoding='utf-8') as f: + result = json.load(f) + except (OSError, ValueError): + return None + helper = result.get('helper_pid') + if helper and helper != os.getpid(): + import updater_apply + if not updater_apply.pid_alive(helper): + shutil.rmtree(_paths()['staging'], ignore_errors=True) + shutil.rmtree(_paths()['download'], ignore_errors=True) + return result + + +def dismiss_result() -> None: + try: + os.remove(_paths()['status']) + except OSError: + pass + + +def status() -> dict: + _background_check_if_due() + cached = _load_check() or {} + release = cached.get('release') + kind, reason = install_kind() + available = bool(release and is_newer(release.get('version'), config.APP_VERSION)) + can_install = False + if available and kind: + can_install = pick_asset(release, kind, host_platform()) is not None + if not can_install: + reason = 'no_asset' + return { + 'current': config.APP_VERSION, + 'latest': {k: release.get(k) for k in ('version', 'notes', 'url', 'published_at')} + if release else None, + 'available': available, + 'can_install': can_install, + 'reason': None if can_install else (reason if available else None), + 'checked_at': cached.get('checked_at'), + 'check_error': cached.get('error'), + 'job': job.snapshot(), + 'last_result': last_result(), + } diff --git a/settings_manager.py b/settings_manager.py index 15e2f324..6238d9d2 100644 --- a/settings_manager.py +++ b/settings_manager.py @@ -58,6 +58,8 @@ # Feature toggles 'enable_animations': True, + # PASS-59-169 — check GitHub for a new RetroDB once a day (admins see it) + 'update_check_auto': True, 'enable_mist_effect': True, 'enable_scanlines': True, diff --git a/static/asset_manifest.json b/static/asset_manifest.json index a7205ea1..c9646596 100644 --- a/static/asset_manifest.json +++ b/static/asset_manifest.json @@ -1,7 +1,7 @@ { "css/core/fonts.css": "87796dac", - "css/main.min.css": "eeeccda7", - "js/core.bundle.js": "5d24a88d", - "js/games.bundle.js": "72c5ea2e", + "css/main.min.css": "137ae98a", + "js/core.bundle.js": "4600be9c", + "js/games.bundle.js": "deb12869", "js/vendor/chart.umd.min.js": "48444a82" } \ No newline at end of file diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css index 45c2d4fe..4aab2e89 100644 --- a/static/css/components/buttons.css +++ b/static/css/components/buttons.css @@ -26,7 +26,7 @@ .skip-link:focus { transform: translateY(0); - outline: 2px solid var(--primary-magenta); + outline: 2px solid var(--secondary-magenta); outline-offset: 2px; } diff --git a/static/css/components/launch-indicator.css b/static/css/components/launch-indicator.css index 94409a2e..39a256fe 100644 --- a/static/css/components/launch-indicator.css +++ b/static/css/components/launch-indicator.css @@ -11,7 +11,7 @@ align-items: center; gap: 0.5rem; padding: 0.4rem 0.9rem; - background: var(--bg-card); + background: var(--card-bg); border: 1px solid var(--primary-cyan); border-radius: 999px; box-shadow: 0 0 12px rgba(76, 201, 240, 0.35); @@ -45,9 +45,9 @@ z-index: 1001; min-width: 280px; padding: var(--spacing-md); - background: var(--bg-card); + background: var(--card-bg); border: 1px solid var(--primary-cyan); - border-radius: var(--border-radius); + border-radius: var(--radius-md); box-shadow: 0 0 20px rgba(76, 201, 240, 0.4); color: var(--text-primary); } diff --git a/static/css/features/hltb.css b/static/css/features/hltb.css index a23b27ce..d7f00c97 100644 --- a/static/css/features/hltb.css +++ b/static/css/features/hltb.css @@ -125,7 +125,7 @@ } .hltb-match-confidence { - color: var(--accent-green); + color: var(--neon-green); font-size: 0.8rem; font-weight: 500; } diff --git a/static/css/features/updates.css b/static/css/features/updates.css new file mode 100644 index 00000000..8b18a524 --- /dev/null +++ b/static/css/features/updates.css @@ -0,0 +1,88 @@ +/* ============================================================================= + Self-updater (PASS-59-169): dashboard banner, Settings > System > Updates + ============================================================================= */ + +/* The page code toggles `hidden`; .btn and friends set their own display, + which would otherwise beat the browser's [hidden] rule. */ +[data-update-card] [hidden], +[data-update-banner][hidden] { display: none; } + +.update-banner { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: color-mix(in srgb, var(--primary-cyan) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-cyan) 35%, transparent); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-md); + font-size: 0.85rem; +} +.update-banner-text { color: var(--text-primary); flex: 1; } +.update-banner-link { color: var(--primary-cyan); text-decoration: none; font-weight: 600; } +.update-banner-link:hover { text-decoration: underline; } +.update-banner-close { + background: none; border: none; color: var(--text-muted); cursor: pointer; + font-size: 1rem; padding: 2px 6px; line-height: 1; +} + +.update-notes { + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 18rem; + overflow-y: auto; + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-darkest); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.85rem; +} + +.update-progress { + height: 8px; + margin: var(--spacing-sm) 0; + background: var(--bg-darkest); + border-radius: var(--radius-md); + overflow: hidden; +} +.update-progress-bar { + width: 0; + height: 100%; + background: var(--primary-cyan); + transition: width 0.3s ease; +} + +.update-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin: var(--spacing-md) 0; +} + +.update-result { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-md); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} +.update-result-ok { border-color: var(--neon-green); } +.update-result-failed { border-color: var(--neon-red); } + +.update-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: color-mix(in srgb, var(--bg-darkest) 85%, transparent); + color: var(--text-primary); + font-size: 1.1rem; + text-align: center; +} diff --git a/static/css/layout/sidebar.css b/static/css/layout/sidebar.css index 5cbb5cd9..29314f43 100644 --- a/static/css/layout/sidebar.css +++ b/static/css/layout/sidebar.css @@ -340,11 +340,11 @@ .user-indicator-rank.rank-tier-5 { background: rgba(245, 158, 11, 0.15); color: var(--neon-orange); border-color: rgba(245, 158, 11, 0.45); } .user-indicator-rank.rank-tier-6 { background: rgba(168, 85, 247, 0.15); color: var(--neon-purple); border-color: rgba(168, 85, 247, 0.4); } .user-indicator-rank.rank-tier-7 { background: rgba(168, 85, 247, 0.22); color: var(--neon-purple); border-color: rgba(168, 85, 247, 0.55); } -.user-indicator-rank.rank-tier-8 { background: rgba(247, 37, 133, 0.18); color: var(--primary-magenta); border-color: rgba(247, 37, 133, 0.5); } +.user-indicator-rank.rank-tier-8 { background: rgba(247, 37, 133, 0.18); color: var(--secondary-magenta); border-color: rgba(247, 37, 133, 0.5); } .user-indicator-rank.rank-tier-9 { background: linear-gradient(135deg, rgba(247, 37, 133, 0.3), rgba(168, 85, 247, 0.3)); color: var(--text-primary); - border-color: var(--primary-magenta); + border-color: var(--secondary-magenta); box-shadow: 0 0 8px rgba(247, 37, 133, 0.35); } diff --git a/static/css/main.min.css b/static/css/main.min.css index 6e861ffd..fe02aeb4 100644 --- a/static/css/main.min.css +++ b/static/css/main.min.css @@ -1 +1 @@ -:root{--primary-cyan:#4cc9f0;--primary-cyan-dark:#3a9fc2;--primary-cyan-light:#7dd8f5;--primary-cyan-glow:rgba(76,201,240,0.5);--secondary-magenta:#f72585;--secondary-magenta-dark:#c41d6a;--secondary-magenta-light:#f95fa3;--secondary-magenta-glow:rgba(247,37,133,0.5);--accent-purple:#7209b7;--accent-blue:#3a0ca3;--accent-violet:#560bad;--bg-darkest:#0a0e17;--bg-dark:#0f1419;--bg-darker:#0d1117;--bg-medium:#151b24;--bg-light:#1a222d;--bg-lighter:#212b38;--card-bg:#141a23;--card-bg-hover:#1a222d;--card-border:#2a3542;--card-border-hover:#3a4552;--text-primary:#e8eaed;--text-secondary:#9aa0a6;--text-muted:#6e7378;--text-accent:var(--primary-cyan);--border-color:#2a3542;--status-success:#00e676;--status-warning:#ffab00;--status-error:#ff5252;--status-info:var(--primary-cyan);--status-online:#00e676;--status-offline:#ff5252;--status-connected:var(--primary-cyan);--shadow-sm:0 2px 4px rgba(0,0,0,0.3);--shadow-md:0 4px 8px rgba(0,0,0,0.4);--shadow-lg:0 8px 16px rgba(0,0,0,0.5);--shadow-xl:0 12px 24px rgba(0,0,0,0.6);--shadow-glow-cyan:0 0 20px rgba(76,201,240,0.3);--shadow-glow-magenta:0 0 20px rgba(247,37,133,0.3);--radius-sm:4px;--radius-md:8px;--radius-lg:12px;--radius-xl:16px;--radius-full:9999px;--transition-fast:0.15s ease;--transition-normal:0.25s ease;--transition-slow:0.4s ease;--font-primary:'Rajdhani','Orbitron','Share Tech Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-heading:'Orbitron','Rajdhani','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-mono:'Share Tech Mono','JetBrains Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',monospace;--spacing-xs:4px;--spacing-sm:8px;--spacing-md:16px;--spacing-lg:24px;--spacing-xl:32px;--spacing-2xl:48px;--sidebar-width:260px;--sidebar-collapsed-width:70px;--mobile-header-height:56px;--mobile-bar-height:60px;--sticky-top:0px;--neon-cyan:#4cc9f0;--neon-green:#22c55e;--neon-orange:#f59e0b;--neon-red:#ef4444;--neon-purple:#a855f7;--neon-gray:#64748b;--neon-blue:#3b82f6;--text-on-color:#fff;--font-display:var(--font-heading);--accent-cyan:var(--primary-cyan);--cyan-bg:rgba(76,201,240,0.15);--cyan-bg-light:rgba(76,201,240,0.1);--cyan-bg-strong:rgba(76,201,240,0.2);--green-bg:rgba(34,197,94,0.15);--red-bg:rgba(239,68,68,0.15);--orange-bg:rgba(245,158,11,0.15);--purple-bg:rgba(168,85,247,0.15);--magenta-bg:rgba(247,37,133,0.15);--trophy-platinum:#7bb8d4;--trophy-gold:#cd9a2a;--trophy-silver:#a8a8a8;--trophy-bronze:#cd7f32;--trophy-display-platinum:#a8c8dc;--trophy-display-gold:#ffd700;--trophy-display-silver:#c0c0c0;--trophy-display-bronze:#cd7f32;--trophy-display-text:#111;--rating-esrb:#64b5f6;--rating-esrb-border:rgba(25,118,210,0.3);--rating-esrb-bg:rgba(25,118,210,0.1);--rating-pegi:#ce93d8;--rating-pegi-border:rgba(123,31,162,0.3);--rating-pegi-bg:rgba(123,31,162,0.1);--completion-gold:#fbbf24}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;scroll-behavior:smooth}body{font-family:var(--font-primary);background-color:var(--bg-darkest);color:var(--text-primary);line-height:1.6;min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}ul,ol{list-style:none}img,picture,video,canvas,svg{display:block;max-width:100%}button{font:inherit;cursor:pointer}input,textarea,select{font:inherit}input,button,textarea,select{font-family:inherit}a{color:inherit;text-decoration:none}@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important}}@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');h1,.h1{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:1px;text-transform:uppercase}h2,.h2{font-family:var(--font-heading);font-size:1.5rem;font-weight:600;color:var(--text-primary);letter-spacing:0.5px}h3,.h3{font-family:var(--font-heading);font-size:1.25rem;font-weight:600;color:var(--text-primary)}h4,.h4{font-family:var(--font-primary);font-size:1.1rem;font-weight:600;color:var(--text-primary)}h5,.h5{font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-secondary)}h6,.h6{font-family:var(--font-primary);font-size:0.9rem;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.page-header h1{margin-bottom:var(--spacing-sm)}.page-header p{color:var(--text-secondary);font-size:1rem}.page-title{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:4px;text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow);animation:glow-pulse 3s ease-in-out infinite}.page-subtitle{color:var(--text-secondary);font-size:1rem;margin-top:var(--spacing-xs)}.page-actions{display:flex;gap:var(--spacing-md)}.section-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:2px;display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.section-title::before{content:'';width:4px;height:20px;background:var(--primary-cyan);border-radius:2px}.breadcrumb{display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);font-size:0.9rem}.breadcrumb a{color:var(--text-secondary);text-decoration:none;transition:color var(--transition-fast)}.breadcrumb a:hover{color:var(--primary-cyan)}.breadcrumb-separator{color:var(--text-muted)}.breadcrumb-current{color:var(--text-primary)}code,pre,.mono{font-family:var(--font-mono)}code{padding:0.2em 0.4em;background:rgba(76,201,240,0.1);border-radius:var(--radius-sm);font-size:0.9em;color:var(--primary-cyan)}pre{padding:var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow-x:auto}pre code{padding:0;background:none}[data-theme="matrix"]{--primary-cyan:#00ff41;--primary-cyan-dark:#00cc33;--primary-cyan-light:#66ff88;--primary-cyan-glow:rgba(0,255,65,0.5);--secondary-magenta:#008f11;--secondary-magenta-dark:#006b0d;--secondary-magenta-light:#33b833;--secondary-magenta-glow:rgba(0,143,17,0.5);--accent-purple:#00aa22;--accent-blue:#004d00;--accent-violet:#009a1a;--bg-darkest:#000000;--bg-dark:#050505;--bg-darker:#0a0a0a;--bg-medium:#111111;--bg-light:#161616;--bg-lighter:#1a1a1a;--card-bg:#0a0a0a;--card-bg-hover:#111111;--card-border:#1a3a1a;--card-border-hover:#2a5a2a;--text-primary:#c0ffc0;--text-secondary:#80c080;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a3a1a;--status-success:#00ff41;--status-warning:#a0e000;--status-error:#ff3333;--status-info:var(--primary-cyan);--status-online:#00ff41;--status-offline:#ff3333;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,65,0.3);--shadow-glow-magenta:0 0 20px rgba(0,143,17,0.3);--neon-cyan:#00ff41;--neon-green:#00ff41;--neon-orange:#a0e000;--neon-red:#ff3333;--neon-purple:#33cc66;--neon-gray:#4a6a4a;--trophy-platinum:#66ffaa;--trophy-gold:#a0e060;--trophy-silver:#88bb88;--trophy-bronze:#77aa55;--toast-bg-from:rgba(0,10,0,0.95);--toast-bg-to:rgba(0,20,5,0.95);--toast-queued-bg-from:rgba(0,12,0,0.90);--toast-queued-bg-to:rgba(0,22,5,0.90)}[data-theme="matrix"] body::before{background:linear-gradient( to top right,rgba(0,255,65,0.06) 0%,rgba(0,143,17,0.04) 25%,rgba(0,80,0,0.03) 50%,rgba(0,0,0,0.02) 75%,transparent 100% )}[data-theme="matrix"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,65,0.02) 2px,rgba(0,255,65,0.02) 4px )}[data-theme="amber"]{--primary-cyan:#ffb000;--primary-cyan-dark:#cc8c00;--primary-cyan-light:#ffc944;--primary-cyan-glow:rgba(255,176,0,0.5);--secondary-magenta:#ff6600;--secondary-magenta-dark:#cc5200;--secondary-magenta-light:#ff8833;--secondary-magenta-glow:rgba(255,102,0,0.5);--accent-purple:#cc7700;--accent-blue:#995500;--accent-violet:#bb6600;--bg-darkest:#0a0800;--bg-dark:#0d0a02;--bg-darker:#0f0c04;--bg-medium:#151008;--bg-light:#1a150c;--bg-lighter:#211c12;--card-bg:#120e04;--card-bg-hover:#181208;--card-border:#3a2a10;--card-border-hover:#4a3a1a;--text-primary:#ffe0a0;--text-secondary:#c09050;--text-muted:#806030;--text-accent:var(--primary-cyan);--border-color:#3a2a10;--status-success:#ffcc00;--status-warning:#ff9900;--status-error:#ff4422;--status-info:var(--primary-cyan);--status-online:#ffcc00;--status-offline:#ff4422;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(255,176,0,0.3);--shadow-glow-magenta:0 0 20px rgba(255,102,0,0.3);--neon-cyan:#ffb000;--neon-green:#ddaa00;--neon-orange:#ff7700;--neon-red:#ff4422;--neon-purple:#ff8844;--neon-gray:#7a6a4a;--trophy-platinum:#ffe8aa;--trophy-gold:#ffcc44;--trophy-silver:#ccaa77;--trophy-bronze:#aa7733;--toast-bg-from:rgba(18,14,4,0.95);--toast-bg-to:rgba(28,20,8,0.95);--toast-queued-bg-from:rgba(20,16,4,0.90);--toast-queued-bg-to:rgba(30,22,8,0.90)}[data-theme="amber"] body::before{background:radial-gradient(ellipse 30% 20% at 50% 100%,rgba(255,230,100,0.2) 0%,rgba(255,200,50,0.12) 40%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 100%,rgba(255,160,0,0.1) 0%,rgba(255,100,0,0.05) 50%,transparent 100%),linear-gradient(to top,rgba(255,120,0,0.08) 0%,rgba(255,80,0,0.04) 20%,rgba(120,50,0,0.02) 45%,transparent 70%)}[data-theme="amber"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(255,176,0,0.03) 2px,rgba(255,176,0,0.03) 4px )}[data-theme="ocean"]{--primary-cyan:#4dabf7;--primary-cyan-dark:#3d8bd4;--primary-cyan-light:#74c0fa;--primary-cyan-glow:rgba(77,171,247,0.5);--secondary-magenta:#20c997;--secondary-magenta-dark:#1aaa7f;--secondary-magenta-light:#4dd8ad;--secondary-magenta-glow:rgba(32,201,151,0.5);--accent-purple:#5c7cfa;--accent-blue:#3b5bdb;--accent-violet:#4c6ef5;--bg-darkest:#040810;--bg-dark:#050a14;--bg-darker:#060c18;--bg-medium:#0a1220;--bg-light:#0e1828;--bg-lighter:#142030;--card-bg:#081018;--card-bg-hover:#0c1620;--card-border:#1a2a40;--card-border-hover:#2a3a55;--text-primary:#dce4f0;--text-secondary:#8899b0;--text-muted:#5a6a80;--text-accent:var(--primary-cyan);--border-color:#1a2a40;--status-success:#40c057;--status-warning:#fab005;--status-error:#fa5252;--status-info:var(--primary-cyan);--status-online:#40c057;--status-offline:#fa5252;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(77,171,247,0.3);--shadow-glow-magenta:0 0 20px rgba(32,201,151,0.3);--neon-cyan:#4dabf7;--neon-green:#40c057;--neon-orange:#fab005;--neon-red:#fa5252;--neon-purple:#7950f2;--neon-gray:#5a6a80;--trophy-platinum:#a5d8ff;--trophy-gold:#ffe066;--trophy-silver:#adb5bd;--trophy-bronze:#e8a44a;--toast-bg-from:rgba(6,12,22,0.95);--toast-bg-to:rgba(10,18,32,0.95);--toast-queued-bg-from:rgba(8,14,24,0.90);--toast-queued-bg-to:rgba(12,20,34,0.90)}[data-theme="ocean"] body::before{background:radial-gradient(circle at 70% 10%,rgba(220,235,255,0.4) 0%,rgba(200,220,250,0.2) 1.5%,rgba(150,190,240,0.08) 4%,transparent 10%),radial-gradient(circle at 70% 10%,rgba(77,171,247,0.08) 0%,transparent 18%),radial-gradient(ellipse 80% 50% at 50% 90%,rgba(32,201,151,0.04) 0%,rgba(77,171,247,0.05) 40%,transparent 100%),linear-gradient(to bottom,rgba(10,20,40,0.02) 0%,rgba(77,171,247,0.03) 55%,rgba(32,201,151,0.02) 100%)}[data-theme="ocean"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(77,171,247,0.02) 2px,rgba(77,171,247,0.02) 4px )}[data-theme="christian"]{--primary-cyan:#d4a843;--primary-cyan-dark:#b08a30;--primary-cyan-light:#e8c86a;--primary-cyan-glow:rgba(212,168,67,0.5);--secondary-magenta:#7b4db0;--secondary-magenta-dark:#5f3a8a;--secondary-magenta-light:#9b6dd0;--secondary-magenta-glow:rgba(123,77,176,0.5);--accent-purple:#6a3d9a;--accent-blue:#4a2d7a;--accent-violet:#7b4db0;--bg-darkest:#0c0810;--bg-dark:#0e0a14;--bg-darker:#110d18;--bg-medium:#16101f;--bg-light:#1c1528;--bg-lighter:#231c30;--card-bg:#120e1a;--card-bg-hover:#181222;--card-border:#2a1f3a;--card-border-hover:#3a2d4e;--text-primary:#f0e8d8;--text-secondary:#b0a090;--text-muted:#706050;--text-accent:var(--primary-cyan);--border-color:#2a1f3a;--status-success:#d4a843;--status-warning:#e8a030;--status-error:#c04040;--status-info:var(--primary-cyan);--status-online:#d4a843;--status-offline:#c04040;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(212,168,67,0.3);--shadow-glow-magenta:0 0 20px rgba(123,77,176,0.3);--neon-cyan:#d4a843;--neon-green:#c0a040;--neon-orange:#e8a030;--neon-red:#c04040;--neon-purple:#9b6dd0;--neon-gray:#6a5a50;--trophy-platinum:#f0e0c0;--trophy-gold:#d4a843;--trophy-silver:#b0a090;--trophy-bronze:#8a6a40;--toast-bg-from:rgba(14,10,20,0.95);--toast-bg-to:rgba(22,16,30,0.95);--toast-queued-bg-from:rgba(16,12,22,0.90);--toast-queued-bg-to:rgba(24,18,32,0.90)}[data-theme="christian"] body::before{background:radial-gradient(ellipse 40% 60% at 50% 0%,rgba(212,168,67,0.15) 0%,rgba(212,168,67,0.06) 30%,transparent 70%),radial-gradient(ellipse 70% 50% at 50% 10%,rgba(180,140,50,0.06) 0%,rgba(123,77,176,0.03) 50%,transparent 100%),linear-gradient(to top,rgba(100,60,20,0.04) 0%,rgba(80,40,120,0.02) 40%,transparent 70%)}[data-theme="christian"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(212,168,67,0.015) 2px,rgba(212,168,67,0.015) 4px )}[data-theme="bladerunner"]{--primary-cyan:#1a9fff;--primary-cyan-dark:#1480d4;--primary-cyan-light:#55b8ff;--primary-cyan-glow:rgba(26,159,255,0.5);--secondary-magenta:#ff2d7c;--secondary-magenta-dark:#d42060;--secondary-magenta-light:#ff5a9a;--secondary-magenta-glow:rgba(255,45,124,0.5);--accent-purple:#b040e0;--accent-blue:#1060c0;--accent-violet:#8830c0;--bg-darkest:#06080e;--bg-dark:#080a12;--bg-darker:#0a0d16;--bg-medium:#0e121c;--bg-light:#131824;--bg-lighter:#1a1f2e;--card-bg:#0a0e16;--card-bg-hover:#10141e;--card-border:#1a2236;--card-border-hover:#2a3248;--text-primary:#d8dce8;--text-secondary:#8892a8;--text-muted:#78809a;--text-accent:var(--primary-cyan);--border-color:#1a2236;--status-success:#22cc88;--status-warning:#ff8c30;--status-error:#ff2d5e;--status-info:var(--primary-cyan);--status-online:#22cc88;--status-offline:#ff2d5e;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(26,159,255,0.3);--shadow-glow-magenta:0 0 20px rgba(255,45,124,0.3);--neon-cyan:#1a9fff;--neon-green:#22cc88;--neon-orange:#ff8c30;--neon-red:#ff2d5e;--neon-purple:#b040e0;--neon-gray:#4a5268;--trophy-platinum:#c0d8ff;--trophy-gold:#ffd066;--trophy-silver:#a0aab8;--trophy-bronze:#d08a40;--toast-bg-from:rgba(8,10,18,0.95);--toast-bg-to:rgba(14,18,28,0.95);--toast-queued-bg-from:rgba(10,12,20,0.90);--toast-queued-bg-to:rgba(16,20,30,0.90)}[data-theme="bladerunner"] body::before{background:radial-gradient(ellipse 40% 30% at 20% 95%,rgba(255,45,124,0.08) 0%,rgba(255,45,124,0.03) 50%,transparent 100%),radial-gradient(ellipse 40% 30% at 80% 90%,rgba(26,159,255,0.08) 0%,rgba(26,159,255,0.03) 50%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 70%,rgba(130,40,180,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(6,8,14,0.02) 0%,rgba(20,15,30,0.04) 60%,rgba(255,45,124,0.02) 100%)}[data-theme="bladerunner"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(26,159,255,0.02) 2px,rgba(26,159,255,0.02) 4px )}[data-theme="elite"]{--primary-cyan:#00ff00;--primary-cyan-dark:#00cc00;--primary-cyan-light:#66ff66;--primary-cyan-glow:rgba(0,255,0,0.5);--secondary-magenta:#cccccc;--secondary-magenta-dark:#999999;--secondary-magenta-light:#e0e0e0;--secondary-magenta-glow:rgba(204,204,204,0.5);--accent-purple:#00cc66;--accent-blue:#008844;--accent-violet:#00aa55;--bg-darkest:#000000;--bg-dark:#020202;--bg-darker:#040404;--bg-medium:#080808;--bg-light:#0c0c0c;--bg-lighter:#111111;--card-bg:#050505;--card-bg-hover:#0a0a0a;--card-border:#1a331a;--card-border-hover:#2a552a;--text-primary:#d0ffd0;--text-secondary:#80cc80;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a331a;--status-success:#00ff00;--status-warning:#ffff00;--status-error:#ff0000;--status-info:var(--primary-cyan);--status-online:#00ff00;--status-offline:#ff0000;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,0,0.3);--shadow-glow-magenta:0 0 20px rgba(204,204,204,0.3);--neon-cyan:#00ff00;--neon-green:#00ff00;--neon-orange:#ffff00;--neon-red:#ff0000;--neon-purple:#00cc66;--neon-gray:#4a6a4a;--trophy-platinum:#ccffcc;--trophy-gold:#ffff66;--trophy-silver:#aaccaa;--trophy-bronze:#88aa44;--toast-bg-from:rgba(0,5,0,0.95);--toast-bg-to:rgba(0,12,0,0.95);--toast-queued-bg-from:rgba(0,8,0,0.90);--toast-queued-bg-to:rgba(0,15,0,0.90)}[data-theme="elite"] body::before{background:radial-gradient(circle at 30% 20%,rgba(255,255,255,0.03) 0%,transparent 8%),radial-gradient(circle at 70% 15%,rgba(255,255,255,0.02) 0%,transparent 5%),radial-gradient(ellipse 40% 25% at 50% 95%,rgba(0,255,0,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(0,0,0,0) 0%,rgba(0,20,0,0.02) 100%)}[data-theme="elite"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,0,0.025) 2px,rgba(0,255,0,0.025) 4px )}.app-container{display:flex;min-height:100vh;position:relative;z-index:2}.main-content{flex:1;margin-left:var(--sidebar-width);padding:var(--spacing-xl);transition:margin-left var(--transition-normal);display:flex;justify-content:center;position:relative}.main-content.sidebar-collapsed{margin-left:var(--sidebar-collapsed-width)}.content-wrapper{width:65%;max-width:1400px;min-width:600px;position:relative;z-index:1}.content-wrapper.adaptive{width:100%;max-width:none;min-width:0}.main-content.adaptive{min-width:0}.page-box{max-width:var(--page-max,none);margin-inline:auto}.page-box.grid-4{--page-max:calc(5 * var(--card-min) + 4 * var(--card-gap) - 1px)}.page-box.data-page{--page-max:1400px}.page-box.form-page{--page-max:1100px}.page-box.detail-page{--page-max:1600px}.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.section-link{color:var(--primary-cyan);text-decoration:none;font-size:0.85rem;font-weight:500;text-transform:uppercase;letter-spacing:1px;transition:all var(--transition-fast)}.section-link:hover{text-shadow:0 0 10px var(--primary-cyan-glow)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--spacing-lg)}.systems-section{margin-bottom:var(--spacing-xl)}.sidebar{position:fixed;left:0;top:0;width:var(--sidebar-width);height:100vh;background:linear-gradient(180deg,color-mix(in srgb,var(--bg-medium) 88%,transparent) 0%,color-mix(in srgb,var(--bg-dark) 88%,transparent) 100% );backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-right:1px solid var(--card-border);display:flex;flex-direction:column;z-index:1000;transition:width var(--transition-normal)}.sidebar-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.logo{display:flex;align-items:center;gap:var(--spacing-md);text-decoration:none}.logo-icon{width:40px;height:40px;background:linear-gradient(135deg,var(--primary-cyan) 0%,var(--secondary-magenta) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--shadow-glow-cyan)}.logo-text{font-family:var(--font-heading);font-size:1.5rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.sidebar-nav{flex:1;padding:var(--spacing-md);overflow-y:auto}.nav-section{margin-bottom:var(--spacing-lg)}.nav-section-title{font-size:0.7rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;padding:var(--spacing-sm) var(--spacing-md);margin-bottom:var(--spacing-xs)}.nav-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);border-radius:var(--radius-md);color:var(--text-secondary);text-decoration:none;transition:all var(--transition-fast);margin-bottom:var(--spacing-xs);position:relative;overflow:hidden}.nav-item::before{content:'';position:absolute;left:0;top:0;height:100%;width:3px;background:var(--primary-cyan);transform:scaleY(0);transition:transform var(--transition-fast)}.nav-item:hover{background:var(--card-bg-hover);color:var(--text-primary)}.nav-item:hover::before{transform:scaleY(1)}.nav-item.active{background:linear-gradient(90deg,rgba(76,201,240,0.15) 0%,transparent 100%);color:var(--primary-cyan);border-left:3px solid var(--primary-cyan)}.nav-item.active::before{transform:scaleY(1)}.nav-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:1.1rem}.nav-text{font-weight:500;font-size:0.95rem}.sidebar-footer{padding:var(--spacing-md);border-top:1px solid var(--card-border)}.version-info{font-size:0.75rem;color:var(--primary-cyan);text-align:center;font-family:var(--font-mono);opacity:0.8;text-shadow:0 0 8px rgba(76,201,240,0.4);display:flex;align-items:center;justify-content:center;gap:0.5rem;transition:all var(--transition-fast);padding:0.5rem;border-radius:var(--radius-md);background:transparent;border:none;width:100%;cursor:pointer}.version-info:hover{background:rgba(76,201,240,0.1);color:var(--primary-cyan)}.version-icon{font-size:1rem}.upscale-status{display:flex;align-items:center;gap:0.5rem;margin-bottom:var(--spacing-sm);padding:0.5rem;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--card-border);border-radius:var(--radius-md)}.upscale-status[hidden]{display:none}.upscale-status-icon{font-size:1rem;color:var(--primary-cyan)}.user-indicator{display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.75rem;background:rgba(76,201,240,0.05);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.user-indicator-info{display:flex;align-items:center;gap:0.75rem;min-width:0;flex:1}.user-indicator-avatar{font-size:1.25rem;flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center}.user-avatar-img{width:100%;height:100%;border-radius:50%;object-fit:cover}.user-indicator-details{display:flex;flex-direction:column;min-width:0}.user-indicator-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-indicator-role{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:0.1rem 0.4rem;border-radius:var(--radius-full);width:fit-content}.user-indicator-role.admin{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.user-indicator-role.editor{background:rgba(34,197,94,0.2);color:var(--neon-green)}.user-indicator-role.viewer{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.user-indicator-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:2px}.user-indicator-rank{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;padding:2px 8px;border-radius:var(--radius-full);text-decoration:none;white-space:nowrap;border:1px solid transparent;transition:all var(--transition-fast);line-height:1;display:inline-flex;align-items:center;gap:3px}.user-indicator-rank:hover{transform:translateY(-1px);filter:brightness(1.15)}.user-indicator-rank.rank-tier-0{background:rgba(110,115,120,0.18);color:var(--text-muted);border-color:rgba(110,115,120,0.35)}.user-indicator-rank.rank-tier-1{background:rgba(154,160,166,0.18);color:var(--text-secondary);border-color:rgba(154,160,166,0.4)}.user-indicator-rank.rank-tier-2{background:rgba(76,201,240,0.12);color:var(--primary-cyan);border-color:rgba(76,201,240,0.3)}.user-indicator-rank.rank-tier-3{background:rgba(76,201,240,0.18);color:var(--primary-cyan);border-color:rgba(76,201,240,0.45)}.user-indicator-rank.rank-tier-4{background:rgba(34,197,94,0.15);color:var(--neon-green);border-color:rgba(34,197,94,0.4)}.user-indicator-rank.rank-tier-5{background:rgba(245,158,11,0.15);color:var(--neon-orange);border-color:rgba(245,158,11,0.45)}.user-indicator-rank.rank-tier-6{background:rgba(168,85,247,0.15);color:var(--neon-purple);border-color:rgba(168,85,247,0.4)}.user-indicator-rank.rank-tier-7{background:rgba(168,85,247,0.22);color:var(--neon-purple);border-color:rgba(168,85,247,0.55)}.user-indicator-rank.rank-tier-8{background:rgba(247,37,133,0.18);color:var(--primary-magenta);border-color:rgba(247,37,133,0.5)}.user-indicator-rank.rank-tier-9{background:linear-gradient(135deg,rgba(247,37,133,0.3),rgba(168,85,247,0.3));color:var(--text-primary);border-color:var(--primary-magenta);box-shadow:0 0 8px rgba(247,37,133,0.35)}.user-logout-btn{font-size:1.1rem;padding:0.25rem;opacity:0.6;transition:all var(--transition-fast);text-decoration:none;flex-shrink:0}.user-logout-btn:hover{opacity:1;transform:scale(1.1)}.skip-link{position:absolute;top:0;left:0;padding:0.75rem 1.25rem;background:var(--primary-cyan);color:var(--bg-darkest);font-family:var(--font-heading);font-weight:700;text-decoration:none;border-radius:0 0 6px 0;z-index:10000;transform:translateY(-150%);transition:transform 0.15s ease-out}.skip-link:focus{transform:translateY(0);outline:2px solid var(--primary-magenta);outline-offset:2px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md) var(--spacing-lg);font-family:var(--font-primary);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;border:2px solid;border-radius:var(--radius-md);cursor:pointer;transition:all 0.3s ease;text-decoration:none;position:relative;overflow:hidden}.btn-primary{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-primary:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15);transform:translateY(-2px)}.btn-secondary{background:rgba(148,163,184,0.1);border-color:var(--neon-gray);color:#94a3b8;box-shadow:0 0 10px rgba(100,116,139,0.3),0 0 20px rgba(100,116,139,0.2)}.btn-secondary:hover{background:rgba(148,163,184,0.2);border-color:#94a3b8;color:#cbd5e1;box-shadow:0 0 15px rgba(148,163,184,0.5),0 0 30px rgba(148,163,184,0.3)}.btn-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green);box-shadow:0 0 15px rgba(34,197,94,0.5),0 0 30px rgba(34,197,94,0.3),inset 0 0 20px rgba(34,197,94,0.1);text-shadow:0 0 10px rgba(34,197,94,0.8)}.btn-success:hover{background:rgba(34,197,94,0.25);box-shadow:0 0 20px rgba(34,197,94,0.7),0 0 40px rgba(34,197,94,0.4),inset 0 0 25px rgba(34,197,94,0.15);transform:translateY(-2px)}.btn-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange);box-shadow:0 0 15px rgba(245,158,11,0.5),0 0 30px rgba(245,158,11,0.3),inset 0 0 20px rgba(245,158,11,0.1);text-shadow:0 0 10px rgba(245,158,11,0.8)}.btn-warning:hover{background:rgba(245,158,11,0.25);box-shadow:0 0 20px rgba(245,158,11,0.7),0 0 40px rgba(245,158,11,0.4),inset 0 0 25px rgba(245,158,11,0.15);transform:translateY(-2px)}.btn-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3),inset 0 0 20px rgba(239,68,68,0.1);text-shadow:0 0 10px rgba(239,68,68,0.8)}.btn-danger:hover{background:rgba(239,68,68,0.25);box-shadow:0 0 20px rgba(239,68,68,0.7),0 0 40px rgba(239,68,68,0.4),inset 0 0 25px rgba(239,68,68,0.15);transform:translateY(-2px)}.btn-sm{padding:var(--spacing-sm) var(--spacing-md);font-size:0.8rem}.btn-xs{padding:3px 8px;font-size:0.7rem;line-height:1.2}.btn-lg{padding:var(--spacing-lg) var(--spacing-xl);font-size:1rem}.btn-icon{font-size:1.1em}.btn:focus-visible{outline:none;box-shadow:0 0 0 2px rgba(76,201,240,0.5),0 0 15px rgba(76,201,240,0.3)}.btn:disabled,.btn[disabled]{opacity:0.4;cursor:not-allowed;pointer-events:none;box-shadow:none}input[type="file"]::file-selector-button{padding:8px 16px;margin-right:12px;background:rgba(76,201,240,0.15);border:2px solid var(--neon-cyan);border-radius:var(--radius-md);color:var(--neon-cyan);cursor:pointer;font-weight:600;font-family:var(--font-primary);text-transform:uppercase;letter-spacing:0.5px;font-size:0.85rem;box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8);transition:all 0.3s ease}input[type="file"]::file-selector-button:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15)}.pagination{display:flex;justify-content:center;align-items:center;gap:var(--spacing-sm);margin-top:var(--spacing-xl)}.pagination-btn{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);cursor:pointer;transition:all var(--transition-fast)}.pagination-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan)}.pagination-btn.active{background:var(--primary-cyan);border-color:var(--primary-cyan);color:var(--bg-darkest)}.pagination-btn:disabled{opacity:0.5;cursor:not-allowed}.back-to-top-btn{position:fixed;bottom:30px;right:30px;width:50px;height:50px;border-radius:50%;background:rgba(20,26,35,0.95);border:2px solid var(--neon-cyan);color:var(--neon-cyan);font-size:1.5rem;cursor:pointer;opacity:0;visibility:hidden;transform:translateY(20px);transition:opacity 0.3s ease,visibility 0.3s ease,transform 0.3s ease,background 0.3s ease,box-shadow 0.3s ease;z-index:9998;display:flex;align-items:center;justify-content:center;box-shadow:0 0 15px rgba(76,201,240,0.3),inset 0 0 10px rgba(76,201,240,0.1)}.back-to-top-btn.visible{opacity:1;visibility:visible;transform:translateY(0)}.back-to-top-btn:hover{background:rgba(76,201,240,0.2);box-shadow:0 0 25px rgba(76,201,240,0.5),inset 0 0 15px rgba(76,201,240,0.2);transform:translateY(-3px)}.back-to-top-btn:active{transform:translateY(0)}.back-to-top-icon{font-weight:bold;text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-group{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.btn-danger-outline{background:rgba(239,68,68,0.1);border:2px solid var(--neon-red);color:var(--neon-red);box-shadow:0 0 10px rgba(239,68,68,0.3),0 0 20px rgba(239,68,68,0.2);text-shadow:0 0 8px rgba(239,68,68,0.6)}.btn-danger-outline:hover{background:rgba(239,68,68,0.2);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3)}.form-input,input[type="text"],input[type="number"],input[type="email"],input[type="password"],input[type="search"],input[type="date"],textarea{width:100%;padding:0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color 0.2s ease,box-shadow 0.2s ease}.form-input:focus,input[type="text"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="search"]:focus,input[type="date"]:focus,textarea:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}.form-input::placeholder,input::placeholder,textarea::placeholder{color:var(--text-muted)}select,.form-select{width:100%;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}select:focus,.form-select:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}select:hover,.form-select:hover{border-color:var(--neon-cyan)}select option{background:var(--bg-medium);color:var(--text-primary);padding:0.5rem}.styled-select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}.styled-select:hover{border-color:var(--primary-cyan)}.styled-select:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.toggle-switch{position:relative;display:inline-block;width:52px;height:28px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-red);border-radius:28px;transition:all 0.3s ease;box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(239,68,68,0.3)}.toggle-slider:before{position:absolute;content:"";height:22px;width:22px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all 0.3s ease;box-shadow:0 2px 4px rgba(0,0,0,0.3)}.toggle-switch input:checked + .toggle-slider{background:var(--neon-green);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 15px rgba(34,197,94,0.5)}.toggle-switch input:checked + .toggle-slider:before{transform:translateX(24px)}.toggle-switch input:focus + .toggle-slider{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 0 2px rgba(76,201,240,0.3)}.toggle-item{display:flex;align-items:center;gap:0.75rem}.toggle-label{color:var(--text-secondary);font-size:0.9rem;cursor:pointer;user-select:none;transition:color 0.2s ease}.toggle-label.active{color:var(--text-primary)}.toggle-field{display:flex;align-items:center;gap:var(--spacing-md)}.toggle-switch-wrapper{display:flex;align-items:center;gap:var(--spacing-sm)}.toggle-switch-wrapper .toggle-label-text{font-size:0.85rem;color:var(--text-secondary)}.form-group{margin-bottom:var(--spacing-md)}.form-label{display:block;font-size:0.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:var(--spacing-xs)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.locked-path-wrapper{display:flex;align-items:center;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);overflow:hidden;flex:1}.locked-path-wrapper:focus-within{border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.locked-path-prefix{padding:0.75rem 0 0.75rem 1rem;color:var(--text-muted);font-family:var(--font-mono);font-size:0.9rem;background:rgba(76,201,240,0.05);border-right:1px solid var(--border-color);white-space:nowrap;user-select:none}.locked-path-wrapper input{flex:1;padding:0.75rem 1rem;background:transparent;border:none;color:var(--text-primary);font-family:var(--font-mono);font-size:0.9rem;outline:none}.locked-path-wrapper input::placeholder{color:var(--text-muted)}.edit-section{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.edit-section:last-child{border-bottom:none;margin-bottom:0}.edit-section-title{color:var(--primary-cyan);font-family:var(--font-heading);font-size:1rem;font-weight:600;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-sm);border-bottom:1px solid rgba(76,201,240,0.3)}.edit-field{margin-bottom:var(--spacing-md)}.edit-field label{display:block;color:var(--text-primary);font-weight:500;font-size:0.9rem;margin-bottom:6px}.edit-field .field-tip{display:block;font-size:0.75rem;color:var(--text-muted);margin-top:4px;font-style:italic}.edit-field .title-lock-check,.gem-field .title-lock-check{display:flex;align-items:center;gap:var(--spacing-xs);margin:var(--spacing-xs) 0 0;font-size:0.8rem;font-weight:normal;color:var(--text-muted);text-transform:none;letter-spacing:normal;cursor:pointer}.edit-field .title-lock-check input,.gem-field .title-lock-check input{width:auto;padding:0;margin:0}.edit-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md) var(--spacing-lg)}.edit-grid .full-width{grid-column:1 / -1}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-md)}.form-help{font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.toggle-grid{display:flex;flex-wrap:wrap;gap:1.5rem}.card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);transition:all var(--transition-normal)}.card:hover{border-color:var(--card-border-hover);box-shadow:var(--shadow-lg)}.card + .card{margin-top:1.5rem}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.card-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:0.5rem;text-transform:uppercase;letter-spacing:1px}.card-body{color:var(--text-secondary)}.glass-card,.card.glass,.glass-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-light,.glass-panel-light{background:rgba(20,26,35,0.6) !important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.glass-medium,.glass-panel-medium{background:rgba(20,26,35,0.8) !important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}.glass-dark,.glass-panel-dark{background:rgba(20,26,35,0.9) !important;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.glass-panel-hover:hover{background:rgba(20,26,35,0.85);border-color:var(--card-border-hover);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-decoration:none;transition:all var(--transition-normal);display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;overflow:hidden}.system-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(180deg,transparent 60%,rgba(76,201,240,0.05) 100%);opacity:0;transition:opacity var(--transition-normal)}.system-card:hover{border-color:var(--primary-cyan);transform:translateY(-4px);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card:hover::before{opacity:1}.system-logo{width:80px;height:80px;margin-bottom:var(--spacing-md);display:flex;align-items:center;justify-content:center}.system-logo img{max-width:100%;max-height:100%;object-fit:contain;filter:brightness(0.9);transition:all var(--transition-normal)}.system-card:hover .system-logo img{filter:brightness(1) drop-shadow(0 0 8px var(--primary-cyan-glow));transform:scale(1.05)}.system-logo-placeholder{width:80px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:var(--text-muted)}.system-name{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-xs);text-transform:uppercase;letter-spacing:1px}.system-info{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.system-info-icon{color:var(--primary-cyan)}.section{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.status-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.status-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md) 0;border-bottom:1px solid var(--card-border)}.status-item:last-child{border-bottom:none}.status-info{display:flex;align-items:center;gap:var(--spacing-md)}.status-indicator{width:10px;height:10px;border-radius:50%;animation:pulse-status 2s ease-in-out infinite}.status-indicator.online{background:var(--status-online);box-shadow:0 0 10px var(--status-online)}.status-indicator.offline{background:var(--status-offline);box-shadow:0 0 10px var(--status-offline);animation:none}.status-name{color:var(--text-primary);font-weight:500}.status-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:1px}.status-badge.online{background:rgba(0,230,118,0.15);color:var(--status-online);border:1px solid rgba(0,230,118,0.3)}.status-badge.connected{background:rgba(76,201,240,0.15);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.status-badge.offline{background:rgba(255,82,82,0.15);color:var(--status-offline);border:1px solid rgba(255,82,82,0.3)}.scraper-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);margin-bottom:var(--spacing-xl)}.scraper-section h2{font-family:var(--font-heading);font-size:1.2rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.scraper-form{margin-bottom:var(--spacing-lg)}.system-controller-card{transition:border-color 0.2s ease,box-shadow 0.2s ease}.system-controller-card:hover{border-color:rgba(76,201,240,0.3);box-shadow:0 0 10px rgba(76,201,240,0.1)}.controller-item{transition:background 0.2s ease,transform 0.15s ease}.controller-item:hover{background:rgba(76,201,240,0.1) !important;transform:translateX(4px)}.controller-details{border-left:2px solid var(--neon-cyan)}.add-controller-form{border:1px solid rgba(34,197,94,0.2)}.add-controller-form:hover{border-color:rgba(34,197,94,0.3)}.badge{display:inline-flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);font-size:0.8rem;font-weight:500;color:var(--neon-cyan);white-space:nowrap}.badge-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.badge-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.badge-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.badge-purple{background:rgba(168,85,247,0.15);border-color:var(--neon-purple);color:var(--neon-purple)}.badge-secondary{background:rgba(100,116,139,0.15);border-color:var(--neon-gray);color:#94a3b8}.badge-removable{padding-right:0.5rem}.badge-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:0.25rem;background:transparent;border:none;border-radius:50%;color:inherit;cursor:pointer;font-size:1rem;line-height:1;opacity:0.7;transition:all 0.2s ease}.badge-remove:hover{opacity:1;background:rgba(255,255,255,0.1)}.game-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 10px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;white-space:nowrap;line-height:1;height:28px;box-sizing:border-box}.game-badge.esrb-e{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.esrb-e10{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.esrb-t{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.esrb-m{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.esrb-ao{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.esrb-rp{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.pegi-3{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.pegi-7{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.pegi-12{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.pegi-16{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.pegi-18{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.age-rating-badge{background:transparent;border-color:transparent;color:var(--text-secondary);padding:0}.game-badge.age-rating-badge .age-rating-img{height:28px;width:auto;display:block;filter:drop-shadow(0 1px 3px rgba(0,0,0,0.4))}.game-badge.age-rating-badge.crossmapped{opacity:0.75}.game-badge.genre{background:rgba(168,85,247,0.15);border-color:#a855f7;color:#a855f7}.game-badge.year{background:rgba(76,201,240,0.15);border-color:#4cc9f0;color:#4cc9f0}.game-badge.platform{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.region{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.players{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion{font-size:0.7rem}.game-badge.completion.not-started{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.completion.in-progress{background:rgba(245,158,11,0.15);border-color:#f59e0b;color:#f59e0b}.game-badge.completion.played{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion.completed{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.completion.\31 00-percent,.game-badge.completion.hundred-percent{background:linear-gradient(135deg,rgba(255,215,0,0.25) 0%,rgba(255,170,0,0.25) 100%);border-color:#ffd700;color:#ffd700;text-shadow:0 0 8px rgba(255,215,0,0.5);box-shadow:0 0 10px rgba(255,215,0,0.3),inset 0 0 10px rgba(255,215,0,0.1)}.game-badge.score-badge{font-weight:700;min-width:50px}.game-badge.score-badge.score-good{background:rgba(34,197,94,0.2);border-color:#22c55e;color:#22c55e}.game-badge.score-badge.score-mixed{background:rgba(234,179,8,0.2);border-color:#eab308;color:#fcd34d}.game-badge.score-badge.score-bad{background:rgba(239,68,68,0.2);border-color:#ef4444;color:#f87171}.meta-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.8rem;font-weight:600}.meta-badge.system{background:linear-gradient(135deg,var(--primary-cyan),var(--primary-cyan-dark));color:var(--bg-darkest)}.meta-badge.year{background:var(--bg-lighter);color:var(--text-primary);border:1px solid var(--card-border)}.meta-badge.genre{background:rgba(114,9,183,0.2);color:#b388ff;border:1px solid rgba(114,9,183,0.3)}.metadata-status{display:inline-flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-xs) var(--spacing-sm);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase}.metadata-status.complete{background:rgba(0,230,118,0.15);color:var(--status-success)}.metadata-status.missing{background:rgba(255,171,0,0.15);color:var(--status-warning)}.source-badge{font-weight:600;text-transform:uppercase}.source-badge.thegamesdb{background:rgba(76,175,80,0.2);color:#81c784}.source-badge.igdb{background:rgba(33,150,243,0.2);color:#64b5f6}.source-badge.primary-source{border:2px solid currentColor;position:relative}.source-badge.primary-source::before{content:"★ ";font-size:0.8em}.source-badge.fallback-source{opacity:0.75;border:1px dashed currentColor}.psn-platform-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;width:fit-content}.platform-ps3{background:rgba(205,154,42,0.15);color:#cd9a2a;border-color:rgba(205,154,42,0.3)}.platform-ps4{background:rgba(0,112,209,0.15);color:#4da6ff;border-color:rgba(0,112,209,0.3)}.platform-ps5{background:rgba(76,201,240,0.15);color:#4cc9f0;border-color:rgba(76,201,240,0.3)}.platform-psvita,.platform-vita{background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)}.data-table{width:100%;border-collapse:collapse}@media (max-width:600px){.page-box .data-table{display:block;overflow-x:auto}}@media (max-width:600px){.page-box .data-table.stack-narrow,.data-table.stack-narrow tbody,.data-table.stack-narrow tr,.data-table.stack-narrow td{display:block;overflow-x:visible}.data-table.stack-narrow thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.data-table.stack-narrow tr{margin-bottom:var(--spacing-sm);padding:var(--spacing-xs) 0;border:1px solid var(--card-border);border-radius:var(--radius-md)}.data-table.stack-narrow tr[hidden]{display:none}.data-table.stack-narrow td{padding:var(--spacing-xs) var(--spacing-sm);border-bottom:none;overflow-wrap:anywhere}.data-table.stack-narrow td[data-label]{display:flex;gap:var(--spacing-sm)}.data-table.stack-narrow td[data-label]::before{content:attr(data-label);flex:0 0 35%;color:var(--text-secondary);font-size:0.8rem;font-weight:600}}.data-table th{background:var(--bg-darker,#0d1117);color:var(--text-secondary);font-weight:600;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid var(--card-border);position:sticky;top:0;z-index:10}.data-table td{padding:0.75rem 1rem;border-bottom:1px solid var(--card-border);color:var(--text-primary);font-size:0.9rem}.data-table tbody tr:hover{background:rgba(76,201,240,0.05)}.games-table-container{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.games-table{width:100%;border-collapse:collapse}.games-table thead{background:linear-gradient(90deg,var(--bg-medium) 0%,var(--bg-light) 100%)}.games-table th{padding:var(--spacing-md) var(--spacing-lg);text-align:left;font-family:var(--font-heading);font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;border-bottom:1px solid var(--card-border)}.games-table tbody tr{border-bottom:1px solid var(--card-border);transition:background-color var(--transition-fast)}.games-table tbody tr:hover{background:var(--card-bg-hover)}.games-table tbody tr:last-child{border-bottom:none}.games-table td{padding:var(--spacing-md) var(--spacing-lg);vertical-align:middle}.game-row-boxart{width:60px;height:80px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--card-border)}.game-row-boxart.boxart-3d{object-fit:contain;background:var(--bg-medium)}.game-row-boxart-placeholder{width:60px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:1.5rem}.game-row-title{color:var(--text-primary);font-weight:600;text-decoration:none;transition:color var(--transition-fast)}.game-row-title:hover{color:var(--primary-cyan)}.game-row-system{color:var(--text-secondary);font-size:0.85rem}.game-row-genre{color:var(--text-secondary);font-size:0.85rem}.game-row-year{color:var(--text-secondary);font-size:0.85rem;font-family:var(--font-mono)}.score-list-section{background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.score-list-section h4{color:var(--text-primary);font-family:var(--font-heading);font-size:1.1rem;margin-bottom:var(--spacing-md)}.score-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.score-list-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;transition:var(--transition-fast)}.score-list-item:hover{border-color:var(--primary-cyan);background:var(--card-bg-hover)}.score-game-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.score-game-title{color:var(--text-primary);font-weight:600;font-size:0.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.score-game-system{color:var(--text-muted);font-size:0.8rem}.score-game-scores{display:flex;gap:var(--spacing-sm);flex-shrink:0}.score-lists-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-xl);margin-top:var(--spacing-xl)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.tool-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.tool-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.tool-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.tool-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.tool-tab .tab-icon{font-size:1.1rem}.tool-tab .tab-label{}.system-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.system-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.625rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-align:center;white-space:nowrap}.system-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.system-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);font-weight:600}.system-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.system-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}@media (max-width:768px){.tool-tabs,.system-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.tool-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.system-tab{padding:0.5rem 0.75rem;font-size:0.8rem}}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;visibility:hidden;transition:all 0.3s ease}.modal-overlay.active{opacity:1;visibility:visible}.modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);max-width:90%;max-height:90vh;overflow:auto;box-shadow:0 0 50px rgba(76,201,240,0.2)}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.modal-header h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--neon-cyan)}.modal-close,.custom-modal-close,.about-modal-close,.game-detail-modal-close,.game-edit-modal-close,.modal-close-btn,.archive-modal-close,.modal .modal-close{background:none;border:none;font-size:1.75rem;font-weight:normal;color:var(--text-secondary);cursor:pointer;padding:0;line-height:1;transition:color 0.2s}.modal-close:hover,.custom-modal-close:hover,.about-modal-close:hover,.game-detail-modal-close:hover,.game-edit-modal-close:hover,.modal-close-btn:hover,.archive-modal-close:hover,.modal .modal-close:hover{color:var(--neon-red)}.modal-close:focus-visible,.custom-modal-close:focus-visible,.about-modal-close:focus-visible,.game-detail-modal-close:focus-visible,.game-edit-modal-close:focus-visible,.modal-close-btn:focus-visible,.archive-modal-close:focus-visible,.modal .modal-close:focus-visible{outline:2px solid rgba(76,201,240,0.5);outline-offset:2px}.modal-body{padding:var(--spacing-lg)}.modal-footer{display:flex;justify-content:flex-end;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:10000;align-items:center;justify-content:center}.modal.active{display:flex}.modal-content{max-width:90%;max-height:90%;border-radius:var(--radius-lg)}.modal-nav{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;background:var(--card-bg);border:none;border-radius:50%;color:var(--text-primary);font-size:1.5rem;cursor:pointer;transition:all var(--transition-fast)}.modal-nav:hover{background:var(--primary-cyan);color:var(--bg-darkest)}.modal-nav.prev{left:var(--spacing-lg)}.modal-nav.next{right:var(--spacing-lg)}.custom-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:10100;align-items:center;justify-content:center}.custom-modal.active{display:flex}.custom-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:90%;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}.custom-modal-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.custom-modal-header h3{margin:0;font-family:var(--font-heading);color:var(--primary-cyan);font-size:1.1rem}.custom-modal-body{padding:var(--spacing-lg);color:var(--text-secondary);line-height:1.6}.custom-modal-body code{word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}.custom-modal-body ul{max-width:100%;overflow:hidden}.custom-modal-body li{word-break:break-word;overflow-wrap:break-word}.custom-modal-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:flex-end;gap:var(--spacing-sm)}.about-modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:10000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}.about-modal-overlay.active{display:flex}.about-modal{background:linear-gradient(135deg,rgba(20,26,35,0.98),rgba(15,20,25,0.98));border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);max-width:560px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 0 40px rgba(76,201,240,0.3),0 20px 40px rgba(0,0,0,0.5);animation:modalSlideIn 0.3s ease}.about-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid var(--card-border)}.about-modal-header h2{margin:0;font-size:1.25rem;font-family:var(--font-heading);color:var(--primary-cyan)}.about-modal-body{padding:1.5rem}.about-logo{display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid var(--card-border)}.about-logo-icon{font-size:3rem;filter:drop-shadow(0 0 10px rgba(76,201,240,0.5))}.about-logo-text{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.about-logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.about-details{display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1.5rem}.about-item{display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.about-item.about-description{flex-direction:column;align-items:flex-start;gap:0.5rem}.about-label{color:var(--text-secondary);font-size:0.85rem;font-weight:500}.about-value{color:var(--text-primary);font-weight:600}.about-description .about-value{font-weight:400;font-size:0.9rem;line-height:1.5}.about-links{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem}.folder-item{display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;border-radius:var(--radius-md);cursor:pointer;transition:background 0.15s ease;background:transparent;border:none;width:100%;text-align:left;font:inherit;color:inherit;appearance:none;-webkit-appearance:none}.folder-item:hover{background:rgba(76,201,240,0.1)}.folder-item.folder-parent{border-bottom:1px solid var(--border-color);margin-bottom:0.5rem;padding-bottom:0.75rem;border-radius:0}.folder-item .folder-icon{font-size:1.25rem}.folder-item .folder-name{flex:1;color:var(--text-primary);font-weight:500}.folder-item .folder-info{color:var(--text-muted);font-size:0.8rem}@keyframes toast-slide-in{from{transform:translateX(-100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes toast-slide-out{from{transform:translateX(0);opacity:1}to{transform:translateX(-100%);opacity:0}}.unified-toast-container{position:fixed;bottom:24px;left:280px;z-index:10000;display:flex;flex-direction:column-reverse;gap:10px;width:360px}.sidebar-collapsed ~ .main-content .unified-toast-container,body:has(.sidebar.collapsed) .unified-toast-container{left:calc(var(--sidebar-collapsed-width, 70px) + 20px)}@media (max-width:768px){.unified-toast-container{left:16px;right:16px;width:auto;max-width:none}}.unified-toast{animation:toast-slide-in 0.3s ease-out forwards;animation-iteration-count:1;width:100%;max-width:360px;border-radius:var(--radius-lg);overflow:hidden;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.unified-toast.removing{animation:toast-slide-out 0.3s ease-out forwards}.toast-content{display:flex;align-items:stretch;background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)));border:2px solid var(--toast-color,var(--primary-cyan));border-radius:var(--radius-lg);box-shadow:0 0 20px rgba(var(--toast-color-rgb,76,201,240),0.3),0 4px 20px rgba(0,0,0,0.4);backdrop-filter:blur(10px)}.active-toast .toast-content{background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)))}.queued-toast .toast-content{background:linear-gradient(135deg,var(--toast-queued-bg-from,rgba(25,30,40,0.90)),var(--toast-queued-bg-to,rgba(35,45,60,0.90)));opacity:0.9}.toast-main{flex:1;display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;transition:background var(--transition-fast);min-width:0}.toast-main:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.08)}.toast-icon{font-size:1.5rem;flex-shrink:0}.toast-icon:not(.paused){animation:pulse 1.5s ease-in-out infinite}.toast-icon.paused{animation:none;opacity:0.7}.toast-info{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}.toast-title{font-weight:600;font-size:0.9rem;color:var(--toast-color,var(--primary-cyan))}.toast-title.paused{color:var(--neon-orange)}.toast-system-name{color:var(--neon-purple);font-size:0.75rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-subtitle{color:var(--text-primary);font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-progress-bar{height:4px;background:rgba(var(--toast-color-rgb,76,201,240),0.2);border-radius:2px;overflow:hidden;margin:4px 0}.toast-progress-fill{height:100%;background:linear-gradient(90deg,var(--toast-color,var(--primary-cyan)),var(--secondary-magenta,#f72585));border-radius:2px;transition:width var(--transition-normal)}.toast-progress-text{display:flex;justify-content:space-between;font-size:0.75rem;color:var(--text-secondary);font-family:var(--font-mono)}.toast-stats{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:0.85rem;font-family:var(--font-mono);margin-top:2px}.toast-stats .stat{display:flex;align-items:center;gap:3px;white-space:nowrap}.toast-stats .stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700;color:var(--text-primary)}.toast-stats .stat.success,.toast-stats .stat-success{color:var(--neon-green)}.toast-stats .stat.failed,.toast-stats .stat-failed{color:var(--neon-red)}.toast-stats .stat.skipped,.toast-stats .stat-skipped{color:var(--neon-orange)}.toast-meta{font-size:0.75rem;color:var(--text-muted)}.toast-queued-info{font-size:0.75rem;color:var(--text-muted)}.toast-controls{display:flex;flex-direction:column;border-left:1px solid rgba(var(--toast-color-rgb,76,201,240),0.2);flex-shrink:0}.toast-btn,.toast-ctrl-btn{flex:1;padding:8px 10px;border:none;background:transparent;cursor:pointer;font-size:0.9rem;transition:all var(--transition-fast);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-width:36px}.toast-btn:hover,.toast-ctrl-btn:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--toast-color,var(--primary-cyan))}.toast-btn.pause:hover,.toast-btn.pause.is-paused,.toast-ctrl-btn.pause:hover{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.toast-btn.pause.is-paused{background:rgba(34,197,94,0.15);color:var(--neon-green)}.toast-btn.cancel:hover,.toast-ctrl-btn.cancel:hover{background:rgba(239,68,68,0.2);color:var(--neon-red)}.bulk-scrape-toast{--toast-color:var(--primary-cyan);--toast-color-rgb:76,201,240}.ra-sync-toast{--toast-color:var(--neon-orange);--toast-color-rgb:245,158,11}.ra-sync-toast .toast-main:hover{background:rgba(245,158,11,0.08)}.ra-refresh-toast{--toast-color:var(--neon-purple);--toast-color-rgb:168,85,247}.ra-refresh-toast .toast-main:hover{background:rgba(168,85,247,0.08)}.bulk-scrape-queued-toast{--toast-color:var(--primary-cyan-dark);--toast-color-rgb:58,159,194}.ra-sync-queued-toast{--toast-color:#c47d09;--toast-color-rgb:196,125,9}.ra-refresh-queued-toast{--toast-color:#8b46d4;--toast-color-rgb:139,70,212}.image-resize-toast{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.image-resize-toast .toast-main:hover{background:rgba(34,197,94,0.08)}.toast-completed{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.queued-toast{width:360px}.queued-toast .toast-content{padding:0}.queued-toast .toast-main{padding:10px 14px}.queued-toast .toast-icon{font-size:1.2rem}.queued-toast .toast-title{font-size:0.85rem}.queued-toast .toast-controls{flex-direction:row;min-width:36px}.queued-toast .toast-btn{min-width:36px;min-height:36px}.notification-container{position:fixed;top:20px;right:20px;z-index:10002;display:flex;flex-direction:column;gap:10px;max-width:400px}.notification{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg),0 0 20px rgba(0,0,0,0.3);transform:translateX(120%);opacity:0;transition:all var(--transition-normal)}.notification.show{transform:translateX(0);opacity:1}.notification-icon{font-size:1.2rem;flex-shrink:0}.notification-message{flex:1;color:var(--text-primary);font-size:0.9rem;line-height:1.4}.notification-close{background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;opacity:0.6;transition:opacity 0.2s}.notification-close:hover{opacity:1;color:var(--text-primary)}.notification-success{border-color:var(--neon-green);box-shadow:var(--shadow-lg),0 0 15px rgba(34,197,94,0.2)}.notification-error{border-color:var(--neon-red);box-shadow:var(--shadow-lg),0 0 15px rgba(239,68,68,0.2)}.notification-warning{border-color:var(--neon-orange);box-shadow:var(--shadow-lg),0 0 15px rgba(245,158,11,0.2)}.notification-info{border-color:var(--primary-cyan);box-shadow:var(--shadow-lg),0 0 15px rgba(76,201,240,0.2)}.progress-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;position:relative;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);border-radius:calc(var(--radius-md) - 2px);display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);transition:width 0.3s ease;min-width:0}.progress-bar[data-percent="0"]{min-width:0;width:0%}.progress-bar:not([data-percent="0"]){min-width:60px}.progress-stats{display:flex;justify-content:space-between;color:var(--text-secondary);font-size:0.85rem;margin-top:0.5rem}.progress-container-sm{height:24px;border-width:1px}.progress-container-sm .progress-bar{font-size:0.75rem}.loading{display:flex;align-items:center;justify-content:center;padding:var(--spacing-2xl)}.loading-spinner{width:40px;height:40px;border:3px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-spinner-small{width:20px;height:20px;border:2px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-text{margin-left:var(--spacing-md);color:var(--text-secondary)}.loading-overlay{position:fixed;inset:0;background:rgba(10,14,23,0.85);display:flex;align-items:center;justify-content:center;z-index:10001;opacity:0;visibility:hidden;transition:all 0.3s ease}.loading-overlay.active{opacity:1;visibility:visible}.loading-content{display:flex;flex-direction:column;align-items:center;gap:16px}.loading-content .loading-text{color:var(--text-primary);font-size:1rem;font-family:var(--font-primary);margin-left:0}.empty-state{text-align:center;padding:var(--spacing-2xl);color:var(--text-secondary)}.empty-state-icon{font-size:4rem;margin-bottom:var(--spacing-md);opacity:0.5}.empty-state-title{font-size:1.2rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.empty-state-text{margin-bottom:var(--spacing-lg)}.scan-log-container{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:var(--spacing-lg)}.scan-log-header{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-dark);border-bottom:1px solid var(--border-color)}.scan-log-header h4{margin:0;font-size:0.9rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.5rem}.scan-log-content{height:200px;overflow-y:auto;padding:0.75rem 1rem;font-family:var(--font-mono);font-size:0.8rem;line-height:1.6}.scan-log-content .log-entry{color:var(--text-secondary);padding:0.15rem 0}.scan-log-content .log-entry.info{color:var(--primary-cyan)}.scan-log-content .log-entry.warning{color:var(--neon-orange)}.scan-log-content .log-entry.error{color:var(--neon-red)}.rate-limits-container{background:var(--bg-darker,#0d1117);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-md)}.rate-limit-header{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:0.8rem;padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.rate-limit-item{display:flex;flex-direction:column;gap:var(--spacing-xs);padding:var(--spacing-md);background:rgba(255,255,255,0.02);border-radius:var(--radius-sm);border-left:3px solid var(--text-muted)}.rate-limit-item.status-green{border-left-color:var(--neon-green)}.rate-limit-item.status-yellow{border-left-color:var(--neon-orange)}.rate-limit-item.status-red{border-left-color:var(--neon-red)}.rate-limit-item.status-gray{border-left-color:var(--text-muted)}.rate-limit-item strong{color:var(--text-primary);font-size:0.95rem}.rate-limit-value{color:var(--text-secondary);font-size:0.9rem}.rate-limit-bar{width:100%;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;margin:4px 0}.rate-limit-bar-fill{height:100%;border-radius:3px;transition:width 0.6s ease}.rate-limit-detail{color:var(--text-muted);font-size:0.8rem;line-height:1.5}.progress-bar-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;margin-bottom:0.5rem;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);width:0%;transition:width 0.3s ease;display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);border-radius:calc(var(--radius-md) - 2px)}.progress-details{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:0.75rem}.progress-status{color:var(--text-secondary);font-size:0.9rem}.progress-status strong{color:var(--primary-cyan)}.scanner-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:1.5rem;margin-bottom:1.5rem}.scanner-section h3{color:var(--text-primary);font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}.scanner-controls{display:flex;flex-wrap:wrap;gap:1rem;align-items:center}.quick-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{display:inline-flex;align-items:center;gap:4px;padding:6px 12px;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);font-size:0.85rem;color:var(--text-secondary);white-space:nowrap}.tag.tag-system{background:rgba(76,201,240,0.15);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.tag.tag-date{background:rgba(168,85,247,0.15);border-color:rgba(168,85,247,0.4);color:#c084fc}.tag.tag-genre{background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80}.genre-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.genre-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);border-radius:var(--radius-full);color:var(--neon-purple);font-size:0.8rem;font-weight:500}.genre-tag .remove-genre{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.genre-tag .remove-genre:hover{opacity:1}.genre-dropdown{position:relative}.genre-dropdown-menu{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);z-index:100;display:none}.genre-dropdown-menu.active{display:block}.genre-option{padding:8px 12px;cursor:pointer;transition:background 0.15s}.genre-option:hover{background:rgba(76,201,240,0.1)}.genre-option.selected{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.controller-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.controller-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);color:var(--neon-cyan);font-size:0.8rem;font-weight:500}.controller-tag .remove-controller{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.controller-tag .remove-controller:hover{opacity:1}.controller-dropdown{position:relative}.active-filter-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.active-filter-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);color:var(--primary-cyan);font-size:0.8rem;font-weight:500}.active-filter-tag .remove-filter{background:none;border:none;color:var(--primary-cyan);font-size:1rem;line-height:1;cursor:pointer;padding:0;opacity:0.7;transition:opacity 0.2s}.active-filter-tag .remove-filter:hover{opacity:1}.gem-tag-container{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);min-height:36px;padding:var(--spacing-sm);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-xs)}.gem-tag-container:empty::before{content:'None selected';color:var(--text-muted);font-style:italic;font-size:0.85rem}.gem-tag-dropdown{width:100%;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem;cursor:pointer}.gem-tag-dropdown:hover,.gem-tag-dropdown:focus{border-color:var(--primary-cyan);outline:none}.gem-controller-inputs{display:flex;gap:var(--spacing-xs)}.gem-controller-inputs select{flex:1}.gem-custom-input{flex:1;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem}.gem-custom-input:hover,.gem-custom-input:focus{border-color:var(--primary-cyan);outline:none}#queueManagerModal .queue-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:10px;transition:all 0.2s ease}#queueManagerModal .queue-item:hover{border-color:var(--primary-cyan)}#queueManagerModal .queue-item.running{border-color:var(--primary-cyan);border-width:2px;background:rgba(76,201,240,0.05)}#queueManagerModal .queue-item.queued{border-color:var(--neon-orange)}#queueManagerModal .queue-item-icon{font-size:1.5rem;flex-shrink:0}#queueManagerModal .queue-item-info{flex:1;min-width:0}#queueManagerModal .queue-item-title{font-weight:600;color:var(--text-primary);margin-bottom:2px}#queueManagerModal .queue-item-system{font-size:0.85rem;color:var(--neon-purple)}#queueManagerModal .queue-item.queued .queue-item-system{color:var(--neon-orange)}#queueManagerModal .queue-item-games{font-size:0.8rem;color:var(--text-secondary)}#queueManagerModal .queue-item-progress{font-size:0.75rem;color:var(--primary-cyan);margin-top:4px}#queueManagerModal .queue-item-controls{display:flex;flex-direction:column;gap:4px}#queueManagerModal .queue-btn{padding:6px 10px;border:1px solid var(--card-border);border-radius:var(--radius-sm);background:var(--bg-medium);color:var(--text-secondary);cursor:pointer;font-size:0.8rem;transition:all 0.15s ease;white-space:nowrap}#queueManagerModal .queue-btn:hover{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}#queueManagerModal .queue-btn.danger:hover{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}#queueManagerModal .queue-btn:disabled{opacity:0.3;cursor:not-allowed}#queueManagerModal .queue-empty{text-align:center;color:var(--text-muted);padding:20px;font-style:italic}.launch-indicator{position:fixed;bottom:var(--spacing-lg);right:var(--spacing-lg);z-index:1000;display:inline-flex;align-items:center;gap:0.5rem;padding:0.4rem 0.9rem;background:var(--bg-card);border:1px solid var(--primary-cyan);border-radius:999px;box-shadow:0 0 12px rgba(76,201,240,0.35);color:var(--text-primary);font-family:var(--font-mono,monospace);font-size:0.85rem;cursor:pointer;transition:opacity 0.2s ease,transform 0.2s ease}.launch-indicator.hidden{opacity:0;pointer-events:none;transform:translateY(8px)}.launch-indicator-icon{color:var(--neon-green,#44ff66);font-weight:bold}.launch-indicator-count{font-weight:bold;color:var(--primary-cyan)}.launch-indicator-popover{position:fixed;bottom:calc(var(--spacing-lg) + 2.5rem);right:var(--spacing-lg);z-index:1001;min-width:280px;padding:var(--spacing-md);background:var(--bg-card);border:1px solid var(--primary-cyan);border-radius:var(--border-radius);box-shadow:0 0 20px rgba(76,201,240,0.4);color:var(--text-primary)}.launch-indicator-popover-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan);margin-bottom:var(--spacing-sm);text-transform:uppercase;letter-spacing:0.05em}.launch-indicator-popover-row{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:0.4rem 0;border-top:1px solid var(--border-color,rgba(255,255,255,0.08));font-size:0.85rem}.launch-indicator-popover-row:first-of-type{border-top:none}.global-search{position:relative;margin-top:var(--spacing-md)}.global-search-input{width:100%;padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.global-search-input::placeholder{color:var(--text-muted)}.global-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:var(--shadow-glow-cyan)}.global-search-list{position:absolute;top:calc(100% + var(--spacing-xs));left:0;right:0;z-index:1001;max-height:60vh;overflow-y:auto;background:var(--card-bg);border:1px solid var(--card-border-hover);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:var(--spacing-xs) 0}.global-search-group{padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em}.global-search-option{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-primary);text-decoration:none;min-height:36px}.global-search-option:hover,.global-search-option[aria-selected="true"]{background:var(--card-bg-hover);color:var(--text-accent)}.global-search-thumb{flex:0 0 24px;height:32px;display:flex;align-items:center;justify-content:center}.global-search-thumb img{max-width:24px;max-height:32px;object-fit:contain}.global-search-text{flex:1;min-width:0;display:flex;flex-direction:column}.global-search-label{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.25}.global-search-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:0.8rem}.global-search-empty{padding:var(--spacing-sm);color:var(--text-muted)}.game-card-wrapper{background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden;transition:all var(--transition-normal)}.game-card-wrapper:hover{border-color:var(--primary-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2);transform:translateY(-2px)}.game-hero{display:grid;grid-template-columns:300px 1fr;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);position:relative;overflow:hidden}.game-hero::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan))}.hero-boxart{width:300px;flex-shrink:0;position:relative}.hero-boxart .game-boxart-image{width:100%;height:auto;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.hero-info{display:flex;flex-direction:column;min-width:0;flex:1}.game-boxart-container{position:relative}.game-boxart{width:100%;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.game-boxart-placeholder{width:100%;aspect-ratio:3/4;background:var(--bg-lighter);border-radius:var(--radius-lg);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-muted);font-size:4rem;border:2px dashed var(--card-border)}.game-boxart-placeholder span{font-size:1rem;margin-top:var(--spacing-md)}.game-info{display:flex;flex-direction:column}.game-title-section{margin-bottom:var(--spacing-lg)}.game-title{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);margin-bottom:var(--spacing-sm);line-height:1.2}.game-meta-badges{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.game-ratings{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.rating-display{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-lighter);border-radius:var(--radius-md);border:1px solid var(--card-border)}.rating-display img{height:32px}.rating-display .rating-text{font-weight:600;font-size:0.9rem}.rating-display.esrb{border-color:var(--rating-esrb-border);background:var(--rating-esrb-bg)}.rating-display.esrb .rating-text{color:var(--rating-esrb)}.rating-display.pegi{border-color:var(--rating-pegi-border);background:var(--rating-pegi-bg)}.rating-display.pegi .rating-text{color:var(--rating-pegi)}.game-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.detail-item{display:flex;flex-direction:column;gap:var(--spacing-xs)}.detail-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.detail-value{color:var(--text-primary);font-size:0.95rem}.game-description{margin-bottom:var(--spacing-lg)}.game-description h3{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.game-description p{color:var(--text-secondary);line-height:1.7}.screenshots-section{margin-bottom:var(--spacing-xl)}.screenshots-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--spacing-md)}.screenshot-item{position:relative;aspect-ratio:16/9;border-radius:var(--radius-md);overflow:hidden;cursor:pointer;border:2px solid var(--card-border);transition:all var(--transition-fast)}.screenshot-item:hover{border-color:var(--primary-cyan);transform:scale(1.02)}.screenshot-item img{width:100%;height:100%;object-fit:cover}.screenshot-item::after{content:'🔍';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;opacity:0;transition:opacity var(--transition-fast)}.screenshot-item:hover::after{opacity:1}.search-results{margin-top:var(--spacing-lg)}.search-result{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm);transition:all var(--transition-fast)}.search-result:hover{border-color:var(--primary-cyan);background:var(--bg-light)}.search-result-info h4{color:var(--text-primary);font-weight:600;margin-bottom:var(--spacing-xs)}.search-result-meta{display:flex;gap:var(--spacing-sm);font-size:0.8rem}.search-result-meta span{padding:2px 8px;background:var(--bg-lighter);border-radius:var(--radius-sm);color:var(--text-secondary)}.search-result-alts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:var(--spacing-xs);font-size:0.78rem}.search-result-alts-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;font-size:0.7rem;font-weight:600}.game-detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.9);backdrop-filter:blur(4px);z-index:10000;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-detail-modal.active{display:flex}.game-detail-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:100%;max-width:800px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 80px rgba(76,201,240,0.1)}.game-detail-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.game-detail-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);min-height:300px;color:var(--text-secondary)}.game-detail-content{padding:var(--spacing-lg)}.gdm-header{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.gdm-boxart-container{flex-shrink:0;width:180px}.gdm-boxart{width:100%;border-radius:var(--radius-md);box-shadow:0 4px 20px rgba(0,0,0,0.3)}.gdm-info{flex:1}.gdm-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin:0 0 var(--spacing-sm) 0}.gdm-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-system-badge{background:var(--primary-cyan);color:var(--bg-darkest);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.gdm-year,.gdm-players{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--text-primary);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600;border:1px solid rgba(var(--toast-color-rgb,76,201,240),0.25)}.gdm-ratings{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-ratings .rating-badge{display:inline-block;padding:4px 8px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-ratings .rating-badge.esrb{background:rgba(168,85,247,0.2);color:var(--neon-purple);border:1px solid rgba(168,85,247,0.3)}.gdm-ratings .rating-badge.score{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .completion-badge{display:inline-block;padding:4px 10px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-completion .status-in_progress,.gdm-completion .status-in-progress{background:rgba(76,201,240,0.2);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.gdm-completion .status-completed{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .status-played{background:rgba(59,130,246,0.2);color:#3b82f6;border:1px solid rgba(59,130,246,0.3)}.gdm-completion .status-100_percent,.gdm-completion .status-100-percent{background:linear-gradient(135deg,rgba(245,158,11,0.2),rgba(247,37,133,0.2));color:var(--completion-gold);border:1px solid rgba(245,158,11,0.3)}.gdm-description{color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.gdm-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-detail{display:flex;flex-direction:column}.gdm-detail .label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.gdm-detail span:last-child{color:var(--text-primary)}.gdm-achievements{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.ra-badge-modal{background:linear-gradient(135deg,var(--neon-orange),#d97706);color:white;padding:6px 12px;border-radius:var(--radius-md);font-weight:600}.gdm-achievements .game-badge.achievement-progress{font-size:0.75rem;padding:4px 10px}.gdm-achievements .ach-icon{width:16px;height:16px;vertical-align:middle}.gdm-achievements .ach-bar{display:inline-block;width:40px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle;margin-left:4px}.gdm-achievements .ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.gdm-screenshots{display:flex;gap:var(--spacing-sm);overflow-x:auto;padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-screenshot{height:80px;border-radius:var(--radius-sm);cursor:pointer;transition:transform var(--transition-fast)}.gdm-screenshot:hover{transform:scale(1.05)}.gdm-video{margin-bottom:var(--spacing-lg)}.gdm-video video{width:100%;max-height:300px;border-radius:var(--radius-md);background:#000}.gdm-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.gdm-lightbox.active{display:flex}.gdm-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md)}.gdm-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.gdm-lightbox-close:hover{color:var(--primary-cyan)}.gdm-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.gdm-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gdm-lightbox-nav.prev{left:20px}.gdm-lightbox-nav.next{right:20px}.gdm-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-size:0.9rem;font-family:var(--font-mono)}.gdm-lightbox-hint{position:absolute;bottom:45px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.gdm-actions{display:flex;gap:var(--spacing-md);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.game-edit-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.95);backdrop-filter:blur(4px);z-index:10001;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-edit-modal.active{display:flex}.game-edit-modal-content{background:var(--bg-medium);border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 60px rgba(76,201,240,0.15)}.game-edit-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.gem-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.gem-title{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin:0;padding-right:2rem}.gem-body{padding:var(--spacing-lg);display:grid}.gem-section{margin-bottom:var(--spacing-lg)}.gem-section:last-child{margin-bottom:0}.gem-label{display:block;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.gem-select{width:100%;padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:1rem;cursor:pointer;transition:border-color var(--transition-fast)}.gem-select:hover,.gem-select:focus{border-color:var(--primary-cyan);outline:none}.gem-hltb-display{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.gem-hltb-empty{color:var(--text-muted);font-style:italic}.gem-hltb-current{color:var(--text-primary);display:block}.gem-hltb-match-info{display:block;font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.gem-hltb-actions{display:flex;gap:var(--spacing-sm)}.gem-hltb-results{margin-top:var(--spacing-md);padding:var(--spacing-md);background:rgba(76,201,240,0.05);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md)}.gem-hltb-searching{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary)}.gem-hltb-match{margin-bottom:var(--spacing-md)}.gem-hltb-match-title{display:block;color:var(--text-primary);font-weight:600}.gem-hltb-match-confidence{font-size:0.8rem;color:var(--neon-green)}.gem-hltb-match-platform{display:block;font-size:0.8rem;color:var(--neon-purple);margin-top:2px}.gem-hltb-times{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.gem-hltb-time{text-align:center;padding:var(--spacing-sm);background:rgba(0,0,0,0.2);border-radius:var(--radius-sm)}.gem-hltb-time-label{display:block;font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px}.gem-hltb-time-value{display:block;color:var(--primary-cyan);font-weight:600}.gem-hltb-error{color:var(--neon-red);font-size:0.9rem}.gem-footer{display:flex;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.gem-footer-right{display:flex;gap:var(--spacing-sm)}.gem-tabs{display:flex;gap:var(--spacing-xs);margin-top:var(--spacing-md);overflow-x:auto;padding-bottom:var(--spacing-xs)}.gem-tab{padding:8px 12px;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast);white-space:nowrap}.gem-tab:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.gem-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gem-tab-content{grid-area:1 / 1;visibility:hidden;pointer-events:none}.gem-tab-content.active{visibility:visible;pointer-events:auto}.gem-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md)}.gem-field{display:flex;flex-direction:column}.gem-field label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-xs)}.gem-field input,.gem-field select,.gem-field textarea{padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;transition:border-color var(--transition-fast)}.gem-field input:hover,.gem-field input:focus,.gem-field select:hover,.gem-field select:focus,.gem-field textarea:hover,.gem-field textarea:focus{border-color:var(--primary-cyan);outline:none}.gem-field input::placeholder,.gem-field textarea::placeholder{color:var(--text-muted)}.gem-field-full{grid-column:1 / -1}.gem-field textarea{resize:vertical;min-height:120px}.gem-field-row{display:flex;gap:var(--spacing-xs)}.gem-field-row input{flex:1}.gem-field-row .btn{flex-shrink:0}.gem-toggle-row{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) 0}.gem-toggle-label{color:var(--text-secondary);font-size:0.9rem}.psn-profile-bar{display:flex;align-items:center;gap:var(--spacing-lg);padding:var(--spacing-md) var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-avatar{width:48px;height:48px;border-radius:50%;border:2px solid var(--primary-cyan);object-fit:cover}.psn-avatar-placeholder{width:48px;height:48px;border-radius:50%;border:2px solid var(--card-border);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.psn-profile-info{flex:1}.psn-username{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary)}.psn-level{font-size:0.8rem;color:var(--text-secondary)}.psn-level strong{color:var(--primary-cyan)}.psn-sync-status{font-size:0.75rem;color:var(--text-muted);display:flex;align-items:center;gap:6px}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-muted);pointer-events:none}.psn-search-input{width:100%;padding:10px 12px 10px 36px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem}.psn-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.psn-search-input::placeholder{color:var(--text-muted)}.psn-sort-controls{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-sort-label{font-size:0.8rem;color:var(--text-muted);white-space:nowrap}.psn-sort-select{padding:8px 32px 8px 12px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.psn-games-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-trophy-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);transition:all var(--transition-fast);text-decoration:none;color:inherit}.psn-trophy-game-card:hover{border-color:var(--primary-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.psn-trophy-game-thumb{width:64px;height:64px;border-radius:var(--radius-sm);object-fit:cover;border:1px solid var(--card-border);flex-shrink:0}.psn-trophy-game-thumb-placeholder{width:64px;height:64px;border-radius:var(--radius-sm);background:var(--bg-medium);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0}.psn-trophy-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.psn-trophy-game-name{font-size:0.9rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.psn-trophy-game-npwr{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted)}.psn-trophy-game-progress{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-progress-track{flex:1;height:6px;background:var(--bg-dark);border-radius:3px;overflow:hidden}.psn-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:3px;transition:width var(--transition-normal)}.psn-progress-pct{font-family:var(--font-mono);font-size:0.7rem;color:var(--primary-cyan);min-width:32px;text-align:right}.psn-trophy-mini-badges{display:flex;gap:6px}.trophy-mini-badge{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;font-weight:600}.tmb-platinum{color:var(--trophy-platinum)}.tmb-gold{color:var(--trophy-gold)}.tmb-silver{color:var(--trophy-silver)}.tmb-bronze{color:var(--trophy-bronze)}.psn-game-info-card{display:flex;gap:var(--spacing-lg);padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-game-boxart{width:180px;flex-shrink:0}.psn-game-boxart img{width:100%;border-radius:var(--radius-md);border:1px solid var(--card-border)}.psn-game-boxart-placeholder{width:100%;aspect-ratio:1;background:var(--bg-medium);border-radius:var(--radius-md);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:4rem}.psn-game-meta{flex:1}.psn-game-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-bottom:var(--spacing-md)}.psn-game-description{font-size:0.85rem;color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-md);max-height:100px;overflow:hidden}.psn-meta-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-meta-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:2px}.psn-meta-value{font-size:0.85rem;color:var(--text-secondary)}.psn-trophy-progress-card{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-trophy-progress-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--spacing-md)}.psn-trophy-progress-title{font-family:var(--font-heading);font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px}.psn-trophy-progress-fraction{font-family:var(--font-mono);font-size:1rem;color:var(--text-secondary)}.psn-trophy-progress-pct{font-family:var(--font-heading);font-size:1.2rem;font-weight:700;color:var(--primary-cyan)}.psn-trophy-progress-bar-container{height:28px;background:rgba(26,26,62,0.8);border:2px solid var(--primary-cyan);border-radius:var(--radius-md);overflow:hidden;margin-bottom:var(--spacing-md);box-shadow:0 0 15px rgba(76,201,240,0.2)}.psn-trophy-progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:var(--radius-md);transition:width 0.8s ease;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7)}.psn-trophy-type-breakdown{display:flex;justify-content:center;gap:var(--spacing-xl)}.psn-trophy-type-item{display:flex;align-items:center;gap:6px;font-size:0.85rem}.psn-trophy-type-icon{font-size:1.1rem}.psn-trophy-type-count{font-family:var(--font-mono)}.type-platinum .psn-trophy-type-count{color:var(--trophy-platinum)}.type-gold .psn-trophy-type-count{color:var(--trophy-gold)}.type-silver .psn-trophy-type-count{color:var(--trophy-silver)}.type-bronze .psn-trophy-type-count{color:var(--trophy-bronze)}.psn-info-panels-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.psn-info-panel{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-info-panel-title{font-family:var(--font-heading);font-size:0.9rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md);display:flex;align-items:center;gap:8px}.psn-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-sm) var(--spacing-lg)}.psn-detail-label{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.psn-detail-value{font-size:0.85rem;color:var(--text-secondary)}.psn-mono{font-family:var(--font-mono);font-size:0.8rem}.psn-hltb-input{width:100%;padding:8px 12px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;margin-bottom:var(--spacing-sm)}.psn-hltb-input:focus{outline:none;border-color:var(--primary-cyan)}.psn-trophy-lists-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg)}.psn-trophy-list-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.psn-trophy-list-header{padding:var(--spacing-md) var(--spacing-lg);border-bottom:1px solid var(--card-border);font-family:var(--font-heading);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}.psn-trophy-list-header.header-unlocked{color:var(--neon-green)}.psn-trophy-list-header.header-locked{color:var(--neon-gray)}.psn-trophy-list-body{max-height:600px;overflow-y:auto;padding:var(--spacing-sm)}.psn-trophy-list-body::-webkit-scrollbar{width:6px}.psn-trophy-list-body::-webkit-scrollbar-track{background:var(--bg-dark)}.psn-trophy-list-body::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:3px}.psn-trophy-list-body::-webkit-scrollbar-thumb:hover{background:var(--primary-cyan)}.psn-trophy-entry{display:flex;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-radius:var(--radius-md);transition:background var(--transition-fast)}.psn-trophy-entry:hover{background:rgba(76,201,240,0.03)}.psn-trophy-entry + .psn-trophy-entry{border-top:1px solid rgba(42,53,66,0.5)}.psn-trophy-icon-box{width:48px;height:48px;border-radius:var(--radius-sm);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0;border:1px solid var(--card-border);overflow:hidden}.psn-trophy-icon-box.locked{opacity:0.5;filter:grayscale(0.5)}.psn-trophy-icon-img{width:100%;height:100%;object-fit:cover}.psn-trophy-details{flex:1;min-width:0}.psn-trophy-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);margin-bottom:2px}.psn-trophy-description{font-size:0.75rem;color:var(--text-muted);line-height:1.4}.psn-trophy-earned-info{display:flex;align-items:center;gap:var(--spacing-sm);margin-top:4px;flex-wrap:wrap}.psn-trophy-type-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:3px;font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}.psn-trophy-type-badge.ttb-platinum{background:rgba(123,184,212,0.2);color:var(--trophy-platinum);border:1px solid rgba(123,184,212,0.3)}.psn-trophy-type-badge.ttb-gold{background:rgba(205,154,42,0.2);color:var(--trophy-gold);border:1px solid rgba(205,154,42,0.3)}.psn-trophy-type-badge.ttb-silver{background:rgba(168,168,168,0.2);color:var(--trophy-silver);border:1px solid rgba(168,168,168,0.3)}.psn-trophy-type-badge.ttb-bronze{background:rgba(205,127,50,0.2);color:var(--trophy-bronze);border:1px solid rgba(205,127,50,0.3)}.psn-trophy-rarity{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;color:var(--text-muted);padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.03)}.psn-trophy-rarity.rarity-ultra-rare{color:var(--neon-cyan)}.psn-trophy-rarity.rarity-very-rare{color:var(--neon-purple)}.psn-trophy-rarity.rarity-rare{color:var(--neon-orange)}.psn-trophy-rarity.rarity-common{color:var(--neon-gray)}.psn-trophy-earned-date{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted);display:flex;align-items:center;gap:4px}.psn-trophy-earned-date .date-icon{font-size:0.6rem}.psn-dlc-separator{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);margin-top:var(--spacing-sm)}.psn-dlc-separator-line{flex:1;height:1px;background:var(--card-border)}.psn-dlc-separator-label{font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--neon-purple);white-space:nowrap}.psn-trophy-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);color:var(--text-muted);text-align:center}.psn-trophy-empty-icon{font-size:2rem;margin-bottom:var(--spacing-sm)}.psn-trophy-complete{color:var(--neon-green)}.psn-not-configured{text-align:center;padding:var(--spacing-2xl)}.psn-not-configured-icon{font-size:4rem;margin-bottom:var(--spacing-lg)}.psn-not-configured-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--text-primary);margin-bottom:var(--spacing-md)}.psn-not-configured-text{color:var(--text-secondary);margin-bottom:var(--spacing-lg);max-width:500px;margin-left:auto;margin-right:auto}.psn-no-games{text-align:center;padding:var(--spacing-2xl);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-no-games-icon{font-size:3rem;margin-bottom:var(--spacing-md)}.psn-no-games-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.psn-no-games-text{color:var(--text-muted)}.psn-visible-count{text-align:center;padding:var(--spacing-md);color:var(--text-muted);font-size:0.85rem}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.achievements-page{--card-min:280px;--card-gap:var(--spacing-md)}.achievement-games-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.achievement-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;color:inherit;transition:all 0.2s ease}.achievement-game-card:hover{border-color:var(--neon-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.achievement-game-icon{flex-shrink:0;width:80px;height:80px;border-radius:var(--radius-sm);overflow:hidden;background:var(--bg-darker)}.achievement-game-icon img{width:100%;height:100%;object-fit:cover}.achievement-game-card .no-boxart{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2rem;background:var(--bg-darker)}.achievement-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.achievement-game-title{font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.achievement-game-system{font-size:0.8rem;color:var(--text-muted)}.achievement-game-progress{margin-top:auto}.achievement-progress-bar{height:6px;background:var(--bg-darker);border-radius:3px;overflow:hidden;margin-bottom:4px}.achievement-progress-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),var(--neon-purple));border-radius:3px;transition:width 0.3s ease}.achievement-progress-fill.xbox-fill{background:linear-gradient(90deg,var(--neon-green),#14b873)}.achievement-progress-text{font-size:0.75rem;color:var(--text-muted)}.no-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-darker);color:var(--text-muted)}.trophy-type-label.gamerscore-earned{background:#ffd700;color:#111}.trophy-type-label.gamerscore-locked{background:var(--bg-lighter);color:var(--text-muted)}.stat-box{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-align:center;transition:all var(--transition-normal)}.stat-box:hover{border-color:var(--neon-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2)}.stat-value{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--neon-cyan);line-height:1.2;text-shadow:0 0 20px rgba(76,201,240,0.5)}.stat-label{font-size:0.9rem;color:var(--text-secondary);margin-top:0.5rem;text-transform:uppercase;letter-spacing:0.5px}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.stat-card{background:linear-gradient(135deg,var(--card-bg) 0%,var(--bg-medium) 100%);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);position:relative;overflow:hidden;transition:all var(--transition-normal)}.stat-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:3px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta))}.stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-xl);border-color:var(--primary-cyan)}.stat-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:var(--spacing-md)}.stat-card .stat-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;margin-top:0}.stat-icon{width:40px;height:40px;background:linear-gradient(135deg,rgba(76,201,240,0.2) 0%,rgba(247,37,133,0.2) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:var(--primary-cyan)}.stat-card .stat-value{font-family:var(--font-heading);font-size:3rem;font-weight:700;color:var(--text-primary);line-height:1;margin-bottom:var(--spacing-xs);text-shadow:none}.stat-subtitle{font-size:0.85rem;color:var(--text-secondary)}.stat-card.warning .stat-icon{background:linear-gradient(135deg,rgba(255,171,0,0.2) 0%,rgba(255,143,0,0.2) 100%);color:var(--status-warning)}.stat-card.warning::before{background:var(--status-warning)}.stat-card.success .stat-icon{background:linear-gradient(135deg,rgba(0,230,118,0.2) 0%,rgba(0,200,83,0.2) 100%);color:var(--status-success)}.stat-card.success::before{background:var(--status-success)}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.stat-box.warning .stat-value{color:var(--neon-orange)}.stat-box.danger .stat-value{color:var(--neon-red)}.stat-box.success .stat-value{color:var(--neon-green)}.filter-bar{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-xl);flex-wrap:wrap}.search-box{--search-icon-offset:var(--spacing-md);--search-icon-size:1.15rem;--search-icon-width:2rem;--search-icon-gap:var(--spacing-sm);flex:1;min-width:250px;position:relative}.search-icon{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:var(--search-icon-size)}input.search-input{width:100%;padding:var(--spacing-md) var(--spacing-lg);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:all var(--transition-fast)}input.search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 3px rgba(76,201,240,0.1)}input.search-input::placeholder{color:var(--text-muted)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-wrapper .search-input{width:100%;padding-left:calc(var(--search-icon-offset, var(--spacing-md)) + var(--search-icon-width, 2rem) + var(--search-icon-gap, var(--spacing-sm)))}.psn-sort-select{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;cursor:pointer}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.hltb-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.hltb-card-title{display:flex;align-items:center;gap:var(--spacing-sm);font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md)}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}.hltb-results{margin-top:var(--spacing-md)}.hltb-match-header{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-xs);padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.hltb-match-title{color:var(--primary-cyan);font-weight:600;font-size:0.9rem}.hltb-match-platform{color:var(--text-secondary);font-size:0.8rem}.hltb-match-year{color:var(--neon-orange);font-size:0.8rem;font-weight:500}.hltb-match-developer{color:var(--text-secondary);font-size:0.8rem;font-style:italic}.hltb-match-warning{color:var(--neon-orange);font-size:0.8rem;font-weight:600}.hltb-match-confidence{color:var(--accent-green);font-size:0.8rem;font-weight:500}.hltb-match-alt-notice{display:inline-flex;align-items:center;gap:0.25rem;color:var(--neon-orange);background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.4);padding:2px 8px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;cursor:help;white-space:nowrap}.hltb-time-row{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.hltb-time-row:last-child{border-bottom:none}.hltb-label{color:var(--text-secondary);font-size:0.85rem}.hltb-value{color:var(--text-primary);font-size:0.85rem;font-weight:500}.hltb-value.highlight{color:var(--primary-cyan)}.hltb-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.hltb-actions .btn{flex:1}.hltb-empty{color:var(--text-muted);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-loading{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-compact .hltb-time-row{padding:var(--spacing-xs) 0}.hltb-compact .hltb-label,.hltb-compact .hltb-value{font-size:0.8rem}.manual-reader{display:flex;flex-direction:column;width:min(96vw,1400px);height:94vh;background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl)}.manual-reader-header{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-bottom:1px solid var(--card-border)}.manual-reader-header h2{flex:1;min-width:0;margin:0;font-family:var(--font-heading);font-size:1.1rem;color:var(--neon-cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.manual-reader-stage{flex:1;min-height:0;display:grid;place-items:center;padding:var(--spacing-sm);overflow:hidden;perspective:2400px;touch-action:pan-y;user-select:none}.manual-reader-stage:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:-2px}.manual-reader-sheet{grid-area:1 / 1;max-width:100%;max-height:100%;box-shadow:var(--shadow-lg);transform-origin:left center;backface-visibility:hidden}.manual-reader-sheet.turn-out{z-index:1;animation:manual-turn-out 0.6s ease-in-out forwards}.manual-reader-sheet.turn-in{z-index:1;animation:manual-turn-in 0.6s ease-in-out forwards}@keyframes manual-turn-out{from{transform:rotateY(0deg)}to{transform:rotateY(-180deg)}}@keyframes manual-turn-in{from{transform:rotateY(-180deg)}to{transform:rotateY(0deg)}}@media (prefers-reduced-motion:reduce){.manual-reader-sheet.turn-out,.manual-reader-sheet.turn-in{animation:none}}.manual-reader-message{grid-area:1 / 1;color:var(--text-secondary);text-align:center}.manual-reader-controls{display:flex;align-items:center;justify-content:center;gap:var(--spacing-lg);padding:var(--spacing-sm);border-top:1px solid var(--card-border)}.manual-reader-controls .btn{white-space:nowrap}.manual-reader-page{min-width:8em;text-align:center;color:var(--text-primary);font-family:var(--font-mono)}@media (max-width:600px){.manual-reader{width:100vw;height:100dvh;border-radius:0}.manual-reader-header .btn{font-size:0.75rem}}.score-badge.score-good{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.score-badge.score-mixed{background:rgba(234,179,8,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.score-badge.score-bad{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.bulk-option-label{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(168,85,247,0.1);border:1px solid var(--neon-purple);border-radius:var(--radius-md);color:var(--neon-purple);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast)}.bulk-option-label:hover{background:rgba(168,85,247,0.2)}.bulk-option-label input[type="checkbox"]{width:16px;height:16px;accent-color:var(--neon-purple);cursor:pointer}.bulk-mode-selector{display:flex;gap:8px;align-items:center}.scrape-mode-toggle{display:flex;align-items:center;gap:12px;padding:6px 16px;background:rgba(100,116,139,0.1);border:1px solid var(--neon-gray);border-radius:var(--radius-md)}.scrape-mode-toggle .mode-label{font-size:0.85rem;font-weight:500;transition:all var(--transition-fast)}.scrape-mode-toggle .mode-label.mode-off{color:var(--text-muted)}.scrape-mode-toggle .mode-label.mode-on{color:var(--text-muted)}.scrape-mode-toggle:has(input:not(:checked)) .mode-off{color:var(--neon-orange);text-shadow:0 0 8px rgba(245,158,11,0.5)}.scrape-mode-toggle:has(input:checked) .mode-on{color:var(--neon-cyan);text-shadow:0 0 8px rgba(76,201,240,0.5)}.keep-media-option{display:flex;align-items:center;gap:6px;font-size:0.85rem;color:var(--text-muted);cursor:pointer}.bulk-mode-selector:has(#scrapeModeToggle:checked) .keep-media-option{display:none}.scrape-mode-toggle .toggle-switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}.scrape-mode-toggle .toggle-switch input{opacity:0;width:0;height:0}.scrape-mode-toggle .toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-orange);border-radius:24px;transition:all var(--transition-normal);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(245,158,11,0.3)}.scrape-mode-toggle .toggle-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all var(--transition-normal);box-shadow:0 2px 4px rgba(0,0,0,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider{background:var(--neon-cyan);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(76,201,240,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider:before{transform:translateX(20px)}.fanart-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;background-size:cover;background-position:center;background-repeat:no-repeat;opacity:0;transition:opacity 0.5s ease,background-image 0.5s ease;pointer-events:none}.fanart-background::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient( to bottom,rgba(10,14,20,0.7) 0%,rgba(10,14,20,0.85) 50%,rgba(10,14,20,0.95) 100% )}.fanart-background.active{opacity:1}.glass-panel{background:rgba(20,28,40,0.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-card{background:rgba(20,28,40,0.5);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}.glitch-text{position:relative}.glitch-text::before,.glitch-text::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%}.glitch-text::before{left:2px;text-shadow:-2px 0 var(--primary-magenta);clip:rect(24px,550px,90px,0);animation:glitch-anim 3s infinite linear alternate-reverse}.glitch-text::after{left:-2px;text-shadow:-2px 0 var(--primary-cyan);clip:rect(85px,550px,140px,0);animation:glitch-anim2 3s infinite linear alternate-reverse}@keyframes glitch-anim{0%{clip:rect(20px,9999px,40px,0)}25%{clip:rect(60px,9999px,80px,0)}50%{clip:rect(10px,9999px,30px,0)}75%{clip:rect(70px,9999px,90px,0)}100%{clip:rect(30px,9999px,50px,0)}}@keyframes glitch-anim2{0%{clip:rect(80px,9999px,100px,0)}25%{clip:rect(20px,9999px,40px,0)}50%{clip:rect(50px,9999px,70px,0)}75%{clip:rect(10px,9999px,30px,0)}100%{clip:rect(60px,9999px,80px,0)}}.scan-lines{position:absolute;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,rgba(0,0,0,0.1),rgba(0,0,0,0.1) 1px,transparent 1px,transparent 2px );pointer-events:none;opacity:0;transition:opacity var(--transition-normal)}.game-card-new:hover .scan-lines{opacity:0.3}.card-glow{position:absolute;top:-2px;left:-2px;right:-2px;bottom:-2px;background:linear-gradient(45deg,var(--primary-cyan),var(--primary-magenta),var(--primary-cyan));border-radius:calc(var(--radius-lg) + 2px);z-index:-1;opacity:0;filter:blur(8px);transition:opacity var(--transition-normal)}.game-card-new:hover .card-glow{opacity:0.5;animation:glow-pulse 2s ease-in-out infinite}@keyframes glow-pulse{0%,100%{opacity:0.3}50%{opacity:0.6}}.filter-bar-new{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;padding:var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);position:sticky;top:var(--sticky-top);z-index:100;background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.search-box-integrated{--search-icon-offset:12px;--search-icon-size:1rem;--search-icon-width:1.75rem;--search-icon-gap:var(--spacing-sm);position:relative;min-width:200px;max-width:350px;flex:1}.search-icon-inside{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);font-size:var(--search-icon-size);pointer-events:none;z-index:1}.search-input-integrated{width:100%;padding:var(--spacing-sm) var(--spacing-md);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));padding-right:32px;background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;outline:none;transition:border-color var(--transition-fast)}.search-clear-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;line-height:1;opacity:0;transition:opacity 0.2s,color 0.2s;z-index:1}.search-clear-btn:hover{color:var(--primary-cyan)}.search-box-integrated:has(.search-input-integrated:not(:placeholder-shown)) .search-clear-btn{opacity:1}.search-input-integrated:focus{border-color:var(--primary-cyan)}.search-input-integrated::placeholder{color:var(--text-muted)}.filter-buttons{display:flex;gap:var(--spacing-sm);flex-wrap:wrap}.filter-btn{display:flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-sm) var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;transition:all var(--transition-fast);font-size:0.85rem}.filter-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-btn:disabled{opacity:0.35;cursor:default;pointer-events:none}.filter-btn.active{border-color:var(--primary-cyan);background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-btn.clear-btn{border-color:var(--status-error);color:var(--status-error)}.filter-btn.clear-btn:hover{background:rgba(255,107,107,0.2)}.filter-btn.ra-filter-btn{border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-btn.ra-filter-btn:hover{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.15)}.filter-btn.ra-filter-btn.active{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.25);color:var(--trophy-display-gold);box-shadow:0 0 10px rgba(255,215,0,0.2)}.filter-btn.bonus-filter-btn{border-color:rgba(245,158,11,0.4);color:var(--neon-orange)}.filter-btn.bonus-filter-btn:hover{border-color:var(--neon-orange);background:rgba(245,158,11,0.15)}.filter-btn.bonus-filter-btn.active{border-color:var(--neon-orange);background:rgba(245,158,11,0.25);color:var(--neon-orange);box-shadow:0 0 10px rgba(245,158,11,0.2)}.filter-btn.refresh-ra-btn{border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.filter-btn.refresh-ra-btn:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-btn.refresh-ra-btn:disabled{opacity:0.6;cursor:not-allowed}.filter-tag.ra-tag{background:rgba(255,215,0,0.2);border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-bar-top{display:flex;align-items:center;gap:var(--spacing-md);width:100%}.bulk-controls{display:flex;gap:var(--spacing-sm);align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end}.bulk-btn{border-color:rgba(147,51,234,0.4);color:var(--neon-purple)}.bulk-btn:hover{border-color:var(--neon-purple);background:rgba(147,51,234,0.15)}.bulk-btn.active{border-color:var(--neon-purple);background:rgba(147,51,234,0.25);color:var(--neon-purple)}.bulk-action-btn{border-color:rgba(59,130,246,0.4);color:var(--neon-blue)}.bulk-action-btn:hover{border-color:var(--neon-blue);background:rgba(59,130,246,0.15)}.bulk-scrape-btn{border-color:rgba(34,197,94,0.4);color:var(--neon-green)}.bulk-scrape-btn:hover{border-color:var(--neon-green);background:rgba(34,197,94,0.15)}.game-card-wrapper{position:relative}.bulk-select-checkbox{position:absolute;top:8px;right:8px;z-index:20}.bulk-select-checkbox input[type="checkbox"]{width:22px;height:22px;cursor:pointer;accent-color:var(--primary-cyan)}.game-card-wrapper.selected .game-card-new{border-color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.4)}.bulk-scrape-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:3000;align-items:center;justify-content:center}.bulk-scrape-modal.active{display:flex}.bulk-scrape-content{width:90%;max-width:500px;border-radius:var(--radius-lg);overflow:hidden}.bulk-scrape-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.bulk-scrape-header h2{margin:0;color:var(--primary-cyan)}.bulk-scrape-body{padding:var(--spacing-lg)}.bulk-progress-info{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md)}.bulk-progress-text{flex:1;overflow:hidden}#bulkCurrentGame{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary);font-weight:500}.bulk-progress-numbers{color:var(--primary-cyan);font-family:var(--font-mono);font-size:1.1rem;margin-left:var(--spacing-md)}.bulk-progress-bar{width:100%;height:20px;background:var(--bg-medium);border-radius:10px;margin-bottom:var(--spacing-md);border:2px solid rgba(76,201,240,0.5);box-shadow:inset 0 2px 4px rgba(0,0,0,0.5);overflow:hidden;padding:2px}.bulk-progress-fill{display:block;height:100%;width:var(--progress,0%);min-width:0;max-width:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta));border-radius:8px;box-shadow:0 0 10px rgba(76,201,240,0.5)}.bulk-status{text-align:center;color:var(--text-secondary);font-size:0.9rem;margin-bottom:var(--spacing-md)}.bulk-controls-row{display:flex;justify-content:center;gap:var(--spacing-md);margin-top:var(--spacing-lg);margin-bottom:var(--spacing-md);padding-top:var(--spacing-sm)}.bulk-results{display:flex;justify-content:space-around;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.bulk-result-item{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.9rem}.bulk-result-item.success{color:var(--neon-green)}.bulk-result-item.failed{color:var(--neon-red)}.bulk-result-item.skipped{color:var(--neon-orange)}.bulk-scrape-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:center}.active-filter-tags{display:flex;gap:var(--spacing-sm);flex:1;min-width:0;overflow-x:auto;scrollbar-width:thin}.active-filter-tags .filter-tag{flex-shrink:0;white-space:nowrap}.filter-tag{display:flex;align-items:center;gap:var(--spacing-xs);padding:4px 10px;background:var(--primary-cyan);color:var(--bg-dark);border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.filter-tag-exclude{background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.5);color:var(--neon-red)}.filter-tag-remove{cursor:pointer;opacity:0.7;transition:opacity 0.2s}.filter-tag-remove:hover{opacity:1}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:calc(var(--sticky-top) + 80px);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.85rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:4px}.alphabet-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.3;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.1);border-color:rgba(76,201,240,0.3);color:var(--text-secondary)}.cards-page{--card-min:380px;--card-gap:var(--spacing-lg);max-width:calc(4 * 480px + 3 * var(--card-gap));margin-inline:auto}.games-grid-new{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,380px),1fr));grid-auto-rows:1px;column-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:span 296;align-self:start;transition:transform var(--transition-normal);contain:layout style paint;content-visibility:auto;contain-intrinsic-size:auto 380px 296px}.game-card-new{position:relative;display:flex;flex-direction:row;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;transition:all var(--transition-normal);border:1px solid transparent;min-height:240px}.game-card-wrapper:hover{transform:translateY(-4px)}.game-card-new:hover{box-shadow:0 8px 30px rgba(76,201,240,0.2)}.game-card-wrapper.hidden{display:none}.game-card-new.hidden{display:none}.card-boxart-section{flex-shrink:0;width:135px;align-self:stretch;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:relative}.card-play-btn{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);z-index:5;width:28px;height:28px;border-radius:var(--radius-full);border:1px solid var(--primary-cyan);background:var(--bg-darkest);color:var(--primary-cyan);font-size:0.8rem;line-height:1;cursor:pointer;box-shadow:var(--shadow-md);transition:background var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.card-play-btn{display:flex;align-items:center;justify-content:center;padding:0}.card-play-btn svg{width:60%;height:60%;fill:currentColor}.card-play-btn:hover,.card-play-btn:focus-visible{background:var(--primary-cyan);color:var(--bg-darkest);box-shadow:var(--shadow-glow-cyan)}.card-play-btn:disabled{cursor:progress}.games-grid-new.bulk-mode .card-play-btn{display:none}.game-card-new.has-play .card-info-section >:first-child{padding-right:calc(28px + var(--spacing-sm))}.card-info-section{flex:1;padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-sm);background:rgba(0,0,0,0.4)}.title-row{display:flex;align-items:flex-start;gap:var(--spacing-sm)}.title-row .game-card-title{flex:1}.game-card-ra-badge{position:absolute;top:8px;left:8px;width:28px;height:28px;background:linear-gradient(135deg,var(--trophy-display-gold) 0%,#ffaa00 100%);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.9rem;z-index:10;box-shadow:0 2px 8px rgba(255,215,0,0.4);border:2px solid rgba(255,255,255,0.3)}.game-card-rating-badge{position:absolute;top:8px;right:8px;z-index:10}.rating-text-badge{font-size:0.65rem;padding:2px 6px;border-radius:var(--radius-sm);font-weight:700;text-transform:uppercase}.rating-text-badge.esrb{background:rgba(0,0,0,0.7);color:var(--text-on-color);border:1px solid var(--text-on-color)}.rating-text-badge.pegi{background:rgba(141,198,63,0.9);color:var(--text-on-color);border:1px solid var(--text-on-color)}.system-badge-row{display:flex;align-items:center;gap:var(--spacing-sm)}.system-badge{padding:3px 8px;background:rgba(76,201,240,0.2);border:1px solid var(--primary-cyan);border-radius:var(--radius-sm);font-size:0.65rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.system-badge.nes{background:rgba(198,40,40,0.25);border-color:#e53935;color:#e53935}.system-badge.snes{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.n64{background:rgba(244,67,54,0.2);border-color:#f44336;color:#f44336}.system-badge.gc,.system-badge.gamecube{background:rgba(103,58,183,0.2);border-color:#9575cd;color:#9575cd}.system-badge.wii{background:rgba(255,255,255,0.15);border-color:#e0e0e0;color:#e0e0e0}.system-badge.wiiu{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.switch{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.gb{background:rgba(139,195,74,0.25);border-color:#9ccc65;color:#9ccc65}.system-badge.gbc{background:rgba(156,39,176,0.2);border-color:#ba68c8;color:#ba68c8}.system-badge.gba{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#7e57c2}.system-badge.nds{background:rgba(158,158,158,0.2);border-color:#bdbdbd;color:#bdbdbd}.system-badge.3ds{background:rgba(229,57,53,0.2);border-color:#e57373;color:#e57373}.system-badge.virtualboy,.system-badge.vb{background:rgba(183,28,28,0.3);border-color:#c62828;color:#ef5350}.system-badge.fds{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.psx,.system-badge.ps1{background:rgba(144,202,249,0.25);border-color:#90caf9;color:#90caf9}.system-badge.ps2{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.ps3{background:rgba(21,101,192,0.25);border-color:#1976d2;color:#64b5f6}.system-badge.ps4{background:rgba(13,71,161,0.3);border-color:#1565c0;color:#42a5f5}.system-badge.psp{background:rgba(100,181,246,0.2);border-color:#64b5f6;color:#64b5f6}.system-badge.psvita,.system-badge.vita{background:rgba(30,136,229,0.2);border-color:#1e88e5;color:#42a5f5}.system-badge.sg-1000,.system-badge.sg1000{background:rgba(0,77,64,0.3);border-color:#00897b;color:#4db6ac}.system-badge.mastersystem,.system-badge.sms{background:rgba(0,121,107,0.25);border-color:#00897b;color:#4db6ac}.system-badge.genesis,.system-badge.megadrive{background:rgba(0,150,136,0.2);border-color:#26a69a;color:#26a69a}.system-badge.segacd,.system-badge.megacd{background:rgba(0,137,123,0.25);border-color:#00897b;color:#4db6ac}.system-badge.sega32x,.system-badge.32x{background:rgba(0,105,92,0.3);border-color:#00796b;color:#4db6ac}.system-badge.saturn{background:rgba(128,203,196,0.2);border-color:#80cbc4;color:#80cbc4}.system-badge.dreamcast,.system-badge.dc{background:rgba(255,152,0,0.2);border-color:#ffb74d;color:#ffb74d}.system-badge.gamegear,.system-badge.gg{background:rgba(0,188,212,0.2);border-color:#4dd0e1;color:#4dd0e1}.system-badge.atari2600{background:rgba(230,81,0,0.25);border-color:#ff6d00;color:#ffab40}.system-badge.atari5200{background:rgba(255,111,0,0.2);border-color:#ff9100;color:#ffab40}.system-badge.atari7800{background:rgba(255,143,0,0.2);border-color:#ffa000;color:#ffca28}.system-badge.atarilynx,.system-badge.lynx{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.atarijaguar,.system-badge.jaguar{background:rgba(255,87,34,0.2);border-color:#ff7043;color:#ff7043}.system-badge.atarist,.system-badge.st{background:rgba(121,85,72,0.25);border-color:#a1887f;color:#bcaaa4}.system-badge.arcade{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.mame{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.fbneo{background:rgba(233,30,99,0.2);border-color:#f06292;color:#f06292}.system-badge.cps1,.system-badge.cps2,.system-badge.cps3{background:rgba(216,27,96,0.2);border-color:#e91e63;color:#f48fb1}.system-badge.naomi{background:rgba(194,24,91,0.25);border-color:#d81b60;color:#f48fb1}.system-badge.neogeo{background:rgba(255,193,7,0.25);border-color:#ffc107;color:#ffc107}.system-badge.neogeocd{background:rgba(255,179,0,0.2);border-color:#ffb300;color:#ffc107}.system-badge.ngp{background:rgba(255,213,79,0.2);border-color:#ffd54f;color:#ffd54f}.system-badge.ngpc{background:rgba(255,224,130,0.2);border-color:#ffe082;color:#ffe082}.system-badge.pcengine,.system-badge.tg16{background:rgba(156,39,176,0.2);border-color:#ab47bc;color:#ce93d8}.system-badge.pcenginecd,.system-badge.tg-cd{background:rgba(142,36,170,0.2);border-color:#9c27b0;color:#ce93d8}.system-badge.supergrafx{background:rgba(171,71,188,0.2);border-color:#ba68c8;color:#ce93d8}.system-badge.pcfx{background:rgba(123,31,162,0.25);border-color:#8e24aa;color:#ce93d8}.system-badge.c64{background:rgba(121,85,72,0.25);border-color:#8d6e63;color:#bcaaa4}.system-badge.amiga{background:rgba(141,110,99,0.2);border-color:#a1887f;color:#d7ccc8}.system-badge.vic20{background:rgba(93,64,55,0.3);border-color:#6d4c41;color:#a1887f}.system-badge.xbox{background:rgba(76,175,80,0.2);border-color:#66bb6a;color:#81c784}.system-badge.xbox360{background:rgba(56,142,60,0.25);border-color:#43a047;color:#81c784}.system-badge.3do{background:rgba(255,215,0,0.2);border-color:#ffd700;color:#ffd700}.system-badge.philipscdi,.system-badge.cdi{background:rgba(255,193,7,0.15);border-color:#ffca28;color:#ffca28}.system-badge.dos{background:rgba(96,125,139,0.2);border-color:#78909c;color:#90a4ae}.system-badge.pc{background:rgba(69,90,100,0.25);border-color:#607d8b;color:#90a4ae}.system-badge.pc88,.system-badge.pc98{background:rgba(84,110,122,0.2);border-color:#78909c;color:#b0bec5}.system-badge.msx{background:rgba(0,188,212,0.2);border-color:#00bcd4;color:#4dd0e1}.system-badge.msx2{background:rgba(0,172,193,0.2);border-color:#00acc1;color:#4dd0e1}.system-badge.wonderswan,.system-badge.ws{background:rgba(63,81,181,0.2);border-color:#5c6bc0;color:#7986cb}.system-badge.wonderswancolor,.system-badge.wsc{background:rgba(48,63,159,0.2);border-color:#3f51b5;color:#7986cb}.system-badge.pokemini{background:rgba(255,235,59,0.25);border-color:#ffee58;color:#fff176}.system-badge.nggage,.system-badge.ngage{background:rgba(255,112,67,0.2);border-color:#ff7043;color:#ff8a65}.system-badge.colecovision{background:rgba(183,28,28,0.2);border-color:#c62828;color:#ef5350}.system-badge.intellivision{background:rgba(255,152,0,0.2);border-color:#ff9800;color:#ffb74d}.system-badge.vectrex{background:rgba(255,255,255,0.2);border-color:#eeeeee;color:#eeeeee}.system-badge.scummvm{background:rgba(139,195,74,0.2);border-color:#8bc34a;color:#aed581}.system-badge.daphne{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#b39ddb}.title-badges{display:flex;gap:4px;flex-shrink:0}.title-badges .ra-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain;filter:drop-shadow(0 0 4px rgba(255,193,7,0.5))}.import-badge.clz-badge{filter:drop-shadow(0 0 4px rgba(168,85,247,0.5))}.import-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain}.import-badge.steam-badge{filter:drop-shadow(0 0 4px rgba(102,192,244,0.5))}.import-badge.xbox-badge{filter:drop-shadow(0 0 4px rgba(16,124,16,0.5))}.import-badge.psn-badge{filter:drop-shadow(0 0 4px rgba(0,55,145,0.5))}.bonus-badge{font-size:0.9rem;filter:drop-shadow(0 0 4px rgba(245,158,11,0.5));flex-shrink:0}.has-bonus-badge{font-size:0.75rem;background:rgba(168,85,247,0.2);border:1px solid var(--neon-purple);color:var(--neon-purple);padding:1px 4px;border-radius:4px;filter:drop-shadow(0 0 4px rgba(168,85,247,0.4));flex-shrink:0}.versions-badge{font-size:0.75rem;background:color-mix(in srgb,var(--neon-cyan) 20%,transparent);border:1px solid var(--neon-cyan);color:var(--neon-cyan);padding:1px 4px;border-radius:4px;flex-shrink:0}.game-card-wrapper.bonus-disc .game-card-new{border-left:3px solid var(--neon-orange);opacity:0.85}.game-card-wrapper.bonus-disc .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(245,158,11,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.clz-import .game-card-new{border-left:3px solid var(--neon-purple)}.game-card-wrapper.clz-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(168,85,247,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.steam-import .game-card-new{border-left:3px solid #66c0f4}.game-card-wrapper.steam-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(102,192,244,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.xbox-import .game-card-new{border-left:3px solid #107c10}.game-card-wrapper.xbox-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(16,124,16,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.psn-import .game-card-new{border-left:3px solid #0070d1}.game-card-wrapper.psn-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(0,112,209,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-title{font-size:0.95rem;font-weight:600;color:var(--text-primary);margin:0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;flex:1}.meta-item{display:flex;flex-direction:column;gap:1px}.meta-label{font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.meta-value{font-size:0.75rem;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta-value.meta-clickable{cursor:pointer;padding:2px 6px;margin:-2px -6px;border-radius:var(--radius-sm);transition:all var(--transition-fast);display:inline-block}.meta-value.meta-clickable:hover{background:rgba(76,201,240,0.15);color:var(--neon-cyan)}.rating-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-top:auto}.rating-badge{padding:3px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:700}.rating-badge.esrb{background:linear-gradient(135deg,var(--bg-medium),#16213e);color:#fff;border:1px solid #4a4a6a}.rating-badge.pegi{background:linear-gradient(135deg,#0f3460,#16213e);color:var(--primary-cyan);border:1px solid var(--primary-cyan)}.game-badge.achievement-progress{gap:4px;font-size:0.65rem;font-weight:700;font-family:var(--font-mono)}.ach-icon{width:14px;height:14px;object-fit:contain;flex-shrink:0;filter:drop-shadow(0 0 3px rgba(255,255,255,0.3))}.game-badge.achievement-progress.ra{background:linear-gradient(135deg,rgba(74,144,217,0.12) 0%,rgba(212,160,23,0.12) 100%);border-image:linear-gradient(135deg,#4A90D9,#D4A017) 1;color:#D4A017}.game-badge.achievement-progress.ra.ach-complete{background:linear-gradient(135deg,rgba(74,144,217,0.2) 0%,rgba(212,160,23,0.2) 100%);color:#e8b820}.game-badge.achievement-progress.steam{background:linear-gradient(135deg,rgba(27,40,56,0.35) 0%,rgba(102,192,244,0.12) 100%);border-image:linear-gradient(135deg,#2A6DAB,#66C0F4) 1;color:#66C0F4}.game-badge.achievement-progress.steam.ach-complete{background:linear-gradient(135deg,rgba(27,40,56,0.45) 0%,rgba(102,192,244,0.2) 100%);color:#88d4f8}.game-badge.achievement-progress.xbox{background:linear-gradient(135deg,rgba(16,124,16,0.12) 0%,rgba(82,176,67,0.12) 100%);border-image:linear-gradient(135deg,#107C10,#52B043) 1;color:#52B043}.game-badge.achievement-progress.xbox.ach-complete{background:linear-gradient(135deg,rgba(16,124,16,0.2) 0%,rgba(82,176,67,0.2) 100%);color:#6dcc5e}.game-badge.achievement-progress.psn{background:linear-gradient(135deg,rgba(0,48,135,0.15) 0%,rgba(108,168,217,0.12) 100%);border-image:linear-gradient(135deg,#0070D1,#6CA8D9) 1;color:#6CA8D9}.game-badge.achievement-progress.psn.ach-complete{background:linear-gradient(135deg,rgba(0,48,135,0.25) 0%,rgba(108,168,217,0.2) 100%);color:#8dc0e8}.game-badge.achievement-progress.rpcs3{background:linear-gradient(135deg,rgba(74,122,140,0.12) 0%,rgba(168,208,224,0.1) 100%);border-image:linear-gradient(135deg,#4A7A8C,#A8D0E0) 1;color:#A8D0E0}.game-badge.achievement-progress.rpcs3.ach-complete{background:linear-gradient(135deg,rgba(74,122,140,0.2) 0%,rgba(168,208,224,0.18) 100%);color:#c0e0ec}.game-badge.achievement-progress.ach-low{opacity:0.7}.ach-bar{display:inline-block;width:30px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle}.ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.ra .ach-fill{background:linear-gradient(90deg,#4A90D9,#D4A017)}.ra.ach-complete .ach-fill{box-shadow:0 0 4px #D4A017}.steam .ach-fill{background:linear-gradient(90deg,#2A6DAB,#66C0F4)}.steam.ach-complete .ach-fill{box-shadow:0 0 4px #66C0F4}.xbox .ach-fill{background:linear-gradient(90deg,#107C10,#52B043)}.xbox.ach-complete .ach-fill{box-shadow:0 0 4px #52B043}.psn .ach-fill{background:linear-gradient(90deg,#0070D1,#6CA8D9)}.psn.ach-complete .ach-fill{box-shadow:0 0 4px #6CA8D9}.rpcs3 .ach-fill{background:linear-gradient(90deg,#4A7A8C,#A8D0E0)}.rpcs3.ach-complete .ach-fill{box-shadow:0 0 4px #A8D0E0}.game-card-image{width:100%;height:100%;overflow:hidden;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:absolute;top:0;left:0;right:0;bottom:0}.game-boxart-img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;transition:transform var(--transition-normal)}.game-boxart-img.boxart-3d{object-fit:contain;padding:4px}.game-card-new:hover .game-boxart-img{transform:scale(1.05)}.game-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--bg-medium),var(--bg-dark))}.placeholder-icon{font-size:3rem;opacity:0.3}.games-count-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--spacing-md);min-height:3rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.showing-text{flex-shrink:0;font-size:0.85rem;color:var(--text-muted);letter-spacing:1px}.showing-text span{color:var(--primary-cyan);font-weight:600}.sort-control{display:flex;align-items:center;gap:var(--spacing-sm)}.sort-label{font-size:0.85rem;color:var(--text-muted);white-space:nowrap}.sort-select{padding:var(--spacing-xs) var(--spacing-sm);background-color:var(--bg-light);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.85rem;cursor:pointer}.sort-select:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:1px}#gamesEmpty .clear-btn{display:inline-flex;margin-top:var(--spacing-md)}.alphabet-nav.is-disabled{opacity:0.35}.bulk-action-bar{position:sticky;bottom:var(--spacing-md);z-index:101;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md);margin-top:var(--spacing-lg);border-radius:var(--radius-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.bulk-action-bar[hidden]{display:none}.filters-toggle-btn{display:none}.filter-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:2000;align-items:center;justify-content:center}.filter-modal.active{display:flex}.filter-modal-content{width:90%;max-width:500px;max-height:70vh;border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column}.filter-modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);gap:var(--spacing-md)}.filter-modal-header h2{color:var(--primary-cyan);font-family:var(--font-heading);margin:0}.filter-mode-toggle{display:flex;border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.filter-mode-btn{padding:4px 12px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-size:0.85rem;font-weight:600;transition:all var(--transition-fast)}.filter-mode-btn:hover{color:var(--text-primary)}.filter-mode-btn.active[data-mode="include"]{background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-mode-btn.active[data-mode="exclude"]{background:rgba(239,68,68,0.2);color:var(--neon-red)}.filter-modal-body{padding:var(--spacing-lg);overflow-y:auto;flex:1}.filter-options{display:flex;flex-direction:column;gap:var(--spacing-sm)}.filter-option{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:all var(--transition-fast)}.filter-option:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-modal.exclude-mode .filter-option:hover{border-color:var(--neon-red);background:rgba(239,68,68,0.1)}.filter-option-name{color:var(--text-primary)}.filter-option-count{color:var(--text-muted);font-size:0.85rem}button.filter-option{width:100%;font:inherit;color:inherit;text-align:left}.filter-option[aria-checked]::before{content:'';flex:0 0 auto;width:1.1rem;height:1.1rem;margin-right:var(--spacing-sm);border:1px solid var(--card-border);border-radius:var(--radius-sm);line-height:1.1rem;text-align:center}.filter-option[aria-checked] .filter-option-name{flex:1}.filter-option[aria-checked="true"]{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-option[aria-checked="true"]::before{content:'✓';color:var(--primary-cyan);border-color:var(--primary-cyan)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]{border-color:var(--neon-red);background:rgba(239,68,68,0.15)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]::before{content:'✕';color:var(--neon-red);border-color:var(--neon-red)}.filter-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border)}.filter-modal-footer[hidden]{display:none}.filter-modal-hint{color:var(--text-muted);font-size:0.85rem}.filter-modal .hint-exclude,.filter-modal.exclude-mode .hint-include{display:none}.filter-modal.exclude-mode .hint-exclude{display:inline}@media (max-width:768px){.games-grid-new{grid-template-columns:1fr;grid-auto-rows:auto;row-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:auto}.game-card-new{min-height:180px}.card-boxart-section{width:100px}.meta-grid{grid-template-columns:1fr}.filter-bar-top{flex-wrap:wrap}.filter-buttons{width:100%;justify-content:flex-start}.filters-toggle-btn{display:flex}.filter-bar-new .filter-buttons{display:none;position:absolute;top:100%;left:0;right:0;margin-top:var(--spacing-xs);padding:var(--spacing-md);border:1px solid var(--card-border);border-radius:var(--radius-lg);background:var(--bg-medium);box-shadow:0 8px 24px rgba(0,0,0,0.5)}.filter-bar-new.filters-open .filter-buttons{display:flex}.games-count-bar{flex-wrap:wrap}.games-count-bar .active-filter-tags{order:3;flex-basis:100%;min-height:1.75rem}.filter-btn{flex:1;min-width:80px;justify-content:center}.bulk-controls{width:100%;justify-content:flex-start}.glitch-text::before,.glitch-text::after{display:none}}.game-page-header{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg);position:relative;z-index:1}.game-page-title{font-family:var(--font-display);font-size:2rem;color:var(--primary-cyan);text-shadow:0 0 30px rgba(76,201,240,0.3);margin:0}.header-buttons{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.play-key-hint{align-self:center;font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md);font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator .scan-icon{font-size:1rem}.last-scan-indicator .scan-time{color:var(--primary-cyan);font-weight:500}.report-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.report-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1.25rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.report-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.report-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.report-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.report-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}.report-tab .tab-icon{font-size:1.1rem}.report-panel{display:none}.report-panel.active{display:block;animation:fadeIn 0.3s ease}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.results-table-container{background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.settings-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.settings-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.settings-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.settings-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.settings-tab .tab-icon{font-size:1.1rem}.settings-tab-panel{display:none}.settings-tab-panel.active{display:block}.naming-tag-option{padding:0.25rem 0.6rem;border-radius:4px;font-size:0.8rem;font-weight:600;background:rgba(100,116,139,0.15);border:1px solid rgba(100,116,139,0.3);color:var(--text-secondary);cursor:pointer;transition:all 0.15s ease}.naming-tag-option:hover{border-color:var(--neon-green);color:var(--neon-green)}.parent-option{padding:var(--spacing-sm);border-bottom:1px solid var(--card-border);cursor:pointer}.parent-option:hover{background:rgba(76,201,240,0.1)}.tab-subnav{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:rgba(0,0,0,0.75);border-radius:var(--radius-md);position:sticky;top:calc(var(--sticky-top) + 54px);z-index:99;backdrop-filter:blur(10px)}.subnav-link{padding:var(--spacing-xs) var(--spacing-md);color:var(--text-secondary);text-decoration:none;font-size:0.85rem;border-radius:var(--radius-sm);transition:all var(--transition-fast)}.subnav-link:hover{background:rgba(76,201,240,0.1);color:var(--text-primary)}.subnav-link.active{background:rgba(76,201,240,0.15);color:var(--primary-cyan)}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.dashboard-page{--card-min:280px;--card-gap:var(--spacing-lg)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min,280px),100%),1fr));gap:var(--card-gap,var(--spacing-lg))}.system-card{display:flex;flex-direction:column;padding:var(--spacing-lg);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg);text-decoration:none;color:inherit;transition:all var(--transition-fast)}.system-card:hover{border-color:var(--neon-cyan);transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 15px rgba(76,201,240,0.15)}.system-card-header{margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.system-name{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);margin:0}.system-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.system-stat{display:flex;flex-direction:column;align-items:center;text-align:center}.system-stat-value{font-size:1.2rem;font-weight:600;color:var(--neon-cyan)}.system-stat-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px}.system-card-action{margin-top:auto;padding-top:var(--spacing-md);text-align:center;color:var(--neon-cyan);font-size:0.9rem;font-weight:500;opacity:0.7;transition:opacity 0.2s}.system-card:hover .system-card-action{opacity:1}.filter-group{display:flex;align-items:center;gap:0.5rem}.filter-group label{color:var(--text-secondary);font-size:0.85rem;white-space:nowrap}.filter-group select,.filter-group input{padding:0.5rem 0.75rem;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:0.85rem}.filter-group select:focus,.filter-group input:focus{outline:none;border-color:var(--primary-cyan)}.alert{padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-md);margin-bottom:var(--spacing-md)}.alert-warning{background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:var(--neon-orange)}.alert-danger{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:var(--neon-red)}.alert-success{background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);color:var(--neon-green)}.alert-info{background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);color:var(--primary-cyan)}.alert a{color:inherit;text-decoration:underline}.field-tip{display:block;margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-muted)}.changelog-entry{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-xl);border-bottom:1px solid var(--card-border)}.changelog-entry:last-child{border-bottom:none}.changelog-version{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.changelog-date{font-size:0.85rem;color:var(--text-muted);margin-bottom:var(--spacing-md)}.changelog-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);margin-bottom:var(--spacing-md)}.changelog-tag{padding:4px 12px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;text-transform:uppercase}.tag-feature{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-minor{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-patch{background:rgba(245,158,11,0.15);border:1px solid var(--neon-orange);color:var(--neon-orange)}.tag-fix{background:rgba(239,68,68,0.15);border:1px solid var(--neon-red);color:var(--neon-red)}.tag-major{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.tag-ui{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-improvement{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-enhancement{background:rgba(34,197,94,0.15);border:1px solid var(--neon-green);color:var(--neon-green)}.tag-initial{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.changelog-list{list-style:none;padding:0;margin:0}.changelog-list li{padding:var(--spacing-xs) 0;padding-left:var(--spacing-lg);position:relative;color:var(--text-secondary)}.changelog-list li::before{content:'•';position:absolute;left:0;color:var(--primary-cyan)}.login-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:var(--spacing-lg)}.login-box{width:100%;max-width:400px;padding:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:0 0 40px rgba(76,201,240,0.1)}.login-header{text-align:center;margin-bottom:var(--spacing-xl)}.login-logo{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.login-subtitle{color:var(--text-secondary);font-size:0.9rem}.help-page{--help-text-max:calc(75ch + 2 * var(--spacing-lg));--help-nav-width:220px;--page-max:var(--help-text-max)}.help-page > .help-nav{flex-wrap:nowrap;overflow-x:auto}@media (max-width:600px){.help-page code{overflow-wrap:anywhere}}@media (min-width:1300px){.help-page{--page-max:calc(var(--help-nav-width) + var(--spacing-xl) + var(--help-text-max));display:grid;grid-template-columns:var(--help-nav-width) minmax(0,1fr);column-gap:var(--spacing-xl);align-items:start}.help-page > *{grid-column:2}.help-page > .page-header{grid-column:1 / -1}.help-page > .help-nav{grid-column:1;grid-row:2 / span 100;flex-direction:column;flex-wrap:nowrap;margin:0;max-height:100vh;overflow-x:hidden;overflow-y:auto}.help-page .help-nav-item{white-space:normal}}.help-section{margin-bottom:var(--spacing-xl)}.help-section h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin-bottom:var(--spacing-md)}.help-item{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.help-item h3{color:var(--text-primary);font-size:1rem;margin-bottom:var(--spacing-xs)}.help-item p{color:var(--text-secondary);font-size:0.9rem;margin:0}.tool-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.tool-description{color:var(--text-secondary);font-size:0.95rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);border-left:3px solid var(--primary-cyan)}.tool-results{margin-top:var(--spacing-lg)}.tool-result-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.tool-result-item:hover{border-color:var(--primary-cyan)}.tool-result-icon{font-size:1.5rem}.tool-result-info{flex:1}.tool-result-name{color:var(--text-primary);font-weight:500}.tool-result-detail{color:var(--text-muted);font-size:0.85rem}@media (max-width:768px){.settings-tabs,.report-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.settings-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.settings-tab .tab-text{display:none}.settings-tab .tab-icon{font-size:1.25rem}.report-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.report-tab .tab-label{display:none}.report-tab .tab-icon{font-size:1.25rem}}.game-fanart-bg{position:fixed;top:0;left:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0;z-index:-1;transition:opacity 0.5s ease}.game-fanart-bg.active{opacity:0.15}.game-fanart-bg::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(to bottom,transparent 0%,var(--bg-primary) 100%)}.game-compact-layout{max-width:100%}.game-hero{display:flex;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.hero-boxart{position:relative;flex-shrink:0;width:200px}.hero-boxart-frame{position:relative}.upscale-game-note{margin:var(--spacing-sm) 0 0;font-size:0.75rem;color:var(--text-secondary)}.game-boxart-image{width:100%;height:auto;border-radius:var(--radius-md);box-shadow:0 8px 32px rgba(0,0,0,0.4)}.hero-age-ratings{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:var(--spacing-sm)}.hero-info{flex:1;min-width:0;display:flex;flex-direction:column}.quick-tags{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{padding:4px 10px;border-radius:4px;font-size:0.8rem;font-weight:500;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1)}.quick-tags .tag,.quick-tags .game-badge{height:32px;box-sizing:border-box;line-height:1}.quick-tags .game-badge.age-rating-badge .age-rating-img{height:32px}.tag-system{color:var(--primary-cyan);border-color:var(--primary-cyan)}.hero-description{flex:1;font-size:0.9rem;line-height:1.6;color:var(--text-secondary);margin-bottom:var(--spacing-md);overflow-y:auto;max-height:120px}.key-details{display:flex;flex-wrap:wrap;gap:var(--spacing-lg);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.kd-item{display:flex;flex-direction:column;gap:2px}.kd-label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase}.kd-value{font-size:0.9rem;color:var(--text-primary)}.trophy-summary{text-align:center}.trophy-summary-header{display:flex;justify-content:center;align-items:center;gap:var(--spacing-lg);margin-bottom:var(--spacing-md)}.trophy-summary-title{font-size:1.1rem;font-weight:600}.trophy-summary-total{font-size:1.3rem;font-weight:bold;color:var(--primary-cyan)}.trophy-summary-progress{display:flex;align-items:center;gap:var(--spacing-md);max-width:500px;margin:0 auto var(--spacing-lg)}.trophy-progress-bar.large{flex:1;height:12px;background:rgba(255,255,255,0.1);border-radius:6px;overflow:hidden}.trophy-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),#00ddff);border-radius:6px}.trophy-progress-percent{font-size:1.2rem;font-weight:bold;color:var(--primary-cyan);min-width:60px}.trophy-summary-counts{display:flex;justify-content:center;gap:var(--spacing-xl);flex-wrap:wrap}.trophy-count{display:flex;align-items:center;gap:var(--spacing-sm);font-size:1.1rem;font-weight:600}.trophy-count img{width:28px;height:28px}.trophy-count.platinum{color:var(--trophy-display-platinum)}.trophy-count.gold{color:var(--trophy-display-gold)}.trophy-count.silver{color:var(--trophy-display-silver)}.trophy-count.bronze{color:var(--trophy-display-bronze)}.bottom-row{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.bottom-section{flex:1;background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.section-title-sm{font-size:0.9rem;color:var(--text-secondary);margin-bottom:var(--spacing-md)}.hltb-section{display:flex;flex-direction:column;gap:var(--spacing-sm)}.more-details{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm)}.md-item{display:flex;flex-direction:column}.md-item.md-inline{flex-direction:row;align-items:baseline;gap:var(--spacing-sm);grid-column:span 2}.md-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase}.md-value{font-size:0.85rem;color:var(--text-primary)}.trophy-columns{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:650px;gap:var(--spacing-xl)}.trophy-columns > .card{height:100%;display:flex;flex-direction:column;overflow:hidden}.trophy-columns > .card + .card{margin-top:0}.trophy-columns > .card .card-header{flex-shrink:0}.trophy-column .card-body{flex:1;overflow-y:auto;min-height:0}.trophy-list{display:flex;flex-direction:column;gap:var(--spacing-md)}.trophy-item{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.trophy-item.locked{opacity:0.5}.trophy-item.locked .trophy-item-icon img:first-child{filter:grayscale(1) brightness(0.5)}.trophy-item-icon{position:relative;flex-shrink:0}.trophy-item-icon > img:first-child{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);object-fit:cover}.trophy-item-icon > img.trophy-type-badge{position:absolute;bottom:-4px;right:-4px;width:24px;height:24px}.trophy-item-icon .badge-placeholder{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.trophy-item-info{flex:1;min-width:0}.trophy-item-name{font-weight:600;font-size:0.95rem;margin-bottom:4px}.trophy-item-desc{font-size:0.85rem;color:var(--text-secondary);margin-bottom:8px}.trophy-item-meta{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;font-size:0.8rem}.trophy-type-label{padding:2px 8px;border-radius:4px;font-weight:600;text-transform:uppercase;font-size:0.7rem}.trophy-type-label.p{background:var(--trophy-display-platinum);color:var(--trophy-display-text)}.trophy-type-label.g{background:var(--trophy-display-gold);color:var(--trophy-display-text)}.trophy-type-label.s{background:var(--trophy-display-silver);color:var(--trophy-display-text)}.trophy-type-label.b{background:var(--trophy-display-bronze);color:var(--trophy-display-text)}.trophy-timestamp{color:var(--primary-cyan)}.trophy-empty{text-align:center;padding:var(--spacing-xl);color:var(--text-muted);font-style:italic}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}@media (max-width:900px){.trophy-columns{grid-template-columns:1fr;grid-template-rows:auto}.trophy-columns > .card{height:auto;max-height:500px}.game-hero{flex-direction:column;align-items:center}.hero-boxart{width:150px}.bottom-row{flex-direction:column}}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);position:sticky;top:var(--sticky-top);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.8rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:3px}.alphabet-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.75rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast);padding:0}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.25;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.08);border-color:rgba(76,201,240,0.2);color:var(--text-secondary)}@media (max-width:768px){.alphabet-nav{padding:var(--spacing-xs) var(--spacing-md)}.alphabet-btn{width:24px;height:24px;font-size:0.65rem}.alphabet-label{display:none}}.warning-banner{background:linear-gradient(135deg,rgba(255,193,7,0.1),rgba(247,37,133,0.1));border:1px solid rgba(255,193,7,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.warning-banner .warning-icon{font-size:1.5rem}.warning-banner .warning-text h4{color:var(--neon-orange);margin-bottom:0.25rem}.warning-banner .warning-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.info-banner{background:linear-gradient(135deg,rgba(76,201,240,0.1),rgba(114,9,183,0.1));border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.info-banner .info-icon{font-size:1.5rem}.info-banner .info-text h4{color:var(--text-primary);margin-bottom:0.25rem}.info-banner .info-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.file-list{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);max-height:400px;overflow-y:auto}.file-list-header{display:grid;gap:1rem;padding:0.75rem 1rem;background:var(--bg-medium);border-bottom:1px solid var(--border-color);font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);position:sticky;top:0;z-index:10}.file-list-item{display:grid;gap:1rem;padding:0.75rem 1rem;border-bottom:1px solid var(--border-color);font-size:0.9rem;align-items:center}.file-list-item:hover{background:rgba(76,201,240,0.05)}.file-list-item:last-child{border-bottom:none}.file-list-item input[type="checkbox"]{width:18px;height:18px;accent-color:var(--neon-cyan);cursor:pointer}.file-list-item .filename{color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-list-item .filesize{color:var(--text-secondary)}.action-bar{display:flex;gap:1rem;margin-top:1rem;padding:1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md)}.action-btn{padding:0.4rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-sm);color:var(--neon-cyan);cursor:pointer;font-size:0.8rem;transition:all var(--transition-fast)}.action-btn:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 10px rgba(76,201,240,0.3)}.theme-selector{display:flex;gap:var(--spacing-md);flex-wrap:wrap}.theme-option{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-md);background:var(--card-bg);border:2px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--transition-fast),box-shadow var(--transition-fast);min-width:110px}.theme-option:hover{border-color:var(--card-border-hover)}.theme-option.active{border-color:var(--primary-cyan);box-shadow:0 0 12px var(--primary-cyan-glow)}.theme-preview{display:flex;gap:4px;padding:4px;background:rgba(0,0,0,0.3);border-radius:var(--radius-sm)}.theme-swatch{width:24px;height:24px;border-radius:3px}.theme-name{font-family:var(--font-primary);font-size:0.85rem;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.theme-option.active .theme-name{color:var(--primary-cyan)}.section-title{font-family:var(--font-display);font-size:1rem;color:var(--text-primary);margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:0.5rem}.emu-folder-list{list-style:none;margin:0 0 var(--spacing-md);padding:0}.emu-folder-list li{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--border-color)}.emu-folder-list code{overflow-wrap:anywhere}.emu-folder-add{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.emu-folder-add .form-input{flex:1 1 16rem}.museum-progress{padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.museum-progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-sm)}.museum-progress-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan)}.museum-progress-stats{font-size:0.85rem;color:var(--text-secondary);margin-top:var(--spacing-sm)}.museum-progress-stats .text-success{color:var(--status-success)}.museum-progress-stats .text-warning{color:var(--status-warning)}.museum-progress-stats .text-error{color:var(--status-error)}.museum-timeline{display:flex;flex-direction:column;gap:var(--spacing-xl)}.museum-era{position:relative}.museum-era-header{display:flex;align-items:center;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.museum-era-line{width:4px;height:48px;background:linear-gradient(180deg,var(--primary-cyan),transparent);border-radius:2px;flex-shrink:0}.museum-era-info{display:flex;flex-direction:column;gap:2px}.museum-era-title{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 20px rgba(76,201,240,0.3)}.museum-era-date{font-family:var(--font-mono);font-size:0.8rem;color:var(--text-muted)}.museum-era-count{font-size:0.8rem;color:var(--text-secondary)}.museum-page{--card-min:260px;--card-gap:var(--spacing-md)}.museum-systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-system-card{display:flex;flex-direction:column;position:relative;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;color:inherit;transition:transform var(--transition-normal),box-shadow var(--transition-normal)}.museum-system-card:hover{transform:translateY(-3px);box-shadow:0 0 20px rgba(76,201,240,0.15);border-color:var(--card-border-hover)}.museum-card-image{height:140px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);padding:var(--spacing-md)}.museum-card-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-card-placeholder{font-size:3rem;opacity:0.3}.museum-card-body{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs);flex:1}.museum-card-title{font-family:var(--font-heading);font-size:0.9rem;color:var(--text-primary);margin:0;line-height:1.3}.museum-card-summary{font-size:0.78rem;color:var(--text-secondary);margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4}.museum-card-meta{display:flex;flex-wrap:wrap;gap:4px;margin-top:auto}.museum-meta-chip{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--text-secondary);background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.15);border-radius:var(--radius-full);white-space:nowrap}.museum-content-indicator{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:rgba(34,197,94,0.2);color:var(--neon-green);font-size:0.7rem}.museum-system-header{margin-bottom:var(--spacing-xl)}.museum-hero-layout{display:flex;gap:var(--spacing-xl);align-items:flex-start}.museum-hero-image{flex-shrink:0;width:240px;height:200px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);border:1px solid var(--card-border);padding:var(--spacing-md)}.museum-hero-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-hero-placeholder{font-size:4rem;opacity:0.3}.museum-hero-info{flex:1;display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-hero-title{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 30px rgba(76,201,240,0.3)}.museum-hero-summary{font-size:1rem;color:var(--text-secondary);line-height:1.5;margin:0}.museum-hero-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-xs)}.museum-hero-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-sm);flex-wrap:wrap}.museum-nav{display:flex;gap:0;border-bottom:2px solid var(--border-color);margin-bottom:var(--spacing-lg);position:sticky;background:var(--bg-darkest);z-index:10;padding-top:var(--spacing-xs)}.museum-nav-item{padding:var(--spacing-sm) var(--spacing-lg);font-family:var(--font-heading);font-size:0.85rem;color:var(--text-secondary);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color var(--transition-fast),border-color var(--transition-fast);white-space:nowrap}.museum-nav-item:hover{color:var(--text-primary)}.museum-nav-item.active{color:var(--primary-cyan);border-bottom-color:var(--primary-cyan)}.museum-section{margin-bottom:var(--spacing-xl)}.museum-section-actions{display:flex;align-items:center;flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--primary-cyan);margin:0}.museum-history-content{max-width:calc(75ch + 2 * var(--spacing-lg));padding:var(--spacing-lg);line-height:1.75;color:var(--text-primary);font-size:0.95rem}.museum-history-content p{margin:0 0 var(--spacing-md) 0}.museum-history-content p:last-child{margin-bottom:0}.museum-placeholder{padding:var(--spacing-xl);text-align:center}.museum-placeholder .text-muted{color:var(--text-muted)}.museum-generated-badge{font-size:0.75rem;font-family:var(--font-mono);color:var(--text-muted);padding:4px 10px;background:rgba(76,201,240,0.06);border:1px solid rgba(76,201,240,0.12);border-radius:var(--radius-full)}.museum-ai-badge{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--neon-purple);background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.2);border-radius:var(--radius-full)}.museum-system-page{--card-min:280px;--card-gap:var(--spacing-md)}.museum-specs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-spec-item{padding:var(--spacing-md);display:flex;flex-direction:column;gap:4px}.museum-spec-label{font-family:var(--font-heading);font-size:0.75rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.museum-spec-value{font-size:0.9rem;color:var(--text-primary);font-family:var(--font-mono);word-break:break-word}.museum-top-games-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-top-game{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);transition:border-color var(--transition-fast)}.museum-top-game:hover{border-color:var(--card-border-hover)}.museum-rank{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);min-width:40px;text-align:center;flex-shrink:0}.museum-top-game-art{width:48px;height:64px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);overflow:hidden;background:rgba(0,0,0,0.2)}.museum-top-game-art img{max-height:100%;max-width:100%;object-fit:cover}.museum-top-game-placeholder{font-size:1.5rem;opacity:0.3}.museum-top-game-info{flex:1;display:flex;flex-direction:column;gap:2px}.museum-top-game-title{font-family:var(--font-primary);font-weight:600;font-size:0.95rem;color:var(--text-primary);text-decoration:none}a.museum-top-game-title:hover{color:var(--primary-cyan)}.museum-top-game-meta{display:flex;gap:var(--spacing-xs);align-items:center}.museum-controllers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--spacing-md)}.museum-controller-card{display:flex;flex-direction:column;overflow:hidden;border-radius:var(--radius-lg)}.museum-controller-image{height:180px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.15);padding:var(--spacing-md);position:relative}.museum-controller-image img{max-height:100%;max-width:100%;object-fit:contain;cursor:zoom-in}.museum-controller-image-actions{display:flex;gap:4px}.museum-controller-image-actions .btn{padding:2px 6px;font-size:0.65rem;line-height:1.2;letter-spacing:0.5px;backdrop-filter:blur(8px);background:rgba(0,0,0,0.6);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan);box-shadow:none;text-shadow:none}.museum-controller-image-actions .btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan)}.museum-controller-image > .museum-controller-image-actions{position:absolute;bottom:var(--spacing-xs);right:var(--spacing-xs);opacity:0;transition:opacity var(--transition-fast)}.museum-controller-image:hover > .museum-controller-image-actions{opacity:1}.museum-controller-upload-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--spacing-sm);background:rgba(10,14,23,0.85);z-index:5;border-radius:inherit}.museum-controller-upload-text{font-family:var(--font-primary);font-size:0.75rem;color:var(--text-secondary);letter-spacing:0.5px}.museum-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.museum-lightbox.active{display:flex}.museum-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md);position:relative;z-index:1}.museum-lightbox::before{content:"";position:absolute;top:45%;left:50%;transform:translate(-50%,-50%);width:90vmin;height:90vmin;background:radial-gradient(circle at center,rgba(76,201,240,0.19) 0%,rgba(76,201,240,0.09) 25%,rgba(76,201,240,0.03) 45%,transparent 40%);border-radius:50%;pointer-events:none;z-index:0}.museum-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.museum-lightbox-close:hover{color:var(--primary-cyan)}.museum-lightbox-title{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-family:var(--font-heading);font-size:0.9rem;white-space:nowrap}.museum-lightbox-hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.museum-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.museum-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.museum-lightbox-nav.prev{left:20px}.museum-lightbox-nav.next{right:20px}.museum-controller-placeholder{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:2.5rem}.museum-controller-info{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs)}.museum-controller-name{font-family:var(--font-heading);font-size:0.85rem;color:var(--text-primary);margin:0}.museum-controller-meta{display:flex;flex-wrap:wrap;gap:4px}.museum-controller-desc{font-size:0.8rem;color:var(--text-secondary);margin:var(--spacing-xs) 0 0 0;line-height:1.4}.museum-controller-buttons{margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.museum-controller-buttons summary{cursor:pointer;color:var(--primary-cyan);font-family:var(--font-heading);font-size:0.75rem}.museum-controller-buttons p{margin:var(--spacing-xs) 0 0 0;font-family:var(--font-mono);font-size:0.75rem;line-height:1.5}@media (max-width:768px){.museum-hero-layout{flex-direction:column;align-items:center;text-align:center}.museum-hero-image{width:180px;height:150px}.museum-hero-meta,.museum-hero-actions{justify-content:center}.museum-systems-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.museum-specs-grid{grid-template-columns:1fr}.museum-controllers-grid{grid-template-columns:1fr}.museum-nav{overflow-x:auto;-webkit-overflow-scrolling:touch}}body::before{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:linear-gradient( to top right,rgba(76,201,240,0.08) 0%,rgba(114,9,183,0.06) 25%,rgba(247,37,133,0.04) 50%,rgba(20,26,35,0.02) 75%,transparent 100% );pointer-events:none;z-index:-2}body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px );pointer-events:none;z-index:9999}@media (prefers-reduced-motion:reduce){body::after{display:none}}@keyframes glow-pulse{0%,100%{text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow)}50%{text-shadow:0 0 15px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow),0 0 45px var(--primary-cyan-glow)}}@keyframes pulse-status{0%,100%{opacity:1}50%{opacity:0.5}}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes highlight-pulse{0%,100%{box-shadow:0 0 0 0 rgba(76,201,240,0)}50%{box-shadow:0 0 20px 5px rgba(76,201,240,0.4)}}.highlight-jump{animation:highlight-pulse 0.75s ease-in-out 2}@keyframes modalSlideIn{from{opacity:0;transform:scale(0.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}@media (prefers-reduced-motion:reduce){.highlight-jump{animation:none}}.mt-0{margin-top:0}.mt-xs{margin-top:var(--spacing-xs)}.mt-sm{margin-top:var(--spacing-sm)}.mt-md{margin-top:var(--spacing-md)}.mt-lg{margin-top:var(--spacing-lg)}.mt-xl{margin-top:var(--spacing-xl)}.mt-2xl{margin-top:var(--spacing-2xl)}.mb-0{margin-bottom:0}.mb-xs{margin-bottom:var(--spacing-xs)}.mb-sm{margin-bottom:var(--spacing-sm)}.mb-md{margin-bottom:var(--spacing-md)}.mb-lg{margin-bottom:var(--spacing-lg)}.mb-xl{margin-bottom:var(--spacing-xl)}.mb-2xl{margin-bottom:var(--spacing-2xl)}.ml-0{margin-left:0}.ml-xs{margin-left:var(--spacing-xs)}.ml-sm{margin-left:var(--spacing-sm)}.ml-md{margin-left:var(--spacing-md)}.ml-lg{margin-left:var(--spacing-lg)}.ml-xl{margin-left:var(--spacing-xl)}.mr-0{margin-right:0}.mr-xs{margin-right:var(--spacing-xs)}.mr-sm{margin-right:var(--spacing-sm)}.mr-md{margin-right:var(--spacing-md)}.mr-lg{margin-right:var(--spacing-lg)}.mr-xl{margin-right:var(--spacing-xl)}.mx-0{margin-left:0;margin-right:0}.mx-sm{margin-left:var(--spacing-sm);margin-right:var(--spacing-sm)}.mx-md{margin-left:var(--spacing-md);margin-right:var(--spacing-md)}.mx-lg{margin-left:var(--spacing-lg);margin-right:var(--spacing-lg)}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-sm{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.my-md{margin-top:var(--spacing-md);margin-bottom:var(--spacing-md)}.my-lg{margin-top:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.pt-0{padding-top:0}.pt-xs{padding-top:var(--spacing-xs)}.pt-sm{padding-top:var(--spacing-sm)}.pt-md{padding-top:var(--spacing-md)}.pt-lg{padding-top:var(--spacing-lg)}.pt-xl{padding-top:var(--spacing-xl)}.pb-0{padding-bottom:0}.pb-xs{padding-bottom:var(--spacing-xs)}.pb-sm{padding-bottom:var(--spacing-sm)}.pb-md{padding-bottom:var(--spacing-md)}.pb-lg{padding-bottom:var(--spacing-lg)}.pb-xl{padding-bottom:var(--spacing-xl)}.pl-0{padding-left:0}.pl-sm{padding-left:var(--spacing-sm)}.pl-md{padding-left:var(--spacing-md)}.pl-lg{padding-left:var(--spacing-lg)}.pr-0{padding-right:0}.pr-sm{padding-right:var(--spacing-sm)}.pr-md{padding-right:var(--spacing-md)}.pr-lg{padding-right:var(--spacing-lg)}.px-0{padding-left:0;padding-right:0}.px-sm{padding-left:var(--spacing-sm);padding-right:var(--spacing-sm)}.px-md{padding-left:var(--spacing-md);padding-right:var(--spacing-md)}.px-lg{padding-left:var(--spacing-lg);padding-right:var(--spacing-lg)}.py-0{padding-top:0;padding-bottom:0}.py-sm{padding-top:var(--spacing-sm);padding-bottom:var(--spacing-sm)}.py-md{padding-top:var(--spacing-md);padding-bottom:var(--spacing-md)}.py-lg{padding-top:var(--spacing-lg);padding-bottom:var(--spacing-lg)}.d-none{display:none}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:flex}.d-inline-flex{display:inline-flex}.d-grid{display:grid}.flex-1{flex:1}.flex-auto{flex:auto}.flex-none{flex:none}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.align-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-0{gap:0}.gap-xs{gap:var(--spacing-xs)}.gap-sm{gap:var(--spacing-sm)}.gap-md{gap:var(--spacing-md)}.gap-lg{gap:var(--spacing-lg)}.gap-xl{gap:var(--spacing-xl)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-muted{color:var(--text-muted)}.text-cyan{color:var(--primary-cyan)}.text-magenta{color:var(--secondary-magenta)}.text-success{color:var(--neon-green)}.text-warning{color:var(--neon-orange)}.text-danger{color:var(--neon-red)}.text-purple{color:var(--neon-purple)}.font-heading{font-family:var(--font-heading)}.font-mono{font-family:var(--font-mono)}.font-normal{font-weight:400}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.text-sm{font-size:0.85rem}.text-base{font-size:1rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.leading-normal{line-height:1.6}.leading-loose{line-height:2}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.break-word{word-wrap:break-word;overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}.w-full{width:100%}.w-auto{width:auto}.h-full{height:100%}.h-auto{height:auto}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.overflow-auto{overflow:auto}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.border{border:1px solid var(--card-border)}.border-0{border:0}.border-t{border-top:1px solid var(--card-border)}.border-b{border-bottom:1px solid var(--card-border)}.border-l{border-left:1px solid var(--card-border)}.border-r{border-right:1px solid var(--card-border)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-md{border-radius:var(--radius-md)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-full{border-radius:var(--radius-full)}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.opacity-0{opacity:0}.opacity-25{opacity:0.25}.opacity-50{opacity:0.5}.opacity-75{opacity:0.75}.opacity-100{opacity:1}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.select-none{user-select:none}.select-text{user-select:text}.select-all{user-select:all}.mobile-header,.mobile-bottom-bar,.sidebar-scrim{display:none}@media (max-width:992px){:root{--sticky-top:var(--mobile-header-height)}.mobile-header{position:fixed;top:0;left:0;right:0;z-index:1001;height:var(--mobile-header-height);display:flex;align-items:center;gap:var(--spacing-sm);padding:0 var(--spacing-sm);background:var(--bg-medium);border-bottom:1px solid var(--card-border)}.mobile-header-logo{flex:1}.mobile-header-logo .logo-text{font-size:1.25rem}.mobile-menu-toggle,.mobile-search-toggle{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:none;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer}.mobile-menu-toggle{flex-direction:column;gap:4px}.mobile-menu-toggle span{width:24px;height:3px;background:var(--text-primary);border-radius:2px}.mobile-search-toggle{font-size:1.25rem}.mobile-menu-toggle:focus-visible,.mobile-search-toggle:focus-visible{outline:none;box-shadow:0 0 0 2px var(--primary-cyan)}.sidebar{top:var(--mobile-header-height);height:calc(100dvh - var(--mobile-header-height));width:min(var(--sidebar-width),85vw);background:linear-gradient(180deg,var(--bg-medium) 0%,var(--bg-dark) 100%);backdrop-filter:none;-webkit-backdrop-filter:none;visibility:hidden;transition:transform var(--transition-normal),visibility 0s var(--transition-normal)}.sidebar.open{visibility:visible;transition:transform var(--transition-normal)}.sidebar-header > .logo{display:none}.sidebar-header > .logo + *{margin-top:0}.global-search-list{max-height:50dvh}.sidebar-nav{overflow-y:auto}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,0.6);opacity:0;pointer-events:none;transition:opacity var(--transition-normal)}body.mobile-menu-open .sidebar-scrim{opacity:1;pointer-events:auto}body.mobile-menu-open{overflow:hidden}.mobile-bottom-bar{position:fixed;left:0;right:0;bottom:0;z-index:998;display:flex;height:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px));padding-bottom:env(safe-area-inset-bottom,0px);background:var(--bg-medium);border-top:1px solid var(--card-border)}.mobile-bottom-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-width:0;color:var(--text-secondary);text-decoration:none;font-size:0.75rem}.mobile-bottom-icon{font-size:1.25rem}.mobile-bottom-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mobile-bottom-item.active{color:var(--primary-cyan);box-shadow:inset 0 2px 0 var(--primary-cyan)}.mobile-bottom-item:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--primary-cyan)}.main-content{padding-top:calc(var(--mobile-header-height) + var(--spacing-md));padding-bottom:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px) + var(--spacing-md))}body:has(.mobile-bottom-bar) .back-to-top-btn{bottom:calc(var(--mobile-bar-height) + 16px)}body:has(.mobile-bottom-bar) .unified-toast-container{bottom:calc(var(--mobile-bar-height) + 12px)}body:has(.mobile-bottom-bar) .launch-indicator{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm))}body:has(.mobile-bottom-bar) .launch-indicator-popover{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm) + 2.5rem)}}@media (max-width:1600px){.content-wrapper{width:80%}}@media (max-width:1200px){.content-wrapper{width:95%;min-width:auto}.game-hero{grid-template-columns:250px 1fr}.game-details-grid{grid-template-columns:1fr}}@media (max-width:992px){.sidebar{transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.main-content{margin-left:0}.game-hero{grid-template-columns:1fr}.game-boxart-container{max-width:300px;margin:0 auto}.unified-toast-container{left:20px;right:20px;width:auto;max-width:calc(100% - 40px)}.system-controller-grid{grid-template-columns:1fr !important}}@media (max-width:900px){.score-lists-grid{grid-template-columns:1fr}.psn-trophy-lists-row{grid-template-columns:1fr}.psn-info-panels-row{grid-template-columns:1fr}.psn-game-info-card{flex-direction:column}.psn-game-boxart{width:140px}.psn-meta-grid{grid-template-columns:1fr 1fr}.psn-stats-bar{grid-template-columns:repeat(2,1fr)}}@media (max-width:768px){.page-header{flex-direction:column;align-items:flex-start;gap:var(--spacing-md)}.page-title{font-size:1.8rem}.stats-grid{grid-template-columns:1fr}.systems-grid{grid-template-columns:repeat(2,1fr)}.filter-bar{flex-direction:column}.search-box{min-width:100%}.game-meta-badges{flex-direction:column}.game-ratings{flex-direction:column}.game-hero{grid-template-columns:1fr}.hero-boxart{width:200px;margin:0 auto}.tool-tab{padding:0.75rem 0.5rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.edit-grid{grid-template-columns:1fr}.psn-games-grid{grid-template-columns:1fr}.psn-search-sort-row{flex-direction:column}.psn-sort-controls{width:100%}.psn-sort-select{flex:1}.psn-profile-bar{flex-direction:column;text-align:center}}@media (max-width:600px){.gdm-header{flex-direction:column;align-items:center;text-align:center}.gdm-boxart-container{width:140px}.gdm-details-grid{grid-template-columns:1fr}.gdm-meta{justify-content:center}.gdm-ratings{justify-content:center}.gdm-actions{flex-direction:column}.gdm-actions .btn{width:100%}.gem-hltb-times{grid-template-columns:1fr}.gem-footer{flex-direction:column}.gem-footer .btn{width:100%}.gem-tabs{flex-wrap:nowrap}.gem-tab{padding:6px 10px;font-size:0.75rem}.gem-grid{grid-template-columns:1fr}.gem-footer-right{flex-direction:column;width:100%}.psn-meta-grid{grid-template-columns:1fr}.psn-trophy-type-breakdown{flex-wrap:wrap;gap:var(--spacing-md)}.platform-filters{justify-content:center}}@media (max-width:480px){.systems-grid{grid-template-columns:1fr}.page-title{font-size:1.5rem;letter-spacing:2px}.stat-value{font-size:2.5rem}} \ No newline at end of file +:root{--primary-cyan:#4cc9f0;--primary-cyan-dark:#3a9fc2;--primary-cyan-light:#7dd8f5;--primary-cyan-glow:rgba(76,201,240,0.5);--secondary-magenta:#f72585;--secondary-magenta-dark:#c41d6a;--secondary-magenta-light:#f95fa3;--secondary-magenta-glow:rgba(247,37,133,0.5);--accent-purple:#7209b7;--accent-blue:#3a0ca3;--accent-violet:#560bad;--bg-darkest:#0a0e17;--bg-dark:#0f1419;--bg-darker:#0d1117;--bg-medium:#151b24;--bg-light:#1a222d;--bg-lighter:#212b38;--card-bg:#141a23;--card-bg-hover:#1a222d;--card-border:#2a3542;--card-border-hover:#3a4552;--text-primary:#e8eaed;--text-secondary:#9aa0a6;--text-muted:#6e7378;--text-accent:var(--primary-cyan);--border-color:#2a3542;--status-success:#00e676;--status-warning:#ffab00;--status-error:#ff5252;--status-info:var(--primary-cyan);--status-online:#00e676;--status-offline:#ff5252;--status-connected:var(--primary-cyan);--shadow-sm:0 2px 4px rgba(0,0,0,0.3);--shadow-md:0 4px 8px rgba(0,0,0,0.4);--shadow-lg:0 8px 16px rgba(0,0,0,0.5);--shadow-xl:0 12px 24px rgba(0,0,0,0.6);--shadow-glow-cyan:0 0 20px rgba(76,201,240,0.3);--shadow-glow-magenta:0 0 20px rgba(247,37,133,0.3);--radius-sm:4px;--radius-md:8px;--radius-lg:12px;--radius-xl:16px;--radius-full:9999px;--transition-fast:0.15s ease;--transition-normal:0.25s ease;--transition-slow:0.4s ease;--font-primary:'Rajdhani','Orbitron','Share Tech Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-heading:'Orbitron','Rajdhani','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif;--font-mono:'Share Tech Mono','JetBrains Mono','Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',monospace;--spacing-xs:4px;--spacing-sm:8px;--spacing-md:16px;--spacing-lg:24px;--spacing-xl:32px;--spacing-2xl:48px;--sidebar-width:260px;--sidebar-collapsed-width:70px;--mobile-header-height:56px;--mobile-bar-height:60px;--sticky-top:0px;--neon-cyan:#4cc9f0;--neon-green:#22c55e;--neon-orange:#f59e0b;--neon-red:#ef4444;--neon-purple:#a855f7;--neon-gray:#64748b;--neon-blue:#3b82f6;--text-on-color:#fff;--font-display:var(--font-heading);--accent-cyan:var(--primary-cyan);--cyan-bg:rgba(76,201,240,0.15);--cyan-bg-light:rgba(76,201,240,0.1);--cyan-bg-strong:rgba(76,201,240,0.2);--green-bg:rgba(34,197,94,0.15);--red-bg:rgba(239,68,68,0.15);--orange-bg:rgba(245,158,11,0.15);--purple-bg:rgba(168,85,247,0.15);--magenta-bg:rgba(247,37,133,0.15);--trophy-platinum:#7bb8d4;--trophy-gold:#cd9a2a;--trophy-silver:#a8a8a8;--trophy-bronze:#cd7f32;--trophy-display-platinum:#a8c8dc;--trophy-display-gold:#ffd700;--trophy-display-silver:#c0c0c0;--trophy-display-bronze:#cd7f32;--trophy-display-text:#111;--rating-esrb:#64b5f6;--rating-esrb-border:rgba(25,118,210,0.3);--rating-esrb-bg:rgba(25,118,210,0.1);--rating-pegi:#ce93d8;--rating-pegi-border:rgba(123,31,162,0.3);--rating-pegi-bg:rgba(123,31,162,0.1);--completion-gold:#fbbf24}*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;scroll-behavior:smooth}body{font-family:var(--font-primary);background-color:var(--bg-darkest);color:var(--text-primary);line-height:1.6;min-height:100vh;overflow-x:hidden;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}ul,ol{list-style:none}img,picture,video,canvas,svg{display:block;max-width:100%}button{font:inherit;cursor:pointer}input,textarea,select{font:inherit}input,button,textarea,select{font-family:inherit}a{color:inherit;text-decoration:none}@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important}}@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');h1,.h1{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:1px;text-transform:uppercase}h2,.h2{font-family:var(--font-heading);font-size:1.5rem;font-weight:600;color:var(--text-primary);letter-spacing:0.5px}h3,.h3{font-family:var(--font-heading);font-size:1.25rem;font-weight:600;color:var(--text-primary)}h4,.h4{font-family:var(--font-primary);font-size:1.1rem;font-weight:600;color:var(--text-primary)}h5,.h5{font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-secondary)}h6,.h6{font-family:var(--font-primary);font-size:0.9rem;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.page-header h1{margin-bottom:var(--spacing-sm)}.page-header p{color:var(--text-secondary);font-size:1rem}.page-title{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:4px;text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow);animation:glow-pulse 3s ease-in-out infinite}.page-subtitle{color:var(--text-secondary);font-size:1rem;margin-top:var(--spacing-xs)}.page-actions{display:flex;gap:var(--spacing-md)}.section-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:2px;display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.section-title::before{content:'';width:4px;height:20px;background:var(--primary-cyan);border-radius:2px}.breadcrumb{display:flex;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);font-size:0.9rem}.breadcrumb a{color:var(--text-secondary);text-decoration:none;transition:color var(--transition-fast)}.breadcrumb a:hover{color:var(--primary-cyan)}.breadcrumb-separator{color:var(--text-muted)}.breadcrumb-current{color:var(--text-primary)}code,pre,.mono{font-family:var(--font-mono)}code{padding:0.2em 0.4em;background:rgba(76,201,240,0.1);border-radius:var(--radius-sm);font-size:0.9em;color:var(--primary-cyan)}pre{padding:var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow-x:auto}pre code{padding:0;background:none}[data-theme="matrix"]{--primary-cyan:#00ff41;--primary-cyan-dark:#00cc33;--primary-cyan-light:#66ff88;--primary-cyan-glow:rgba(0,255,65,0.5);--secondary-magenta:#008f11;--secondary-magenta-dark:#006b0d;--secondary-magenta-light:#33b833;--secondary-magenta-glow:rgba(0,143,17,0.5);--accent-purple:#00aa22;--accent-blue:#004d00;--accent-violet:#009a1a;--bg-darkest:#000000;--bg-dark:#050505;--bg-darker:#0a0a0a;--bg-medium:#111111;--bg-light:#161616;--bg-lighter:#1a1a1a;--card-bg:#0a0a0a;--card-bg-hover:#111111;--card-border:#1a3a1a;--card-border-hover:#2a5a2a;--text-primary:#c0ffc0;--text-secondary:#80c080;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a3a1a;--status-success:#00ff41;--status-warning:#a0e000;--status-error:#ff3333;--status-info:var(--primary-cyan);--status-online:#00ff41;--status-offline:#ff3333;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,65,0.3);--shadow-glow-magenta:0 0 20px rgba(0,143,17,0.3);--neon-cyan:#00ff41;--neon-green:#00ff41;--neon-orange:#a0e000;--neon-red:#ff3333;--neon-purple:#33cc66;--neon-gray:#4a6a4a;--trophy-platinum:#66ffaa;--trophy-gold:#a0e060;--trophy-silver:#88bb88;--trophy-bronze:#77aa55;--toast-bg-from:rgba(0,10,0,0.95);--toast-bg-to:rgba(0,20,5,0.95);--toast-queued-bg-from:rgba(0,12,0,0.90);--toast-queued-bg-to:rgba(0,22,5,0.90)}[data-theme="matrix"] body::before{background:linear-gradient( to top right,rgba(0,255,65,0.06) 0%,rgba(0,143,17,0.04) 25%,rgba(0,80,0,0.03) 50%,rgba(0,0,0,0.02) 75%,transparent 100% )}[data-theme="matrix"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,65,0.02) 2px,rgba(0,255,65,0.02) 4px )}[data-theme="amber"]{--primary-cyan:#ffb000;--primary-cyan-dark:#cc8c00;--primary-cyan-light:#ffc944;--primary-cyan-glow:rgba(255,176,0,0.5);--secondary-magenta:#ff6600;--secondary-magenta-dark:#cc5200;--secondary-magenta-light:#ff8833;--secondary-magenta-glow:rgba(255,102,0,0.5);--accent-purple:#cc7700;--accent-blue:#995500;--accent-violet:#bb6600;--bg-darkest:#0a0800;--bg-dark:#0d0a02;--bg-darker:#0f0c04;--bg-medium:#151008;--bg-light:#1a150c;--bg-lighter:#211c12;--card-bg:#120e04;--card-bg-hover:#181208;--card-border:#3a2a10;--card-border-hover:#4a3a1a;--text-primary:#ffe0a0;--text-secondary:#c09050;--text-muted:#806030;--text-accent:var(--primary-cyan);--border-color:#3a2a10;--status-success:#ffcc00;--status-warning:#ff9900;--status-error:#ff4422;--status-info:var(--primary-cyan);--status-online:#ffcc00;--status-offline:#ff4422;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(255,176,0,0.3);--shadow-glow-magenta:0 0 20px rgba(255,102,0,0.3);--neon-cyan:#ffb000;--neon-green:#ddaa00;--neon-orange:#ff7700;--neon-red:#ff4422;--neon-purple:#ff8844;--neon-gray:#7a6a4a;--trophy-platinum:#ffe8aa;--trophy-gold:#ffcc44;--trophy-silver:#ccaa77;--trophy-bronze:#aa7733;--toast-bg-from:rgba(18,14,4,0.95);--toast-bg-to:rgba(28,20,8,0.95);--toast-queued-bg-from:rgba(20,16,4,0.90);--toast-queued-bg-to:rgba(30,22,8,0.90)}[data-theme="amber"] body::before{background:radial-gradient(ellipse 30% 20% at 50% 100%,rgba(255,230,100,0.2) 0%,rgba(255,200,50,0.12) 40%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 100%,rgba(255,160,0,0.1) 0%,rgba(255,100,0,0.05) 50%,transparent 100%),linear-gradient(to top,rgba(255,120,0,0.08) 0%,rgba(255,80,0,0.04) 20%,rgba(120,50,0,0.02) 45%,transparent 70%)}[data-theme="amber"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(255,176,0,0.03) 2px,rgba(255,176,0,0.03) 4px )}[data-theme="ocean"]{--primary-cyan:#4dabf7;--primary-cyan-dark:#3d8bd4;--primary-cyan-light:#74c0fa;--primary-cyan-glow:rgba(77,171,247,0.5);--secondary-magenta:#20c997;--secondary-magenta-dark:#1aaa7f;--secondary-magenta-light:#4dd8ad;--secondary-magenta-glow:rgba(32,201,151,0.5);--accent-purple:#5c7cfa;--accent-blue:#3b5bdb;--accent-violet:#4c6ef5;--bg-darkest:#040810;--bg-dark:#050a14;--bg-darker:#060c18;--bg-medium:#0a1220;--bg-light:#0e1828;--bg-lighter:#142030;--card-bg:#081018;--card-bg-hover:#0c1620;--card-border:#1a2a40;--card-border-hover:#2a3a55;--text-primary:#dce4f0;--text-secondary:#8899b0;--text-muted:#5a6a80;--text-accent:var(--primary-cyan);--border-color:#1a2a40;--status-success:#40c057;--status-warning:#fab005;--status-error:#fa5252;--status-info:var(--primary-cyan);--status-online:#40c057;--status-offline:#fa5252;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(77,171,247,0.3);--shadow-glow-magenta:0 0 20px rgba(32,201,151,0.3);--neon-cyan:#4dabf7;--neon-green:#40c057;--neon-orange:#fab005;--neon-red:#fa5252;--neon-purple:#7950f2;--neon-gray:#5a6a80;--trophy-platinum:#a5d8ff;--trophy-gold:#ffe066;--trophy-silver:#adb5bd;--trophy-bronze:#e8a44a;--toast-bg-from:rgba(6,12,22,0.95);--toast-bg-to:rgba(10,18,32,0.95);--toast-queued-bg-from:rgba(8,14,24,0.90);--toast-queued-bg-to:rgba(12,20,34,0.90)}[data-theme="ocean"] body::before{background:radial-gradient(circle at 70% 10%,rgba(220,235,255,0.4) 0%,rgba(200,220,250,0.2) 1.5%,rgba(150,190,240,0.08) 4%,transparent 10%),radial-gradient(circle at 70% 10%,rgba(77,171,247,0.08) 0%,transparent 18%),radial-gradient(ellipse 80% 50% at 50% 90%,rgba(32,201,151,0.04) 0%,rgba(77,171,247,0.05) 40%,transparent 100%),linear-gradient(to bottom,rgba(10,20,40,0.02) 0%,rgba(77,171,247,0.03) 55%,rgba(32,201,151,0.02) 100%)}[data-theme="ocean"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(77,171,247,0.02) 2px,rgba(77,171,247,0.02) 4px )}[data-theme="christian"]{--primary-cyan:#d4a843;--primary-cyan-dark:#b08a30;--primary-cyan-light:#e8c86a;--primary-cyan-glow:rgba(212,168,67,0.5);--secondary-magenta:#7b4db0;--secondary-magenta-dark:#5f3a8a;--secondary-magenta-light:#9b6dd0;--secondary-magenta-glow:rgba(123,77,176,0.5);--accent-purple:#6a3d9a;--accent-blue:#4a2d7a;--accent-violet:#7b4db0;--bg-darkest:#0c0810;--bg-dark:#0e0a14;--bg-darker:#110d18;--bg-medium:#16101f;--bg-light:#1c1528;--bg-lighter:#231c30;--card-bg:#120e1a;--card-bg-hover:#181222;--card-border:#2a1f3a;--card-border-hover:#3a2d4e;--text-primary:#f0e8d8;--text-secondary:#b0a090;--text-muted:#706050;--text-accent:var(--primary-cyan);--border-color:#2a1f3a;--status-success:#d4a843;--status-warning:#e8a030;--status-error:#c04040;--status-info:var(--primary-cyan);--status-online:#d4a843;--status-offline:#c04040;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(212,168,67,0.3);--shadow-glow-magenta:0 0 20px rgba(123,77,176,0.3);--neon-cyan:#d4a843;--neon-green:#c0a040;--neon-orange:#e8a030;--neon-red:#c04040;--neon-purple:#9b6dd0;--neon-gray:#6a5a50;--trophy-platinum:#f0e0c0;--trophy-gold:#d4a843;--trophy-silver:#b0a090;--trophy-bronze:#8a6a40;--toast-bg-from:rgba(14,10,20,0.95);--toast-bg-to:rgba(22,16,30,0.95);--toast-queued-bg-from:rgba(16,12,22,0.90);--toast-queued-bg-to:rgba(24,18,32,0.90)}[data-theme="christian"] body::before{background:radial-gradient(ellipse 40% 60% at 50% 0%,rgba(212,168,67,0.15) 0%,rgba(212,168,67,0.06) 30%,transparent 70%),radial-gradient(ellipse 70% 50% at 50% 10%,rgba(180,140,50,0.06) 0%,rgba(123,77,176,0.03) 50%,transparent 100%),linear-gradient(to top,rgba(100,60,20,0.04) 0%,rgba(80,40,120,0.02) 40%,transparent 70%)}[data-theme="christian"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(212,168,67,0.015) 2px,rgba(212,168,67,0.015) 4px )}[data-theme="bladerunner"]{--primary-cyan:#1a9fff;--primary-cyan-dark:#1480d4;--primary-cyan-light:#55b8ff;--primary-cyan-glow:rgba(26,159,255,0.5);--secondary-magenta:#ff2d7c;--secondary-magenta-dark:#d42060;--secondary-magenta-light:#ff5a9a;--secondary-magenta-glow:rgba(255,45,124,0.5);--accent-purple:#b040e0;--accent-blue:#1060c0;--accent-violet:#8830c0;--bg-darkest:#06080e;--bg-dark:#080a12;--bg-darker:#0a0d16;--bg-medium:#0e121c;--bg-light:#131824;--bg-lighter:#1a1f2e;--card-bg:#0a0e16;--card-bg-hover:#10141e;--card-border:#1a2236;--card-border-hover:#2a3248;--text-primary:#d8dce8;--text-secondary:#8892a8;--text-muted:#78809a;--text-accent:var(--primary-cyan);--border-color:#1a2236;--status-success:#22cc88;--status-warning:#ff8c30;--status-error:#ff2d5e;--status-info:var(--primary-cyan);--status-online:#22cc88;--status-offline:#ff2d5e;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(26,159,255,0.3);--shadow-glow-magenta:0 0 20px rgba(255,45,124,0.3);--neon-cyan:#1a9fff;--neon-green:#22cc88;--neon-orange:#ff8c30;--neon-red:#ff2d5e;--neon-purple:#b040e0;--neon-gray:#4a5268;--trophy-platinum:#c0d8ff;--trophy-gold:#ffd066;--trophy-silver:#a0aab8;--trophy-bronze:#d08a40;--toast-bg-from:rgba(8,10,18,0.95);--toast-bg-to:rgba(14,18,28,0.95);--toast-queued-bg-from:rgba(10,12,20,0.90);--toast-queued-bg-to:rgba(16,20,30,0.90)}[data-theme="bladerunner"] body::before{background:radial-gradient(ellipse 40% 30% at 20% 95%,rgba(255,45,124,0.08) 0%,rgba(255,45,124,0.03) 50%,transparent 100%),radial-gradient(ellipse 40% 30% at 80% 90%,rgba(26,159,255,0.08) 0%,rgba(26,159,255,0.03) 50%,transparent 100%),radial-gradient(ellipse 60% 40% at 50% 70%,rgba(130,40,180,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(6,8,14,0.02) 0%,rgba(20,15,30,0.04) 60%,rgba(255,45,124,0.02) 100%)}[data-theme="bladerunner"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(26,159,255,0.02) 2px,rgba(26,159,255,0.02) 4px )}[data-theme="elite"]{--primary-cyan:#00ff00;--primary-cyan-dark:#00cc00;--primary-cyan-light:#66ff66;--primary-cyan-glow:rgba(0,255,0,0.5);--secondary-magenta:#cccccc;--secondary-magenta-dark:#999999;--secondary-magenta-light:#e0e0e0;--secondary-magenta-glow:rgba(204,204,204,0.5);--accent-purple:#00cc66;--accent-blue:#008844;--accent-violet:#00aa55;--bg-darkest:#000000;--bg-dark:#020202;--bg-darker:#040404;--bg-medium:#080808;--bg-light:#0c0c0c;--bg-lighter:#111111;--card-bg:#050505;--card-bg-hover:#0a0a0a;--card-border:#1a331a;--card-border-hover:#2a552a;--text-primary:#d0ffd0;--text-secondary:#80cc80;--text-muted:#508050;--text-accent:var(--primary-cyan);--border-color:#1a331a;--status-success:#00ff00;--status-warning:#ffff00;--status-error:#ff0000;--status-info:var(--primary-cyan);--status-online:#00ff00;--status-offline:#ff0000;--status-connected:var(--primary-cyan);--shadow-glow-cyan:0 0 20px rgba(0,255,0,0.3);--shadow-glow-magenta:0 0 20px rgba(204,204,204,0.3);--neon-cyan:#00ff00;--neon-green:#00ff00;--neon-orange:#ffff00;--neon-red:#ff0000;--neon-purple:#00cc66;--neon-gray:#4a6a4a;--trophy-platinum:#ccffcc;--trophy-gold:#ffff66;--trophy-silver:#aaccaa;--trophy-bronze:#88aa44;--toast-bg-from:rgba(0,5,0,0.95);--toast-bg-to:rgba(0,12,0,0.95);--toast-queued-bg-from:rgba(0,8,0,0.90);--toast-queued-bg-to:rgba(0,15,0,0.90)}[data-theme="elite"] body::before{background:radial-gradient(circle at 30% 20%,rgba(255,255,255,0.03) 0%,transparent 8%),radial-gradient(circle at 70% 15%,rgba(255,255,255,0.02) 0%,transparent 5%),radial-gradient(ellipse 40% 25% at 50% 95%,rgba(0,255,0,0.04) 0%,transparent 80%),linear-gradient(to bottom,rgba(0,0,0,0) 0%,rgba(0,20,0,0.02) 100%)}[data-theme="elite"] body::after{background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,255,0,0.025) 2px,rgba(0,255,0,0.025) 4px )}.app-container{display:flex;min-height:100vh;position:relative;z-index:2}.main-content{flex:1;margin-left:var(--sidebar-width);padding:var(--spacing-xl);transition:margin-left var(--transition-normal);display:flex;justify-content:center;position:relative}.main-content.sidebar-collapsed{margin-left:var(--sidebar-collapsed-width)}.content-wrapper{width:65%;max-width:1400px;min-width:600px;position:relative;z-index:1}.content-wrapper.adaptive{width:100%;max-width:none;min-width:0}.main-content.adaptive{min-width:0}.page-box{max-width:var(--page-max,none);margin-inline:auto}.page-box.grid-4{--page-max:calc(5 * var(--card-min) + 4 * var(--card-gap) - 1px)}.page-box.data-page{--page-max:1400px}.page-box.form-page{--page-max:1100px}.page-box.detail-page{--page-max:1600px}.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.section-link{color:var(--primary-cyan);text-decoration:none;font-size:0.85rem;font-weight:500;text-transform:uppercase;letter-spacing:1px;transition:all var(--transition-fast)}.section-link:hover{text-shadow:0 0 10px var(--primary-cyan-glow)}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--spacing-lg)}.systems-section{margin-bottom:var(--spacing-xl)}.sidebar{position:fixed;left:0;top:0;width:var(--sidebar-width);height:100vh;background:linear-gradient(180deg,color-mix(in srgb,var(--bg-medium) 88%,transparent) 0%,color-mix(in srgb,var(--bg-dark) 88%,transparent) 100% );backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-right:1px solid var(--card-border);display:flex;flex-direction:column;z-index:1000;transition:width var(--transition-normal)}.sidebar-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.logo{display:flex;align-items:center;gap:var(--spacing-md);text-decoration:none}.logo-icon{width:40px;height:40px;background:linear-gradient(135deg,var(--primary-cyan) 0%,var(--secondary-magenta) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--shadow-glow-cyan)}.logo-text{font-family:var(--font-heading);font-size:1.5rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.sidebar-nav{flex:1;padding:var(--spacing-md);overflow-y:auto}.nav-section{margin-bottom:var(--spacing-lg)}.nav-section-title{font-size:0.7rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;padding:var(--spacing-sm) var(--spacing-md);margin-bottom:var(--spacing-xs)}.nav-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);border-radius:var(--radius-md);color:var(--text-secondary);text-decoration:none;transition:all var(--transition-fast);margin-bottom:var(--spacing-xs);position:relative;overflow:hidden}.nav-item::before{content:'';position:absolute;left:0;top:0;height:100%;width:3px;background:var(--primary-cyan);transform:scaleY(0);transition:transform var(--transition-fast)}.nav-item:hover{background:var(--card-bg-hover);color:var(--text-primary)}.nav-item:hover::before{transform:scaleY(1)}.nav-item.active{background:linear-gradient(90deg,rgba(76,201,240,0.15) 0%,transparent 100%);color:var(--primary-cyan);border-left:3px solid var(--primary-cyan)}.nav-item.active::before{transform:scaleY(1)}.nav-icon{width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:1.1rem}.nav-text{font-weight:500;font-size:0.95rem}.sidebar-footer{padding:var(--spacing-md);border-top:1px solid var(--card-border)}.version-info{font-size:0.75rem;color:var(--primary-cyan);text-align:center;font-family:var(--font-mono);opacity:0.8;text-shadow:0 0 8px rgba(76,201,240,0.4);display:flex;align-items:center;justify-content:center;gap:0.5rem;transition:all var(--transition-fast);padding:0.5rem;border-radius:var(--radius-md);background:transparent;border:none;width:100%;cursor:pointer}.version-info:hover{background:rgba(76,201,240,0.1);color:var(--primary-cyan)}.version-icon{font-size:1rem}.upscale-status{display:flex;align-items:center;gap:0.5rem;margin-bottom:var(--spacing-sm);padding:0.5rem;font-size:0.75rem;color:var(--text-secondary);border:1px solid var(--card-border);border-radius:var(--radius-md)}.upscale-status[hidden]{display:none}.upscale-status-icon{font-size:1rem;color:var(--primary-cyan)}.user-indicator{display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.75rem;background:rgba(76,201,240,0.05);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.user-indicator-info{display:flex;align-items:center;gap:0.75rem;min-width:0;flex:1}.user-indicator-avatar{font-size:1.25rem;flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center}.user-avatar-img{width:100%;height:100%;border-radius:50%;object-fit:cover}.user-indicator-details{display:flex;flex-direction:column;min-width:0}.user-indicator-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.user-indicator-role{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:0.1rem 0.4rem;border-radius:var(--radius-full);width:fit-content}.user-indicator-role.admin{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.user-indicator-role.editor{background:rgba(34,197,94,0.2);color:var(--neon-green)}.user-indicator-role.viewer{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.user-indicator-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:2px}.user-indicator-rank{font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;padding:2px 8px;border-radius:var(--radius-full);text-decoration:none;white-space:nowrap;border:1px solid transparent;transition:all var(--transition-fast);line-height:1;display:inline-flex;align-items:center;gap:3px}.user-indicator-rank:hover{transform:translateY(-1px);filter:brightness(1.15)}.user-indicator-rank.rank-tier-0{background:rgba(110,115,120,0.18);color:var(--text-muted);border-color:rgba(110,115,120,0.35)}.user-indicator-rank.rank-tier-1{background:rgba(154,160,166,0.18);color:var(--text-secondary);border-color:rgba(154,160,166,0.4)}.user-indicator-rank.rank-tier-2{background:rgba(76,201,240,0.12);color:var(--primary-cyan);border-color:rgba(76,201,240,0.3)}.user-indicator-rank.rank-tier-3{background:rgba(76,201,240,0.18);color:var(--primary-cyan);border-color:rgba(76,201,240,0.45)}.user-indicator-rank.rank-tier-4{background:rgba(34,197,94,0.15);color:var(--neon-green);border-color:rgba(34,197,94,0.4)}.user-indicator-rank.rank-tier-5{background:rgba(245,158,11,0.15);color:var(--neon-orange);border-color:rgba(245,158,11,0.45)}.user-indicator-rank.rank-tier-6{background:rgba(168,85,247,0.15);color:var(--neon-purple);border-color:rgba(168,85,247,0.4)}.user-indicator-rank.rank-tier-7{background:rgba(168,85,247,0.22);color:var(--neon-purple);border-color:rgba(168,85,247,0.55)}.user-indicator-rank.rank-tier-8{background:rgba(247,37,133,0.18);color:var(--secondary-magenta);border-color:rgba(247,37,133,0.5)}.user-indicator-rank.rank-tier-9{background:linear-gradient(135deg,rgba(247,37,133,0.3),rgba(168,85,247,0.3));color:var(--text-primary);border-color:var(--secondary-magenta);box-shadow:0 0 8px rgba(247,37,133,0.35)}.user-logout-btn{font-size:1.1rem;padding:0.25rem;opacity:0.6;transition:all var(--transition-fast);text-decoration:none;flex-shrink:0}.user-logout-btn:hover{opacity:1;transform:scale(1.1)}.skip-link{position:absolute;top:0;left:0;padding:0.75rem 1.25rem;background:var(--primary-cyan);color:var(--bg-darkest);font-family:var(--font-heading);font-weight:700;text-decoration:none;border-radius:0 0 6px 0;z-index:10000;transform:translateY(-150%);transition:transform 0.15s ease-out}.skip-link:focus{transform:translateY(0);outline:2px solid var(--secondary-magenta);outline-offset:2px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md) var(--spacing-lg);font-family:var(--font-primary);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;border:2px solid;border-radius:var(--radius-md);cursor:pointer;transition:all 0.3s ease;text-decoration:none;position:relative;overflow:hidden}.btn-primary{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-primary:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15);transform:translateY(-2px)}.btn-secondary{background:rgba(148,163,184,0.1);border-color:var(--neon-gray);color:#94a3b8;box-shadow:0 0 10px rgba(100,116,139,0.3),0 0 20px rgba(100,116,139,0.2)}.btn-secondary:hover{background:rgba(148,163,184,0.2);border-color:#94a3b8;color:#cbd5e1;box-shadow:0 0 15px rgba(148,163,184,0.5),0 0 30px rgba(148,163,184,0.3)}.btn-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green);box-shadow:0 0 15px rgba(34,197,94,0.5),0 0 30px rgba(34,197,94,0.3),inset 0 0 20px rgba(34,197,94,0.1);text-shadow:0 0 10px rgba(34,197,94,0.8)}.btn-success:hover{background:rgba(34,197,94,0.25);box-shadow:0 0 20px rgba(34,197,94,0.7),0 0 40px rgba(34,197,94,0.4),inset 0 0 25px rgba(34,197,94,0.15);transform:translateY(-2px)}.btn-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange);box-shadow:0 0 15px rgba(245,158,11,0.5),0 0 30px rgba(245,158,11,0.3),inset 0 0 20px rgba(245,158,11,0.1);text-shadow:0 0 10px rgba(245,158,11,0.8)}.btn-warning:hover{background:rgba(245,158,11,0.25);box-shadow:0 0 20px rgba(245,158,11,0.7),0 0 40px rgba(245,158,11,0.4),inset 0 0 25px rgba(245,158,11,0.15);transform:translateY(-2px)}.btn-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3),inset 0 0 20px rgba(239,68,68,0.1);text-shadow:0 0 10px rgba(239,68,68,0.8)}.btn-danger:hover{background:rgba(239,68,68,0.25);box-shadow:0 0 20px rgba(239,68,68,0.7),0 0 40px rgba(239,68,68,0.4),inset 0 0 25px rgba(239,68,68,0.15);transform:translateY(-2px)}.btn-sm{padding:var(--spacing-sm) var(--spacing-md);font-size:0.8rem}.btn-xs{padding:3px 8px;font-size:0.7rem;line-height:1.2}.btn-lg{padding:var(--spacing-lg) var(--spacing-xl);font-size:1rem}.btn-icon{font-size:1.1em}.btn:focus-visible{outline:none;box-shadow:0 0 0 2px rgba(76,201,240,0.5),0 0 15px rgba(76,201,240,0.3)}.btn:disabled,.btn[disabled]{opacity:0.4;cursor:not-allowed;pointer-events:none;box-shadow:none}input[type="file"]::file-selector-button{padding:8px 16px;margin-right:12px;background:rgba(76,201,240,0.15);border:2px solid var(--neon-cyan);border-radius:var(--radius-md);color:var(--neon-cyan);cursor:pointer;font-weight:600;font-family:var(--font-primary);text-transform:uppercase;letter-spacing:0.5px;font-size:0.85rem;box-shadow:0 0 15px rgba(76,201,240,0.5),0 0 30px rgba(76,201,240,0.3),inset 0 0 20px rgba(76,201,240,0.1);text-shadow:0 0 10px rgba(76,201,240,0.8);transition:all 0.3s ease}input[type="file"]::file-selector-button:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 20px rgba(76,201,240,0.7),0 0 40px rgba(76,201,240,0.4),inset 0 0 25px rgba(76,201,240,0.15)}.pagination{display:flex;justify-content:center;align-items:center;gap:var(--spacing-sm);margin-top:var(--spacing-xl)}.pagination-btn{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);cursor:pointer;transition:all var(--transition-fast)}.pagination-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan)}.pagination-btn.active{background:var(--primary-cyan);border-color:var(--primary-cyan);color:var(--bg-darkest)}.pagination-btn:disabled{opacity:0.5;cursor:not-allowed}.back-to-top-btn{position:fixed;bottom:30px;right:30px;width:50px;height:50px;border-radius:50%;background:rgba(20,26,35,0.95);border:2px solid var(--neon-cyan);color:var(--neon-cyan);font-size:1.5rem;cursor:pointer;opacity:0;visibility:hidden;transform:translateY(20px);transition:opacity 0.3s ease,visibility 0.3s ease,transform 0.3s ease,background 0.3s ease,box-shadow 0.3s ease;z-index:9998;display:flex;align-items:center;justify-content:center;box-shadow:0 0 15px rgba(76,201,240,0.3),inset 0 0 10px rgba(76,201,240,0.1)}.back-to-top-btn.visible{opacity:1;visibility:visible;transform:translateY(0)}.back-to-top-btn:hover{background:rgba(76,201,240,0.2);box-shadow:0 0 25px rgba(76,201,240,0.5),inset 0 0 15px rgba(76,201,240,0.2);transform:translateY(-3px)}.back-to-top-btn:active{transform:translateY(0)}.back-to-top-icon{font-weight:bold;text-shadow:0 0 10px rgba(76,201,240,0.8)}.btn-group{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.btn-danger-outline{background:rgba(239,68,68,0.1);border:2px solid var(--neon-red);color:var(--neon-red);box-shadow:0 0 10px rgba(239,68,68,0.3),0 0 20px rgba(239,68,68,0.2);text-shadow:0 0 8px rgba(239,68,68,0.6)}.btn-danger-outline:hover{background:rgba(239,68,68,0.2);box-shadow:0 0 15px rgba(239,68,68,0.5),0 0 30px rgba(239,68,68,0.3)}.form-input,input[type="text"],input[type="number"],input[type="email"],input[type="password"],input[type="search"],input[type="date"],textarea{width:100%;padding:0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color 0.2s ease,box-shadow 0.2s ease}.form-input:focus,input[type="text"]:focus,input[type="number"]:focus,input[type="email"]:focus,input[type="password"]:focus,input[type="search"]:focus,input[type="date"]:focus,textarea:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}.form-input::placeholder,input::placeholder,textarea::placeholder{color:var(--text-muted)}select,.form-select{width:100%;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker,#0d1117);border:1px solid var(--border-color,#2a3542);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}select:focus,.form-select:focus{outline:none;border-color:var(--neon-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15),0 0 15px rgba(76,201,240,0.2)}select:hover,.form-select:hover{border-color:var(--neon-cyan)}select option{background:var(--bg-medium);color:var(--text-primary);padding:0.5rem}.styled-select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding:0.75rem 2.5rem 0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 1rem center;transition:border-color 0.2s ease,box-shadow 0.2s ease}.styled-select:hover{border-color:var(--primary-cyan)}.styled-select:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.toggle-switch{position:relative;display:inline-block;width:52px;height:28px;flex-shrink:0}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-red);border-radius:28px;transition:all 0.3s ease;box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(239,68,68,0.3)}.toggle-slider:before{position:absolute;content:"";height:22px;width:22px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all 0.3s ease;box-shadow:0 2px 4px rgba(0,0,0,0.3)}.toggle-switch input:checked + .toggle-slider{background:var(--neon-green);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 15px rgba(34,197,94,0.5)}.toggle-switch input:checked + .toggle-slider:before{transform:translateX(24px)}.toggle-switch input:focus + .toggle-slider{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 0 2px rgba(76,201,240,0.3)}.toggle-item{display:flex;align-items:center;gap:0.75rem}.toggle-label{color:var(--text-secondary);font-size:0.9rem;cursor:pointer;user-select:none;transition:color 0.2s ease}.toggle-label.active{color:var(--text-primary)}.toggle-field{display:flex;align-items:center;gap:var(--spacing-md)}.toggle-switch-wrapper{display:flex;align-items:center;gap:var(--spacing-sm)}.toggle-switch-wrapper .toggle-label-text{font-size:0.85rem;color:var(--text-secondary)}.form-group{margin-bottom:var(--spacing-md)}.form-label{display:block;font-size:0.85rem;font-weight:600;color:var(--text-secondary);margin-bottom:var(--spacing-xs)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.locked-path-wrapper{display:flex;align-items:center;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);overflow:hidden;flex:1}.locked-path-wrapper:focus-within{border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.locked-path-prefix{padding:0.75rem 0 0.75rem 1rem;color:var(--text-muted);font-family:var(--font-mono);font-size:0.9rem;background:rgba(76,201,240,0.05);border-right:1px solid var(--border-color);white-space:nowrap;user-select:none}.locked-path-wrapper input{flex:1;padding:0.75rem 1rem;background:transparent;border:none;color:var(--text-primary);font-family:var(--font-mono);font-size:0.9rem;outline:none}.locked-path-wrapper input::placeholder{color:var(--text-muted)}.edit-section{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.edit-section:last-child{border-bottom:none;margin-bottom:0}.edit-section-title{color:var(--primary-cyan);font-family:var(--font-heading);font-size:1rem;font-weight:600;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-sm);border-bottom:1px solid rgba(76,201,240,0.3)}.edit-field{margin-bottom:var(--spacing-md)}.edit-field label{display:block;color:var(--text-primary);font-weight:500;font-size:0.9rem;margin-bottom:6px}.edit-field .field-tip{display:block;font-size:0.75rem;color:var(--text-muted);margin-top:4px;font-style:italic}.edit-field .title-lock-check,.gem-field .title-lock-check{display:flex;align-items:center;gap:var(--spacing-xs);margin:var(--spacing-xs) 0 0;font-size:0.8rem;font-weight:normal;color:var(--text-muted);text-transform:none;letter-spacing:normal;cursor:pointer}.edit-field .title-lock-check input,.gem-field .title-lock-check input{width:auto;padding:0;margin:0}.edit-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md) var(--spacing-lg)}.edit-grid .full-width{grid-column:1 / -1}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-md)}.form-help{font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.toggle-grid{display:flex;flex-wrap:wrap;gap:1.5rem}.card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);transition:all var(--transition-normal)}.card:hover{border-color:var(--card-border-hover);box-shadow:var(--shadow-lg)}.card + .card{margin-top:1.5rem}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.card-title{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:0.5rem;text-transform:uppercase;letter-spacing:1px}.card-body{color:var(--text-secondary)}.glass-card,.card.glass,.glass-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-light,.glass-panel-light{background:rgba(20,26,35,0.6) !important;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}.glass-medium,.glass-panel-medium{background:rgba(20,26,35,0.8) !important;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}.glass-dark,.glass-panel-dark{background:rgba(20,26,35,0.9) !important;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.glass-panel-hover:hover{background:rgba(20,26,35,0.85);border-color:var(--card-border-hover);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-decoration:none;transition:all var(--transition-normal);display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;overflow:hidden}.system-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(180deg,transparent 60%,rgba(76,201,240,0.05) 100%);opacity:0;transition:opacity var(--transition-normal)}.system-card:hover{border-color:var(--primary-cyan);transform:translateY(-4px);box-shadow:var(--shadow-lg),0 0 20px rgba(76,201,240,0.1)}.system-card:hover::before{opacity:1}.system-logo{width:80px;height:80px;margin-bottom:var(--spacing-md);display:flex;align-items:center;justify-content:center}.system-logo img{max-width:100%;max-height:100%;object-fit:contain;filter:brightness(0.9);transition:all var(--transition-normal)}.system-card:hover .system-logo img{filter:brightness(1) drop-shadow(0 0 8px var(--primary-cyan-glow));transform:scale(1.05)}.system-logo-placeholder{width:80px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:2.5rem;color:var(--text-muted)}.system-name{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-xs);text-transform:uppercase;letter-spacing:1px}.system-info{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.system-info-icon{color:var(--primary-cyan)}.section{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.status-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.status-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md) 0;border-bottom:1px solid var(--card-border)}.status-item:last-child{border-bottom:none}.status-info{display:flex;align-items:center;gap:var(--spacing-md)}.status-indicator{width:10px;height:10px;border-radius:50%;animation:pulse-status 2s ease-in-out infinite}.status-indicator.online{background:var(--status-online);box-shadow:0 0 10px var(--status-online)}.status-indicator.offline{background:var(--status-offline);box-shadow:0 0 10px var(--status-offline);animation:none}.status-name{color:var(--text-primary);font-weight:500}.status-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:1px}.status-badge.online{background:rgba(0,230,118,0.15);color:var(--status-online);border:1px solid rgba(0,230,118,0.3)}.status-badge.connected{background:rgba(76,201,240,0.15);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.status-badge.offline{background:rgba(255,82,82,0.15);color:var(--status-offline);border:1px solid rgba(255,82,82,0.3)}.scraper-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);margin-bottom:var(--spacing-xl)}.scraper-section h2{font-family:var(--font-heading);font-size:1.2rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-lg);padding-bottom:var(--spacing-md);border-bottom:1px solid var(--card-border)}.scraper-form{margin-bottom:var(--spacing-lg)}.system-controller-card{transition:border-color 0.2s ease,box-shadow 0.2s ease}.system-controller-card:hover{border-color:rgba(76,201,240,0.3);box-shadow:0 0 10px rgba(76,201,240,0.1)}.controller-item{transition:background 0.2s ease,transform 0.15s ease}.controller-item:hover{background:rgba(76,201,240,0.1) !important;transform:translateX(4px)}.controller-details{border-left:2px solid var(--neon-cyan)}.add-controller-form{border:1px solid rgba(34,197,94,0.2)}.add-controller-form:hover{border-color:rgba(34,197,94,0.3)}.badge{display:inline-flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);font-size:0.8rem;font-weight:500;color:var(--neon-cyan);white-space:nowrap}.badge-success{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.badge-warning{background:rgba(245,158,11,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.badge-danger{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.badge-purple{background:rgba(168,85,247,0.15);border-color:var(--neon-purple);color:var(--neon-purple)}.badge-secondary{background:rgba(100,116,139,0.15);border-color:var(--neon-gray);color:#94a3b8}.badge-removable{padding-right:0.5rem}.badge-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-left:0.25rem;background:transparent;border:none;border-radius:50%;color:inherit;cursor:pointer;font-size:1rem;line-height:1;opacity:0.7;transition:all 0.2s ease}.badge-remove:hover{opacity:1;background:rgba(255,255,255,0.1)}.game-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 10px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;white-space:nowrap;line-height:1;height:28px;box-sizing:border-box}.game-badge.esrb-e{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.esrb-e10{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.esrb-t{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.esrb-m{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.esrb-ao{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.esrb-rp{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.pegi-3{background:rgba(32,178,170,0.15);border-color:#20b2aa;color:#20b2aa}.game-badge.pegi-7{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.pegi-12{background:rgba(234,179,8,0.15);border-color:#eab308;color:#eab308}.game-badge.pegi-16{background:rgba(249,115,22,0.15);border-color:#f97316;color:#f97316}.game-badge.pegi-18{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}.game-badge.age-rating-badge{background:transparent;border-color:transparent;color:var(--text-secondary);padding:0}.game-badge.age-rating-badge .age-rating-img{height:28px;width:auto;display:block;filter:drop-shadow(0 1px 3px rgba(0,0,0,0.4))}.game-badge.age-rating-badge.crossmapped{opacity:0.75}.game-badge.genre{background:rgba(168,85,247,0.15);border-color:#a855f7;color:#a855f7}.game-badge.year{background:rgba(76,201,240,0.15);border-color:#4cc9f0;color:#4cc9f0}.game-badge.platform{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.region{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.players{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion{font-size:0.7rem}.game-badge.completion.not-started{background:rgba(100,116,139,0.15);border-color:#64748b;color:#94a3b8}.game-badge.completion.in-progress{background:rgba(245,158,11,0.15);border-color:#f59e0b;color:#f59e0b}.game-badge.completion.played{background:rgba(59,130,246,0.15);border-color:#3b82f6;color:#3b82f6}.game-badge.completion.completed{background:rgba(34,197,94,0.15);border-color:#22c55e;color:#22c55e}.game-badge.completion.\31 00-percent,.game-badge.completion.hundred-percent{background:linear-gradient(135deg,rgba(255,215,0,0.25) 0%,rgba(255,170,0,0.25) 100%);border-color:#ffd700;color:#ffd700;text-shadow:0 0 8px rgba(255,215,0,0.5);box-shadow:0 0 10px rgba(255,215,0,0.3),inset 0 0 10px rgba(255,215,0,0.1)}.game-badge.score-badge{font-weight:700;min-width:50px}.game-badge.score-badge.score-good{background:rgba(34,197,94,0.2);border-color:#22c55e;color:#22c55e}.game-badge.score-badge.score-mixed{background:rgba(234,179,8,0.2);border-color:#eab308;color:#fcd34d}.game-badge.score-badge.score-bad{background:rgba(239,68,68,0.2);border-color:#ef4444;color:#f87171}.meta-badge{padding:var(--spacing-xs) var(--spacing-md);border-radius:var(--radius-full);font-size:0.8rem;font-weight:600}.meta-badge.system{background:linear-gradient(135deg,var(--primary-cyan),var(--primary-cyan-dark));color:var(--bg-darkest)}.meta-badge.year{background:var(--bg-lighter);color:var(--text-primary);border:1px solid var(--card-border)}.meta-badge.genre{background:rgba(114,9,183,0.2);color:#b388ff;border:1px solid rgba(114,9,183,0.3)}.metadata-status{display:inline-flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-xs) var(--spacing-sm);border-radius:var(--radius-full);font-size:0.7rem;font-weight:600;text-transform:uppercase}.metadata-status.complete{background:rgba(0,230,118,0.15);color:var(--status-success)}.metadata-status.missing{background:rgba(255,171,0,0.15);color:var(--status-warning)}.source-badge{font-weight:600;text-transform:uppercase}.source-badge.thegamesdb{background:rgba(76,175,80,0.2);color:#81c784}.source-badge.igdb{background:rgba(33,150,243,0.2);color:#64b5f6}.source-badge.primary-source{border:2px solid currentColor;position:relative}.source-badge.primary-source::before{content:"★ ";font-size:0.8em}.source-badge.fallback-source{opacity:0.75;border:1px dashed currentColor}.psn-platform-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;border:1px solid;width:fit-content}.platform-ps3{background:rgba(205,154,42,0.15);color:#cd9a2a;border-color:rgba(205,154,42,0.3)}.platform-ps4{background:rgba(0,112,209,0.15);color:#4da6ff;border-color:rgba(0,112,209,0.3)}.platform-ps5{background:rgba(76,201,240,0.15);color:#4cc9f0;border-color:rgba(76,201,240,0.3)}.platform-psvita,.platform-vita{background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)}.data-table{width:100%;border-collapse:collapse}@media (max-width:600px){.page-box .data-table{display:block;overflow-x:auto}}@media (max-width:600px){.page-box .data-table.stack-narrow,.data-table.stack-narrow tbody,.data-table.stack-narrow tr,.data-table.stack-narrow td{display:block;overflow-x:visible}.data-table.stack-narrow thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.data-table.stack-narrow tr{margin-bottom:var(--spacing-sm);padding:var(--spacing-xs) 0;border:1px solid var(--card-border);border-radius:var(--radius-md)}.data-table.stack-narrow tr[hidden]{display:none}.data-table.stack-narrow td{padding:var(--spacing-xs) var(--spacing-sm);border-bottom:none;overflow-wrap:anywhere}.data-table.stack-narrow td[data-label]{display:flex;gap:var(--spacing-sm)}.data-table.stack-narrow td[data-label]::before{content:attr(data-label);flex:0 0 35%;color:var(--text-secondary);font-size:0.8rem;font-weight:600}}.data-table th{background:var(--bg-darker,#0d1117);color:var(--text-secondary);font-weight:600;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid var(--card-border);position:sticky;top:0;z-index:10}.data-table td{padding:0.75rem 1rem;border-bottom:1px solid var(--card-border);color:var(--text-primary);font-size:0.9rem}.data-table tbody tr:hover{background:rgba(76,201,240,0.05)}.games-table-container{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.games-table{width:100%;border-collapse:collapse}.games-table thead{background:linear-gradient(90deg,var(--bg-medium) 0%,var(--bg-light) 100%)}.games-table th{padding:var(--spacing-md) var(--spacing-lg);text-align:left;font-family:var(--font-heading);font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;border-bottom:1px solid var(--card-border)}.games-table tbody tr{border-bottom:1px solid var(--card-border);transition:background-color var(--transition-fast)}.games-table tbody tr:hover{background:var(--card-bg-hover)}.games-table tbody tr:last-child{border-bottom:none}.games-table td{padding:var(--spacing-md) var(--spacing-lg);vertical-align:middle}.game-row-boxart{width:60px;height:80px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--card-border)}.game-row-boxart.boxart-3d{object-fit:contain;background:var(--bg-medium)}.game-row-boxart-placeholder{width:60px;height:80px;background:var(--bg-lighter);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:1.5rem}.game-row-title{color:var(--text-primary);font-weight:600;text-decoration:none;transition:color var(--transition-fast)}.game-row-title:hover{color:var(--primary-cyan)}.game-row-system{color:var(--text-secondary);font-size:0.85rem}.game-row-genre{color:var(--text-secondary);font-size:0.85rem}.game-row-year{color:var(--text-secondary);font-size:0.85rem;font-family:var(--font-mono)}.score-list-section{background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.score-list-section h4{color:var(--text-primary);font-family:var(--font-heading);font-size:1.1rem;margin-bottom:var(--spacing-md)}.score-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.score-list-item{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;transition:var(--transition-fast)}.score-list-item:hover{border-color:var(--primary-cyan);background:var(--card-bg-hover)}.score-game-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}.score-game-title{color:var(--text-primary);font-weight:600;font-size:0.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.score-game-system{color:var(--text-muted);font-size:0.8rem}.score-game-scores{display:flex;gap:var(--spacing-sm);flex-shrink:0}.score-lists-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-xl);margin-top:var(--spacing-xl)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.tool-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.tool-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.tool-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.tool-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.tool-tab .tab-icon{font-size:1.1rem}.tool-tab .tab-label{}.system-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.system-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.625rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-align:center;white-space:nowrap}.system-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.system-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan);font-weight:600}.system-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.system-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}@media (max-width:768px){.tool-tabs,.system-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.tool-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.system-tab{padding:0.5rem 0.75rem;font-size:0.8rem}}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;visibility:hidden;transition:all 0.3s ease}.modal-overlay.active{opacity:1;visibility:visible}.modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);max-width:90%;max-height:90vh;overflow:auto;box-shadow:0 0 50px rgba(76,201,240,0.2)}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.modal-header h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--neon-cyan)}.modal-close,.custom-modal-close,.about-modal-close,.game-detail-modal-close,.game-edit-modal-close,.modal-close-btn,.archive-modal-close,.modal .modal-close{background:none;border:none;font-size:1.75rem;font-weight:normal;color:var(--text-secondary);cursor:pointer;padding:0;line-height:1;transition:color 0.2s}.modal-close:hover,.custom-modal-close:hover,.about-modal-close:hover,.game-detail-modal-close:hover,.game-edit-modal-close:hover,.modal-close-btn:hover,.archive-modal-close:hover,.modal .modal-close:hover{color:var(--neon-red)}.modal-close:focus-visible,.custom-modal-close:focus-visible,.about-modal-close:focus-visible,.game-detail-modal-close:focus-visible,.game-edit-modal-close:focus-visible,.modal-close-btn:focus-visible,.archive-modal-close:focus-visible,.modal .modal-close:focus-visible{outline:2px solid rgba(76,201,240,0.5);outline-offset:2px}.modal-body{padding:var(--spacing-lg)}.modal-footer{display:flex;justify-content:flex-end;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:10000;align-items:center;justify-content:center}.modal.active{display:flex}.modal-content{max-width:90%;max-height:90%;border-radius:var(--radius-lg)}.modal-nav{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;background:var(--card-bg);border:none;border-radius:50%;color:var(--text-primary);font-size:1.5rem;cursor:pointer;transition:all var(--transition-fast)}.modal-nav:hover{background:var(--primary-cyan);color:var(--bg-darkest)}.modal-nav.prev{left:var(--spacing-lg)}.modal-nav.next{right:var(--spacing-lg)}.custom-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:10100;align-items:center;justify-content:center}.custom-modal.active{display:flex}.custom-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:90%;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.5)}.custom-modal-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.custom-modal-header h3{margin:0;font-family:var(--font-heading);color:var(--primary-cyan);font-size:1.1rem}.custom-modal-body{padding:var(--spacing-lg);color:var(--text-secondary);line-height:1.6}.custom-modal-body code{word-break:break-all;overflow-wrap:break-word;display:inline-block;max-width:100%}.custom-modal-body ul{max-width:100%;overflow:hidden}.custom-modal-body li{word-break:break-word;overflow-wrap:break-word}.custom-modal-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:flex-end;gap:var(--spacing-sm)}.about-modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);z-index:10000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}.about-modal-overlay.active{display:flex}.about-modal{background:linear-gradient(135deg,rgba(20,26,35,0.98),rgba(15,20,25,0.98));border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);max-width:560px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 0 40px rgba(76,201,240,0.3),0 20px 40px rgba(0,0,0,0.5);animation:modalSlideIn 0.3s ease}.about-modal-header{display:flex;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;border-bottom:1px solid var(--card-border)}.about-modal-header h2{margin:0;font-size:1.25rem;font-family:var(--font-heading);color:var(--primary-cyan)}.about-modal-body{padding:1.5rem}.about-logo{display:flex;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;padding-bottom:1.5rem;border-bottom:1px solid var(--card-border)}.about-logo-icon{font-size:3rem;filter:drop-shadow(0 0 10px rgba(76,201,240,0.5))}.about-logo-text{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);letter-spacing:2px}.about-logo-text span{color:var(--primary-cyan);text-shadow:0 0 10px var(--primary-cyan-glow)}.about-details{display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1.5rem}.about-item{display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.about-item.about-description{flex-direction:column;align-items:flex-start;gap:0.5rem}.about-label{color:var(--text-secondary);font-size:0.85rem;font-weight:500}.about-value{color:var(--text-primary);font-weight:600}.about-description .about-value{font-weight:400;font-size:0.9rem;line-height:1.5}.about-links{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem}.folder-item{display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;border-radius:var(--radius-md);cursor:pointer;transition:background 0.15s ease;background:transparent;border:none;width:100%;text-align:left;font:inherit;color:inherit;appearance:none;-webkit-appearance:none}.folder-item:hover{background:rgba(76,201,240,0.1)}.folder-item.folder-parent{border-bottom:1px solid var(--border-color);margin-bottom:0.5rem;padding-bottom:0.75rem;border-radius:0}.folder-item .folder-icon{font-size:1.25rem}.folder-item .folder-name{flex:1;color:var(--text-primary);font-weight:500}.folder-item .folder-info{color:var(--text-muted);font-size:0.8rem}@keyframes toast-slide-in{from{transform:translateX(-100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes toast-slide-out{from{transform:translateX(0);opacity:1}to{transform:translateX(-100%);opacity:0}}.unified-toast-container{position:fixed;bottom:24px;left:280px;z-index:10000;display:flex;flex-direction:column-reverse;gap:10px;width:360px}.sidebar-collapsed ~ .main-content .unified-toast-container,body:has(.sidebar.collapsed) .unified-toast-container{left:calc(var(--sidebar-collapsed-width, 70px) + 20px)}@media (max-width:768px){.unified-toast-container{left:16px;right:16px;width:auto;max-width:none}}.unified-toast{animation:toast-slide-in 0.3s ease-out forwards;animation-iteration-count:1;width:100%;max-width:360px;border-radius:var(--radius-lg);overflow:hidden;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.unified-toast.removing{animation:toast-slide-out 0.3s ease-out forwards}.toast-content{display:flex;align-items:stretch;background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)));border:2px solid var(--toast-color,var(--primary-cyan));border-radius:var(--radius-lg);box-shadow:0 0 20px rgba(var(--toast-color-rgb,76,201,240),0.3),0 4px 20px rgba(0,0,0,0.4);backdrop-filter:blur(10px)}.active-toast .toast-content{background:linear-gradient(135deg,var(--toast-bg-from,rgba(20,26,35,0.95)),var(--toast-bg-to,rgba(30,40,55,0.95)))}.queued-toast .toast-content{background:linear-gradient(135deg,var(--toast-queued-bg-from,rgba(25,30,40,0.90)),var(--toast-queued-bg-to,rgba(35,45,60,0.90)));opacity:0.9}.toast-main{flex:1;display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;transition:background var(--transition-fast);min-width:0}.toast-main:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.08)}.toast-icon{font-size:1.5rem;flex-shrink:0}.toast-icon:not(.paused){animation:pulse 1.5s ease-in-out infinite}.toast-icon.paused{animation:none;opacity:0.7}.toast-info{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}.toast-title{font-weight:600;font-size:0.9rem;color:var(--toast-color,var(--primary-cyan))}.toast-title.paused{color:var(--neon-orange)}.toast-system-name{color:var(--neon-purple);font-size:0.75rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-subtitle{color:var(--text-primary);font-size:0.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.toast-progress-bar{height:4px;background:rgba(var(--toast-color-rgb,76,201,240),0.2);border-radius:2px;overflow:hidden;margin:4px 0}.toast-progress-fill{height:100%;background:linear-gradient(90deg,var(--toast-color,var(--primary-cyan)),var(--secondary-magenta,#f72585));border-radius:2px;transition:width var(--transition-normal)}.toast-progress-text{display:flex;justify-content:space-between;font-size:0.75rem;color:var(--text-secondary);font-family:var(--font-mono)}.toast-stats{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:0.85rem;font-family:var(--font-mono);margin-top:2px}.toast-stats .stat{display:flex;align-items:center;gap:3px;white-space:nowrap}.toast-stats .stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700;color:var(--text-primary)}.toast-stats .stat.success,.toast-stats .stat-success{color:var(--neon-green)}.toast-stats .stat.failed,.toast-stats .stat-failed{color:var(--neon-red)}.toast-stats .stat.skipped,.toast-stats .stat-skipped{color:var(--neon-orange)}.toast-meta{font-size:0.75rem;color:var(--text-muted)}.toast-queued-info{font-size:0.75rem;color:var(--text-muted)}.toast-controls{display:flex;flex-direction:column;border-left:1px solid rgba(var(--toast-color-rgb,76,201,240),0.2);flex-shrink:0}.toast-btn,.toast-ctrl-btn{flex:1;padding:8px 10px;border:none;background:transparent;cursor:pointer;font-size:0.9rem;transition:all var(--transition-fast);color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-width:36px}.toast-btn:hover,.toast-ctrl-btn:hover{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--toast-color,var(--primary-cyan))}.toast-btn.pause:hover,.toast-btn.pause.is-paused,.toast-ctrl-btn.pause:hover{background:rgba(245,158,11,0.2);color:var(--neon-orange)}.toast-btn.pause.is-paused{background:rgba(34,197,94,0.15);color:var(--neon-green)}.toast-btn.cancel:hover,.toast-ctrl-btn.cancel:hover{background:rgba(239,68,68,0.2);color:var(--neon-red)}.bulk-scrape-toast{--toast-color:var(--primary-cyan);--toast-color-rgb:76,201,240}.ra-sync-toast{--toast-color:var(--neon-orange);--toast-color-rgb:245,158,11}.ra-sync-toast .toast-main:hover{background:rgba(245,158,11,0.08)}.ra-refresh-toast{--toast-color:var(--neon-purple);--toast-color-rgb:168,85,247}.ra-refresh-toast .toast-main:hover{background:rgba(168,85,247,0.08)}.bulk-scrape-queued-toast{--toast-color:var(--primary-cyan-dark);--toast-color-rgb:58,159,194}.ra-sync-queued-toast{--toast-color:#c47d09;--toast-color-rgb:196,125,9}.ra-refresh-queued-toast{--toast-color:#8b46d4;--toast-color-rgb:139,70,212}.image-resize-toast{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.image-resize-toast .toast-main:hover{background:rgba(34,197,94,0.08)}.toast-completed{--toast-color:var(--neon-green);--toast-color-rgb:34,197,94}.queued-toast{width:360px}.queued-toast .toast-content{padding:0}.queued-toast .toast-main{padding:10px 14px}.queued-toast .toast-icon{font-size:1.2rem}.queued-toast .toast-title{font-size:0.85rem}.queued-toast .toast-controls{flex-direction:row;min-width:36px}.queued-toast .toast-btn{min-width:36px;min-height:36px}.notification-container{position:fixed;top:20px;right:20px;z-index:10002;display:flex;flex-direction:column;gap:10px;max-width:400px}.notification{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);box-shadow:var(--shadow-lg),0 0 20px rgba(0,0,0,0.3);transform:translateX(120%);opacity:0;transition:all var(--transition-normal)}.notification.show{transform:translateX(0);opacity:1}.notification-icon{font-size:1.2rem;flex-shrink:0}.notification-message{flex:1;color:var(--text-primary);font-size:0.9rem;line-height:1.4}.notification-close{background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;opacity:0.6;transition:opacity 0.2s}.notification-close:hover{opacity:1;color:var(--text-primary)}.notification-success{border-color:var(--neon-green);box-shadow:var(--shadow-lg),0 0 15px rgba(34,197,94,0.2)}.notification-error{border-color:var(--neon-red);box-shadow:var(--shadow-lg),0 0 15px rgba(239,68,68,0.2)}.notification-warning{border-color:var(--neon-orange);box-shadow:var(--shadow-lg),0 0 15px rgba(245,158,11,0.2)}.notification-info{border-color:var(--primary-cyan);box-shadow:var(--shadow-lg),0 0 15px rgba(76,201,240,0.2)}.progress-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;position:relative;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);border-radius:calc(var(--radius-md) - 2px);display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);transition:width 0.3s ease;min-width:0}.progress-bar[data-percent="0"]{min-width:0;width:0%}.progress-bar:not([data-percent="0"]){min-width:60px}.progress-stats{display:flex;justify-content:space-between;color:var(--text-secondary);font-size:0.85rem;margin-top:0.5rem}.progress-container-sm{height:24px;border-width:1px}.progress-container-sm .progress-bar{font-size:0.75rem}.loading{display:flex;align-items:center;justify-content:center;padding:var(--spacing-2xl)}.loading-spinner{width:40px;height:40px;border:3px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-spinner-small{width:20px;height:20px;border:2px solid var(--card-border);border-top-color:var(--primary-cyan);border-radius:50%;animation:spin 1s linear infinite}.loading-text{margin-left:var(--spacing-md);color:var(--text-secondary)}.loading-overlay{position:fixed;inset:0;background:rgba(10,14,23,0.85);display:flex;align-items:center;justify-content:center;z-index:10001;opacity:0;visibility:hidden;transition:all 0.3s ease}.loading-overlay.active{opacity:1;visibility:visible}.loading-content{display:flex;flex-direction:column;align-items:center;gap:16px}.loading-content .loading-text{color:var(--text-primary);font-size:1rem;font-family:var(--font-primary);margin-left:0}.empty-state{text-align:center;padding:var(--spacing-2xl);color:var(--text-secondary)}.empty-state-icon{font-size:4rem;margin-bottom:var(--spacing-md);opacity:0.5}.empty-state-title{font-size:1.2rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.empty-state-text{margin-bottom:var(--spacing-lg)}.scan-log-container{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:var(--spacing-lg)}.scan-log-header{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-dark);border-bottom:1px solid var(--border-color)}.scan-log-header h4{margin:0;font-size:0.9rem;color:var(--text-secondary);display:flex;align-items:center;gap:0.5rem}.scan-log-content{height:200px;overflow-y:auto;padding:0.75rem 1rem;font-family:var(--font-mono);font-size:0.8rem;line-height:1.6}.scan-log-content .log-entry{color:var(--text-secondary);padding:0.15rem 0}.scan-log-content .log-entry.info{color:var(--primary-cyan)}.scan-log-content .log-entry.warning{color:var(--neon-orange)}.scan-log-content .log-entry.error{color:var(--neon-red)}.rate-limits-container{background:var(--bg-darker,#0d1117);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-md)}.rate-limit-header{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:0.8rem;padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.rate-limit-item{display:flex;flex-direction:column;gap:var(--spacing-xs);padding:var(--spacing-md);background:rgba(255,255,255,0.02);border-radius:var(--radius-sm);border-left:3px solid var(--text-muted)}.rate-limit-item.status-green{border-left-color:var(--neon-green)}.rate-limit-item.status-yellow{border-left-color:var(--neon-orange)}.rate-limit-item.status-red{border-left-color:var(--neon-red)}.rate-limit-item.status-gray{border-left-color:var(--text-muted)}.rate-limit-item strong{color:var(--text-primary);font-size:0.95rem}.rate-limit-value{color:var(--text-secondary);font-size:0.9rem}.rate-limit-bar{width:100%;height:6px;background:rgba(255,255,255,0.08);border-radius:3px;overflow:hidden;margin:4px 0}.rate-limit-bar-fill{height:100%;border-radius:3px;transition:width 0.6s ease}.rate-limit-detail{color:var(--text-muted);font-size:0.8rem;line-height:1.5}.progress-bar-container{background:#1a1a3e;border:2px solid var(--neon-cyan);border-radius:var(--radius-md);height:36px;overflow:hidden;margin-bottom:0.5rem;box-shadow:0 0 15px rgba(76,201,240,0.3)}.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),#7209b3);width:0%;transition:width 0.3s ease;display:flex;align-items:center;justify-content:center;color:white;font-size:0.9rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7);border-radius:calc(var(--radius-md) - 2px)}.progress-details{display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);margin-top:0.75rem}.progress-status{color:var(--text-secondary);font-size:0.9rem}.progress-status strong{color:var(--primary-cyan)}.scanner-section{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);padding:1.5rem;margin-bottom:1.5rem}.scanner-section h3{color:var(--text-primary);font-size:1rem;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}.scanner-controls{display:flex;flex-wrap:wrap;gap:1rem;align-items:center}.quick-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{display:inline-flex;align-items:center;gap:4px;padding:6px 12px;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);font-size:0.85rem;color:var(--text-secondary);white-space:nowrap}.tag.tag-system{background:rgba(76,201,240,0.15);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.tag.tag-date{background:rgba(168,85,247,0.15);border-color:rgba(168,85,247,0.4);color:#c084fc}.tag.tag-genre{background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80}.genre-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.genre-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);border-radius:var(--radius-full);color:var(--neon-purple);font-size:0.8rem;font-weight:500}.genre-tag .remove-genre{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.genre-tag .remove-genre:hover{opacity:1}.genre-dropdown{position:relative}.genre-dropdown-menu{position:absolute;top:100%;left:0;right:0;max-height:200px;overflow-y:auto;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);z-index:100;display:none}.genre-dropdown-menu.active{display:block}.genre-option{padding:8px 12px;cursor:pointer;transition:background 0.15s}.genre-option:hover{background:rgba(76,201,240,0.1)}.genre-option.selected{background:rgba(168,85,247,0.2);color:var(--neon-purple)}.controller-select-container{display:flex;flex-wrap:wrap;gap:8px;padding:10px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);min-height:44px}.controller-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-full);color:var(--neon-cyan);font-size:0.8rem;font-weight:500}.controller-tag .remove-controller{cursor:pointer;opacity:0.7;transition:opacity 0.15s}.controller-tag .remove-controller:hover{opacity:1}.controller-dropdown{position:relative}.active-filter-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.active-filter-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(76,201,240,0.15);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-full);color:var(--primary-cyan);font-size:0.8rem;font-weight:500}.active-filter-tag .remove-filter{background:none;border:none;color:var(--primary-cyan);font-size:1rem;line-height:1;cursor:pointer;padding:0;opacity:0.7;transition:opacity 0.2s}.active-filter-tag .remove-filter:hover{opacity:1}.gem-tag-container{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);min-height:36px;padding:var(--spacing-sm);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-xs)}.gem-tag-container:empty::before{content:'None selected';color:var(--text-muted);font-style:italic;font-size:0.85rem}.gem-tag-dropdown{width:100%;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem;cursor:pointer}.gem-tag-dropdown:hover,.gem-tag-dropdown:focus{border-color:var(--primary-cyan);outline:none}.gem-controller-inputs{display:flex;gap:var(--spacing-xs)}.gem-controller-inputs select{flex:1}.gem-custom-input{flex:1;padding:8px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.9rem}.gem-custom-input:hover,.gem-custom-input:focus{border-color:var(--primary-cyan);outline:none}#queueManagerModal .queue-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:10px;transition:all 0.2s ease}#queueManagerModal .queue-item:hover{border-color:var(--primary-cyan)}#queueManagerModal .queue-item.running{border-color:var(--primary-cyan);border-width:2px;background:rgba(76,201,240,0.05)}#queueManagerModal .queue-item.queued{border-color:var(--neon-orange)}#queueManagerModal .queue-item-icon{font-size:1.5rem;flex-shrink:0}#queueManagerModal .queue-item-info{flex:1;min-width:0}#queueManagerModal .queue-item-title{font-weight:600;color:var(--text-primary);margin-bottom:2px}#queueManagerModal .queue-item-system{font-size:0.85rem;color:var(--neon-purple)}#queueManagerModal .queue-item.queued .queue-item-system{color:var(--neon-orange)}#queueManagerModal .queue-item-games{font-size:0.8rem;color:var(--text-secondary)}#queueManagerModal .queue-item-progress{font-size:0.75rem;color:var(--primary-cyan);margin-top:4px}#queueManagerModal .queue-item-controls{display:flex;flex-direction:column;gap:4px}#queueManagerModal .queue-btn{padding:6px 10px;border:1px solid var(--card-border);border-radius:var(--radius-sm);background:var(--bg-medium);color:var(--text-secondary);cursor:pointer;font-size:0.8rem;transition:all 0.15s ease;white-space:nowrap}#queueManagerModal .queue-btn:hover{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}#queueManagerModal .queue-btn.danger:hover{background:rgba(239,68,68,0.15);border-color:#ef4444;color:#ef4444}#queueManagerModal .queue-btn:disabled{opacity:0.3;cursor:not-allowed}#queueManagerModal .queue-empty{text-align:center;color:var(--text-muted);padding:20px;font-style:italic}.launch-indicator{position:fixed;bottom:var(--spacing-lg);right:var(--spacing-lg);z-index:1000;display:inline-flex;align-items:center;gap:0.5rem;padding:0.4rem 0.9rem;background:var(--card-bg);border:1px solid var(--primary-cyan);border-radius:999px;box-shadow:0 0 12px rgba(76,201,240,0.35);color:var(--text-primary);font-family:var(--font-mono,monospace);font-size:0.85rem;cursor:pointer;transition:opacity 0.2s ease,transform 0.2s ease}.launch-indicator.hidden{opacity:0;pointer-events:none;transform:translateY(8px)}.launch-indicator-icon{color:var(--neon-green,#44ff66);font-weight:bold}.launch-indicator-count{font-weight:bold;color:var(--primary-cyan)}.launch-indicator-popover{position:fixed;bottom:calc(var(--spacing-lg) + 2.5rem);right:var(--spacing-lg);z-index:1001;min-width:280px;padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);box-shadow:0 0 20px rgba(76,201,240,0.4);color:var(--text-primary)}.launch-indicator-popover-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan);margin-bottom:var(--spacing-sm);text-transform:uppercase;letter-spacing:0.05em}.launch-indicator-popover-row{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:0.4rem 0;border-top:1px solid var(--border-color,rgba(255,255,255,0.08));font-size:0.85rem}.launch-indicator-popover-row:first-of-type{border-top:none}.global-search{position:relative;margin-top:var(--spacing-md)}.global-search-input{width:100%;padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.global-search-input::placeholder{color:var(--text-muted)}.global-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:var(--shadow-glow-cyan)}.global-search-list{position:absolute;top:calc(100% + var(--spacing-xs));left:0;right:0;z-index:1001;max-height:60vh;overflow-y:auto;background:var(--card-bg);border:1px solid var(--card-border-hover);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:var(--spacing-xs) 0}.global-search-group{padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em}.global-search-option{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-xs) var(--spacing-sm);color:var(--text-primary);text-decoration:none;min-height:36px}.global-search-option:hover,.global-search-option[aria-selected="true"]{background:var(--card-bg-hover);color:var(--text-accent)}.global-search-thumb{flex:0 0 24px;height:32px;display:flex;align-items:center;justify-content:center}.global-search-thumb img{max-width:24px;max-height:32px;object-fit:contain}.global-search-text{flex:1;min-width:0;display:flex;flex-direction:column}.global-search-label{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.25}.global-search-meta{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:0.8rem}.global-search-empty{padding:var(--spacing-sm);color:var(--text-muted)}.game-card-wrapper{background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden;transition:all var(--transition-normal)}.game-card-wrapper:hover{border-color:var(--primary-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2);transform:translateY(-2px)}.game-hero{display:grid;grid-template-columns:300px 1fr;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-xl);position:relative;overflow:hidden}.game-hero::before{content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan))}.hero-boxart{width:300px;flex-shrink:0;position:relative}.hero-boxart .game-boxart-image{width:100%;height:auto;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.hero-info{display:flex;flex-direction:column;min-width:0;flex:1}.game-boxart-container{position:relative}.game-boxart{width:100%;border-radius:var(--radius-lg);box-shadow:var(--shadow-xl);border:2px solid var(--card-border)}.game-boxart-placeholder{width:100%;aspect-ratio:3/4;background:var(--bg-lighter);border-radius:var(--radius-lg);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text-muted);font-size:4rem;border:2px dashed var(--card-border)}.game-boxart-placeholder span{font-size:1rem;margin-top:var(--spacing-md)}.game-info{display:flex;flex-direction:column}.game-title-section{margin-bottom:var(--spacing-lg)}.game-title{font-family:var(--font-heading);font-size:2rem;font-weight:700;color:var(--text-primary);margin-bottom:var(--spacing-sm);line-height:1.2}.game-meta-badges{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.game-ratings{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.rating-display{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-lighter);border-radius:var(--radius-md);border:1px solid var(--card-border)}.rating-display img{height:32px}.rating-display .rating-text{font-weight:600;font-size:0.9rem}.rating-display.esrb{border-color:var(--rating-esrb-border);background:var(--rating-esrb-bg)}.rating-display.esrb .rating-text{color:var(--rating-esrb)}.rating-display.pegi{border-color:var(--rating-pegi-border);background:var(--rating-pegi-bg)}.rating-display.pegi .rating-text{color:var(--rating-pegi)}.game-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.detail-item{display:flex;flex-direction:column;gap:var(--spacing-xs)}.detail-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.detail-value{color:var(--text-primary);font-size:0.95rem}.game-description{margin-bottom:var(--spacing-lg)}.game-description h3{font-family:var(--font-heading);font-size:1rem;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.game-description p{color:var(--text-secondary);line-height:1.7}.screenshots-section{margin-bottom:var(--spacing-xl)}.screenshots-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:var(--spacing-md)}.screenshot-item{position:relative;aspect-ratio:16/9;border-radius:var(--radius-md);overflow:hidden;cursor:pointer;border:2px solid var(--card-border);transition:all var(--transition-fast)}.screenshot-item:hover{border-color:var(--primary-cyan);transform:scale(1.02)}.screenshot-item img{width:100%;height:100%;object-fit:cover}.screenshot-item::after{content:'🔍';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;opacity:0;transition:opacity var(--transition-fast)}.screenshot-item:hover::after{opacity:1}.search-results{margin-top:var(--spacing-lg)}.search-result{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm);transition:all var(--transition-fast)}.search-result:hover{border-color:var(--primary-cyan);background:var(--bg-light)}.search-result-info h4{color:var(--text-primary);font-weight:600;margin-bottom:var(--spacing-xs)}.search-result-meta{display:flex;gap:var(--spacing-sm);font-size:0.8rem}.search-result-meta span{padding:2px 8px;background:var(--bg-lighter);border-radius:var(--radius-sm);color:var(--text-secondary)}.search-result-alts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:var(--spacing-xs);font-size:0.78rem}.search-result-alts-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;font-size:0.7rem;font-weight:600}.game-detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.9);backdrop-filter:blur(4px);z-index:10000;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-detail-modal.active{display:flex}.game-detail-modal-content{background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);width:100%;max-width:800px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 80px rgba(76,201,240,0.1)}.game-detail-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.game-detail-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);min-height:300px;color:var(--text-secondary)}.game-detail-content{padding:var(--spacing-lg)}.gdm-header{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.gdm-boxart-container{flex-shrink:0;width:180px}.gdm-boxart{width:100%;border-radius:var(--radius-md);box-shadow:0 4px 20px rgba(0,0,0,0.3)}.gdm-info{flex:1}.gdm-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin:0 0 var(--spacing-sm) 0}.gdm-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-system-badge{background:var(--primary-cyan);color:var(--bg-darkest);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.gdm-year,.gdm-players{background:rgba(var(--toast-color-rgb,76,201,240),0.15);color:var(--text-primary);padding:2px 8px;border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600;border:1px solid rgba(var(--toast-color-rgb,76,201,240),0.25)}.gdm-ratings{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.gdm-ratings .rating-badge{display:inline-block;padding:4px 8px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-ratings .rating-badge.esrb{background:rgba(168,85,247,0.2);color:var(--neon-purple);border:1px solid rgba(168,85,247,0.3)}.gdm-ratings .rating-badge.score{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .completion-badge{display:inline-block;padding:4px 10px;border-radius:var(--radius-sm);font-size:0.75rem;font-weight:600}.gdm-completion .status-in_progress,.gdm-completion .status-in-progress{background:rgba(76,201,240,0.2);color:var(--primary-cyan);border:1px solid rgba(76,201,240,0.3)}.gdm-completion .status-completed{background:rgba(34,197,94,0.2);color:var(--neon-green);border:1px solid rgba(34,197,94,0.3)}.gdm-completion .status-played{background:rgba(59,130,246,0.2);color:#3b82f6;border:1px solid rgba(59,130,246,0.3)}.gdm-completion .status-100_percent,.gdm-completion .status-100-percent{background:linear-gradient(135deg,rgba(245,158,11,0.2),rgba(247,37,133,0.2));color:var(--completion-gold);border:1px solid rgba(245,158,11,0.3)}.gdm-description{color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.gdm-details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-detail{display:flex;flex-direction:column}.gdm-detail .label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px}.gdm-detail span:last-child{color:var(--text-primary)}.gdm-achievements{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.ra-badge-modal{background:linear-gradient(135deg,var(--neon-orange),#d97706);color:white;padding:6px 12px;border-radius:var(--radius-md);font-weight:600}.gdm-achievements .game-badge.achievement-progress{font-size:0.75rem;padding:4px 10px}.gdm-achievements .ach-icon{width:16px;height:16px;vertical-align:middle}.gdm-achievements .ach-bar{display:inline-block;width:40px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle;margin-left:4px}.gdm-achievements .ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.gdm-screenshots{display:flex;gap:var(--spacing-sm);overflow-x:auto;padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-lg)}.gdm-screenshot{height:80px;border-radius:var(--radius-sm);cursor:pointer;transition:transform var(--transition-fast)}.gdm-screenshot:hover{transform:scale(1.05)}.gdm-video{margin-bottom:var(--spacing-lg)}.gdm-video video{width:100%;max-height:300px;border-radius:var(--radius-md);background:#000}.gdm-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.gdm-lightbox.active{display:flex}.gdm-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md)}.gdm-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.gdm-lightbox-close:hover{color:var(--primary-cyan)}.gdm-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.gdm-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gdm-lightbox-nav.prev{left:20px}.gdm-lightbox-nav.next{right:20px}.gdm-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-size:0.9rem;font-family:var(--font-mono)}.gdm-lightbox-hint{position:absolute;bottom:45px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.gdm-actions{display:flex;gap:var(--spacing-md);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.game-edit-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,23,0.95);backdrop-filter:blur(4px);z-index:10001;align-items:center;justify-content:center;padding:var(--spacing-lg)}.game-edit-modal.active{display:flex}.game-edit-modal-content{background:var(--bg-medium);border:1px solid var(--primary-cyan);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,0.5),0 0 60px rgba(76,201,240,0.15)}.game-edit-modal-close{position:absolute;top:var(--spacing-md);right:var(--spacing-md);z-index:10}.gem-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border)}.gem-title{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin:0;padding-right:2rem}.gem-body{padding:var(--spacing-lg);display:grid}.gem-section{margin-bottom:var(--spacing-lg)}.gem-section:last-child{margin-bottom:0}.gem-label{display:block;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-sm)}.gem-select{width:100%;padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:1rem;cursor:pointer;transition:border-color var(--transition-fast)}.gem-select:hover,.gem-select:focus{border-color:var(--primary-cyan);outline:none}.gem-hltb-display{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.gem-hltb-empty{color:var(--text-muted);font-style:italic}.gem-hltb-current{color:var(--text-primary);display:block}.gem-hltb-match-info{display:block;font-size:0.8rem;color:var(--text-muted);margin-top:var(--spacing-xs)}.gem-hltb-actions{display:flex;gap:var(--spacing-sm)}.gem-hltb-results{margin-top:var(--spacing-md);padding:var(--spacing-md);background:rgba(76,201,240,0.05);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md)}.gem-hltb-searching{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary)}.gem-hltb-match{margin-bottom:var(--spacing-md)}.gem-hltb-match-title{display:block;color:var(--text-primary);font-weight:600}.gem-hltb-match-confidence{font-size:0.8rem;color:var(--neon-green)}.gem-hltb-match-platform{display:block;font-size:0.8rem;color:var(--neon-purple);margin-top:2px}.gem-hltb-times{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.gem-hltb-time{text-align:center;padding:var(--spacing-sm);background:rgba(0,0,0,0.2);border-radius:var(--radius-sm)}.gem-hltb-time-label{display:block;font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px}.gem-hltb-time-value{display:block;color:var(--primary-cyan);font-weight:600}.gem-hltb-error{color:var(--neon-red);font-size:0.9rem}.gem-footer{display:flex;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-lg);border-top:1px solid var(--card-border)}.gem-footer-right{display:flex;gap:var(--spacing-sm)}.gem-tabs{display:flex;gap:var(--spacing-xs);margin-top:var(--spacing-md);overflow-x:auto;padding-bottom:var(--spacing-xs)}.gem-tab{padding:8px 12px;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast);white-space:nowrap}.gem-tab:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.gem-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.gem-tab-content{grid-area:1 / 1;visibility:hidden;pointer-events:none}.gem-tab-content.active{visibility:visible;pointer-events:auto}.gem-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-md)}.gem-field{display:flex;flex-direction:column}.gem-field label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:var(--spacing-xs)}.gem-field input,.gem-field select,.gem-field textarea{padding:10px 12px;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;transition:border-color var(--transition-fast)}.gem-field input:hover,.gem-field input:focus,.gem-field select:hover,.gem-field select:focus,.gem-field textarea:hover,.gem-field textarea:focus{border-color:var(--primary-cyan);outline:none}.gem-field input::placeholder,.gem-field textarea::placeholder{color:var(--text-muted)}.gem-field-full{grid-column:1 / -1}.gem-field textarea{resize:vertical;min-height:120px}.gem-field-row{display:flex;gap:var(--spacing-xs)}.gem-field-row input{flex:1}.gem-field-row .btn{flex-shrink:0}.gem-toggle-row{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) 0}.gem-toggle-label{color:var(--text-secondary);font-size:0.9rem}.psn-profile-bar{display:flex;align-items:center;gap:var(--spacing-lg);padding:var(--spacing-md) var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-avatar{width:48px;height:48px;border-radius:50%;border:2px solid var(--primary-cyan);object-fit:cover}.psn-avatar-placeholder{width:48px;height:48px;border-radius:50%;border:2px solid var(--card-border);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.psn-profile-info{flex:1}.psn-username{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary)}.psn-level{font-size:0.8rem;color:var(--text-secondary)}.psn-level strong{color:var(--primary-cyan)}.psn-sync-status{font-size:0.75rem;color:var(--text-muted);display:flex;align-items:center;gap:6px}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-muted);pointer-events:none}.psn-search-input{width:100%;padding:10px 12px 10px 36px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem}.psn-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.psn-search-input::placeholder{color:var(--text-muted)}.psn-sort-controls{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-sort-label{font-size:0.8rem;color:var(--text-muted);white-space:nowrap}.psn-sort-select{padding:8px 32px 8px 12px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%234cc9f0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.psn-games-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-trophy-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);transition:all var(--transition-fast);text-decoration:none;color:inherit}.psn-trophy-game-card:hover{border-color:var(--primary-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.psn-trophy-game-thumb{width:64px;height:64px;border-radius:var(--radius-sm);object-fit:cover;border:1px solid var(--card-border);flex-shrink:0}.psn-trophy-game-thumb-placeholder{width:64px;height:64px;border-radius:var(--radius-sm);background:var(--bg-medium);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0}.psn-trophy-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.psn-trophy-game-name{font-size:0.9rem;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.psn-trophy-game-npwr{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted)}.psn-trophy-game-progress{display:flex;align-items:center;gap:var(--spacing-sm)}.psn-progress-track{flex:1;height:6px;background:var(--bg-dark);border-radius:3px;overflow:hidden}.psn-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:3px;transition:width var(--transition-normal)}.psn-progress-pct{font-family:var(--font-mono);font-size:0.7rem;color:var(--primary-cyan);min-width:32px;text-align:right}.psn-trophy-mini-badges{display:flex;gap:6px}.trophy-mini-badge{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;font-weight:600}.tmb-platinum{color:var(--trophy-platinum)}.tmb-gold{color:var(--trophy-gold)}.tmb-silver{color:var(--trophy-silver)}.tmb-bronze{color:var(--trophy-bronze)}.psn-game-info-card{display:flex;gap:var(--spacing-lg);padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-game-boxart{width:180px;flex-shrink:0}.psn-game-boxart img{width:100%;border-radius:var(--radius-md);border:1px solid var(--card-border)}.psn-game-boxart-placeholder{width:100%;aspect-ratio:1;background:var(--bg-medium);border-radius:var(--radius-md);border:1px solid var(--card-border);display:flex;align-items:center;justify-content:center;font-size:4rem}.psn-game-meta{flex:1}.psn-game-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-bottom:var(--spacing-md)}.psn-game-description{font-size:0.85rem;color:var(--text-secondary);line-height:1.6;margin-bottom:var(--spacing-md);max-height:100px;overflow:hidden}.psn-meta-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-md)}.psn-meta-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:2px}.psn-meta-value{font-size:0.85rem;color:var(--text-secondary)}.psn-trophy-progress-card{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg)}.psn-trophy-progress-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--spacing-md)}.psn-trophy-progress-title{font-family:var(--font-heading);font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px}.psn-trophy-progress-fraction{font-family:var(--font-mono);font-size:1rem;color:var(--text-secondary)}.psn-trophy-progress-pct{font-family:var(--font-heading);font-size:1.2rem;font-weight:700;color:var(--primary-cyan)}.psn-trophy-progress-bar-container{height:28px;background:rgba(26,26,62,0.8);border:2px solid var(--primary-cyan);border-radius:var(--radius-md);overflow:hidden;margin-bottom:var(--spacing-md);box-shadow:0 0 15px rgba(76,201,240,0.2)}.psn-trophy-progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--neon-purple));border-radius:var(--radius-md);transition:width 0.8s ease;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;text-shadow:0 1px 3px rgba(0,0,0,0.7)}.psn-trophy-type-breakdown{display:flex;justify-content:center;gap:var(--spacing-xl)}.psn-trophy-type-item{display:flex;align-items:center;gap:6px;font-size:0.85rem}.psn-trophy-type-icon{font-size:1.1rem}.psn-trophy-type-count{font-family:var(--font-mono)}.type-platinum .psn-trophy-type-count{color:var(--trophy-platinum)}.type-gold .psn-trophy-type-count{color:var(--trophy-gold)}.type-silver .psn-trophy-type-count{color:var(--trophy-silver)}.type-bronze .psn-trophy-type-count{color:var(--trophy-bronze)}.psn-info-panels-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.psn-info-panel{padding:var(--spacing-lg);background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-info-panel-title{font-family:var(--font-heading);font-size:0.9rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md);display:flex;align-items:center;gap:8px}.psn-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-sm) var(--spacing-lg)}.psn-detail-label{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.psn-detail-value{font-size:0.85rem;color:var(--text-secondary)}.psn-mono{font-family:var(--font-mono);font-size:0.8rem}.psn-hltb-input{width:100%;padding:8px 12px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.85rem;margin-bottom:var(--spacing-sm)}.psn-hltb-input:focus{outline:none;border-color:var(--primary-cyan)}.psn-trophy-lists-row{display:grid;grid-template-columns:1fr 1fr;gap:var(--spacing-lg)}.psn-trophy-list-panel{background:rgba(20,26,35,0.8);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);overflow:hidden}.psn-trophy-list-header{padding:var(--spacing-md) var(--spacing-lg);border-bottom:1px solid var(--card-border);font-family:var(--font-heading);font-size:0.9rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}.psn-trophy-list-header.header-unlocked{color:var(--neon-green)}.psn-trophy-list-header.header-locked{color:var(--neon-gray)}.psn-trophy-list-body{max-height:600px;overflow-y:auto;padding:var(--spacing-sm)}.psn-trophy-list-body::-webkit-scrollbar{width:6px}.psn-trophy-list-body::-webkit-scrollbar-track{background:var(--bg-dark)}.psn-trophy-list-body::-webkit-scrollbar-thumb{background:var(--card-border);border-radius:3px}.psn-trophy-list-body::-webkit-scrollbar-thumb:hover{background:var(--primary-cyan)}.psn-trophy-entry{display:flex;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-radius:var(--radius-md);transition:background var(--transition-fast)}.psn-trophy-entry:hover{background:rgba(76,201,240,0.03)}.psn-trophy-entry + .psn-trophy-entry{border-top:1px solid rgba(42,53,66,0.5)}.psn-trophy-icon-box{width:48px;height:48px;border-radius:var(--radius-sm);background:var(--bg-medium);display:flex;align-items:center;justify-content:center;font-size:1.4rem;flex-shrink:0;border:1px solid var(--card-border);overflow:hidden}.psn-trophy-icon-box.locked{opacity:0.5;filter:grayscale(0.5)}.psn-trophy-icon-img{width:100%;height:100%;object-fit:cover}.psn-trophy-details{flex:1;min-width:0}.psn-trophy-name{font-size:0.85rem;font-weight:600;color:var(--text-primary);margin-bottom:2px}.psn-trophy-description{font-size:0.75rem;color:var(--text-muted);line-height:1.4}.psn-trophy-earned-info{display:flex;align-items:center;gap:var(--spacing-sm);margin-top:4px;flex-wrap:wrap}.psn-trophy-type-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:3px;font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px}.psn-trophy-type-badge.ttb-platinum{background:rgba(123,184,212,0.2);color:var(--trophy-platinum);border:1px solid rgba(123,184,212,0.3)}.psn-trophy-type-badge.ttb-gold{background:rgba(205,154,42,0.2);color:var(--trophy-gold);border:1px solid rgba(205,154,42,0.3)}.psn-trophy-type-badge.ttb-silver{background:rgba(168,168,168,0.2);color:var(--trophy-silver);border:1px solid rgba(168,168,168,0.3)}.psn-trophy-type-badge.ttb-bronze{background:rgba(205,127,50,0.2);color:var(--trophy-bronze);border:1px solid rgba(205,127,50,0.3)}.psn-trophy-rarity{display:inline-flex;align-items:center;gap:3px;font-size:0.65rem;color:var(--text-muted);padding:2px 6px;border-radius:3px;background:rgba(255,255,255,0.03)}.psn-trophy-rarity.rarity-ultra-rare{color:var(--neon-cyan)}.psn-trophy-rarity.rarity-very-rare{color:var(--neon-purple)}.psn-trophy-rarity.rarity-rare{color:var(--neon-orange)}.psn-trophy-rarity.rarity-common{color:var(--neon-gray)}.psn-trophy-earned-date{font-family:var(--font-mono);font-size:0.65rem;color:var(--text-muted);display:flex;align-items:center;gap:4px}.psn-trophy-earned-date .date-icon{font-size:0.6rem}.psn-dlc-separator{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);margin-top:var(--spacing-sm)}.psn-dlc-separator-line{flex:1;height:1px;background:var(--card-border)}.psn-dlc-separator-label{font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--neon-purple);white-space:nowrap}.psn-trophy-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--spacing-xl);color:var(--text-muted);text-align:center}.psn-trophy-empty-icon{font-size:2rem;margin-bottom:var(--spacing-sm)}.psn-trophy-complete{color:var(--neon-green)}.psn-not-configured{text-align:center;padding:var(--spacing-2xl)}.psn-not-configured-icon{font-size:4rem;margin-bottom:var(--spacing-lg)}.psn-not-configured-title{font-family:var(--font-heading);font-size:1.5rem;color:var(--text-primary);margin-bottom:var(--spacing-md)}.psn-not-configured-text{color:var(--text-secondary);margin-bottom:var(--spacing-lg);max-width:500px;margin-left:auto;margin-right:auto}.psn-no-games{text-align:center;padding:var(--spacing-2xl);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg)}.psn-no-games-icon{font-size:3rem;margin-bottom:var(--spacing-md)}.psn-no-games-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--text-primary);margin-bottom:var(--spacing-sm)}.psn-no-games-text{color:var(--text-muted)}.psn-visible-count{text-align:center;padding:var(--spacing-md);color:var(--text-muted);font-size:0.85rem}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.achievements-page{--card-min:280px;--card-gap:var(--spacing-md)}.achievement-games-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.achievement-game-card{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md);text-decoration:none;color:inherit;transition:all 0.2s ease}.achievement-game-card:hover{border-color:var(--neon-cyan);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3)}.achievement-game-icon{flex-shrink:0;width:80px;height:80px;border-radius:var(--radius-sm);overflow:hidden;background:var(--bg-darker)}.achievement-game-icon img{width:100%;height:100%;object-fit:cover}.achievement-game-card .no-boxart{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2rem;background:var(--bg-darker)}.achievement-game-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.achievement-game-title{font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.achievement-game-system{font-size:0.8rem;color:var(--text-muted)}.achievement-game-progress{margin-top:auto}.achievement-progress-bar{height:6px;background:var(--bg-darker);border-radius:3px;overflow:hidden;margin-bottom:4px}.achievement-progress-fill{height:100%;background:linear-gradient(90deg,var(--neon-cyan),var(--neon-purple));border-radius:3px;transition:width 0.3s ease}.achievement-progress-fill.xbox-fill{background:linear-gradient(90deg,var(--neon-green),#14b873)}.achievement-progress-text{font-size:0.75rem;color:var(--text-muted)}.no-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:4rem;background:var(--bg-darker);color:var(--text-muted)}.trophy-type-label.gamerscore-earned{background:#ffd700;color:#111}.trophy-type-label.gamerscore-locked{background:var(--bg-lighter);color:var(--text-muted)}.stat-box{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);text-align:center;transition:all var(--transition-normal)}.stat-box:hover{border-color:var(--neon-cyan);box-shadow:0 0 20px rgba(76,201,240,0.2)}.stat-value{font-family:var(--font-heading);font-size:2.5rem;font-weight:700;color:var(--neon-cyan);line-height:1.2;text-shadow:0 0 20px rgba(76,201,240,0.5)}.stat-label{font-size:0.9rem;color:var(--text-secondary);margin-top:0.5rem;text-transform:uppercase;letter-spacing:0.5px}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.stat-card{background:linear-gradient(135deg,var(--card-bg) 0%,var(--bg-medium) 100%);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg);position:relative;overflow:hidden;transition:all var(--transition-normal)}.stat-card::before{content:'';position:absolute;top:0;left:0;width:100%;height:3px;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta))}.stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-xl);border-color:var(--primary-cyan)}.stat-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:var(--spacing-md)}.stat-card .stat-label{font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:1.5px;margin-top:0}.stat-icon{width:40px;height:40px;background:linear-gradient(135deg,rgba(76,201,240,0.2) 0%,rgba(247,37,133,0.2) 100%);border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:1.2rem;color:var(--primary-cyan)}.stat-card .stat-value{font-family:var(--font-heading);font-size:3rem;font-weight:700;color:var(--text-primary);line-height:1;margin-bottom:var(--spacing-xs);text-shadow:none}.stat-subtitle{font-size:0.85rem;color:var(--text-secondary)}.stat-card.warning .stat-icon{background:linear-gradient(135deg,rgba(255,171,0,0.2) 0%,rgba(255,143,0,0.2) 100%);color:var(--status-warning)}.stat-card.warning::before{background:var(--status-warning)}.stat-card.success .stat-icon{background:linear-gradient(135deg,rgba(0,230,118,0.2) 0%,rgba(0,200,83,0.2) 100%);color:var(--status-success)}.stat-card.success::before{background:var(--status-success)}.psn-stats-bar{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.psn-stat-box{padding:var(--spacing-md);background:rgba(20,26,35,0.7);backdrop-filter:blur(8px);border:1px solid var(--card-border);border-radius:var(--radius-md);text-align:center}.psn-stat-icon{font-size:1.5rem;margin-bottom:var(--spacing-xs)}.psn-stat-value{font-family:var(--font-heading);font-size:1.25rem;font-weight:700}.psn-stat-value.platinum{color:var(--trophy-platinum)}.psn-stat-value.gold{color:var(--trophy-gold)}.psn-stat-value.silver{color:var(--trophy-silver)}.psn-stat-value.bronze{color:var(--trophy-bronze)}.psn-stat-label{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)}.stat-box.warning .stat-value{color:var(--neon-orange)}.stat-box.danger .stat-value{color:var(--neon-red)}.stat-box.success .stat-value{color:var(--neon-green)}.filter-bar{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-xl);flex-wrap:wrap}.search-box{--search-icon-offset:var(--spacing-md);--search-icon-size:1.15rem;--search-icon-width:2rem;--search-icon-gap:var(--spacing-sm);flex:1;min-width:250px;position:relative}.search-icon{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:var(--search-icon-size)}input.search-input{width:100%;padding:var(--spacing-md) var(--spacing-lg);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;transition:all var(--transition-fast)}input.search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 3px rgba(76,201,240,0.1)}input.search-input::placeholder{color:var(--text-muted)}.filter-select{padding:var(--spacing-md) var(--spacing-lg);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.95rem;cursor:pointer;transition:all var(--transition-fast);min-width:150px}.filter-select:focus{outline:none;border-color:var(--primary-cyan)}.platform-filters{display:flex;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);flex-wrap:wrap}.platform-filter-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:rgba(20,26,35,0.8);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.85rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast)}.platform-filter-btn:hover{border-color:var(--primary-cyan);color:var(--text-primary)}.platform-filter-btn.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.filter-count{font-size:0.7rem;padding:2px 6px;background:rgba(255,255,255,0.1);border-radius:var(--radius-full)}.platform-filter-btn.active .filter-count{background:rgba(76,201,240,0.2)}.bulk-filter-section{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.bulk-action-section{padding:var(--spacing-lg);background:var(--bg-dark);border-radius:var(--radius-md);border:1px solid var(--card-border)}.bulk-preview-count{font-size:1.5rem;font-weight:700;color:var(--primary-cyan);margin-bottom:var(--spacing-sm)}.bulk-game-list{max-height:300px;overflow-y:auto;margin-top:var(--spacing-md)}.bulk-game-item{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.bulk-game-item:last-child{border-bottom:none}.psn-search-sort-row{display:flex;gap:var(--spacing-md);margin-bottom:var(--spacing-lg);align-items:center}.psn-search-wrapper{flex:1;position:relative}.psn-search-wrapper .search-input{width:100%;padding-left:calc(var(--search-icon-offset, var(--spacing-md)) + var(--search-icon-width, 2rem) + var(--search-icon-gap, var(--spacing-sm)))}.psn-sort-select{padding:var(--spacing-sm) var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;cursor:pointer}.psn-sort-select:focus{outline:none;border-color:var(--primary-cyan)}.hltb-card{background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.hltb-card-title{display:flex;align-items:center;gap:var(--spacing-sm);font-family:var(--font-primary);font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--spacing-md)}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}.hltb-results{margin-top:var(--spacing-md)}.hltb-match-header{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-xs);padding-bottom:var(--spacing-sm);margin-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.hltb-match-title{color:var(--primary-cyan);font-weight:600;font-size:0.9rem}.hltb-match-platform{color:var(--text-secondary);font-size:0.8rem}.hltb-match-year{color:var(--neon-orange);font-size:0.8rem;font-weight:500}.hltb-match-developer{color:var(--text-secondary);font-size:0.8rem;font-style:italic}.hltb-match-warning{color:var(--neon-orange);font-size:0.8rem;font-weight:600}.hltb-match-confidence{color:var(--neon-green);font-size:0.8rem;font-weight:500}.hltb-match-alt-notice{display:inline-flex;align-items:center;gap:0.25rem;color:var(--neon-orange);background:rgba(245,158,11,0.12);border:1px solid rgba(245,158,11,0.4);padding:2px 8px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;cursor:help;white-space:nowrap}.hltb-time-row{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.hltb-time-row:last-child{border-bottom:none}.hltb-label{color:var(--text-secondary);font-size:0.85rem}.hltb-value{color:var(--text-primary);font-size:0.85rem;font-weight:500}.hltb-value.highlight{color:var(--primary-cyan)}.hltb-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-md)}.hltb-actions .btn{flex:1}.hltb-empty{color:var(--text-muted);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-loading{display:flex;align-items:center;gap:var(--spacing-sm);color:var(--text-secondary);font-size:0.9rem;padding:var(--spacing-sm) 0}.hltb-compact .hltb-time-row{padding:var(--spacing-xs) 0}.hltb-compact .hltb-label,.hltb-compact .hltb-value{font-size:0.8rem}.manual-reader{display:flex;flex-direction:column;width:min(96vw,1400px);height:94vh;background:var(--bg-medium);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xl)}.manual-reader-header{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);border-bottom:1px solid var(--card-border)}.manual-reader-header h2{flex:1;min-width:0;margin:0;font-family:var(--font-heading);font-size:1.1rem;color:var(--neon-cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.manual-reader-stage{flex:1;min-height:0;display:grid;place-items:center;padding:var(--spacing-sm);overflow:hidden;perspective:2400px;touch-action:pan-y;user-select:none}.manual-reader-stage:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:-2px}.manual-reader-sheet{grid-area:1 / 1;max-width:100%;max-height:100%;box-shadow:var(--shadow-lg);transform-origin:left center;backface-visibility:hidden}.manual-reader-sheet.turn-out{z-index:1;animation:manual-turn-out 0.6s ease-in-out forwards}.manual-reader-sheet.turn-in{z-index:1;animation:manual-turn-in 0.6s ease-in-out forwards}@keyframes manual-turn-out{from{transform:rotateY(0deg)}to{transform:rotateY(-180deg)}}@keyframes manual-turn-in{from{transform:rotateY(-180deg)}to{transform:rotateY(0deg)}}@media (prefers-reduced-motion:reduce){.manual-reader-sheet.turn-out,.manual-reader-sheet.turn-in{animation:none}}.manual-reader-message{grid-area:1 / 1;color:var(--text-secondary);text-align:center}.manual-reader-controls{display:flex;align-items:center;justify-content:center;gap:var(--spacing-lg);padding:var(--spacing-sm);border-top:1px solid var(--card-border)}.manual-reader-controls .btn{white-space:nowrap}.manual-reader-page{min-width:8em;text-align:center;color:var(--text-primary);font-family:var(--font-mono)}@media (max-width:600px){.manual-reader{width:100vw;height:100dvh;border-radius:0}.manual-reader-header .btn{font-size:0.75rem}}[data-update-card] [hidden],[data-update-banner][hidden]{display:none}.update-banner{display:flex;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);background:color-mix(in srgb,var(--primary-cyan) 10%,transparent);border:1px solid color-mix(in srgb,var(--primary-cyan) 35%,transparent);border-radius:var(--radius-md);margin-bottom:var(--spacing-md);font-size:0.85rem}.update-banner-text{color:var(--text-primary);flex:1}.update-banner-link{color:var(--primary-cyan);text-decoration:none;font-weight:600}.update-banner-link:hover{text-decoration:underline}.update-banner-close{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1rem;padding:2px 6px;line-height:1}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;max-height:18rem;overflow-y:auto;padding:var(--spacing-sm) var(--spacing-md);background:var(--bg-darkest);border:1px solid var(--border-color);border-radius:var(--radius-md);font-family:inherit;font-size:0.85rem}.update-progress{height:8px;margin:var(--spacing-sm) 0;background:var(--bg-darkest);border-radius:var(--radius-md);overflow:hidden}.update-progress-bar{width:0;height:100%;background:var(--primary-cyan);transition:width 0.3s ease}.update-actions{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin:var(--spacing-md) 0}.update-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-sm) var(--spacing-md);margin-bottom:var(--spacing-md);border-radius:var(--radius-md);border:1px solid var(--border-color)}.update-result-ok{border-color:var(--neon-green)}.update-result-failed{border-color:var(--neon-red)}.update-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:var(--spacing-md);background:color-mix(in srgb,var(--bg-darkest) 85%,transparent);color:var(--text-primary);font-size:1.1rem;text-align:center}.score-badge.score-good{background:rgba(34,197,94,0.15);border-color:var(--neon-green);color:var(--neon-green)}.score-badge.score-mixed{background:rgba(234,179,8,0.15);border-color:var(--neon-orange);color:var(--neon-orange)}.score-badge.score-bad{background:rgba(239,68,68,0.15);border-color:var(--neon-red);color:var(--neon-red)}.bulk-option-label{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(168,85,247,0.1);border:1px solid var(--neon-purple);border-radius:var(--radius-md);color:var(--neon-purple);font-size:0.85rem;cursor:pointer;transition:all var(--transition-fast)}.bulk-option-label:hover{background:rgba(168,85,247,0.2)}.bulk-option-label input[type="checkbox"]{width:16px;height:16px;accent-color:var(--neon-purple);cursor:pointer}.bulk-mode-selector{display:flex;gap:8px;align-items:center}.scrape-mode-toggle{display:flex;align-items:center;gap:12px;padding:6px 16px;background:rgba(100,116,139,0.1);border:1px solid var(--neon-gray);border-radius:var(--radius-md)}.scrape-mode-toggle .mode-label{font-size:0.85rem;font-weight:500;transition:all var(--transition-fast)}.scrape-mode-toggle .mode-label.mode-off{color:var(--text-muted)}.scrape-mode-toggle .mode-label.mode-on{color:var(--text-muted)}.scrape-mode-toggle:has(input:not(:checked)) .mode-off{color:var(--neon-orange);text-shadow:0 0 8px rgba(245,158,11,0.5)}.scrape-mode-toggle:has(input:checked) .mode-on{color:var(--neon-cyan);text-shadow:0 0 8px rgba(76,201,240,0.5)}.keep-media-option{display:flex;align-items:center;gap:6px;font-size:0.85rem;color:var(--text-muted);cursor:pointer}.bulk-mode-selector:has(#scrapeModeToggle:checked) .keep-media-option{display:none}.scrape-mode-toggle .toggle-switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}.scrape-mode-toggle .toggle-switch input{opacity:0;width:0;height:0}.scrape-mode-toggle .toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--neon-orange);border-radius:24px;transition:all var(--transition-normal);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(245,158,11,0.3)}.scrape-mode-toggle .toggle-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background:white;border-radius:50%;transition:all var(--transition-normal);box-shadow:0 2px 4px rgba(0,0,0,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider{background:var(--neon-cyan);box-shadow:inset 0 2px 4px rgba(0,0,0,0.3),0 0 10px rgba(76,201,240,0.3)}.scrape-mode-toggle .toggle-switch input:checked + .toggle-slider:before{transform:translateX(20px)}.fanart-background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;background-size:cover;background-position:center;background-repeat:no-repeat;opacity:0;transition:opacity 0.5s ease,background-image 0.5s ease;pointer-events:none}.fanart-background::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient( to bottom,rgba(10,14,20,0.7) 0%,rgba(10,14,20,0.85) 50%,rgba(10,14,20,0.95) 100% )}.fanart-background.active{opacity:1}.glass-panel{background:rgba(20,28,40,0.7);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(76,201,240,0.2)}.glass-card{background:rgba(20,28,40,0.5);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}.glitch-text{position:relative}.glitch-text::before,.glitch-text::after{content:attr(data-text);position:absolute;top:0;left:0;width:100%;height:100%}.glitch-text::before{left:2px;text-shadow:-2px 0 var(--secondary-magenta);clip:rect(24px,550px,90px,0);animation:glitch-anim 3s infinite linear alternate-reverse}.glitch-text::after{left:-2px;text-shadow:-2px 0 var(--primary-cyan);clip:rect(85px,550px,140px,0);animation:glitch-anim2 3s infinite linear alternate-reverse}@keyframes glitch-anim{0%{clip:rect(20px,9999px,40px,0)}25%{clip:rect(60px,9999px,80px,0)}50%{clip:rect(10px,9999px,30px,0)}75%{clip:rect(70px,9999px,90px,0)}100%{clip:rect(30px,9999px,50px,0)}}@keyframes glitch-anim2{0%{clip:rect(80px,9999px,100px,0)}25%{clip:rect(20px,9999px,40px,0)}50%{clip:rect(50px,9999px,70px,0)}75%{clip:rect(10px,9999px,30px,0)}100%{clip:rect(60px,9999px,80px,0)}}.scan-lines{position:absolute;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,rgba(0,0,0,0.1),rgba(0,0,0,0.1) 1px,transparent 1px,transparent 2px );pointer-events:none;opacity:0;transition:opacity var(--transition-normal)}.game-card-new:hover .scan-lines{opacity:0.3}.card-glow{position:absolute;top:-2px;left:-2px;right:-2px;bottom:-2px;background:linear-gradient(45deg,var(--primary-cyan),var(--secondary-magenta),var(--primary-cyan));border-radius:calc(var(--radius-lg) + 2px);z-index:-1;opacity:0;filter:blur(8px);transition:opacity var(--transition-normal)}.game-card-new:hover .card-glow{opacity:0.5;animation:glow-pulse 2s ease-in-out infinite}@keyframes glow-pulse{0%,100%{opacity:0.3}50%{opacity:0.6}}.filter-bar-new{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;padding:var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);position:sticky;top:var(--sticky-top);z-index:100;background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.search-box-integrated{--search-icon-offset:12px;--search-icon-size:1rem;--search-icon-width:1.75rem;--search-icon-gap:var(--spacing-sm);position:relative;min-width:200px;max-width:350px;flex:1}.search-icon-inside{position:absolute;left:var(--search-icon-offset);top:50%;transform:translateY(-50%);font-size:var(--search-icon-size);pointer-events:none;z-index:1}.search-input-integrated{width:100%;padding:var(--spacing-sm) var(--spacing-md);padding-left:calc(var(--search-icon-offset) + var(--search-icon-width) + var(--search-icon-gap));padding-right:32px;background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.95rem;outline:none;transition:border-color var(--transition-fast)}.search-clear-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text-muted);font-size:1.2rem;cursor:pointer;padding:0 4px;line-height:1;opacity:0;transition:opacity 0.2s,color 0.2s;z-index:1}.search-clear-btn:hover{color:var(--primary-cyan)}.search-box-integrated:has(.search-input-integrated:not(:placeholder-shown)) .search-clear-btn{opacity:1}.search-input-integrated:focus{border-color:var(--primary-cyan)}.search-input-integrated::placeholder{color:var(--text-muted)}.filter-buttons{display:flex;gap:var(--spacing-sm);flex-wrap:wrap}.filter-btn{display:flex;align-items:center;gap:var(--spacing-xs);padding:var(--spacing-sm) var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);cursor:pointer;transition:all var(--transition-fast);font-size:0.85rem}.filter-btn:hover{border-color:var(--primary-cyan);color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-btn:disabled{opacity:0.35;cursor:default;pointer-events:none}.filter-btn.active{border-color:var(--primary-cyan);background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-btn.clear-btn{border-color:var(--status-error);color:var(--status-error)}.filter-btn.clear-btn:hover{background:rgba(255,107,107,0.2)}.filter-btn.ra-filter-btn{border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-btn.ra-filter-btn:hover{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.15)}.filter-btn.ra-filter-btn.active{border-color:var(--trophy-display-gold);background:rgba(255,215,0,0.25);color:var(--trophy-display-gold);box-shadow:0 0 10px rgba(255,215,0,0.2)}.filter-btn.bonus-filter-btn{border-color:rgba(245,158,11,0.4);color:var(--neon-orange)}.filter-btn.bonus-filter-btn:hover{border-color:var(--neon-orange);background:rgba(245,158,11,0.15)}.filter-btn.bonus-filter-btn.active{border-color:var(--neon-orange);background:rgba(245,158,11,0.25);color:var(--neon-orange);box-shadow:0 0 10px rgba(245,158,11,0.2)}.filter-btn.refresh-ra-btn{border-color:rgba(76,201,240,0.4);color:var(--primary-cyan)}.filter-btn.refresh-ra-btn:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-btn.refresh-ra-btn:disabled{opacity:0.6;cursor:not-allowed}.filter-tag.ra-tag{background:rgba(255,215,0,0.2);border-color:rgba(255,215,0,0.4);color:var(--trophy-display-gold)}.filter-bar-top{display:flex;align-items:center;gap:var(--spacing-md);width:100%}.bulk-controls{display:flex;gap:var(--spacing-sm);align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end}.bulk-btn{border-color:rgba(147,51,234,0.4);color:var(--neon-purple)}.bulk-btn:hover{border-color:var(--neon-purple);background:rgba(147,51,234,0.15)}.bulk-btn.active{border-color:var(--neon-purple);background:rgba(147,51,234,0.25);color:var(--neon-purple)}.bulk-action-btn{border-color:rgba(59,130,246,0.4);color:var(--neon-blue)}.bulk-action-btn:hover{border-color:var(--neon-blue);background:rgba(59,130,246,0.15)}.bulk-scrape-btn{border-color:rgba(34,197,94,0.4);color:var(--neon-green)}.bulk-scrape-btn:hover{border-color:var(--neon-green);background:rgba(34,197,94,0.15)}.game-card-wrapper{position:relative}.bulk-select-checkbox{position:absolute;top:8px;right:8px;z-index:20}.bulk-select-checkbox input[type="checkbox"]{width:22px;height:22px;cursor:pointer;accent-color:var(--primary-cyan)}.game-card-wrapper.selected .game-card-new{border-color:var(--primary-cyan);box-shadow:0 0 15px rgba(76,201,240,0.4)}.bulk-scrape-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:3000;align-items:center;justify-content:center}.bulk-scrape-modal.active{display:flex}.bulk-scrape-content{width:90%;max-width:500px;border-radius:var(--radius-lg);overflow:hidden}.bulk-scrape-header{padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);display:flex;justify-content:space-between;align-items:center}.bulk-scrape-header h2{margin:0;color:var(--primary-cyan)}.bulk-scrape-body{padding:var(--spacing-lg)}.bulk-progress-info{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md)}.bulk-progress-text{flex:1;overflow:hidden}#bulkCurrentGame{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text-primary);font-weight:500}.bulk-progress-numbers{color:var(--primary-cyan);font-family:var(--font-mono);font-size:1.1rem;margin-left:var(--spacing-md)}.bulk-progress-bar{width:100%;height:20px;background:var(--bg-medium);border-radius:10px;margin-bottom:var(--spacing-md);border:2px solid rgba(76,201,240,0.5);box-shadow:inset 0 2px 4px rgba(0,0,0,0.5);overflow:hidden;padding:2px}.bulk-progress-fill{display:block;height:100%;width:var(--progress,0%);min-width:0;max-width:100%;background:linear-gradient(90deg,var(--primary-cyan),var(--secondary-magenta));border-radius:8px;box-shadow:0 0 10px rgba(76,201,240,0.5)}.bulk-status{text-align:center;color:var(--text-secondary);font-size:0.9rem;margin-bottom:var(--spacing-md)}.bulk-controls-row{display:flex;justify-content:center;gap:var(--spacing-md);margin-top:var(--spacing-lg);margin-bottom:var(--spacing-md);padding-top:var(--spacing-sm)}.bulk-results{display:flex;justify-content:space-around;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border-radius:var(--radius-md);margin-top:var(--spacing-md)}.bulk-result-item{display:flex;align-items:center;gap:var(--spacing-xs);font-size:0.9rem}.bulk-result-item.success{color:var(--neon-green)}.bulk-result-item.failed{color:var(--neon-red)}.bulk-result-item.skipped{color:var(--neon-orange)}.bulk-scrape-footer{padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border);display:flex;justify-content:center}.active-filter-tags{display:flex;gap:var(--spacing-sm);flex:1;min-width:0;overflow-x:auto;scrollbar-width:thin}.active-filter-tags .filter-tag{flex-shrink:0;white-space:nowrap}.filter-tag{display:flex;align-items:center;gap:var(--spacing-xs);padding:4px 10px;background:var(--primary-cyan);color:var(--bg-dark);border-radius:var(--radius-sm);font-size:0.8rem;font-weight:600}.filter-tag-exclude{background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.5);color:var(--neon-red)}.filter-tag-remove{cursor:pointer;opacity:0.7;transition:opacity 0.2s}.filter-tag-remove:hover{opacity:1}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:calc(var(--sticky-top) + 80px);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.85rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:4px}.alphabet-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.3;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.1);border-color:rgba(76,201,240,0.3);color:var(--text-secondary)}.cards-page{--card-min:380px;--card-gap:var(--spacing-lg);max-width:calc(4 * 480px + 3 * var(--card-gap));margin-inline:auto}.games-grid-new{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,380px),1fr));grid-auto-rows:1px;column-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:span 296;align-self:start;transition:transform var(--transition-normal);contain:layout style paint;content-visibility:auto;contain-intrinsic-size:auto 380px 296px}.game-card-new{position:relative;display:flex;flex-direction:row;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;transition:all var(--transition-normal);border:1px solid transparent;min-height:240px}.game-card-wrapper:hover{transform:translateY(-4px)}.game-card-new:hover{box-shadow:0 8px 30px rgba(76,201,240,0.2)}.game-card-wrapper.hidden{display:none}.game-card-new.hidden{display:none}.card-boxart-section{flex-shrink:0;width:135px;align-self:stretch;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:relative}.card-play-btn{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);z-index:5;width:28px;height:28px;border-radius:var(--radius-full);border:1px solid var(--primary-cyan);background:var(--bg-darkest);color:var(--primary-cyan);font-size:0.8rem;line-height:1;cursor:pointer;box-shadow:var(--shadow-md);transition:background var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.card-play-btn{display:flex;align-items:center;justify-content:center;padding:0}.card-play-btn svg{width:60%;height:60%;fill:currentColor}.card-play-btn:hover,.card-play-btn:focus-visible{background:var(--primary-cyan);color:var(--bg-darkest);box-shadow:var(--shadow-glow-cyan)}.card-play-btn:disabled{cursor:progress}.games-grid-new.bulk-mode .card-play-btn{display:none}.game-card-new.has-play .card-info-section >:first-child{padding-right:calc(28px + var(--spacing-sm))}.card-info-section{flex:1;padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-sm);background:rgba(0,0,0,0.4)}.title-row{display:flex;align-items:flex-start;gap:var(--spacing-sm)}.title-row .game-card-title{flex:1}.game-card-ra-badge{position:absolute;top:8px;left:8px;width:28px;height:28px;background:linear-gradient(135deg,var(--trophy-display-gold) 0%,#ffaa00 100%);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.9rem;z-index:10;box-shadow:0 2px 8px rgba(255,215,0,0.4);border:2px solid rgba(255,255,255,0.3)}.game-card-rating-badge{position:absolute;top:8px;right:8px;z-index:10}.rating-text-badge{font-size:0.65rem;padding:2px 6px;border-radius:var(--radius-sm);font-weight:700;text-transform:uppercase}.rating-text-badge.esrb{background:rgba(0,0,0,0.7);color:var(--text-on-color);border:1px solid var(--text-on-color)}.rating-text-badge.pegi{background:rgba(141,198,63,0.9);color:var(--text-on-color);border:1px solid var(--text-on-color)}.system-badge-row{display:flex;align-items:center;gap:var(--spacing-sm)}.system-badge{padding:3px 8px;background:rgba(76,201,240,0.2);border:1px solid var(--primary-cyan);border-radius:var(--radius-sm);font-size:0.65rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.system-badge.nes{background:rgba(198,40,40,0.25);border-color:#e53935;color:#e53935}.system-badge.snes{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.n64{background:rgba(244,67,54,0.2);border-color:#f44336;color:#f44336}.system-badge.gc,.system-badge.gamecube{background:rgba(103,58,183,0.2);border-color:#9575cd;color:#9575cd}.system-badge.wii{background:rgba(255,255,255,0.15);border-color:#e0e0e0;color:#e0e0e0}.system-badge.wiiu{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.switch{background:rgba(229,57,53,0.2);border-color:#ef5350;color:#ef5350}.system-badge.gb{background:rgba(139,195,74,0.25);border-color:#9ccc65;color:#9ccc65}.system-badge.gbc{background:rgba(156,39,176,0.2);border-color:#ba68c8;color:#ba68c8}.system-badge.gba{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#7e57c2}.system-badge.nds{background:rgba(158,158,158,0.2);border-color:#bdbdbd;color:#bdbdbd}.system-badge.3ds{background:rgba(229,57,53,0.2);border-color:#e57373;color:#e57373}.system-badge.virtualboy,.system-badge.vb{background:rgba(183,28,28,0.3);border-color:#c62828;color:#ef5350}.system-badge.fds{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.psx,.system-badge.ps1{background:rgba(144,202,249,0.25);border-color:#90caf9;color:#90caf9}.system-badge.ps2{background:rgba(33,150,243,0.2);border-color:#42a5f5;color:#42a5f5}.system-badge.ps3{background:rgba(21,101,192,0.25);border-color:#1976d2;color:#64b5f6}.system-badge.ps4{background:rgba(13,71,161,0.3);border-color:#1565c0;color:#42a5f5}.system-badge.psp{background:rgba(100,181,246,0.2);border-color:#64b5f6;color:#64b5f6}.system-badge.psvita,.system-badge.vita{background:rgba(30,136,229,0.2);border-color:#1e88e5;color:#42a5f5}.system-badge.sg-1000,.system-badge.sg1000{background:rgba(0,77,64,0.3);border-color:#00897b;color:#4db6ac}.system-badge.mastersystem,.system-badge.sms{background:rgba(0,121,107,0.25);border-color:#00897b;color:#4db6ac}.system-badge.genesis,.system-badge.megadrive{background:rgba(0,150,136,0.2);border-color:#26a69a;color:#26a69a}.system-badge.segacd,.system-badge.megacd{background:rgba(0,137,123,0.25);border-color:#00897b;color:#4db6ac}.system-badge.sega32x,.system-badge.32x{background:rgba(0,105,92,0.3);border-color:#00796b;color:#4db6ac}.system-badge.saturn{background:rgba(128,203,196,0.2);border-color:#80cbc4;color:#80cbc4}.system-badge.dreamcast,.system-badge.dc{background:rgba(255,152,0,0.2);border-color:#ffb74d;color:#ffb74d}.system-badge.gamegear,.system-badge.gg{background:rgba(0,188,212,0.2);border-color:#4dd0e1;color:#4dd0e1}.system-badge.atari2600{background:rgba(230,81,0,0.25);border-color:#ff6d00;color:#ffab40}.system-badge.atari5200{background:rgba(255,111,0,0.2);border-color:#ff9100;color:#ffab40}.system-badge.atari7800{background:rgba(255,143,0,0.2);border-color:#ffa000;color:#ffca28}.system-badge.atarilynx,.system-badge.lynx{background:rgba(255,193,7,0.2);border-color:#ffc107;color:#ffc107}.system-badge.atarijaguar,.system-badge.jaguar{background:rgba(255,87,34,0.2);border-color:#ff7043;color:#ff7043}.system-badge.atarist,.system-badge.st{background:rgba(121,85,72,0.25);border-color:#a1887f;color:#bcaaa4}.system-badge.arcade{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.mame{background:rgba(233,30,99,0.2);border-color:#ec407a;color:#ec407a}.system-badge.fbneo{background:rgba(233,30,99,0.2);border-color:#f06292;color:#f06292}.system-badge.cps1,.system-badge.cps2,.system-badge.cps3{background:rgba(216,27,96,0.2);border-color:#e91e63;color:#f48fb1}.system-badge.naomi{background:rgba(194,24,91,0.25);border-color:#d81b60;color:#f48fb1}.system-badge.neogeo{background:rgba(255,193,7,0.25);border-color:#ffc107;color:#ffc107}.system-badge.neogeocd{background:rgba(255,179,0,0.2);border-color:#ffb300;color:#ffc107}.system-badge.ngp{background:rgba(255,213,79,0.2);border-color:#ffd54f;color:#ffd54f}.system-badge.ngpc{background:rgba(255,224,130,0.2);border-color:#ffe082;color:#ffe082}.system-badge.pcengine,.system-badge.tg16{background:rgba(156,39,176,0.2);border-color:#ab47bc;color:#ce93d8}.system-badge.pcenginecd,.system-badge.tg-cd{background:rgba(142,36,170,0.2);border-color:#9c27b0;color:#ce93d8}.system-badge.supergrafx{background:rgba(171,71,188,0.2);border-color:#ba68c8;color:#ce93d8}.system-badge.pcfx{background:rgba(123,31,162,0.25);border-color:#8e24aa;color:#ce93d8}.system-badge.c64{background:rgba(121,85,72,0.25);border-color:#8d6e63;color:#bcaaa4}.system-badge.amiga{background:rgba(141,110,99,0.2);border-color:#a1887f;color:#d7ccc8}.system-badge.vic20{background:rgba(93,64,55,0.3);border-color:#6d4c41;color:#a1887f}.system-badge.xbox{background:rgba(76,175,80,0.2);border-color:#66bb6a;color:#81c784}.system-badge.xbox360{background:rgba(56,142,60,0.25);border-color:#43a047;color:#81c784}.system-badge.3do{background:rgba(255,215,0,0.2);border-color:#ffd700;color:#ffd700}.system-badge.philipscdi,.system-badge.cdi{background:rgba(255,193,7,0.15);border-color:#ffca28;color:#ffca28}.system-badge.dos{background:rgba(96,125,139,0.2);border-color:#78909c;color:#90a4ae}.system-badge.pc{background:rgba(69,90,100,0.25);border-color:#607d8b;color:#90a4ae}.system-badge.pc88,.system-badge.pc98{background:rgba(84,110,122,0.2);border-color:#78909c;color:#b0bec5}.system-badge.msx{background:rgba(0,188,212,0.2);border-color:#00bcd4;color:#4dd0e1}.system-badge.msx2{background:rgba(0,172,193,0.2);border-color:#00acc1;color:#4dd0e1}.system-badge.wonderswan,.system-badge.ws{background:rgba(63,81,181,0.2);border-color:#5c6bc0;color:#7986cb}.system-badge.wonderswancolor,.system-badge.wsc{background:rgba(48,63,159,0.2);border-color:#3f51b5;color:#7986cb}.system-badge.pokemini{background:rgba(255,235,59,0.25);border-color:#ffee58;color:#fff176}.system-badge.nggage,.system-badge.ngage{background:rgba(255,112,67,0.2);border-color:#ff7043;color:#ff8a65}.system-badge.colecovision{background:rgba(183,28,28,0.2);border-color:#c62828;color:#ef5350}.system-badge.intellivision{background:rgba(255,152,0,0.2);border-color:#ff9800;color:#ffb74d}.system-badge.vectrex{background:rgba(255,255,255,0.2);border-color:#eeeeee;color:#eeeeee}.system-badge.scummvm{background:rgba(139,195,74,0.2);border-color:#8bc34a;color:#aed581}.system-badge.daphne{background:rgba(103,58,183,0.2);border-color:#7e57c2;color:#b39ddb}.title-badges{display:flex;gap:4px;flex-shrink:0}.title-badges .ra-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain;filter:drop-shadow(0 0 4px rgba(255,193,7,0.5))}.import-badge.clz-badge{filter:drop-shadow(0 0 4px rgba(168,85,247,0.5))}.import-badge{width:14px;height:14px;flex-shrink:0;object-fit:contain}.import-badge.steam-badge{filter:drop-shadow(0 0 4px rgba(102,192,244,0.5))}.import-badge.xbox-badge{filter:drop-shadow(0 0 4px rgba(16,124,16,0.5))}.import-badge.psn-badge{filter:drop-shadow(0 0 4px rgba(0,55,145,0.5))}.bonus-badge{font-size:0.9rem;filter:drop-shadow(0 0 4px rgba(245,158,11,0.5));flex-shrink:0}.has-bonus-badge{font-size:0.75rem;background:rgba(168,85,247,0.2);border:1px solid var(--neon-purple);color:var(--neon-purple);padding:1px 4px;border-radius:4px;filter:drop-shadow(0 0 4px rgba(168,85,247,0.4));flex-shrink:0}.versions-badge{font-size:0.75rem;background:color-mix(in srgb,var(--neon-cyan) 20%,transparent);border:1px solid var(--neon-cyan);color:var(--neon-cyan);padding:1px 4px;border-radius:4px;flex-shrink:0}.game-card-wrapper.bonus-disc .game-card-new{border-left:3px solid var(--neon-orange);opacity:0.85}.game-card-wrapper.bonus-disc .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(245,158,11,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.clz-import .game-card-new{border-left:3px solid var(--neon-purple)}.game-card-wrapper.clz-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(168,85,247,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.steam-import .game-card-new{border-left:3px solid #66c0f4}.game-card-wrapper.steam-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(102,192,244,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.xbox-import .game-card-new{border-left:3px solid #107c10}.game-card-wrapper.xbox-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(16,124,16,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-wrapper.psn-import .game-card-new{border-left:3px solid #0070d1}.game-card-wrapper.psn-import .game-card-new::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(135deg,rgba(0,112,209,0.05) 0%,transparent 50%);pointer-events:none;z-index:0}.game-card-title{font-size:0.95rem;font-weight:600;color:var(--text-primary);margin:0;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;flex:1}.meta-item{display:flex;flex-direction:column;gap:1px}.meta-label{font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.meta-value{font-size:0.75rem;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta-value.meta-clickable{cursor:pointer;padding:2px 6px;margin:-2px -6px;border-radius:var(--radius-sm);transition:all var(--transition-fast);display:inline-block}.meta-value.meta-clickable:hover{background:rgba(76,201,240,0.15);color:var(--neon-cyan)}.rating-badges{display:flex;gap:var(--spacing-sm);flex-wrap:wrap;margin-top:auto}.rating-badge{padding:3px 8px;border-radius:var(--radius-sm);font-size:0.65rem;font-weight:700}.rating-badge.esrb{background:linear-gradient(135deg,var(--bg-medium),#16213e);color:#fff;border:1px solid #4a4a6a}.rating-badge.pegi{background:linear-gradient(135deg,#0f3460,#16213e);color:var(--primary-cyan);border:1px solid var(--primary-cyan)}.game-badge.achievement-progress{gap:4px;font-size:0.65rem;font-weight:700;font-family:var(--font-mono)}.ach-icon{width:14px;height:14px;object-fit:contain;flex-shrink:0;filter:drop-shadow(0 0 3px rgba(255,255,255,0.3))}.game-badge.achievement-progress.ra{background:linear-gradient(135deg,rgba(74,144,217,0.12) 0%,rgba(212,160,23,0.12) 100%);border-image:linear-gradient(135deg,#4A90D9,#D4A017) 1;color:#D4A017}.game-badge.achievement-progress.ra.ach-complete{background:linear-gradient(135deg,rgba(74,144,217,0.2) 0%,rgba(212,160,23,0.2) 100%);color:#e8b820}.game-badge.achievement-progress.steam{background:linear-gradient(135deg,rgba(27,40,56,0.35) 0%,rgba(102,192,244,0.12) 100%);border-image:linear-gradient(135deg,#2A6DAB,#66C0F4) 1;color:#66C0F4}.game-badge.achievement-progress.steam.ach-complete{background:linear-gradient(135deg,rgba(27,40,56,0.45) 0%,rgba(102,192,244,0.2) 100%);color:#88d4f8}.game-badge.achievement-progress.xbox{background:linear-gradient(135deg,rgba(16,124,16,0.12) 0%,rgba(82,176,67,0.12) 100%);border-image:linear-gradient(135deg,#107C10,#52B043) 1;color:#52B043}.game-badge.achievement-progress.xbox.ach-complete{background:linear-gradient(135deg,rgba(16,124,16,0.2) 0%,rgba(82,176,67,0.2) 100%);color:#6dcc5e}.game-badge.achievement-progress.psn{background:linear-gradient(135deg,rgba(0,48,135,0.15) 0%,rgba(108,168,217,0.12) 100%);border-image:linear-gradient(135deg,#0070D1,#6CA8D9) 1;color:#6CA8D9}.game-badge.achievement-progress.psn.ach-complete{background:linear-gradient(135deg,rgba(0,48,135,0.25) 0%,rgba(108,168,217,0.2) 100%);color:#8dc0e8}.game-badge.achievement-progress.rpcs3{background:linear-gradient(135deg,rgba(74,122,140,0.12) 0%,rgba(168,208,224,0.1) 100%);border-image:linear-gradient(135deg,#4A7A8C,#A8D0E0) 1;color:#A8D0E0}.game-badge.achievement-progress.rpcs3.ach-complete{background:linear-gradient(135deg,rgba(74,122,140,0.2) 0%,rgba(168,208,224,0.18) 100%);color:#c0e0ec}.game-badge.achievement-progress.ach-low{opacity:0.7}.ach-bar{display:inline-block;width:30px;height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;vertical-align:middle}.ach-fill{display:block;height:100%;border-radius:2px;transition:width 0.3s ease}.ra .ach-fill{background:linear-gradient(90deg,#4A90D9,#D4A017)}.ra.ach-complete .ach-fill{box-shadow:0 0 4px #D4A017}.steam .ach-fill{background:linear-gradient(90deg,#2A6DAB,#66C0F4)}.steam.ach-complete .ach-fill{box-shadow:0 0 4px #66C0F4}.xbox .ach-fill{background:linear-gradient(90deg,#107C10,#52B043)}.xbox.ach-complete .ach-fill{box-shadow:0 0 4px #52B043}.psn .ach-fill{background:linear-gradient(90deg,#0070D1,#6CA8D9)}.psn.ach-complete .ach-fill{box-shadow:0 0 4px #6CA8D9}.rpcs3 .ach-fill{background:linear-gradient(90deg,#4A7A8C,#A8D0E0)}.rpcs3.ach-complete .ach-fill{box-shadow:0 0 4px #A8D0E0}.game-card-image{width:100%;height:100%;overflow:hidden;background:var(--bg-medium);display:flex;align-items:center;justify-content:center;position:absolute;top:0;left:0;right:0;bottom:0}.game-boxart-img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;transition:transform var(--transition-normal)}.game-boxart-img.boxart-3d{object-fit:contain;padding:4px}.game-card-new:hover .game-boxart-img{transform:scale(1.05)}.game-boxart-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--bg-medium),var(--bg-dark))}.placeholder-icon{font-size:3rem;opacity:0.3}.games-count-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--spacing-md);min-height:3rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--card-border)}.showing-text{flex-shrink:0;font-size:0.85rem;color:var(--text-muted);letter-spacing:1px}.showing-text span{color:var(--primary-cyan);font-weight:600}.sort-control{display:flex;align-items:center;gap:var(--spacing-sm)}.sort-label{font-size:0.85rem;color:var(--text-muted);white-space:nowrap}.sort-select{padding:var(--spacing-xs) var(--spacing-sm);background-color:var(--bg-light);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-size:0.85rem;cursor:pointer}.sort-select:focus-visible{outline:2px solid var(--primary-cyan);outline-offset:1px}#gamesEmpty .clear-btn{display:inline-flex;margin-top:var(--spacing-md)}.alphabet-nav.is-disabled{opacity:0.35}.bulk-action-bar{position:sticky;bottom:var(--spacing-md);z-index:101;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:var(--spacing-md);margin-top:var(--spacing-lg);border-radius:var(--radius-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.bulk-action-bar[hidden]{display:none}.filters-toggle-btn{display:none}.filter-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:2000;align-items:center;justify-content:center}.filter-modal.active{display:flex}.filter-modal-content{width:90%;max-width:500px;max-height:70vh;border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column}.filter-modal-header{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-lg);border-bottom:1px solid var(--card-border);gap:var(--spacing-md)}.filter-modal-header h2{color:var(--primary-cyan);font-family:var(--font-heading);margin:0}.filter-mode-toggle{display:flex;border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.filter-mode-btn{padding:4px 12px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-size:0.85rem;font-weight:600;transition:all var(--transition-fast)}.filter-mode-btn:hover{color:var(--text-primary)}.filter-mode-btn.active[data-mode="include"]{background:rgba(76,201,240,0.2);color:var(--primary-cyan)}.filter-mode-btn.active[data-mode="exclude"]{background:rgba(239,68,68,0.2);color:var(--neon-red)}.filter-modal-body{padding:var(--spacing-lg);overflow-y:auto;flex:1}.filter-options{display:flex;flex-direction:column;gap:var(--spacing-sm)}.filter-option{display:flex;justify-content:space-between;align-items:center;padding:var(--spacing-md);background:rgba(0,0,0,0.3);border:1px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:all var(--transition-fast)}.filter-option:hover{border-color:var(--primary-cyan);background:rgba(76,201,240,0.1)}.filter-modal.exclude-mode .filter-option:hover{border-color:var(--neon-red);background:rgba(239,68,68,0.1)}.filter-option-name{color:var(--text-primary)}.filter-option-count{color:var(--text-muted);font-size:0.85rem}button.filter-option{width:100%;font:inherit;color:inherit;text-align:left}.filter-option[aria-checked]::before{content:'';flex:0 0 auto;width:1.1rem;height:1.1rem;margin-right:var(--spacing-sm);border:1px solid var(--card-border);border-radius:var(--radius-sm);line-height:1.1rem;text-align:center}.filter-option[aria-checked] .filter-option-name{flex:1}.filter-option[aria-checked="true"]{border-color:var(--primary-cyan);background:rgba(76,201,240,0.15)}.filter-option[aria-checked="true"]::before{content:'✓';color:var(--primary-cyan);border-color:var(--primary-cyan)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]{border-color:var(--neon-red);background:rgba(239,68,68,0.15)}.filter-modal.exclude-mode .filter-option[aria-checked="true"]::before{content:'✕';color:var(--neon-red);border-color:var(--neon-red)}.filter-modal-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-md);padding:var(--spacing-md) var(--spacing-lg);border-top:1px solid var(--card-border)}.filter-modal-footer[hidden]{display:none}.filter-modal-hint{color:var(--text-muted);font-size:0.85rem}.filter-modal .hint-exclude,.filter-modal.exclude-mode .hint-include{display:none}.filter-modal.exclude-mode .hint-exclude{display:inline}@media (max-width:768px){.games-grid-new{grid-template-columns:1fr;grid-auto-rows:auto;row-gap:var(--spacing-lg)}.game-card-wrapper{grid-row-end:auto}.game-card-new{min-height:180px}.card-boxart-section{width:100px}.meta-grid{grid-template-columns:1fr}.filter-bar-top{flex-wrap:wrap}.filter-buttons{width:100%;justify-content:flex-start}.filters-toggle-btn{display:flex}.filter-bar-new .filter-buttons{display:none;position:absolute;top:100%;left:0;right:0;margin-top:var(--spacing-xs);padding:var(--spacing-md);border:1px solid var(--card-border);border-radius:var(--radius-lg);background:var(--bg-medium);box-shadow:0 8px 24px rgba(0,0,0,0.5)}.filter-bar-new.filters-open .filter-buttons{display:flex}.games-count-bar{flex-wrap:wrap}.games-count-bar .active-filter-tags{order:3;flex-basis:100%;min-height:1.75rem}.filter-btn{flex:1;min-width:80px;justify-content:center}.bulk-controls{width:100%;justify-content:flex-start}.glitch-text::before,.glitch-text::after{display:none}}.game-page-header{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg);position:relative;z-index:1}.game-page-title{font-family:var(--font-display);font-size:2rem;color:var(--primary-cyan);text-shadow:0 0 30px rgba(76,201,240,0.3);margin:0}.header-buttons{display:flex;flex-wrap:wrap;gap:var(--spacing-sm)}.play-key-hint{align-self:center;font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator{display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-md);font-size:0.8rem;color:var(--text-secondary)}.last-scan-indicator .scan-icon{font-size:1rem}.last-scan-indicator .scan-time{color:var(--primary-cyan);font-weight:500}.report-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.report-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1.25rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.report-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.report-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.report-tab .tab-count{font-size:0.75rem;padding:0.15rem 0.5rem;background:rgba(0,0,0,0.3);border-radius:var(--radius-full);color:var(--text-secondary)}.report-tab.active .tab-count{background:var(--primary-cyan);color:var(--bg-darkest)}.report-tab .tab-icon{font-size:1.1rem}.report-panel{display:none}.report-panel.active{display:block;animation:fadeIn 0.3s ease}@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.results-table-container{background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-md);overflow:hidden}.settings-tabs{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:color-mix(in srgb,var(--card-bg) 90%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);border-radius:var(--radius-lg);position:sticky;top:var(--sticky-top);z-index:100}.settings-tab{flex:1 1 auto;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 1rem;background:transparent;border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-secondary);font-family:var(--font-primary);font-size:0.9rem;font-weight:500;cursor:pointer;transition:all var(--transition-fast);text-decoration:none;text-align:center;white-space:nowrap}.settings-tab:hover{background:rgba(76,201,240,0.05);border-color:rgba(76,201,240,0.3);color:var(--text-primary)}.settings-tab.active{background:rgba(76,201,240,0.15);border-color:var(--primary-cyan);color:var(--primary-cyan)}.settings-tab .tab-icon{font-size:1.1rem}.settings-tab-panel{display:none}.settings-tab-panel.active{display:block}.naming-tag-option{padding:0.25rem 0.6rem;border-radius:4px;font-size:0.8rem;font-weight:600;background:rgba(100,116,139,0.15);border:1px solid rgba(100,116,139,0.3);color:var(--text-secondary);cursor:pointer;transition:all 0.15s ease}.naming-tag-option:hover{border-color:var(--neon-green);color:var(--neon-green)}.parent-option{padding:var(--spacing-sm);border-bottom:1px solid var(--card-border);cursor:pointer}.parent-option:hover{background:rgba(76,201,240,0.1)}.tab-subnav{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-lg);padding:var(--spacing-sm);background:rgba(0,0,0,0.75);border-radius:var(--radius-md);position:sticky;top:calc(var(--sticky-top) + 54px);z-index:99;backdrop-filter:blur(10px)}.subnav-link{padding:var(--spacing-xs) var(--spacing-md);color:var(--text-secondary);text-decoration:none;font-size:0.85rem;border-radius:var(--radius-sm);transition:all var(--transition-fast)}.subnav-link:hover{background:rgba(76,201,240,0.1);color:var(--text-primary)}.subnav-link.active{background:rgba(76,201,240,0.15);color:var(--primary-cyan)}.achievement-overview{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--spacing-lg)}.achievement-stat{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-md)}.achievement-stat-icon{font-size:2rem}.achievement-stat-info{display:flex;flex-direction:column}.achievement-stat-label{font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px}.achievement-stat-value{font-size:1.3rem;font-weight:600;color:var(--neon-cyan)}.dashboard-page{--card-min:280px;--card-gap:var(--spacing-lg)}.systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min,280px),100%),1fr));gap:var(--card-gap,var(--spacing-lg))}.system-card{display:flex;flex-direction:column;padding:var(--spacing-lg);background:rgba(20,26,35,0.6);border:1px solid var(--card-border);border-radius:var(--radius-lg);text-decoration:none;color:inherit;transition:all var(--transition-fast)}.system-card:hover{border-color:var(--neon-cyan);transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 15px rgba(76,201,240,0.15)}.system-card-header{margin-bottom:var(--spacing-md);padding-bottom:var(--spacing-sm);border-bottom:1px solid var(--card-border)}.system-name{font-family:var(--font-heading);font-size:1.1rem;font-weight:600;color:var(--text-primary);margin:0}.system-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.system-stat{display:flex;flex-direction:column;align-items:center;text-align:center}.system-stat-value{font-size:1.2rem;font-weight:600;color:var(--neon-cyan)}.system-stat-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px}.system-card-action{margin-top:auto;padding-top:var(--spacing-md);text-align:center;color:var(--neon-cyan);font-size:0.9rem;font-weight:500;opacity:0.7;transition:opacity 0.2s}.system-card:hover .system-card-action{opacity:1}.filter-group{display:flex;align-items:center;gap:0.5rem}.filter-group label{color:var(--text-secondary);font-size:0.85rem;white-space:nowrap}.filter-group select,.filter-group input{padding:0.5rem 0.75rem;background:var(--bg-darker);border:1px solid var(--card-border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:0.85rem}.filter-group select:focus,.filter-group input:focus{outline:none;border-color:var(--primary-cyan)}.alert{padding:var(--spacing-md) var(--spacing-lg);border-radius:var(--radius-md);margin-bottom:var(--spacing-md)}.alert-warning{background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:var(--neon-orange)}.alert-danger{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:var(--neon-red)}.alert-success{background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);color:var(--neon-green)}.alert-info{background:rgba(76,201,240,0.1);border:1px solid rgba(76,201,240,0.3);color:var(--primary-cyan)}.alert a{color:inherit;text-decoration:underline}.field-tip{display:block;margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-muted)}.changelog-entry{margin-bottom:var(--spacing-xl);padding-bottom:var(--spacing-xl);border-bottom:1px solid var(--card-border)}.changelog-entry:last-child{border-bottom:none}.changelog-version{font-family:var(--font-heading);font-size:1.5rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.changelog-date{font-size:0.85rem;color:var(--text-muted);margin-bottom:var(--spacing-md)}.changelog-tags{display:flex;flex-wrap:wrap;gap:var(--spacing-xs);margin-bottom:var(--spacing-md)}.changelog-tag{padding:4px 12px;border-radius:var(--radius-full);font-size:0.75rem;font-weight:600;text-transform:uppercase}.tag-feature{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-minor{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-patch{background:rgba(245,158,11,0.15);border:1px solid var(--neon-orange);color:var(--neon-orange)}.tag-fix{background:rgba(239,68,68,0.15);border:1px solid var(--neon-red);color:var(--neon-red)}.tag-major{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.tag-ui{background:rgba(168,85,247,0.15);border:1px solid var(--neon-purple);color:var(--neon-purple)}.tag-improvement{background:rgba(76,201,240,0.15);border:1px solid var(--primary-cyan);color:var(--primary-cyan)}.tag-enhancement{background:rgba(34,197,94,0.15);border:1px solid var(--neon-green);color:var(--neon-green)}.tag-initial{background:rgba(247,37,133,0.15);border:1px solid var(--secondary-magenta);color:var(--secondary-magenta)}.changelog-list{list-style:none;padding:0;margin:0}.changelog-list li{padding:var(--spacing-xs) 0;padding-left:var(--spacing-lg);position:relative;color:var(--text-secondary)}.changelog-list li::before{content:'•';position:absolute;left:0;color:var(--primary-cyan)}.login-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:var(--spacing-lg)}.login-box{width:100%;max-width:400px;padding:var(--spacing-xl);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-lg);box-shadow:0 0 40px rgba(76,201,240,0.1)}.login-header{text-align:center;margin-bottom:var(--spacing-xl)}.login-logo{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin-bottom:var(--spacing-xs)}.login-subtitle{color:var(--text-secondary);font-size:0.9rem}.help-page{--help-text-max:calc(75ch + 2 * var(--spacing-lg));--help-nav-width:220px;--page-max:var(--help-text-max)}.help-page > .help-nav{flex-wrap:nowrap;overflow-x:auto}@media (max-width:600px){.help-page code{overflow-wrap:anywhere}}@media (min-width:1300px){.help-page{--page-max:calc(var(--help-nav-width) + var(--spacing-xl) + var(--help-text-max));display:grid;grid-template-columns:var(--help-nav-width) minmax(0,1fr);column-gap:var(--spacing-xl);align-items:start}.help-page > *{grid-column:2}.help-page > .page-header{grid-column:1 / -1}.help-page > .help-nav{grid-column:1;grid-row:2 / span 100;flex-direction:column;flex-wrap:nowrap;margin:0;max-height:100vh;overflow-x:hidden;overflow-y:auto}.help-page .help-nav-item{white-space:normal}}.help-section{margin-bottom:var(--spacing-xl)}.help-section h2{font-family:var(--font-heading);font-size:1.25rem;color:var(--primary-cyan);margin-bottom:var(--spacing-md)}.help-item{padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.help-item h3{color:var(--text-primary);font-size:1rem;margin-bottom:var(--spacing-xs)}.help-item p{color:var(--text-secondary);font-size:0.9rem;margin:0}.tool-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-lg)}.tool-description{color:var(--text-secondary);font-size:0.95rem;margin-bottom:var(--spacing-lg);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md);border-left:3px solid var(--primary-cyan)}.tool-results{margin-top:var(--spacing-lg)}.tool-result-item{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-md);background:var(--card-bg);border:1px solid var(--card-border);border-radius:var(--radius-md);margin-bottom:var(--spacing-sm)}.tool-result-item:hover{border-color:var(--primary-cyan)}.tool-result-icon{font-size:1.5rem}.tool-result-info{flex:1}.tool-result-name{color:var(--text-primary);font-weight:500}.tool-result-detail{color:var(--text-muted);font-size:0.85rem}@media (max-width:768px){.settings-tabs,.report-tabs{gap:var(--spacing-xs);padding:var(--spacing-xs)}.settings-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.settings-tab .tab-text{display:none}.settings-tab .tab-icon{font-size:1.25rem}.report-tab{padding:0.5rem 0.75rem;font-size:0.8rem}.report-tab .tab-label{display:none}.report-tab .tab-icon{font-size:1.25rem}}.game-fanart-bg{position:fixed;top:0;left:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0;z-index:-1;transition:opacity 0.5s ease}.game-fanart-bg.active{opacity:0.15}.game-fanart-bg::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(to bottom,transparent 0%,var(--bg-darkest) 100%)}.game-compact-layout{max-width:100%}.game-hero{display:flex;gap:var(--spacing-xl);margin-bottom:var(--spacing-xl);background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-lg)}.hero-boxart{position:relative;flex-shrink:0;width:200px}.hero-boxart-frame{position:relative}.upscale-game-note{margin:var(--spacing-sm) 0 0;font-size:0.75rem;color:var(--text-secondary)}.game-boxart-image{width:100%;height:auto;border-radius:var(--radius-md);box-shadow:0 8px 32px rgba(0,0,0,0.4)}.hero-age-ratings{display:flex;flex-wrap:wrap;gap:5px;margin-top:auto;padding-top:var(--spacing-sm)}.hero-info{flex:1;min-width:0;display:flex;flex-direction:column}.quick-tags{display:flex;flex-wrap:wrap;align-items:center;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.tag{padding:4px 10px;border-radius:4px;font-size:0.8rem;font-weight:500;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1)}.quick-tags .tag,.quick-tags .game-badge{height:32px;box-sizing:border-box;line-height:1}.quick-tags .game-badge.age-rating-badge .age-rating-img{height:32px}.tag-system{color:var(--primary-cyan);border-color:var(--primary-cyan)}.hero-description{flex:1;font-size:0.9rem;line-height:1.6;color:var(--text-secondary);margin-bottom:var(--spacing-md);overflow-y:auto;max-height:120px}.key-details{display:flex;flex-wrap:wrap;gap:var(--spacing-lg);padding-top:var(--spacing-md);border-top:1px solid var(--card-border)}.kd-item{display:flex;flex-direction:column;gap:2px}.kd-label{font-size:0.75rem;color:var(--text-muted);text-transform:uppercase}.kd-value{font-size:0.9rem;color:var(--text-primary)}.trophy-summary{text-align:center}.trophy-summary-header{display:flex;justify-content:center;align-items:center;gap:var(--spacing-lg);margin-bottom:var(--spacing-md)}.trophy-summary-title{font-size:1.1rem;font-weight:600}.trophy-summary-total{font-size:1.3rem;font-weight:bold;color:var(--primary-cyan)}.trophy-summary-progress{display:flex;align-items:center;gap:var(--spacing-md);max-width:500px;margin:0 auto var(--spacing-lg)}.trophy-progress-bar.large{flex:1;height:12px;background:rgba(255,255,255,0.1);border-radius:6px;overflow:hidden}.trophy-progress-fill{height:100%;background:linear-gradient(90deg,var(--primary-cyan),#00ddff);border-radius:6px}.trophy-progress-percent{font-size:1.2rem;font-weight:bold;color:var(--primary-cyan);min-width:60px}.trophy-summary-counts{display:flex;justify-content:center;gap:var(--spacing-xl);flex-wrap:wrap}.trophy-count{display:flex;align-items:center;gap:var(--spacing-sm);font-size:1.1rem;font-weight:600}.trophy-count img{width:28px;height:28px}.trophy-count.platinum{color:var(--trophy-display-platinum)}.trophy-count.gold{color:var(--trophy-display-gold)}.trophy-count.silver{color:var(--trophy-display-silver)}.trophy-count.bronze{color:var(--trophy-display-bronze)}.bottom-row{display:flex;gap:var(--spacing-lg);margin-bottom:var(--spacing-xl)}.bottom-section{flex:1;background:color-mix(in srgb,var(--bg-medium) 85%,transparent);backdrop-filter:blur(10px);border:1px solid var(--card-border);border-radius:var(--radius-lg);padding:var(--spacing-md)}.section-title-sm{font-size:0.9rem;color:var(--text-secondary);margin-bottom:var(--spacing-md)}.hltb-section{display:flex;flex-direction:column;gap:var(--spacing-sm)}.more-details{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--spacing-sm)}.md-item{display:flex;flex-direction:column}.md-item.md-inline{flex-direction:row;align-items:baseline;gap:var(--spacing-sm);grid-column:span 2}.md-label{font-size:0.7rem;color:var(--text-muted);text-transform:uppercase}.md-value{font-size:0.85rem;color:var(--text-primary)}.trophy-columns{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:650px;gap:var(--spacing-xl)}.trophy-columns > .card{height:100%;display:flex;flex-direction:column;overflow:hidden}.trophy-columns > .card + .card{margin-top:0}.trophy-columns > .card .card-header{flex-shrink:0}.trophy-column .card-body{flex:1;overflow-y:auto;min-height:0}.trophy-list{display:flex;flex-direction:column;gap:var(--spacing-md)}.trophy-item{display:flex;gap:var(--spacing-md);padding:var(--spacing-md);background:rgba(0,0,0,0.2);border-radius:var(--radius-md)}.trophy-item.locked{opacity:0.5}.trophy-item.locked .trophy-item-icon img:first-child{filter:grayscale(1) brightness(0.5)}.trophy-item-icon{position:relative;flex-shrink:0}.trophy-item-icon > img:first-child{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);object-fit:cover}.trophy-item-icon > img.trophy-type-badge{position:absolute;bottom:-4px;right:-4px;width:24px;height:24px}.trophy-item-icon .badge-placeholder{width:60px;height:60px;border-radius:var(--radius-sm);background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:1.5rem}.trophy-item-info{flex:1;min-width:0}.trophy-item-name{font-weight:600;font-size:0.95rem;margin-bottom:4px}.trophy-item-desc{font-size:0.85rem;color:var(--text-secondary);margin-bottom:8px}.trophy-item-meta{display:flex;gap:var(--spacing-md);align-items:center;flex-wrap:wrap;font-size:0.8rem}.trophy-type-label{padding:2px 8px;border-radius:4px;font-weight:600;text-transform:uppercase;font-size:0.7rem}.trophy-type-label.p{background:var(--trophy-display-platinum);color:var(--trophy-display-text)}.trophy-type-label.g{background:var(--trophy-display-gold);color:var(--trophy-display-text)}.trophy-type-label.s{background:var(--trophy-display-silver);color:var(--trophy-display-text)}.trophy-type-label.b{background:var(--trophy-display-bronze);color:var(--trophy-display-text)}.trophy-timestamp{color:var(--primary-cyan)}.trophy-empty{text-align:center;padding:var(--spacing-xl);color:var(--text-muted);font-style:italic}.hltb-search-input{width:100%;padding:10px 14px;background:var(--bg-dark);border:1px solid var(--card-border);border-radius:var(--radius-md);color:var(--text-primary);font-family:var(--font-primary);font-size:0.9rem;margin-bottom:var(--spacing-sm)}.hltb-search-input:focus{outline:none;border-color:var(--primary-cyan);box-shadow:0 0 0 2px rgba(76,201,240,0.15)}.btn-hltb{width:100%;display:flex;align-items:center;justify-content:center;gap:var(--spacing-sm);padding:10px 16px;background:rgba(76,201,240,0.1);border:1px solid var(--primary-cyan);border-radius:var(--radius-md);color:var(--primary-cyan);font-family:var(--font-primary);font-size:0.85rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast)}.btn-hltb:hover{background:rgba(76,201,240,0.2)}.btn-hltb:disabled{opacity:0.5;cursor:not-allowed}@media (max-width:900px){.trophy-columns{grid-template-columns:1fr;grid-template-rows:auto}.trophy-columns > .card{height:auto;max-height:500px}.game-hero{flex-direction:column;align-items:center}.hero-boxart{width:150px}.bottom-row{flex-direction:column}}.alphabet-nav{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-lg);border-radius:var(--radius-lg);margin-bottom:var(--spacing-lg);background:color-mix(in srgb,var(--bg-medium) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--card-border);position:sticky;top:var(--sticky-top);z-index:99}.alphabet-label{color:var(--text-muted);font-size:0.8rem;white-space:nowrap}.alphabet-letters{display:flex;flex-wrap:wrap;gap:3px}.alphabet-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.2);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:0.75rem;font-weight:600;cursor:pointer;transition:all var(--transition-fast);padding:0}.alphabet-btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.active{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.alphabet-btn.disabled{opacity:0.25;cursor:not-allowed}.alphabet-btn.disabled:hover{background:rgba(76,201,240,0.08);border-color:rgba(76,201,240,0.2);color:var(--text-secondary)}@media (max-width:768px){.alphabet-nav{padding:var(--spacing-xs) var(--spacing-md)}.alphabet-btn{width:24px;height:24px;font-size:0.65rem}.alphabet-label{display:none}}.warning-banner{background:linear-gradient(135deg,rgba(255,193,7,0.1),rgba(247,37,133,0.1));border:1px solid rgba(255,193,7,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.warning-banner .warning-icon{font-size:1.5rem}.warning-banner .warning-text h4{color:var(--neon-orange);margin-bottom:0.25rem}.warning-banner .warning-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.info-banner{background:linear-gradient(135deg,rgba(76,201,240,0.1),rgba(114,9,183,0.1));border:1px solid rgba(76,201,240,0.3);border-radius:var(--radius-md);padding:1rem 1.25rem;margin-bottom:1.5rem;display:flex;align-items:flex-start;gap:1rem}.info-banner .info-icon{font-size:1.5rem}.info-banner .info-text h4{color:var(--text-primary);margin-bottom:0.25rem}.info-banner .info-text p{color:var(--text-secondary);font-size:0.9rem;margin:0}.file-list{background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md);max-height:400px;overflow-y:auto}.file-list-header{display:grid;gap:1rem;padding:0.75rem 1rem;background:var(--bg-medium);border-bottom:1px solid var(--border-color);font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);position:sticky;top:0;z-index:10}.file-list-item{display:grid;gap:1rem;padding:0.75rem 1rem;border-bottom:1px solid var(--border-color);font-size:0.9rem;align-items:center}.file-list-item:hover{background:rgba(76,201,240,0.05)}.file-list-item:last-child{border-bottom:none}.file-list-item input[type="checkbox"]{width:18px;height:18px;accent-color:var(--neon-cyan);cursor:pointer}.file-list-item .filename{color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-list-item .filesize{color:var(--text-secondary)}.action-bar{display:flex;gap:1rem;margin-top:1rem;padding:1rem;background:var(--bg-darker);border:1px solid var(--border-color);border-radius:var(--radius-md)}.action-btn{padding:0.4rem 0.75rem;background:rgba(76,201,240,0.15);border:1px solid var(--neon-cyan);border-radius:var(--radius-sm);color:var(--neon-cyan);cursor:pointer;font-size:0.8rem;transition:all var(--transition-fast)}.action-btn:hover{background:rgba(76,201,240,0.25);box-shadow:0 0 10px rgba(76,201,240,0.3)}.theme-selector{display:flex;gap:var(--spacing-md);flex-wrap:wrap}.theme-option{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);padding:var(--spacing-md);background:var(--card-bg);border:2px solid var(--card-border);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--transition-fast),box-shadow var(--transition-fast);min-width:110px}.theme-option:hover{border-color:var(--card-border-hover)}.theme-option.active{border-color:var(--primary-cyan);box-shadow:0 0 12px var(--primary-cyan-glow)}.theme-preview{display:flex;gap:4px;padding:4px;background:rgba(0,0,0,0.3);border-radius:var(--radius-sm)}.theme-swatch{width:24px;height:24px;border-radius:3px}.theme-name{font-family:var(--font-primary);font-size:0.85rem;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px}.theme-option.active .theme-name{color:var(--primary-cyan)}.section-title{font-family:var(--font-display);font-size:1rem;color:var(--text-primary);margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border-color);display:flex;align-items:center;gap:0.5rem}.emu-folder-list{list-style:none;margin:0 0 var(--spacing-md);padding:0}.emu-folder-list li{display:flex;align-items:center;justify-content:space-between;gap:var(--spacing-sm);padding:var(--spacing-sm) 0;border-bottom:1px solid var(--border-color)}.emu-folder-list code{overflow-wrap:anywhere}.emu-folder-add{display:flex;flex-wrap:wrap;gap:var(--spacing-sm);margin-bottom:var(--spacing-md)}.emu-folder-add .form-input{flex:1 1 16rem}.museum-progress{padding:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.museum-progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-sm)}.museum-progress-title{font-family:var(--font-heading);font-size:0.95rem;color:var(--primary-cyan)}.museum-progress-stats{font-size:0.85rem;color:var(--text-secondary);margin-top:var(--spacing-sm)}.museum-progress-stats .text-success{color:var(--status-success)}.museum-progress-stats .text-warning{color:var(--status-warning)}.museum-progress-stats .text-error{color:var(--status-error)}.museum-timeline{display:flex;flex-direction:column;gap:var(--spacing-xl)}.museum-era{position:relative}.museum-era-header{display:flex;align-items:center;gap:var(--spacing-md);margin-bottom:var(--spacing-lg)}.museum-era-line{width:4px;height:48px;background:linear-gradient(180deg,var(--primary-cyan),transparent);border-radius:2px;flex-shrink:0}.museum-era-info{display:flex;flex-direction:column;gap:2px}.museum-era-title{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 20px rgba(76,201,240,0.3)}.museum-era-date{font-family:var(--font-mono);font-size:0.8rem;color:var(--text-muted)}.museum-era-count{font-size:0.8rem;color:var(--text-secondary)}.museum-page{--card-min:260px;--card-gap:var(--spacing-md)}.museum-systems-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-system-card{display:flex;flex-direction:column;position:relative;border-radius:var(--radius-lg);overflow:hidden;text-decoration:none;color:inherit;transition:transform var(--transition-normal),box-shadow var(--transition-normal)}.museum-system-card:hover{transform:translateY(-3px);box-shadow:0 0 20px rgba(76,201,240,0.15);border-color:var(--card-border-hover)}.museum-card-image{height:140px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);padding:var(--spacing-md)}.museum-card-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-card-placeholder{font-size:3rem;opacity:0.3}.museum-card-body{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs);flex:1}.museum-card-title{font-family:var(--font-heading);font-size:0.9rem;color:var(--text-primary);margin:0;line-height:1.3}.museum-card-summary{font-size:0.78rem;color:var(--text-secondary);margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:1.4}.museum-card-meta{display:flex;flex-wrap:wrap;gap:4px;margin-top:auto}.museum-meta-chip{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--text-secondary);background:rgba(76,201,240,0.08);border:1px solid rgba(76,201,240,0.15);border-radius:var(--radius-full);white-space:nowrap}.museum-content-indicator{position:absolute;top:var(--spacing-sm);right:var(--spacing-sm);width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-full);background:rgba(34,197,94,0.2);color:var(--neon-green);font-size:0.7rem}.museum-system-header{margin-bottom:var(--spacing-xl)}.museum-hero-layout{display:flex;gap:var(--spacing-xl);align-items:flex-start}.museum-hero-image{flex-shrink:0;width:240px;height:200px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);border-radius:var(--radius-lg);border:1px solid var(--card-border);padding:var(--spacing-md)}.museum-hero-image img{max-height:100%;max-width:100%;object-fit:contain}.museum-hero-placeholder{font-size:4rem;opacity:0.3}.museum-hero-info{flex:1;display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-hero-title{font-family:var(--font-heading);font-size:2rem;color:var(--primary-cyan);margin:0;text-shadow:0 0 30px rgba(76,201,240,0.3)}.museum-hero-summary{font-size:1rem;color:var(--text-secondary);line-height:1.5;margin:0}.museum-hero-meta{display:flex;flex-wrap:wrap;gap:var(--spacing-xs)}.museum-hero-actions{display:flex;gap:var(--spacing-sm);margin-top:var(--spacing-sm);flex-wrap:wrap}.museum-nav{display:flex;gap:0;border-bottom:2px solid var(--border-color);margin-bottom:var(--spacing-lg);position:sticky;background:var(--bg-darkest);z-index:10;padding-top:var(--spacing-xs)}.museum-nav-item{padding:var(--spacing-sm) var(--spacing-lg);font-family:var(--font-heading);font-size:0.85rem;color:var(--text-secondary);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color var(--transition-fast),border-color var(--transition-fast);white-space:nowrap}.museum-nav-item:hover{color:var(--text-primary)}.museum-nav-item.active{color:var(--primary-cyan);border-bottom-color:var(--primary-cyan)}.museum-section{margin-bottom:var(--spacing-xl)}.museum-section-actions{display:flex;align-items:center;flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--spacing-md);flex-wrap:wrap;gap:var(--spacing-sm)}.museum-section-title{font-family:var(--font-heading);font-size:1.2rem;color:var(--primary-cyan);margin:0}.museum-history-content{max-width:calc(75ch + 2 * var(--spacing-lg));padding:var(--spacing-lg);line-height:1.75;color:var(--text-primary);font-size:0.95rem}.museum-history-content p{margin:0 0 var(--spacing-md) 0}.museum-history-content p:last-child{margin-bottom:0}.museum-placeholder{padding:var(--spacing-xl);text-align:center}.museum-placeholder .text-muted{color:var(--text-muted)}.museum-generated-badge{font-size:0.75rem;font-family:var(--font-mono);color:var(--text-muted);padding:4px 10px;background:rgba(76,201,240,0.06);border:1px solid rgba(76,201,240,0.12);border-radius:var(--radius-full)}.museum-ai-badge{display:inline-block;padding:2px 8px;font-size:0.7rem;font-family:var(--font-mono);color:var(--neon-purple);background:rgba(168,85,247,0.1);border:1px solid rgba(168,85,247,0.2);border-radius:var(--radius-full)}.museum-system-page{--card-min:280px;--card-gap:var(--spacing-md)}.museum-specs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--card-min),100%),1fr));gap:var(--card-gap)}.museum-spec-item{padding:var(--spacing-md);display:flex;flex-direction:column;gap:4px}.museum-spec-label{font-family:var(--font-heading);font-size:0.75rem;color:var(--primary-cyan);text-transform:uppercase;letter-spacing:0.5px}.museum-spec-value{font-size:0.9rem;color:var(--text-primary);font-family:var(--font-mono);word-break:break-word}.museum-top-games-list{display:flex;flex-direction:column;gap:var(--spacing-sm)}.museum-top-game{display:flex;align-items:center;gap:var(--spacing-md);padding:var(--spacing-sm) var(--spacing-md);transition:border-color var(--transition-fast)}.museum-top-game:hover{border-color:var(--card-border-hover)}.museum-rank{font-family:var(--font-heading);font-size:1.3rem;color:var(--primary-cyan);min-width:40px;text-align:center;flex-shrink:0}.museum-top-game-art{width:48px;height:64px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);overflow:hidden;background:rgba(0,0,0,0.2)}.museum-top-game-art img{max-height:100%;max-width:100%;object-fit:cover}.museum-top-game-placeholder{font-size:1.5rem;opacity:0.3}.museum-top-game-info{flex:1;display:flex;flex-direction:column;gap:2px}.museum-top-game-title{font-family:var(--font-primary);font-weight:600;font-size:0.95rem;color:var(--text-primary);text-decoration:none}a.museum-top-game-title:hover{color:var(--primary-cyan)}.museum-top-game-meta{display:flex;gap:var(--spacing-xs);align-items:center}.museum-controllers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--spacing-md)}.museum-controller-card{display:flex;flex-direction:column;overflow:hidden;border-radius:var(--radius-lg)}.museum-controller-image{height:180px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.15);padding:var(--spacing-md);position:relative}.museum-controller-image img{max-height:100%;max-width:100%;object-fit:contain;cursor:zoom-in}.museum-controller-image-actions{display:flex;gap:4px}.museum-controller-image-actions .btn{padding:2px 6px;font-size:0.65rem;line-height:1.2;letter-spacing:0.5px;backdrop-filter:blur(8px);background:rgba(0,0,0,0.6);border-color:rgba(76,201,240,0.4);color:var(--primary-cyan);box-shadow:none;text-shadow:none}.museum-controller-image-actions .btn:hover{background:rgba(76,201,240,0.2);border-color:var(--primary-cyan)}.museum-controller-image > .museum-controller-image-actions{position:absolute;bottom:var(--spacing-xs);right:var(--spacing-xs);opacity:0;transition:opacity var(--transition-fast)}.museum-controller-image:hover > .museum-controller-image-actions{opacity:1}.museum-controller-upload-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--spacing-sm);background:rgba(10,14,23,0.85);z-index:5;border-radius:inherit}.museum-controller-upload-text{font-family:var(--font-primary);font-size:0.75rem;color:var(--text-secondary);letter-spacing:0.5px}.museum-lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.95);z-index:10002;align-items:center;justify-content:center;flex-direction:column}.museum-lightbox.active{display:flex}.museum-lightbox-image{max-width:90%;max-height:80vh;object-fit:contain;border-radius:var(--radius-md);position:relative;z-index:1}.museum-lightbox::before{content:"";position:absolute;top:45%;left:50%;transform:translate(-50%,-50%);width:90vmin;height:90vmin;background:radial-gradient(circle at center,rgba(76,201,240,0.19) 0%,rgba(76,201,240,0.09) 25%,rgba(76,201,240,0.03) 45%,transparent 40%);border-radius:50%;pointer-events:none;z-index:0}.museum-lightbox-close{position:absolute;top:20px;right:35px;font-size:2.5rem;color:var(--text-primary);cursor:pointer;z-index:10003;transition:color var(--transition-fast);background:none;border:none;line-height:1}.museum-lightbox-close:hover{color:var(--primary-cyan)}.museum-lightbox-title{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);color:var(--text-secondary);font-family:var(--font-heading);font-size:0.9rem;white-space:nowrap}.museum-lightbox-hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);color:var(--text-muted);font-size:0.75rem;white-space:nowrap}.museum-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:2px solid var(--card-border);color:var(--text-primary);font-size:3rem;padding:10px 20px;cursor:pointer;border-radius:var(--radius-md);transition:all var(--transition-fast);z-index:10003}.museum-lightbox-nav:hover{background:rgba(76,201,240,0.3);border-color:var(--primary-cyan);color:var(--primary-cyan)}.museum-lightbox-nav.prev{left:20px}.museum-lightbox-nav.next{right:20px}.museum-controller-placeholder{display:flex;flex-direction:column;align-items:center;gap:var(--spacing-sm);color:var(--text-muted);font-size:2.5rem}.museum-controller-info{padding:var(--spacing-md);display:flex;flex-direction:column;gap:var(--spacing-xs)}.museum-controller-name{font-family:var(--font-heading);font-size:0.85rem;color:var(--text-primary);margin:0}.museum-controller-meta{display:flex;flex-wrap:wrap;gap:4px}.museum-controller-desc{font-size:0.8rem;color:var(--text-secondary);margin:var(--spacing-xs) 0 0 0;line-height:1.4}.museum-controller-buttons{margin-top:var(--spacing-xs);font-size:0.8rem;color:var(--text-secondary)}.museum-controller-buttons summary{cursor:pointer;color:var(--primary-cyan);font-family:var(--font-heading);font-size:0.75rem}.museum-controller-buttons p{margin:var(--spacing-xs) 0 0 0;font-family:var(--font-mono);font-size:0.75rem;line-height:1.5}@media (max-width:768px){.museum-hero-layout{flex-direction:column;align-items:center;text-align:center}.museum-hero-image{width:180px;height:150px}.museum-hero-meta,.museum-hero-actions{justify-content:center}.museum-systems-grid{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}.museum-specs-grid{grid-template-columns:1fr}.museum-controllers-grid{grid-template-columns:1fr}.museum-nav{overflow-x:auto;-webkit-overflow-scrolling:touch}}body::before{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:linear-gradient( to top right,rgba(76,201,240,0.08) 0%,rgba(114,9,183,0.06) 25%,rgba(247,37,133,0.04) 50%,rgba(20,26,35,0.02) 75%,transparent 100% );pointer-events:none;z-index:-2}body::after{content:'';position:fixed;top:0;left:0;width:100%;height:100%;background:repeating-linear-gradient( 0deg,transparent,transparent 2px,rgba(0,0,0,0.03) 2px,rgba(0,0,0,0.03) 4px );pointer-events:none;z-index:9999}@media (prefers-reduced-motion:reduce){body::after{display:none}}@keyframes glow-pulse{0%,100%{text-shadow:0 0 10px var(--primary-cyan-glow),0 0 20px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow)}50%{text-shadow:0 0 15px var(--primary-cyan-glow),0 0 30px var(--primary-cyan-glow),0 0 45px var(--primary-cyan-glow)}}@keyframes pulse-status{0%,100%{opacity:1}50%{opacity:0.5}}@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes highlight-pulse{0%,100%{box-shadow:0 0 0 0 rgba(76,201,240,0)}50%{box-shadow:0 0 20px 5px rgba(76,201,240,0.4)}}.highlight-jump{animation:highlight-pulse 0.75s ease-in-out 2}@keyframes modalSlideIn{from{opacity:0;transform:scale(0.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}@media (prefers-reduced-motion:reduce){.highlight-jump{animation:none}}.mt-0{margin-top:0}.mt-xs{margin-top:var(--spacing-xs)}.mt-sm{margin-top:var(--spacing-sm)}.mt-md{margin-top:var(--spacing-md)}.mt-lg{margin-top:var(--spacing-lg)}.mt-xl{margin-top:var(--spacing-xl)}.mt-2xl{margin-top:var(--spacing-2xl)}.mb-0{margin-bottom:0}.mb-xs{margin-bottom:var(--spacing-xs)}.mb-sm{margin-bottom:var(--spacing-sm)}.mb-md{margin-bottom:var(--spacing-md)}.mb-lg{margin-bottom:var(--spacing-lg)}.mb-xl{margin-bottom:var(--spacing-xl)}.mb-2xl{margin-bottom:var(--spacing-2xl)}.ml-0{margin-left:0}.ml-xs{margin-left:var(--spacing-xs)}.ml-sm{margin-left:var(--spacing-sm)}.ml-md{margin-left:var(--spacing-md)}.ml-lg{margin-left:var(--spacing-lg)}.ml-xl{margin-left:var(--spacing-xl)}.mr-0{margin-right:0}.mr-xs{margin-right:var(--spacing-xs)}.mr-sm{margin-right:var(--spacing-sm)}.mr-md{margin-right:var(--spacing-md)}.mr-lg{margin-right:var(--spacing-lg)}.mr-xl{margin-right:var(--spacing-xl)}.mx-0{margin-left:0;margin-right:0}.mx-sm{margin-left:var(--spacing-sm);margin-right:var(--spacing-sm)}.mx-md{margin-left:var(--spacing-md);margin-right:var(--spacing-md)}.mx-lg{margin-left:var(--spacing-lg);margin-right:var(--spacing-lg)}.mx-auto{margin-left:auto;margin-right:auto}.my-0{margin-top:0;margin-bottom:0}.my-sm{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm)}.my-md{margin-top:var(--spacing-md);margin-bottom:var(--spacing-md)}.my-lg{margin-top:var(--spacing-lg);margin-bottom:var(--spacing-lg)}.pt-0{padding-top:0}.pt-xs{padding-top:var(--spacing-xs)}.pt-sm{padding-top:var(--spacing-sm)}.pt-md{padding-top:var(--spacing-md)}.pt-lg{padding-top:var(--spacing-lg)}.pt-xl{padding-top:var(--spacing-xl)}.pb-0{padding-bottom:0}.pb-xs{padding-bottom:var(--spacing-xs)}.pb-sm{padding-bottom:var(--spacing-sm)}.pb-md{padding-bottom:var(--spacing-md)}.pb-lg{padding-bottom:var(--spacing-lg)}.pb-xl{padding-bottom:var(--spacing-xl)}.pl-0{padding-left:0}.pl-sm{padding-left:var(--spacing-sm)}.pl-md{padding-left:var(--spacing-md)}.pl-lg{padding-left:var(--spacing-lg)}.pr-0{padding-right:0}.pr-sm{padding-right:var(--spacing-sm)}.pr-md{padding-right:var(--spacing-md)}.pr-lg{padding-right:var(--spacing-lg)}.px-0{padding-left:0;padding-right:0}.px-sm{padding-left:var(--spacing-sm);padding-right:var(--spacing-sm)}.px-md{padding-left:var(--spacing-md);padding-right:var(--spacing-md)}.px-lg{padding-left:var(--spacing-lg);padding-right:var(--spacing-lg)}.py-0{padding-top:0;padding-bottom:0}.py-sm{padding-top:var(--spacing-sm);padding-bottom:var(--spacing-sm)}.py-md{padding-top:var(--spacing-md);padding-bottom:var(--spacing-md)}.py-lg{padding-top:var(--spacing-lg);padding-bottom:var(--spacing-lg)}.d-none{display:none}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:flex}.d-inline-flex{display:inline-flex}.d-grid{display:grid}.flex-1{flex:1}.flex-auto{flex:auto}.flex-none{flex:none}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.align-start{align-items:flex-start}.align-center{align-items:center}.align-end{align-items:flex-end}.align-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-0{gap:0}.gap-xs{gap:var(--spacing-xs)}.gap-sm{gap:var(--spacing-sm)}.gap-md{gap:var(--spacing-md)}.gap-lg{gap:var(--spacing-lg)}.gap-xl{gap:var(--spacing-xl)}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-muted{color:var(--text-muted)}.text-cyan{color:var(--primary-cyan)}.text-magenta{color:var(--secondary-magenta)}.text-success{color:var(--neon-green)}.text-warning{color:var(--neon-orange)}.text-danger{color:var(--neon-red)}.text-purple{color:var(--neon-purple)}.font-heading{font-family:var(--font-heading)}.font-mono{font-family:var(--font-mono)}.font-normal{font-weight:400}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.text-sm{font-size:0.85rem}.text-base{font-size:1rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.leading-tight{line-height:1.25}.leading-normal{line-height:1.6}.leading-loose{line-height:2}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.break-word{word-wrap:break-word;overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}.w-full{width:100%}.w-auto{width:auto}.h-full{height:100%}.h-auto{height:auto}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.overflow-auto{overflow:auto}.overflow-scroll{overflow:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.border{border:1px solid var(--card-border)}.border-0{border:0}.border-t{border-top:1px solid var(--card-border)}.border-b{border-bottom:1px solid var(--card-border)}.border-l{border-left:1px solid var(--card-border)}.border-r{border-right:1px solid var(--card-border)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-md{border-radius:var(--radius-md)}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-full{border-radius:var(--radius-full)}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.opacity-0{opacity:0}.opacity-25{opacity:0.25}.opacity-50{opacity:0.5}.opacity-75{opacity:0.75}.opacity-100{opacity:1}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.select-none{user-select:none}.select-text{user-select:text}.select-all{user-select:all}.mobile-header,.mobile-bottom-bar,.sidebar-scrim{display:none}@media (max-width:992px){:root{--sticky-top:var(--mobile-header-height)}.mobile-header{position:fixed;top:0;left:0;right:0;z-index:1001;height:var(--mobile-header-height);display:flex;align-items:center;gap:var(--spacing-sm);padding:0 var(--spacing-sm);background:var(--bg-medium);border-bottom:1px solid var(--card-border)}.mobile-header-logo{flex:1}.mobile-header-logo .logo-text{font-size:1.25rem}.mobile-menu-toggle,.mobile-search-toggle{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:none;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer}.mobile-menu-toggle{flex-direction:column;gap:4px}.mobile-menu-toggle span{width:24px;height:3px;background:var(--text-primary);border-radius:2px}.mobile-search-toggle{font-size:1.25rem}.mobile-menu-toggle:focus-visible,.mobile-search-toggle:focus-visible{outline:none;box-shadow:0 0 0 2px var(--primary-cyan)}.sidebar{top:var(--mobile-header-height);height:calc(100dvh - var(--mobile-header-height));width:min(var(--sidebar-width),85vw);background:linear-gradient(180deg,var(--bg-medium) 0%,var(--bg-dark) 100%);backdrop-filter:none;-webkit-backdrop-filter:none;visibility:hidden;transition:transform var(--transition-normal),visibility 0s var(--transition-normal)}.sidebar.open{visibility:visible;transition:transform var(--transition-normal)}.sidebar-header > .logo{display:none}.sidebar-header > .logo + *{margin-top:0}.global-search-list{max-height:50dvh}.sidebar-nav{overflow-y:auto}.sidebar-scrim{display:block;position:fixed;inset:0;z-index:999;background:rgba(0,0,0,0.6);opacity:0;pointer-events:none;transition:opacity var(--transition-normal)}body.mobile-menu-open .sidebar-scrim{opacity:1;pointer-events:auto}body.mobile-menu-open{overflow:hidden}.mobile-bottom-bar{position:fixed;left:0;right:0;bottom:0;z-index:998;display:flex;height:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px));padding-bottom:env(safe-area-inset-bottom,0px);background:var(--bg-medium);border-top:1px solid var(--card-border)}.mobile-bottom-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-width:0;color:var(--text-secondary);text-decoration:none;font-size:0.75rem}.mobile-bottom-icon{font-size:1.25rem}.mobile-bottom-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mobile-bottom-item.active{color:var(--primary-cyan);box-shadow:inset 0 2px 0 var(--primary-cyan)}.mobile-bottom-item:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--primary-cyan)}.main-content{padding-top:calc(var(--mobile-header-height) + var(--spacing-md));padding-bottom:calc(var(--mobile-bar-height) + env(safe-area-inset-bottom, 0px) + var(--spacing-md))}body:has(.mobile-bottom-bar) .back-to-top-btn{bottom:calc(var(--mobile-bar-height) + 16px)}body:has(.mobile-bottom-bar) .unified-toast-container{bottom:calc(var(--mobile-bar-height) + 12px)}body:has(.mobile-bottom-bar) .launch-indicator{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm))}body:has(.mobile-bottom-bar) .launch-indicator-popover{bottom:calc(var(--mobile-bar-height) + var(--spacing-sm) + 2.5rem)}}@media (max-width:1600px){.content-wrapper{width:80%}}@media (max-width:1200px){.content-wrapper{width:95%;min-width:auto}.game-hero{grid-template-columns:250px 1fr}.game-details-grid{grid-template-columns:1fr}}@media (max-width:992px){.sidebar{transform:translateX(-100%)}.sidebar.open{transform:translateX(0)}.main-content{margin-left:0}.game-hero{grid-template-columns:1fr}.game-boxart-container{max-width:300px;margin:0 auto}.unified-toast-container{left:20px;right:20px;width:auto;max-width:calc(100% - 40px)}.system-controller-grid{grid-template-columns:1fr !important}}@media (max-width:900px){.score-lists-grid{grid-template-columns:1fr}.psn-trophy-lists-row{grid-template-columns:1fr}.psn-info-panels-row{grid-template-columns:1fr}.psn-game-info-card{flex-direction:column}.psn-game-boxart{width:140px}.psn-meta-grid{grid-template-columns:1fr 1fr}.psn-stats-bar{grid-template-columns:repeat(2,1fr)}}@media (max-width:768px){.page-header{flex-direction:column;align-items:flex-start;gap:var(--spacing-md)}.page-title{font-size:1.8rem}.stats-grid{grid-template-columns:1fr}.systems-grid{grid-template-columns:repeat(2,1fr)}.filter-bar{flex-direction:column}.search-box{min-width:100%}.game-meta-badges{flex-direction:column}.game-ratings{flex-direction:column}.game-hero{grid-template-columns:1fr}.hero-boxart{width:200px;margin:0 auto}.tool-tab{padding:0.75rem 0.5rem;font-size:0.8rem}.tool-tab .tab-label{display:none}.tool-tab .tab-icon{font-size:1.25rem}.edit-grid{grid-template-columns:1fr}.psn-games-grid{grid-template-columns:1fr}.psn-search-sort-row{flex-direction:column}.psn-sort-controls{width:100%}.psn-sort-select{flex:1}.psn-profile-bar{flex-direction:column;text-align:center}}@media (max-width:600px){.gdm-header{flex-direction:column;align-items:center;text-align:center}.gdm-boxart-container{width:140px}.gdm-details-grid{grid-template-columns:1fr}.gdm-meta{justify-content:center}.gdm-ratings{justify-content:center}.gdm-actions{flex-direction:column}.gdm-actions .btn{width:100%}.gem-hltb-times{grid-template-columns:1fr}.gem-footer{flex-direction:column}.gem-footer .btn{width:100%}.gem-tabs{flex-wrap:nowrap}.gem-tab{padding:6px 10px;font-size:0.75rem}.gem-grid{grid-template-columns:1fr}.gem-footer-right{flex-direction:column;width:100%}.psn-meta-grid{grid-template-columns:1fr}.psn-trophy-type-breakdown{flex-wrap:wrap;gap:var(--spacing-md)}.platform-filters{justify-content:center}}@media (max-width:480px){.systems-grid{grid-template-columns:1fr}.page-title{font-size:1.5rem;letter-spacing:2px}.stat-value{font-size:2.5rem}} \ No newline at end of file diff --git a/static/css/pages/game-list.css b/static/css/pages/game-list.css index bb757698..e6e3dbf1 100644 --- a/static/css/pages/game-list.css +++ b/static/css/pages/game-list.css @@ -236,7 +236,7 @@ .glitch-text::before { left: 2px; - text-shadow: -2px 0 var(--primary-magenta); + text-shadow: -2px 0 var(--secondary-magenta); clip: rect(24px, 550px, 90px, 0); animation: glitch-anim 3s infinite linear alternate-reverse; } @@ -302,7 +302,7 @@ bottom: -2px; background: linear-gradient(45deg, var(--primary-cyan), - var(--primary-magenta), + var(--secondary-magenta), var(--primary-cyan)); border-radius: calc(var(--radius-lg) + 2px); z-index: -1; diff --git a/static/css/pages/pages.css b/static/css/pages/pages.css index e50c53a2..e25e5f33 100644 --- a/static/css/pages/pages.css +++ b/static/css/pages/pages.css @@ -875,7 +875,7 @@ left: 0; width: 100%; height: 100%; - background: linear-gradient(to bottom, transparent 0%, var(--bg-primary) 100%); + background: linear-gradient(to bottom, transparent 0%, var(--bg-darkest) 100%); } /* Hero section */ diff --git a/static/js/all-games-controller.js b/static/js/all-games-controller.js index 195bcfec..21bfa612 100644 --- a/static/js/all-games-controller.js +++ b/static/js/all-games-controller.js @@ -464,8 +464,18 @@ const AllGamesController = (function() { function layoutMasonryItems(items, append) { const grid = document.getElementById('gamesGrid'); if (!grid || items.length === 0) return; + if (_gridResizeObserver) _gridResizeObserver.observe(grid); const gap = 16; + // The computed track list includes implicit columns that the cards' + // own inline placement created (overlapping row lines spill into + // extra columns too), so a full layout clears it first; otherwise a + // grid narrowed to one column still reads as two. + if (!append) { + items.forEach(w => { + w.style.gridColumn = w.style.gridRowStart = w.style.gridRowEnd = ''; + }); + } const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length; // Reset on fresh render or column count change @@ -480,11 +490,26 @@ const AllGamesController = (function() { } } - items.forEach(wrapper => { + // One column (the phone layout): the CSS stacks the cards with + // auto rows, which inline row lines would override, leaving a + // row-gap per 1px line between cards. + if (columns <= 1) { + items.forEach(w => { + w.style.gridColumn = w.style.gridRowStart = w.style.gridRowEnd = ''; + }); + return; + } + + // Measure every card before placing any, so the loop forces one + // layout rather than one per card. + const heights = items.map(wrapper => { const card = wrapper.querySelector('.game-card-new'); - if (!card) return; + return card ? card.getBoundingClientRect().height : null; + }); - const h = card.getBoundingClientRect().height; + items.forEach((wrapper, i) => { + const h = heights[i]; + if (h === null) return; const span = h > 0 ? Math.ceil(h) + gap : 296; const col = masonryItemCount % columns; @@ -497,6 +522,26 @@ const AllGamesController = (function() { }); } + // A grid width change re-places every card: the old inline columns + // would otherwise point past the grid's last column. The column count + // can't be compared first (the implicit tracks above keep the old + // count), so any width change re-lays out. The grid itself is observed, + // not the window: the sidebar animates across a breakpoint, and a + // layout taken mid-animation reads the wrong column count; the observer + // fires every frame, so the timer settles only once the width does. + let _resizeRelayoutTimer; + let _observedGridWidth = null; + const _gridResizeObserver = typeof ResizeObserver === 'undefined' ? null + : new ResizeObserver(entries => { + const width = Math.round(entries[0].contentRect.width); + const first = _observedGridWidth === null; + if (width === _observedGridWidth) return; + _observedGridWidth = width; + if (first) return; + clearTimeout(_resizeRelayoutTimer); + _resizeRelayoutTimer = setTimeout(() => window.relayoutMasonry(), 150); + }); + // Expose a full re-layout function for external use (e.g. after card edits) window.relayoutMasonry = function() { const grid = document.getElementById('gamesGrid'); @@ -1606,6 +1651,9 @@ const AllGamesController = (function() { clearTimeout(scrollSaveTimer); clearTimeout(_imageRelayoutTimer); window.removeEventListener('scroll', _onScrollSave); + clearTimeout(_resizeRelayoutTimer); + if (_gridResizeObserver) _gridResizeObserver.disconnect(); + _observedGridWidth = null; document.removeEventListener('click', _onFilterBackdropClick); selectedGames.clear(); } diff --git a/static/js/core.bundle.js b/static/js/core.bundle.js index 512d4fe2..2284fd51 100644 --- a/static/js/core.bundle.js +++ b/static/js/core.bundle.js @@ -1726,15 +1726,23 @@ const UnifiedToastController = { * Poll status for a specific type */ async pollStatus(type, endpoint) { + const prevAbort = this._pollAbortControllers.get(type); + if (prevAbort) { + if (Date.now() - prevAbort._startedAt < 10000) return; + prevAbort.abort(); + } + const ac = new AbortController(); + ac._startedAt = Date.now(); + this._pollAbortControllers.set(type, ac); try { - const prevAbort = this._pollAbortControllers.get(type); - if (prevAbort) prevAbort.abort(); - - const ac = new AbortController(); - this._pollAbortControllers.set(type, ac); - - const data = await API.get(endpoint, { signal: ac.signal }); - this._pollAbortControllers.delete(type); + let data; + try { + data = await API.get(endpoint, { signal: ac.signal }); + } finally { + if (this._pollAbortControllers.get(type) === ac) { + this._pollAbortControllers.delete(type); + } + } if (data.success === false) return; @@ -3662,7 +3670,7 @@ async function cleanMissingRoms() { if (resultDiv) { if (data.removed > 0) { let html = `
    `; - html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; html += `
      `; for (const game of data.removed_games) { html += `
    • ${escapeHtml(game.title)}
    • `; @@ -3719,7 +3727,7 @@ async function clearClzImports() { if (resultDiv) { if (data.removed > 0) { let html = `
      `; - html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; html += `
        `; for (const game of data.removed_games) { html += `
      • ${escapeHtml(game.title)}
      • `; diff --git a/static/js/game-modals.js b/static/js/game-modals.js index 7d25a517..c90de5c4 100644 --- a/static/js/game-modals.js +++ b/static/js/game-modals.js @@ -132,6 +132,7 @@ const GameDetailModal = { maxCacheSize: 50, _screenshots: [], _screenshotIndex: 0, + _loadingHtml: null, /** * Open the game detail modal @@ -149,6 +150,11 @@ const GameDetailModal = { const wasOpen = modal.classList.contains('active'); modal.classList.add('active'); document.body.style.overflow = 'hidden'; + // showError() replaced the spinner; put it back for this load. + if (this._loadingHtml) { + loading.innerHTML = this._loadingHtml; + this._loadingHtml = null; + } loading.style.display = 'flex'; content.style.display = 'none'; @@ -162,6 +168,9 @@ const GameDetailModal = { return; } + // A reply that lands after the modal closed or moved on to another + // game must not paint over it. + const isCurrent = () => this.currentId === gameId; API.get(`/api/game/${gameId}/detail`) .then(data => { if (data.success) { @@ -171,12 +180,12 @@ const GameDetailModal = { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } - this.populate(data.game); - } else { + if (isCurrent()) this.populate(data.game); + } else if (isCurrent()) { this.showError(data.error || t('Failed to load game')); } }) - .catch(err => this.showError(t('Network error'))); + .catch(err => { if (isCurrent()) this.showError(t('Network error')); }); }, /** @@ -368,7 +377,9 @@ const GameDetailModal = { * @param {string} message - The error message */ showError(message) { - document.getElementById('gameDetailLoading').innerHTML = + const loading = document.getElementById('gameDetailLoading'); + if (!this._loadingHtml) this._loadingHtml = loading.innerHTML; + loading.innerHTML = `

        ${escapeHtml(message)}

        `; }, @@ -2237,7 +2248,9 @@ window.triggerAiFill = function() { showNotification(`AI filled ${count} field${count !== 1 ? 's' : ''}: ${data.filled_fields.join(', ')}`, 'success'); // Clear cache and refresh modal GameDetailModal.cache.delete(gameId); - GameDetailModal.open(gameId); + // Only if the modal still shows this game: the fill can + // finish after the user closed it or opened another. + if (GameDetailModal.currentId === gameId) GameDetailModal.open(gameId); // Refresh the game card in the list page if (typeof AllGamesController !== 'undefined' && AllGamesController.refreshCards) { AllGamesController.refreshCards(gameId); diff --git a/static/js/games.bundle.js b/static/js/games.bundle.js index b8795ff7..9734b733 100644 --- a/static/js/games.bundle.js +++ b/static/js/games.bundle.js @@ -1162,6 +1162,7 @@ const GameDetailModal = { maxCacheSize: 50, _screenshots: [], _screenshotIndex: 0, + _loadingHtml: null, /** * Open the game detail modal @@ -1176,6 +1177,10 @@ const GameDetailModal = { const wasOpen = modal.classList.contains('active'); modal.classList.add('active'); document.body.style.overflow = 'hidden'; + if (this._loadingHtml) { + loading.innerHTML = this._loadingHtml; + this._loadingHtml = null; + } loading.style.display = 'flex'; content.style.display = 'none'; @@ -1188,6 +1193,7 @@ const GameDetailModal = { return; } + const isCurrent = () => this.currentId === gameId; API.get(`/api/game/${gameId}/detail`) .then(data => { if (data.success) { @@ -1196,12 +1202,12 @@ const GameDetailModal = { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } - this.populate(data.game); - } else { + if (isCurrent()) this.populate(data.game); + } else if (isCurrent()) { this.showError(data.error || t('Failed to load game')); } }) - .catch(err => this.showError(t('Network error'))); + .catch(err => { if (isCurrent()) this.showError(t('Network error')); }); }, /** @@ -1372,7 +1378,9 @@ const GameDetailModal = { * @param {string} message - The error message */ showError(message) { - document.getElementById('gameDetailLoading').innerHTML = + const loading = document.getElementById('gameDetailLoading'); + if (!this._loadingHtml) this._loadingHtml = loading.innerHTML; + loading.innerHTML = `

        ${escapeHtml(message)}

        `; }, @@ -3062,7 +3070,7 @@ window.triggerAiFill = function() { if (count > 0) { showNotification(`AI filled ${count} field${count !== 1 ? 's' : ''}: ${data.filled_fields.join(', ')}`, 'success'); GameDetailModal.cache.delete(gameId); - GameDetailModal.open(gameId); + if (GameDetailModal.currentId === gameId) GameDetailModal.open(gameId); if (typeof AllGamesController !== 'undefined' && AllGamesController.refreshCards) { AllGamesController.refreshCards(gameId); } diff --git a/static/js/launch-indicator.js b/static/js/launch-indicator.js index 66cd3d70..fd032cc2 100644 --- a/static/js/launch-indicator.js +++ b/static/js/launch-indicator.js @@ -50,7 +50,10 @@ await fetch(`/api/launch/${token}/kill`, { method: 'POST', headers: {'X-CSRF-Token': csrfToken()}, - }); + }).catch(() => {}); + // The open list still shows the killed game; close it so the next + // click rebuilds it from the refreshed list. + if (popover) { popover.remove(); popover = null; } await refresh(); } diff --git a/static/js/log-viewer.js b/static/js/log-viewer.js index b3f0bc1d..e6435433 100644 --- a/static/js/log-viewer.js +++ b/static/js/log-viewer.js @@ -175,6 +175,8 @@ const LogViewer = { try { const data = await API.get(`/api/logs/content/${encodeURIComponent(filename)}`); + // Another file was picked while this one loaded; it owns the view. + if (this.currentFile !== filename) return; if (!data.success) { container.innerHTML = `
        ${escapeHtml(data.error || t('Error loading file'))}
        `; return; @@ -187,6 +189,7 @@ const LogViewer = { this.applySmartViewMode(); this.render(); } catch (e) { + if (this.currentFile !== filename) return; container.innerHTML = `
        ${t('Error loading log file')}
        `; console.error('Error loading log file:', e); } diff --git a/static/js/main.js b/static/js/main.js index f5414ffb..bd4121f8 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1056,7 +1056,7 @@ async function cleanMissingRoms() { if (resultDiv) { if (data.removed > 0) { let html = `
        `; - html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} games:', {n: formatNumber(data.removed)}))}`; html += `
          `; for (const game of data.removed_games) { html += `
        • ${escapeHtml(game.title)}
        • `; @@ -1113,7 +1113,7 @@ async function clearClzImports() { if (resultDiv) { if (data.removed > 0) { let html = `
          `; - html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; + html += `${escapeHtml(t('Removed {n} CLZ Import games:', {n: formatNumber(data.removed)}))}`; html += `
            `; for (const game of data.removed_games) { html += `
          • ${escapeHtml(game.title)}
          • `; diff --git a/static/js/museum.js b/static/js/museum.js index 5be787f3..8f7c04bf 100644 --- a/static/js/museum.js +++ b/static/js/museum.js @@ -80,6 +80,9 @@ function _stopPolling() { function _pollStatus() { API.get('/api/museum/generate-status') .then(function (s) { + // A reply still in flight when an earlier one stopped polling + // would announce the finished run a second time. + if (!_pollTimer) return; // Update UI var pct = s.total_systems > 0 ? Math.round((s.current_index / s.total_systems) * 100) diff --git a/static/js/official-names.js b/static/js/official-names.js index 55f99641..81789702 100644 --- a/static/js/official-names.js +++ b/static/js/official-names.js @@ -37,9 +37,12 @@ async function loadProposals() { listStatus.textContent = t('Loading…'); + const selected = systemSelect.value; try { - const qs = new URLSearchParams(systemSelect.value ? { system_id: systemSelect.value } : {}); + const qs = new URLSearchParams(selected ? { system_id: selected } : {}); const data = await API.get(`/api/official-names/proposals?${qs}`); + // The system changed while this loaded; its own load follows. + if (systemSelect.value !== selected) return; list.replaceChildren(...data.proposals.map(renderRow)); actions.hidden = data.proposals.length === 0; listStatus.textContent = data.proposals.length diff --git a/static/js/settings-page.js b/static/js/settings-page.js index 14299c08..f7bb4c27 100644 --- a/static/js/settings-page.js +++ b/static/js/settings-page.js @@ -204,7 +204,7 @@ const NormalizationManager = { // interpolated value goes through escapeHtml (PASS-59-63). let msg = '
            '; msg += ''; - msg += ``; + msg += ``; mappings.forEach(m => { msg += ``; msg += ``; diff --git a/static/js/toast-controller.js b/static/js/toast-controller.js index c2266586..31f8f7b3 100644 --- a/static/js/toast-controller.js +++ b/static/js/toast-controller.js @@ -646,16 +646,27 @@ const UnifiedToastController = { * Poll status for a specific type */ async pollStatus(type, endpoint) { + // One request per type at a time. A tick that finds one in flight + // lets it land (its reply is just as fresh): aborting it instead + // meant a status reply slower than the poll interval never arrived + // and the toast froze. Only a request stuck past 10 s is replaced. + const prevAbort = this._pollAbortControllers.get(type); + if (prevAbort) { + if (Date.now() - prevAbort._startedAt < 10000) return; + prevAbort.abort(); + } + const ac = new AbortController(); + ac._startedAt = Date.now(); + this._pollAbortControllers.set(type, ac); try { - // Abort any previous in-flight request for this type to prevent pileup - const prevAbort = this._pollAbortControllers.get(type); - if (prevAbort) prevAbort.abort(); - - const ac = new AbortController(); - this._pollAbortControllers.set(type, ac); - - const data = await API.get(endpoint, { signal: ac.signal }); - this._pollAbortControllers.delete(type); + let data; + try { + data = await API.get(endpoint, { signal: ac.signal }); + } finally { + if (this._pollAbortControllers.get(type) === ac) { + this._pollAbortControllers.delete(type); + } + } // Different endpoints have different response formats // bulk-scrape-job/status returns { success: true, running: ..., ... } diff --git a/static/js/updates.js b/static/js/updates.js new file mode 100644 index 00000000..33bec5e6 --- /dev/null +++ b/static/js/updates.js @@ -0,0 +1,258 @@ +/** + * RetroDB - Self-updater page code (PASS-59-169) + * + * Settings > System > Updates ([data-update-card]) and the admin dashboard + * banner ([data-update-banner]). Talks to /api/updates/*; release notes come + * from GitHub and are only ever set as text. + * Contract: docs/specs/PASS-59-169-self-updater.md. + */ +(function () { + 'use strict'; + + const REASONS = { + git: () => t('This copy of RetroDB is a git checkout. Update it with git pull.'), + managed: () => t('RetroDB is run by a process manager here. Download the release and install it yourself.'), + no_asset: () => t('This release has no download for this computer yet.'), + busy: () => t('An update is already being downloaded.'), + not_ready: () => t('Download the update first.'), + not_available: () => t('No newer version is available.'), + start_failed: () => t('The update could not be started. See logs/update.log.'), + }; + + // Spelled out: tests/test_pass59_frontend.py matches every call to a route. + const ACTION_URLS = { + check: '/api/updates/check', + download: '/api/updates/download', + }; + + function reasonText(code) { + return (REASONS[code] || (() => t('This update cannot be installed here.')))(); + } + + function show(el, on) { if (el) el.hidden = !on; } + + function formatDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + return isNaN(d) ? '' : d.toLocaleDateString(); + } + + // --------------------------------------------------------------------- + // Dashboard banner + // --------------------------------------------------------------------- + + function renderBanner(banner, st) { + if (!st.available || !st.latest) return; + banner.querySelector('[data-update-banner-text]').textContent = + t('RetroDB {version} is available.', { version: st.latest.version }); + show(banner, true); + } + + // --------------------------------------------------------------------- + // Settings card + // --------------------------------------------------------------------- + + function Card(root) { + this.root = root; + this.q = sel => root.querySelector(sel); + this.pollTimer = null; + root.addEventListener('click', e => { + const btn = e.target.closest('[data-update-action]'); + if (btn) this.act(btn.dataset.updateAction, btn); + }); + const auto = this.q('[data-update-auto]'); + if (auto) auto.addEventListener('change', () => this.saveAuto(auto)); + } + + Card.prototype.render = function (st) { + const q = this.q; + this.current = st.current; + const latest = st.latest; + let summary = ''; + if (st.available && latest) { + summary = t('Version {version} is available.', { version: latest.version }); + } else if (st.check_error && !latest) { + summary = t('Could not check for updates.'); + } else if (st.checked_at) { + summary = t('You have the latest version.'); + } + q('[data-update-summary]').textContent = summary; + + const release = q('[data-update-release]'); + show(release, !!(st.available && latest)); + if (st.available && latest) { + const date = formatDate(latest.published_at); + q('[data-update-title]').textContent = date + ? t('What is new in {version} ({date})', { version: latest.version, date }) + : t('What is new in {version}', { version: latest.version }); + q('[data-update-notes]').textContent = latest.notes || ''; + const link = q('[data-update-link]'); + if (latest.url) link.href = latest.url; + show(link, !!latest.url); + } + + const job = st.job || {}; + const busy = job.state === 'downloading' || job.state === 'installing'; + const note = q('[data-update-note]'); + let noteText = ''; + if (st.available && !st.can_install && st.reason) noteText = reasonText(st.reason); + if (job.state === 'error' && job.error) { + noteText = t('The download failed: {error}', { error: job.error }); + } + if (job.state === 'ready') noteText = t('The update is downloaded and checked.'); + note.textContent = noteText; + show(note, !!noteText); + + show(q('[data-update-progress]'), job.state === 'downloading'); + if (job.state === 'downloading') { + const pct = job.total_bytes ? Math.min(100, Math.round(job.done_bytes * 100 / job.total_bytes)) : 0; + q('[data-update-bar]').style.width = pct + '%'; + } + + show(q('[data-update-action="download"]'), + st.available && st.can_install && !busy && job.state !== 'ready'); + show(q('[data-update-action="install"]'), job.state === 'ready'); + q('[data-update-action="check"]').disabled = busy; + + this.renderResult(st.last_result); + + clearTimeout(this.pollTimer); + if (job.state === 'downloading') { + this.pollTimer = setTimeout(() => this.refresh(), 1000); + } + }; + + Card.prototype.renderResult = function (result) { + const box = this.q('[data-update-result]'); + box.replaceChildren(); + if (!result) { show(box, false); return; } + const text = document.createElement('span'); + if (result.result === 'updated') { + text.textContent = t('Updated from {from} to {to}.', + { from: result.from_version, to: result.to_version }); + box.className = 'update-result update-result-ok'; + } else { + text.textContent = t('The update to {to} did not work, so RetroDB went back to {from}. Reason: {reason}', + { to: result.to_version, from: result.from_version, reason: result.reason || '?' }); + box.className = 'update-result update-result-failed'; + } + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'btn btn-sm btn-secondary'; + close.textContent = t('OK'); + close.addEventListener('click', async () => { + try { await API.post('/api/updates/dismiss'); } catch (_) { /* shown again next time */ } + show(box, false); + }); + box.append(text, ' ', close); + show(box, true); + }; + + Card.prototype.refresh = async function () { + try { + this.render(await API.get('/api/updates/status')); + } catch (err) { + showNotification(t('Could not check for updates.'), 'error'); + } + }; + + Card.prototype.act = async function (action, btn) { + if (action === 'install') { + showConfirm(t('Install update'), + t('RetroDB will back up its database, install the update and restart. Anyone using it will be disconnected for a minute or two.'), + () => this.install(btn)); + return; + } + btn.disabled = true; + try { + const st = await API.post(ACTION_URLS[action]); + this.render(st); + } catch (err) { + const reason = err && err.data && err.data.reason; + showNotification(reason ? reasonText(reason) : t('Could not check for updates.'), 'error'); + } finally { + btn.disabled = false; + } + }; + + Card.prototype.install = async function (btn) { + btn.disabled = true; + try { + await API.post('/api/updates/install'); + } catch (err) { + const reason = err && err.data && err.data.reason; + showNotification(reasonText(reason), 'error'); + btn.disabled = false; + return; + } + waitForRestart(this.current); + }; + + Card.prototype.saveAuto = async function (box) { + try { + await API.post('/api/settings', { update_check_auto: box.checked }); + } catch (err) { + box.checked = !box.checked; + showNotification(t('Failed to save settings'), 'error'); + } + }; + + // --------------------------------------------------------------------- + // After Install: wait for the server to go away and come back + // --------------------------------------------------------------------- + + function waitForRestart(current) { + const overlay = document.createElement('div'); + overlay.className = 'update-overlay'; + const msg = document.createElement('p'); + msg.textContent = t('Installing the update and restarting RetroDB…'); + overlay.append(msg); + document.body.append(overlay); + + const started = Date.now(); + let wentDown = false; + const tick = async () => { + try { + const r = await fetch('/health', { cache: 'no-store' }); + // Back up: after an outage, or already on another version + // (a restart quicker than one poll). + const version = r.ok ? (await r.json()).version : null; + if (r.ok && (wentDown || (version && version !== current))) { + window.location.reload(); + return; + } + if (!r.ok) wentDown = true; + } catch (_) { + wentDown = true; + } + if (Date.now() - started > 8 * 60 * 1000) { + msg.textContent = t('RetroDB has not come back yet. See logs/update.log, then reload this page.'); + return; + } + setTimeout(tick, 2000); + }; + setTimeout(tick, 2000); + } + + // --------------------------------------------------------------------- + + async function init() { + const card = document.querySelector('[data-update-card]'); + const banner = document.querySelector('[data-update-banner]'); + if (!card && !banner) return; + let st; + try { + st = await API.get('/api/updates/status'); + } catch (_) { + return; + } + if (banner) renderBanner(banner, st); + if (card) new Card(card).render(st); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/static/js/versions.js b/static/js/versions.js index f3c8f30f..21c133d8 100644 --- a/static/js/versions.js +++ b/static/js/versions.js @@ -81,6 +81,8 @@ try { const qs = new URLSearchParams({ system: systemId, search: term, per_page: 20 }); const data = await API.get(`/api/games?${qs}`); + // An older search can land after a newer one; keep the newest. + if (input.value.trim() !== term) return; renderCandidates(data.games.filter(g => g.id !== gameId)); } catch (err) { showNotification(failMessage(err, t('Search failed.')), 'error'); diff --git a/templates/_settings_tabs/system.html b/templates/_settings_tabs/system.html index 203d65b1..2f34a0ad 100644 --- a/templates/_settings_tabs/system.html +++ b/templates/_settings_tabs/system.html @@ -4,6 +4,7 @@ {% call tab_subnav('system') %} {% if _is_admin %} {{ subnav_link('maintenance', '🛠️ Maintenance', active=True) }} + {{ subnav_link('updates', '⬆️ ' ~ _('Updates')) }} {{ subnav_link('server', '⚙️ Server') }} {% endif %} {{ subnav_link('logging', '📋 Logging', active=not _is_admin) }} @@ -167,6 +168,36 @@

            +
            +

            {{ _('Updates') }}

            +
            +
            +

            {{ _('This is RetroDB %(version)s.', version=config.APP_VERSION) }} +

            + + + + +
            + + + +
            + +
            + +
            diff --git a/templates/chd_converter.html b/templates/chd_converter.html index c39eaad5..6484ef5d 100644 --- a/templates/chd_converter.html +++ b/templates/chd_converter.html @@ -281,9 +281,9 @@

            📊 {{ _('Conversion Statistics') }}

            html += `
            - ${f.name} + ${escapeHtml(f.name)} ${formatBytes(f.size)} - ${f.type} + ${escapeHtml(f.type)} Pending
            `; diff --git a/templates/chd_verify.html b/templates/chd_verify.html index 7adaa470..35bed220 100644 --- a/templates/chd_verify.html +++ b/templates/chd_verify.html @@ -235,7 +235,7 @@

            📊 {{ _('Verification Results') }}

            html += `
            - ${f.name} + ${escapeHtml(f.name)} ${formatBytes(f.size)} Pending
            diff --git a/templates/collector_trophies.html b/templates/collector_trophies.html index 11d16814..d90a9e12 100644 --- a/templates/collector_trophies.html +++ b/templates/collector_trophies.html @@ -144,7 +144,7 @@

            {{ tier_name }}

            .collector-rank-card.rank-tier-7 { border-color: var(--neon-purple); box-shadow: 0 0 20px rgba(168, 85, 247, 0.25); } .collector-rank-card.rank-tier-8, .collector-rank-card.rank-tier-9 { - border-color: var(--primary-magenta); + border-color: var(--secondary-magenta); background: linear-gradient(135deg, var(--bg-medium) 0%, rgba(247, 37, 133, 0.08) 100%); box-shadow: 0 0 24px rgba(247, 37, 133, 0.3); } @@ -231,14 +231,14 @@

            {{ tier_name }}

            .collector-rank-next-fill { height: 100%; - background: linear-gradient(90deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(90deg, var(--primary-cyan), var(--secondary-magenta)); transition: width 0.4s ease; } .collector-rank-max { font-family: var(--font-heading); font-size: 0.9rem; - color: var(--primary-magenta); + color: var(--secondary-magenta); text-transform: uppercase; letter-spacing: 1px; } @@ -291,7 +291,7 @@

            {{ tier_name }}

            .trophy-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--primary-cyan), var(--primary-magenta)); + background: linear-gradient(90deg, var(--primary-cyan), var(--secondary-magenta)); border-radius: var(--radius-full); transition: width 0.6s ease-out; } diff --git a/templates/compare_games.html b/templates/compare_games.html index 60ff2b82..921d687f 100644 --- a/templates/compare_games.html +++ b/templates/compare_games.html @@ -301,7 +301,7 @@

            Select a Game

            font-family: var(--font-heading); font-size: 1.4rem; font-weight: 700; - color: var(--primary-magenta); + color: var(--secondary-magenta); text-transform: uppercase; letter-spacing: 2px; flex-shrink: 0; @@ -409,7 +409,7 @@

            Select a Game

            .match-badge.diff { background: rgba(247, 37, 133, 0.15); - color: var(--primary-magenta); + color: var(--secondary-magenta); border: 1px solid rgba(247, 37, 133, 0.3); } @@ -436,7 +436,7 @@

            Select a Game

            /* Empty state */ .compare-empty { text-align: center; - padding: var(--spacing-xxl); + padding: var(--spacing-2xl); color: var(--text-muted); font-size: 1rem; } diff --git a/templates/dashboard.html b/templates/dashboard.html index 74eebffe..1f524c03 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -29,6 +29,15 @@
            +{% if current_user and current_user.role == 'admin' %} + + +{% endif %} + {% if recoverable_jobs %}
            @@ -1126,6 +1135,9 @@

            📡 {{ _('System Status') }}

            {% endblock %} {% block scripts %} +{% if current_user and current_user.role == 'admin' %} + +{% endif %} +

            ${escapeHtml(t('From'))}${escapeHtml(t('To'))}${escapeHtml(t('Games'))}
            ${escapeHtml(t('From'))}${escapeHtml(t('To'))}${escapeHtml(t('Games'))}
            ${escapeHtml(m.old)}${escapeHtml(m.new)}