diff --git a/.grype.yaml b/.grype.yaml index f47d625e..068f32fc 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -341,6 +341,33 @@ ignore: version: 2.41-5+dhi3 type: deb + # Grype maps these util-linux source-package findings to libuuid1 in the + # current DHI. Their affected code is in privileged mount/nsenter commands, + # which are absent from the Pullbox runtime image. Debian 13 marks all four + # no-dsa/minor and has not published a stable fix. Keep the High gate active; + # retain only these exact package matches. Re-review by 2026-10-04 or at the + # next DHI refresh, whichever comes first. + - vulnerability: CVE-2026-76642 + package: + name: libuuid1 + version: 2.41.5-0+deb13u1+dhi2 + type: deb + - vulnerability: CVE-2026-78408 + package: + name: libuuid1 + version: 2.41.5-0+deb13u1+dhi2 + type: deb + - vulnerability: CVE-2026-78409 + package: + name: libuuid1 + version: 2.41.5-0+deb13u1+dhi2 + type: deb + - vulnerability: CVE-2026-78410 + package: + name: libuuid1 + version: 2.41.5-0+deb13u1+dhi2 + type: deb + - vulnerability: CVE-2025-6141 package: name: libncursesw6 @@ -387,6 +414,23 @@ ignore: name: zlib1g version: 1:1.3.dfsg+really1.3.1-1+dhi2 type: deb + + # Debian 13 has no fixed zlib package for CVE-2026-85091. The affected path + # requires the non-blocking gzwrite API followed by gzprintf/gzvprintf; + # Pullbox does not call that native sequence. The duplicated development + # package metadata comes from the DHI Python build/runtime dependency tree. + # Keep this exact exception only. Re-review by 2026-10-04 or at the next DHI + # refresh, whichever comes first. + - vulnerability: CVE-2026-85091 + package: + name: zlib1g + version: 1:1.3.dfsg+really1.3.1-1+dhi3 + type: deb + - vulnerability: CVE-2026-85091 + package: + name: zlib1g-dev + version: 1:1.3.dfsg+really1.3.1-1+dhi3 + type: deb - vulnerability: CVE-2026-34743 package: name: liblzma5 diff --git a/CHANGELOG.md b/CHANGELOG.md index d018572b..be4344f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Improved Mylar and folder scan throughput with resource-capped archive + inspection, batched review inserts, and fewer unnecessary duplicate checks. + Corrected current-item scan progress and unknown time estimates, and bounded + pending file-processing work without weakening cancellation or archive safety. - Reconciled stale Mylar filenames with uniquely verified same-folder comic files, including `#1` versus `001` naming changes, without weakening identity or archive safety checks. Added a dry-run-first offline repair for saved diff --git a/docker/.env.example b/docker/.env.example index 4274d1d8..de8864dc 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -145,9 +145,15 @@ PULLBOX_RUNTIME_GID=65532 # PULLBOX_NAMING_SERIES_FORMAT="{series} ({year})" # PULLBOX_NAMING_ISSUE_FORMAT="{series} ({year}) #{issue:03d}" -# Import debug controls. These are for troubleshooting only and should stay -# disabled for normal production use. +# Import concurrency. Scan 0 = auto (up to 4 inspectors, capped by visible +# CPU/container and available-memory limits). Use 1 for slow/shared disks or +# explicitly test 2-16; an explicit value never exceeds the resource ceiling. +# Inspection never changes file-safety policy. Step 4 workers remain separately +# budgeted because conversions can require substantial temporary disk space. +# PULLBOX_IMPORT_SCAN_WORKER_COUNT=0 # PULLBOX_IMPORT_FILE_WORKER_COUNT=2 + +# Import debug controls. These should stay disabled for normal production use. # PULLBOX_IMPORT_DEBUG_SLOW_MODE=false # PULLBOX_IMPORT_DEBUG_PHASE_DELAY_SECONDS=1.25 # PULLBOX_IMPORT_DEBUG_ITEM_DELAY_SECONDS=0.4 diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index 5877788e..b011280e 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -533,9 +533,10 @@ Pullbox also includes sidecar recovery logic for stale or corrupt `-wal` and ### 5.2 Maintenance Coordination Database-size health thresholds allow for large collections and retained logs: -the size sub-check is healthy through 1 GiB (1024 MiB), degraded above 1 GiB, -and unhealthy above 2 GiB (2048 MiB). These are advisory health thresholds, not -storage limits or an automatic cleanup policy. Disk-space, integrity, query +the size sub-check reports the current file size as informational data. Database +health is determined by integrity, representative latency, bloat, and available +storage rather than a fixed size threshold. The file-size observation does not +impose a storage limit or an automatic cleanup policy. Disk-space, integrity, query latency, and database-bloat checks remain independent; a smaller database does not suppress failures in those checks. diff --git a/docs/development/IMPORT_PERFORMANCE.md b/docs/development/IMPORT_PERFORMANCE.md new file mode 100644 index 00000000..c9bb9078 --- /dev/null +++ b/docs/development/IMPORT_PERFORMANCE.md @@ -0,0 +1,74 @@ +# Import Performance + +## Runtime Contract + +Mylar and folder imports share bounded, read-only archive inspection. A worker +receives paths and immutable safety settings, never an AsyncSession. Completion +and diagnostic updates flow through one coordinator. Review file rows are +inserted in batches of at most 500 in the existing transaction; source pages +retain their durable checkpoints. A singleton issue group cannot contain a +duplicate or conflict, so it no longer loads files and commits just to check. +Archive page-name matching runs off the event loop and uses a bounded local +cache for repeated page-title parsing. It still counts every page toward +consensus and does not reuse safety decisions between scans. + +`PULLBOX_IMPORT_SCAN_WORKER_COUNT=0` selects automatic inspection concurrency, +up to four workers. CPU affinity, cgroup v2 CPU quotas and parent limits, +cgroup memory headroom, and OS available memory cap the budget. Common cgroup +v1 mounts are supported too. Missing resource information falls back +conservatively. Explicit values 1-16 are also capped. The budget is reevaluated +between batches; this is not a throughput-learning autotuner or a guarantee +against other host workloads consuming resources. + +The resource ceiling reserves at least one CPU (25% on larger machines) and +512 MiB of available memory, with an additional 512 MiB allowance per inspector. +Automatic mode deliberately does not saturate high-core machines: local ZIP +header parsing did not improve with more than 2-4 workers. Docker Desktop's +VM resources are the relevant limits, not the Mac's advertised RAM. + +Step 4 keeps `PULLBOX_IMPORT_FILE_WORKER_COUNT=2` and its existing temporary-space +preflight, target-collision serialization, per-worker sessions, and rollback +journal. It now bounds submitted tasks as well as active workers, rather than +creating one waiting task per file. Exiting or canceling either worker pool +drains active work before the job can transition; no orphan filesystem work +may continue after cancellation is reported complete. + +## Progress and Evidence + +Unknown inventory totals are indeterminate. Completed series report 100% for +the current item, independent of overall phase weights. The browser must not +invent an ETA when the backend reports an unknown estimate. Matching emits +lightweight completion updates between durable checkpoints without adding a +database commit per item. + +`import_archive_inspection_batch` reports actual workers, effective CPUs, +available memory, files checked, and elapsed milliseconds. Mylar and folder +batch events separately report inspection and persistence durations; existing +Step 2 timing events retain discovery/matching/total durations. These metrics +are observations, not substitutes for transaction-wait or storage profiling. +Inspection batch wall time includes policy reads, reconciliation, and progress +callbacks. Persistence timing covers row materialization/flush, not the later +checkpoint commit. Do not interpret either value as exclusive disk I/O time. + +## Reproducible Benchmarks + +```bash +.venv/bin/python scripts/benchmark_import_scan.py \ + --series-count 100 --files-per-series 12 --trusted-comicinfo \ + --archive-pages 32 --inspection-workers 4 +``` + +The benchmark creates an isolated temporary source tree and database and makes +no external provider calls. Repeat with workers 1, 2, 4, 8, and 16; use medians +and retain the effective worker count, not only the requested count. Compare +identical matched, blocked, conflict, and missing-file outcomes before speed. + +`--inspection-delay-ms 10` adds controlled per-archive latency to evaluate I/O +overlap. It is a simulation, not a NAS measurement. Archive fixtures exercise +member indexes and bounded ComicInfo reads, not full image decoding or physical +multi-gigabyte payloads. Run representative CBR/conversion workloads and real +storage samples before claiming a user's end-to-end speedup. + +Do not add persistent cross-scan safety caches, speculative provider concurrency, +or process pools without evidence and new invalidation/recovery tests. Existing +compact archive metadata reuse remains intact; safety is freshly evaluated. diff --git a/docs/development/IMPORT_REVIEW_RECOVERY.md b/docs/development/IMPORT_REVIEW_RECOVERY.md index 943a0b3c..0dc196d3 100644 --- a/docs/development/IMPORT_REVIEW_RECOVERY.md +++ b/docs/development/IMPORT_REVIEW_RECOVERY.md @@ -1,19 +1,26 @@ # Recheck A Saved Import Review Use this maintenance procedure when a review was generated before the Mylar -sidecar parser and comic-content checks were corrected. It works for Mylar and -folder imports. This is not a full rescan, a database restore, or an import. +sidecar parser and comic-content checks were corrected. Normal completed-job +recovery does not require this command: the **Retry failed** action revalidates +retryable source changes inside Pullbox before execution. It works for Mylar +and folder imports. This is not a full rescan, a database restore, or an import. ## Safety And Scope - Stop Pullbox and back up its database before running the command. `--offline` is the operator's acknowledgement, not an automatic container stop. -- The job must be idle at Step 3 (`REVIEW`). Do not run this against a scan, - import, rollback, or job with a pending control request. -- By default only automatically rejected series with - `trusted_source_identity_conflict` are examined. Repeat `--series-id` to - narrow the operation to specific **import-review series IDs**, not ComicVine - or library series IDs. +- The job must be idle at Step 3 (`REVIEW`) or finished (`COMPLETED`). Do not + run this against a scan, import, rollback, or job with a pending control + request. +- For a `REVIEW` job, only automatically rejected series with + `trusted_source_identity_conflict` are examined by default. Repeat + `--series-id` to narrow the operation to specific **import-review series + IDs**, not ComicVine or library series IDs. +- For a `COMPLETED` job, prefer the in-app **Retry failed** action. It performs + the same bounded source revalidation automatically and retries only files + that remain inside their approved root, pass current safety checks, and do + not conflict with the saved source identity. - An entire series is left untouched if it has manual overrides, selected files, explicit skips, approved exceptions, or other completed file decisions. The report counts these as `skipped_series`; discuss them individually. @@ -48,9 +55,11 @@ docker compose -f pullbox.yml run --rm --no-deps --entrypoint python pullbox \ -m pullbox.cli recheck-import --job 1 --source-root /mnt/comics --offline ``` -The JSON result reports `series_prepared`, `files_checked`, `blocked_files`, -and `skipped_series`. `applied: false` confirms it was only a preview. Review -the counts before running the same command with `--apply`: +For a Step 3 review, the JSON result reports `series_prepared`, +`files_checked`, `blocked_files`, and `skipped_series`. For a completed import, +it reports `files_prepared`, `files_checked`, `blocked_files`, and +`skipped_files`. `applied: false` confirms it was only a preview. Review the +counts before running the same command with `--apply`: ```bash docker compose -f pullbox.yml run --rm --no-deps --entrypoint python pullbox \ @@ -63,13 +72,19 @@ startup recovery resumes from `MATCHING`, preserving the directory inventory and unaffected review decisions, and returns to Step 3. It does not select files or start Step 4. Genuine source-identity conflicts remain in review. +For a completed import, restart Pullbox, open that import's results, and choose +**Retry failed**. Only the rechecked failures are prepared; successful files +and series remain untouched. A file that is still missing, outside an approved +root, unreadable, or unsafe stays failed with refreshed diagnostics. + ## Replaced Files Changed or missing scan signatures normally remain blocked. After deliberately -replacing a defective file, preview a targeted recheck with both `--series-id` -and `--accept-replaced-files`. This explicitly accepts new scan evidence only -after containment and archive checks; it is not an archive-safety override. -Then repeat the reviewed command with `--apply` if appropriate. +replacing a defective file, preview a targeted recheck with +`--accept-replaced-files` and, when useful, one or more `--series-id` filters. +This explicitly accepts new scan evidence only after containment and archive +checks; it is not an archive-safety override. Then repeat the reviewed command +with `--apply` if appropriate. A renamed file at a different path is not automatically discovered by this command. Do not use broad filename guessing to repair ownership. diff --git a/scripts/benchmark_import_scan.py b/scripts/benchmark_import_scan.py index 449a2900..232b8ab1 100644 --- a/scripts/benchmark_import_scan.py +++ b/scripts/benchmark_import_scan.py @@ -9,6 +9,7 @@ import argparse import asyncio import json +import platform import tempfile import time import zipfile @@ -20,6 +21,7 @@ from pullbox.core import file_safety from pullbox.core.archive import ArchiveReader +from pullbox.core.import_resources import detect_import_resources from pullbox.models import import_job as _import_job_models # noqa: F401 from pullbox.models import issue as _issue_models # noqa: F401 from pullbox.models import library as _library_models # noqa: F401 @@ -120,6 +122,7 @@ def _build_tree( series_count: int, files_per_series: int, trusted_comicinfo: bool, + archive_pages: int = 2, ) -> None: for series_idx in range(series_count): title = f"Series {series_idx:04d}" @@ -130,11 +133,8 @@ def _build_tree( for file_idx in range(1, files_per_series + 1): archive_path = folder / f"{title} #{file_idx:03d}.cbz" with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: - archive.writestr( - f"{title} #{file_idx:03d}.jpg", - b"benchmark-page", - ) - archive.writestr(f"{title} #{file_idx:03d} p002.jpg", b"benchmark-page") + for page in range(1, archive_pages + 1): + archive.writestr(f"{title} #{file_idx:03d} p{page:04d}.jpg", b"benchmark-page") if trusted_comicinfo: issue_provider_id = (series_provider_id * 1000) + file_idx archive.writestr( @@ -159,6 +159,9 @@ async def main() -> None: parser.add_argument("--series-count", type=int, default=200) parser.add_argument("--files-per-series", type=int, default=12) parser.add_argument("--trusted-comicinfo", action="store_true") + parser.add_argument("--archive-pages", type=int, default=32) + parser.add_argument("--inspection-workers", type=int, default=0, choices=range(17)) + parser.add_argument("--inspection-delay-ms", type=float, default=0) args = parser.parse_args() provider = FakeMetadataProvider() @@ -169,6 +172,9 @@ async def main() -> None: event_bus=cast("Any", SimpleNamespace()), ) benchmark_service = cast("Any", service) + benchmark_service._settings = service._settings.model_copy( + update={"import_scan_worker_count": args.inspection_workers} + ) benchmark_service._build_scan_metadata_provider = lambda _session: CachedImportMetadataProvider( provider ) @@ -203,6 +209,8 @@ def counting_read_zip( def counting_inspect_zip_archive_safety(*call_args: Any, **call_kwargs: Any) -> Any: nonlocal archive_metadata_evidence_count, archive_safety_inspection_count + if args.inspection_delay_ms > 0: + time.sleep(args.inspection_delay_ms / 1000) archive_safety_inspection_count += 1 report = original_inspect_zip_archive_safety(*call_args, **call_kwargs) if report is not None and report.comicinfo is not None: @@ -238,6 +246,7 @@ def counting_archive_entry_issue_hint_from_names( series_count=args.series_count, files_per_series=args.files_per_series, trusted_comicinfo=args.trusted_comicinfo, + archive_pages=args.archive_pages, ) db_path = Path(tmp) / "benchmark.db" @@ -273,6 +282,14 @@ async def counted_commit() -> None: "series_count": args.series_count, "files_per_series": args.files_per_series, "trusted_comicinfo": args.trusted_comicinfo, + "archive_pages": args.archive_pages, + "inspection_workers_requested": args.inspection_workers, + "simulated_inspection_delay_ms": args.inspection_delay_ms, + "inspection_workers_effective": detect_import_resources().inspection_workers( + requested=args.inspection_workers + ), + "platform": platform.platform(), + "python": platform.python_version(), "elapsed_ms": elapsed_ms, "archive_read_count": archive_read_count, "archive_member_payload_read_count": archive_member_payload_read_count, diff --git a/scripts/generate_import_scale_fixture.py b/scripts/generate_import_scale_fixture.py index c808fec6..c937880e 100644 --- a/scripts/generate_import_scale_fixture.py +++ b/scripts/generate_import_scale_fixture.py @@ -20,6 +20,13 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--series-count", type=int, default=50_000) parser.add_argument("--file-count", type=int, default=200_000) parser.add_argument("--seed", type=int, default=1300) + parser.add_argument("--archive-pages", type=int, default=32) + parser.add_argument( + "--single-page-every", + type=int, + default=0, + help="Include an explicit one-page safety exception every N files (0 disables)", + ) parser.add_argument( "--profile", choices=("balanced", "realistic-skew"), @@ -55,6 +62,8 @@ def main(argv: list[str] | None = None) -> int: series_count=args.series_count, file_count=args.file_count, seed=args.seed, + archive_pages=args.archive_pages, + single_page_every=args.single_page_every, profile=args.profile, max_issues_per_series=args.max_issues_per_series, layout_profile=args.layout_profile, diff --git a/scripts/import_scale_fixtures/generator.py b/scripts/import_scale_fixtures/generator.py index c83cbb5b..ab1e36b7 100644 --- a/scripts/import_scale_fixtures/generator.py +++ b/scripts/import_scale_fixtures/generator.py @@ -43,6 +43,8 @@ class FixtureRequest: profile: FixtureProfile = "balanced" max_issues_per_series: int = 250 layout_profile: LayoutProfile = "series" + archive_pages: int = 32 + single_page_every: int = 0 @dataclass(frozen=True, slots=True) @@ -88,6 +90,8 @@ class _CatalogSeries: def _validate_request(request: FixtureRequest) -> None: + if not 2 <= request.archive_pages <= 1000 or request.single_page_every < 0: + raise ValueError("archive_pages must be 2-1000 and single_page_every nonnegative") if request.series_count < 1: raise ValueError("series_count must be positive") if request.file_count < request.series_count: @@ -412,6 +416,7 @@ def generate_import_scale_fixture(request: FixtureRequest) -> dict[str, object]: issue_distribution: dict[str, int] = defaultdict(int) content_digest = hashlib.sha256() logical_bytes = 0 + ordinal = 0 try: with manifest_path.open("w", encoding="utf-8", newline="\n") as manifest: for series_index, series in enumerate(plan.series): @@ -422,6 +427,12 @@ def generate_import_scale_fixture(request: FixtureRequest) -> dict[str, object]: ) issue_distribution[str(len(series.issues))] += 1 for issue in series.issues: + ordinal += 1 + pages = ( + 1 + if request.single_page_every and ordinal % request.single_page_every == 0 + else request.archive_pages + ) relative_path = directory / _issue_filename(issue) relative_text = relative_path.as_posix() archive_path = staging / "source" / relative_path @@ -436,7 +447,7 @@ def generate_import_scale_fixture(request: FixtureRequest) -> dict[str, object]: publisher=_xml_safe(series.publisher), comicvine_series_id=series.volume_id, comicvine_issue_id=issue.issue_id, - page_count=1, + page_count=pages, ) archive_digest = sha256_file(archive_path) archive_size = archive_path.stat().st_size @@ -444,6 +455,8 @@ def generate_import_scale_fixture(request: FixtureRequest) -> dict[str, object]: row = { "archive_sha256": archive_digest, "archive_size": archive_size, + "page_count": pages, + "expected_safety_review": pages == 1, "issue_id": issue.issue_id, "issue_number": _xml_safe(issue.issue_number), "issue_title": _xml_safe(issue.name), @@ -466,6 +479,11 @@ def generate_import_scale_fixture(request: FixtureRequest) -> dict[str, object]: "file_count": sum(len(series.issues) for series in plan.series), "max_issues_per_series": request.max_issues_per_series, "layout_profile": request.layout_profile, + "archive_pages": request.archive_pages, + "single_page_every": request.single_page_every, + "single_page_files": ordinal // request.single_page_every + if request.single_page_every + else 0, "issue_count_distribution": dict( sorted(issue_distribution.items(), key=lambda row: int(row[0])) ), diff --git a/scripts/import_scale_fixtures/mylar_multipage.py b/scripts/import_scale_fixtures/mylar_multipage.py new file mode 100644 index 00000000..e3d0d492 --- /dev/null +++ b/scripts/import_scale_fixtures/mylar_multipage.py @@ -0,0 +1,187 @@ +"""Create a separate multipage copy of the synthetic Mylar scale fixture.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sqlite3 +import tempfile +import time +import zipfile +from contextlib import closing +from pathlib import Path, PurePosixPath + +from scripts.iu9_acceptance_fixtures.shared import sha256_file + + +def _relative_path(folder: str, name: str, root: PurePosixPath) -> Path: + relative = PurePosixPath(folder).relative_to(root) / name + if relative.is_absolute() or ".." in relative.parts or "\\" in str(relative): + raise ValueError("Fixture source path must stay inside its recorded root") + return Path(*relative.parts) + + +def _copy_archive(original: Path, destination: Path, pages: int) -> None: + if original.stat().st_size > 1_000_000: + raise ValueError("Only tiny synthetic one-page archives may be upgraded") + with zipfile.ZipFile(original) as archive: + names = archive.namelist() + images = [name for name in names if name.endswith(".png")] + if len(images) != 1 or set(names) != {images[0], "ComicInfo.xml"}: + raise ValueError("Expected synthetic PNG plus ComicInfo.xml only") + if any(info.file_size > 1_000_000 for info in archive.infolist()): + raise ValueError("Synthetic member exceeds fixture size limit") + metadata, image = archive.read("ComicInfo.xml"), archive.read(images[0]) + destination.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(destination, "x", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in [ + ("ComicInfo.xml", metadata), + *[(f"pages/{number:03d}.png", image) for number in range(1, pages + 1)], + ]: + info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + + +def upgrade_fixture( + source: Path, + output: Path, + *, + container_root: str, + pages: int = 32, + single_page_every: int = 2000, +) -> dict[str, object]: + """Preserve source data while generating a new synthetic benchmark fixture.""" + started = time.monotonic() + source, output = source.resolve(), output.absolute() + if output.exists(): + raise FileExistsError("Output must not already exist") + if output.is_relative_to(source): + raise ValueError("Output must be separate from the original fixture") + if not 2 <= pages <= 1000 or single_page_every < 0: + raise ValueError("Invalid page count or safety exception interval") + report = json.loads((source / "generation-report.json").read_text()) + if report.get("fixture_kind") != "mylar-import-from-cv-stress-manifest": + raise ValueError("Source is not the supported synthetic Mylar fixture") + old_root, new_root = ( + PurePosixPath(report["recorded_source_root"]), + PurePosixPath(container_root), + ) + if not new_root.is_absolute() or ".." in new_root.parts: + raise ValueError("Container root must be an absolute safe path") + before = sha256_file(source / "mylar.db") + output.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".mylar-multipage-", dir=output.parent)) + count = 0 + total_bytes = 0 + try: + with ( + closing( + sqlite3.connect(f"{(source / 'mylar.db').as_uri()}?mode=ro", uri=True) + ) as original, + closing(sqlite3.connect(staging / "mylar.db")) as db, + ): + original.backup(db) + rows = original.execute( + "SELECT i.IssueID, c.ComicLocation, i.Location FROM issues i " + "JOIN comics c ON c.ComicID=i.ComicID ORDER BY i.IssueID" + ) + with (staging / "fixture-manifest.jsonl").open("w") as manifest: + for issue_id, folder, name in rows: + relative = _relative_path(folder, name, old_root) + source_path = source / "source" / relative + if source_path.resolve() != source_path or not source_path.is_file(): + raise ValueError("Fixture paths must be regular files without symlinks") + count += 1 + page_count = ( + 1 if single_page_every and count % single_page_every == 0 else pages + ) + target = staging / "source" / relative + _copy_archive(source_path, target, page_count) + size = target.stat().st_size + total_bytes += size + db.execute( + "UPDATE issues SET ComicSize=? WHERE IssueID=?", (str(size), issue_id) + ) + manifest.write( + json.dumps( + { + "issue_id": issue_id, + "relative_path": relative.as_posix(), + "archive_sha256": sha256_file(target), + "archive_size": size, + "page_count": page_count, + "expected_safety_review": page_count == 1, + }, + ensure_ascii=False, + sort_keys=True, + ) + + "\n" + ) + for rowid, folder in original.execute("SELECT rowid, ComicLocation FROM comics"): + relative = _relative_path(folder, "", old_root) + db.execute( + "UPDATE comics SET ComicLocation=? WHERE rowid=?", + ((new_root / relative.as_posix()).as_posix(), rowid), + ) + if count != report["issue_count"]: + raise ValueError("Fixture issue count differs from the generation report") + integrity = db.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + raise ValueError("Generated database failed integrity check") + db.commit() + if sha256_file(source / "mylar.db") != before: + raise ValueError("Source database changed during generation") + result: dict[str, object] = { + "fixture_kind": "mylar-import-multipage", + "source_fixture": str(source), + "recorded_source_root": container_root, + "series_count": report["series_count"], + "issue_count": count, + "archive_pages": pages, + "single_page_every": single_page_every, + "single_page_files": count // single_page_every if single_page_every else 0, + "archive_bytes": total_bytes, + "sqlite_integrity_check": integrity, + "source_database_sha256": before, + "database_sha256": sha256_file(staging / "mylar.db"), + "elapsed_seconds": round(time.monotonic() - started, 3), + "notes": [ + "Generated image members, not real comic payloads or disk-throughput evidence.", + "Original fixture and all original archive metadata are preserved.", + "Single-page exceptions are expected to require safety review.", + ], + } + (staging / "generation-report.json").write_text(json.dumps(result, indent=2) + "\n") + staging.rename(output) + return result + except BaseException: + shutil.rmtree(staging) + raise + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--container-root", required=True) + parser.add_argument("--pages", type=int, default=32) + parser.add_argument("--single-page-every", type=int, default=2000) + args = parser.parse_args() + print( + json.dumps( + upgrade_fixture( + args.source, + args.output, + container_root=args.container_root, + pages=args.pages, + single_page_every=args.single_page_every, + ), + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/pullbox/api/v1/import_job_control_actions.py b/src/pullbox/api/v1/import_job_control_actions.py index 3ec7fc14..3539d027 100644 --- a/src/pullbox/api/v1/import_job_control_actions.py +++ b/src/pullbox/api/v1/import_job_control_actions.py @@ -272,7 +272,8 @@ async def retry_failed_series_response( await session.commit() logger.info("import_retry_failed", job_id=job_id, retrying_count=count) - trigger_import_execute(job_id) + if count > 0: + trigger_import_execute(job_id) return RetryFailedResponse(job_id=job.id, retrying_count=count) diff --git a/src/pullbox/api/v1/series.py b/src/pullbox/api/v1/series.py index 347d44bc..bc23e6ef 100644 --- a/src/pullbox/api/v1/series.py +++ b/src/pullbox/api/v1/series.py @@ -6,7 +6,7 @@ from typing import Any, Literal import structlog -from fastapi import APIRouter, Query, Request, Response +from fastapi import APIRouter, HTTPException, Query, Request, Response from sqlalchemy import case, func, inspect, select, update from sqlalchemy.orm import contains_eager @@ -16,7 +16,7 @@ from pullbox.core.library_policy import load_search_on_add_default from pullbox.models.issue import Issue, IssueStatus from pullbox.models.search_log import SearchLog, SearchType -from pullbox.models.series import Series, SeriesStatus +from pullbox.models.series import IssueCatalogState, Series, SeriesStatus from pullbox.schemas.issue import IssueListResponse from pullbox.schemas.pagination import PaginatedResponse from pullbox.schemas.series import ( @@ -567,6 +567,14 @@ async def refresh_series( session: DbSession, ) -> SeriesResponse: """Refresh series metadata from ComicVine.""" + series = await session.get(Series, series_id) + if series is None: + raise NotFoundError("Series", series_id) + if series.issue_catalog_state == IssueCatalogState.HYDRATING: + raise HTTPException( + status_code=409, + detail="Initial metadata sync is already in progress.", + ) metadata_svc = await _build_metadata_service(session) await metadata_svc.refresh_series(session, series_id, force=True) return await _load_series_response(session, series_id) diff --git a/src/pullbox/app.py b/src/pullbox/app.py index 4c852437..29a91c76 100644 --- a/src/pullbox/app.py +++ b/src/pullbox/app.py @@ -492,9 +492,24 @@ def _cleanup_direct_recovery_task(task: asyncio.Task[object]) -> None: async def _resume_deferred_import_metadata() -> tuple[int, int]: async with factory() as session: import_service = await build_import_service(session) - comicinfo_jobs = await import_service.recover_pending_comicinfo_enrichment(factory) - hydrated_series = await import_service.recover_pending_catalog_hydration(factory) - return comicinfo_jobs, hydrated_series + # Each lane owns its sessions and already shares provider rate limiting. + # A long archive rewrite queue must not block visible catalog hydration. + results = await asyncio.gather( + import_service.recover_pending_comicinfo_enrichment(factory), + import_service.recover_pending_catalog_hydration(factory), + return_exceptions=True, + ) + for lane, result in zip(("comicinfo", "catalog"), results, strict=True): + if isinstance(result, BaseException): + logger.warning( + "import_metadata_lane_recovery_failed", + lane=lane, + exc_info=result, + ) + return ( + results[0] if isinstance(results[0], int) else 0, + results[1] if isinstance(results[1], int) else 0, + ) import_metadata_recovery_task = asyncio.create_task(_resume_deferred_import_metadata()) _startup_background_tasks.add(import_metadata_recovery_task) diff --git a/src/pullbox/cli.py b/src/pullbox/cli.py index 72a4dc39..1169b727 100644 --- a/src/pullbox/cli.py +++ b/src/pullbox/cli.py @@ -24,7 +24,7 @@ from pullbox.models.user import User from pullbox.services.auth_service import AuthService from pullbox.services.import_path_reconciliation import reconcile_saved_mylar_paths -from pullbox.services.import_review_recheck import prepare_review_recheck +from pullbox.services.import_review_recheck import prepare_import_recheck async def _reset_password(username: str, candidate_secret: str) -> None: @@ -95,7 +95,7 @@ async def _recheck_import(args: argparse.Namespace) -> None: if args.command == "reconcile-import-paths": report = await reconcile_saved_mylar_paths(session, args.job, **options) else: - report = await prepare_review_recheck( + report = await prepare_import_recheck( session, args.job, **options, @@ -106,11 +106,17 @@ async def _recheck_import(args: argparse.Namespace) -> None: else: await session.rollback() print(json.dumps({"applied": args.apply, **report}, sort_keys=True)) - if args.apply and report.get("series_prepared"): - print( - "Restart Pullbox to resume local matching of the saved review. " - "Sources were not modified." - ) + if args.apply: + if report.get("series_prepared"): + print( + "Restart Pullbox to resume local matching of the saved review. " + "Sources were not modified." + ) + elif report.get("files_prepared"): + print( + "Restart Pullbox, open the completed import, and choose Retry failed. " + "Only rechecked failures will run again." + ) finally: await engine.dispose() @@ -137,7 +143,7 @@ def build_parser() -> argparse.ArgumentParser: recheck = subparsers.add_parser( "recheck-import", help="Recheck saved import evidence while Pullbox is stopped" ) - recheck.add_argument("--job", required=True, type=int, help="Saved REVIEW job ID") + recheck.add_argument("--job", required=True, type=int, help="Saved REVIEW or COMPLETED job ID") recheck.add_argument( "--source-root", required=True, diff --git a/src/pullbox/composition/services.py b/src/pullbox/composition/services.py index 861ba04e..22f57019 100644 --- a/src/pullbox/composition/services.py +++ b/src/pullbox/composition/services.py @@ -209,6 +209,7 @@ async def build_metadata_service(session: AsyncSession) -> MetadataService: settings = get_settings() api_key = await get_comicvine_api_key(session) provider = ComicVineProvider(api_key=api_key) + provider = build_persistent_import_metadata_provider(session, provider) covers_dir = await resolve_covers_dir(session) return MetadataService( provider=provider, diff --git a/src/pullbox/config.py b/src/pullbox/config.py index ae1f0876..38fc8bc8 100644 --- a/src/pullbox/config.py +++ b/src/pullbox/config.py @@ -111,6 +111,7 @@ class PullboxSettings(BaseSettings): import_debug_phase_delay_seconds: float = 1.25 import_debug_item_delay_seconds: float = 0.4 import_file_worker_count: int = 2 + import_scan_worker_count: int = Field(default=0, ge=0, le=16) # ── Scheduler ────────────────────────────────────────────────────── search_interval_hours: int = 6 diff --git a/src/pullbox/core/import_resources.py b/src/pullbox/core/import_resources.py new file mode 100644 index 00000000..0857ebb9 --- /dev/null +++ b/src/pullbox/core/import_resources.py @@ -0,0 +1,169 @@ +"""Conservative container-aware budgets for read-only import inspection.""" + +from __future__ import annotations + +import asyncio +import math +import os +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import psutil + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable + +_MIB = 1024**2 + + +@dataclass(frozen=True) +class ImportResources: + cpu_count: int + available_memory_bytes: int + + def inspection_workers(self, *, requested: int = 0) -> int: + """Reserve CPU and memory for requests and other running services.""" + cpu_budget = max(1, self.cpu_count - math.ceil(self.cpu_count / 4)) + memory_budget = max(1, (self.available_memory_bytes - 512 * _MIB) // (512 * _MIB)) + ceiling = max(1, min(cpu_budget, memory_budget, 16)) + # Local SSD measurements show no gain from saturating the CPU with ZIP parsing. + return min(ceiling, requested) if requested > 0 else min(ceiling, 4) + + +def _visible_cpus() -> int: + try: + affinity_reader = getattr(psutil.Process(), "cpu_affinity", None) + affinity = affinity_reader() if affinity_reader is not None else [] + if affinity: + return len(affinity) + except (AttributeError, OSError, psutil.Error): + pass + return os.cpu_count() or 1 + + +def _available_memory() -> int: + try: + return int(psutil.virtual_memory().available) + except (OSError, psutil.Error): + return 0 + + +def _read(path: Path) -> str: + try: + return path.read_text().strip() + except (OSError, UnicodeError): + return "" + + +def _positive_integer(value: str) -> int | None: + try: + parsed = int(value) + except ValueError: + return None + return parsed if parsed >= 0 else None + + +def detect_import_resources( + *, cgroup_path: Path | None = None, cgroup_root: Path = Path("/sys/fs/cgroup") +) -> ImportResources: + """Use visible resources, honoring cgroup v2 ancestors and v1 limits.""" + cpus = max(1, _visible_cpus()) + memory = max(0, _available_memory()) + if cgroup_path is None: + cgroup_path = cgroup_root + for line in _read(Path("/proc/self/cgroup")).splitlines(): + if line.startswith("0::"): + candidate = cgroup_root / line[3:].lstrip("/") + if ".." not in candidate.parts and candidate.is_dir(): + cgroup_path = candidate + break + groups = [cgroup_path] + if cgroup_path.is_relative_to(cgroup_root): + groups.extend( + parent for parent in cgroup_path.parents if parent.is_relative_to(cgroup_root) + ) + for group in groups: + quota = _read(group / "cpu.max").split() + if len(quota) == 2: + limit, period = (_positive_integer(value) for value in quota) + if limit is not None and period: + cpus = min(cpus, max(1, limit // period)) + maximum = _positive_integer(_read(group / "memory.max")) + used = _positive_integer(_read(group / "memory.current")) + if maximum is not None: + memory = min(memory, max(0, maximum - used)) if used is not None else 0 + # Common v1 container mounts. Affinity also accounts for cpuset restrictions. + quota_v1 = _positive_integer(_read(cgroup_root / "cpu/cpu.cfs_quota_us")) + period_v1 = _positive_integer(_read(cgroup_root / "cpu/cpu.cfs_period_us")) + if quota_v1 is not None and period_v1: + cpus = min(cpus, max(1, quota_v1 // period_v1)) + maximum_v1 = _positive_integer(_read(cgroup_root / "memory/memory.limit_in_bytes")) + used_v1 = _positive_integer(_read(cgroup_root / "memory/memory.usage_in_bytes")) + if maximum_v1 is not None: + memory = min(memory, max(0, maximum_v1 - used_v1)) if used_v1 is not None else 0 + return ImportResources(cpus, memory) + + +@asynccontextmanager +async def bounded_thread_map[Input, Output]( + function: Callable[[Input], Output], values: Iterable[Input], *, workers: int +) -> AsyncIterator[AsyncIterator[Output]]: + """Run bounded filesystem work without passing a database session to threads.""" + + async def run(value: Input) -> Output: + return await asyncio.to_thread(function, value) + + async with bounded_async_map(run, values, workers=workers) as results: + yield results + + +@asynccontextmanager +async def bounded_async_map[Input, Output]( + function: Callable[[Input], Awaitable[Output]], values: Iterable[Input], *, workers: int +) -> AsyncIterator[AsyncIterator[Output]]: + """Yield completed work with bounded submission and drain active work on exit. + + Canceling an asyncio wrapper cannot stop a running filesystem operation. + Keep ownership until it finishes, including when the consumer raises or exits. + """ + source = iter(values) + tasks: set[asyncio.Task[Output]] = set() + + async def run(item: Input) -> Output: + return await function(item) + + async def results() -> AsyncGenerator[Output, None]: + exhausted = False + while tasks or not exhausted: + while not exhausted and len(tasks) < max(1, workers): + try: + value = next(source) + except StopIteration: + exhausted = True + else: + tasks.add(asyncio.create_task(run(value))) + if not tasks: + break + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in done: + result = task.result() + tasks.remove(task) + yield result + + iterator = results() + try: + yield iterator + finally: + await iterator.aclose() + if tasks: + drain = asyncio.gather(*tasks, return_exceptions=True) + interrupted = False + while not drain.done(): + try: + await asyncio.shield(drain) + except asyncio.CancelledError: + interrupted = True + if interrupted: + raise asyncio.CancelledError diff --git a/src/pullbox/core/issue_numbers.py b/src/pullbox/core/issue_numbers.py index 344920ce..826404f6 100644 --- a/src/pullbox/core/issue_numbers.py +++ b/src/pullbox/core/issue_numbers.py @@ -35,8 +35,20 @@ def _issue_number_parts(value: str | float | int) -> tuple[Decimal, str]: raw_value = str(value).strip() if not raw_value: raise ValueError("issue number must not be blank") + if len(raw_value) > _MAX_ISSUE_NUMBER_TEXT_LENGTH: + raise ValueError("issue number exceeds the supported exact-text length") normalized_fraction = raw_value.replace("½", ".5").replace("¼", ".25").replace("¾", ".75") + suffix_start = len(normalized_fraction) + while suffix_start > 0: + character = normalized_fraction[suffix_start - 1] + if not character.isascii() or not character.isalpha(): + break + suffix_start -= 1 + if suffix_start < len(normalized_fraction): + numeric_prefix = normalized_fraction[:suffix_start].rstrip() + if numeric_prefix and numeric_prefix[-1] in "0123456789.": + normalized_fraction = numeric_prefix + normalized_fraction[suffix_start:] suffix = "" try: numeric_value = Decimal(normalized_fraction) diff --git a/src/pullbox/core/source_metadata.py b/src/pullbox/core/source_metadata.py index 5a0493e1..b5bcd3a5 100644 --- a/src/pullbox/core/source_metadata.py +++ b/src/pullbox/core/source_metadata.py @@ -6,6 +6,7 @@ import re from collections import Counter from dataclasses import dataclass, field, replace +from functools import lru_cache from pathlib import Path from typing import Any, TypedDict, cast @@ -204,6 +205,19 @@ def archive_entry_issue_hint_from_names( if len(image_entries) < _ARCHIVE_HINT_MIN_PARSEABLE_IMAGES: return None + @lru_cache(maxsize=256) + def parse_candidate(title: str) -> tuple[str, float, int | None] | None: + # Page suffixes often reduce hundreds of filenames to one release title. + parsed = parse_release_title(title) + if parsed is None or parsed.issue_number is None or not parsed.series_name: + return None + if expected_series_name and not _archive_entry_series_matches( + parsed.series_name, + expected_series_name, + ): + return None + return parsed.series_name, float(parsed.issue_number), parsed.year + parsed_entries: list[tuple[str, float, int | None, str]] = [] for entry in image_entries: file_name = entry.replace("\\", "/").rsplit("/", 1)[-1] @@ -211,15 +225,9 @@ def archive_entry_issue_hint_from_names( candidate_title = _ARCHIVE_PAGE_SUFFIX_RE.sub("", stem).strip() if not candidate_title or candidate_title == stem: continue - parsed = parse_release_title(candidate_title) - if parsed is None or parsed.issue_number is None or not parsed.series_name: - continue - if expected_series_name and not _archive_entry_series_matches( - parsed.series_name, - expected_series_name, - ): - continue - parsed_entries.append((parsed.series_name, float(parsed.issue_number), parsed.year, entry)) + identity = parse_candidate(candidate_title) + if identity is not None: + parsed_entries.append((*identity, entry)) total_image_entries = len(image_entries) parseable_image_entries = len(parsed_entries) diff --git a/src/pullbox/core/sqlite_lock.py b/src/pullbox/core/sqlite_lock.py index 23a94781..9b72eebe 100644 --- a/src/pullbox/core/sqlite_lock.py +++ b/src/pullbox/core/sqlite_lock.py @@ -2,6 +2,14 @@ from __future__ import annotations +import asyncio +from typing import TYPE_CHECKING, Any + +from sqlalchemy.exc import OperationalError + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + SQLITE_LOCK_RETRY_ATTEMPTS = 8 SQLITE_LOCK_RETRY_BASE_DELAY_SECONDS = 0.25 @@ -15,3 +23,34 @@ def is_sqlite_locked_error(exc: BaseException) -> bool: def sqlite_lock_retry_delay(attempt: int) -> float: """Return the linear backoff delay for a SQLite lock retry attempt.""" return SQLITE_LOCK_RETRY_BASE_DELAY_SECONDS * attempt + + +async def run_sqlite_transaction_with_retry[T]( + session: Any, + operation: Callable[[], Awaitable[T]], + *, + event_name: str, + logger: Any, + retry_delay: Callable[[int], float] = sqlite_lock_retry_delay, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + attempts: int = SQLITE_LOCK_RETRY_ATTEMPTS, +) -> T: + """Commit a short transaction, retrying only transient SQLite lock failures.""" + for attempt in range(1, attempts + 1): + try: + result = await operation() + await session.commit() + return result + except OperationalError as exc: + await session.rollback() + if not is_sqlite_locked_error(exc) or attempt == attempts: + raise + delay_seconds = retry_delay(attempt) + logger.warning( + f"{event_name}_retrying_after_sqlite_lock", + attempt=attempt, + max_attempts=attempts, + delay_seconds=delay_seconds, + ) + await sleep(delay_seconds) + raise RuntimeError("SQLite transaction retry loop ended unexpectedly") diff --git a/src/pullbox/providers/metadata/comicvine.py b/src/pullbox/providers/metadata/comicvine.py index 4ac394c1..f6be830c 100644 --- a/src/pullbox/providers/metadata/comicvine.py +++ b/src/pullbox/providers/metadata/comicvine.py @@ -11,9 +11,11 @@ from __future__ import annotations import asyncio +import hashlib import html import re import time +import weakref from typing import TYPE_CHECKING, Any import httpx @@ -51,6 +53,8 @@ _GLOBAL_SERIES_SEARCH_BATCH_SIZE = 100 _GLOBAL_SERIES_SEARCH_MAX_RESULTS = 1000 _GLOBAL_SERIES_SEARCH_CACHE_TTL_SECONDS = 300.0 +_BULK_RESOURCE_PAGE_SIZE = 100 +_MAX_BULK_PROVIDER_IDS = 5000 _GLOBAL_SERIES_SEARCH_CACHE: dict[ tuple[str, int, int], tuple[float, tuple[SeriesSearchResult, ...], int], @@ -94,6 +98,78 @@ def _refill(self) -> None: self._last_refill = now +class _RequestVelocityGate: + """Serialize request starts without conflating velocity and hourly quotas.""" + + def __init__(self) -> None: + self._last_started_at: float | None = None + self._lock = asyncio.Lock() + + async def acquire(self, requests_per_second: int) -> None: + minimum_interval = 1.0 / max(int(requests_per_second), 1) + async with self._lock: + now = time.monotonic() + if self._last_started_at is not None: + wait_seconds = minimum_interval - (now - self._last_started_at) + if wait_seconds > 0: + await asyncio.sleep(wait_seconds) + self._last_started_at = time.monotonic() + + +class _ComicVineRateCoordinator: + """Share per-resource hourly budgets and velocity pacing across clients.""" + + def __init__(self, rate_limit: int) -> None: + self._rate_limit = max(int(rate_limit), 1) + self._resource_limiters: dict[str, _TokenBucket] = {} + self._velocity_gate = _RequestVelocityGate() + + async def acquire(self, resource: str, *, requests_per_second: int) -> None: + limiter = self._resource_limiters.get(resource) + if limiter is None: + limiter = _TokenBucket( + max_tokens=self._rate_limit, + refill_rate=self._rate_limit / 3600.0, + ) + self._resource_limiters[resource] = limiter + await limiter.acquire() + await self._velocity_gate.acquire(requests_per_second) + + +_RATE_COORDINATORS: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, + dict[tuple[str, int], _ComicVineRateCoordinator], +] = weakref.WeakKeyDictionary() + + +def _resource_rate_key(endpoint: str) -> str: + """Return ComicVine's first path segment, which defines its API resource.""" + return endpoint.strip("/").partition("/")[0] or "unknown" + + +def _rate_coordinator_for( + *, + api_key: str, + rate_limit: int, + requests_per_second: int, +) -> _ComicVineRateCoordinator: + """Return one process-local rate coordinator without retaining an API key.""" + _ = requests_per_second + loop = asyncio.get_running_loop() + coordinators = _RATE_COORDINATORS.setdefault(loop, {}) + # This non-secret fingerprint only groups an in-memory rate limiter; it is + # not stored or used to authenticate the API key as a password. + # codeql[py/weak-sensitive-data-hashing] + key_fingerprint = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + normalized_rate_limit = max(int(rate_limit), 1) + coordinator_key = (key_fingerprint, normalized_rate_limit) + coordinator = coordinators.get(coordinator_key) + if coordinator is None: + coordinator = _ComicVineRateCoordinator(normalized_rate_limit) + coordinators[coordinator_key] = coordinator + return coordinator + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -170,6 +246,31 @@ def _series_search_result_from_item(item: dict[str, Any]) -> SeriesSearchResult: ) +def _series_metadata_from_item( + item: dict[str, Any], + *, + fallback_provider_id: str, +) -> SeriesMetadata: + publisher_data = item.get("publisher") + publisher_name = publisher_data.get("name") if isinstance(publisher_data, dict) else None + image_data = item.get("image") + cover_url = image_data.get("medium_url") if isinstance(image_data, dict) else None + title = item.get("name", "Unknown") + return SeriesMetadata( + provider_id=str(item.get("id", fallback_provider_id)), + title=title, + sort_title=_make_sort_title(title), + year_start=_safe_int(item.get("start_year")), + year_end=None, + status=None, + publisher=publisher_name, + description=_strip_html(item.get("description") or item.get("deck")), + cover_url=cover_url, + issue_count=_safe_int(item.get("count_of_issues")), + comicvine_url=item.get("site_detail_url"), + ) + + def _issue_summary_from_item(item: dict[str, Any]) -> IssueSummary: image_data = item.get("image") cover_url = image_data.get("medium_url") if isinstance(image_data, dict) else None @@ -186,6 +287,48 @@ def _issue_summary_from_item(item: dict[str, Any]) -> IssueSummary: ) +def _issue_metadata_from_item( + item: dict[str, Any], + *, + fallback_provider_id: str, +) -> IssueMetadata: + volume_data = item.get("volume") + series_provider_id = str(volume_data["id"]) if isinstance(volume_data, dict) else "" + image_data = item.get("image") + cover_url = image_data.get("medium_url") if isinstance(image_data, dict) else None + issue_number, issue_number_text = _parse_issue_number_fields(item.get("issue_number")) + return IssueMetadata( + provider_id=str(item.get("id", fallback_provider_id)), + series_provider_id=series_provider_id, + issue_number=issue_number, + title=item.get("name"), + description=_strip_html(item.get("description") or item.get("deck")), + release_date=item.get("cover_date"), + store_date=item.get("store_date"), + cover_url=cover_url, + page_count=None, + comicvine_url=item.get("site_detail_url"), + creators=_extract_creators(item.get("person_credits")), + story_arcs=_extract_story_arcs(item.get("story_arc_credits")), + issue_number_text=issue_number_text, + ) + + +def _normalize_bulk_provider_ids(provider_ids: Sequence[str]) -> list[str]: + if isinstance(provider_ids, (str, bytes)) or len(provider_ids) > _MAX_BULK_PROVIDER_IDS: + raise ValueError("ComicVine batch requests require at most 5000 provider IDs") + normalized: list[str] = [] + seen: set[str] = set() + for value in provider_ids: + provider_id = str(value).strip() + if re.fullmatch(r"[1-9][0-9]{0,18}", provider_id) is None: + raise ValueError("ComicVine provider IDs must be positive integers") + if provider_id not in seen: + seen.add(provider_id) + normalized.append(provider_id) + return normalized + + def _discard_global_series_search_inflight( key: _GlobalSeriesSearchInflightKey, task: asyncio.Future[tuple[tuple[SeriesSearchResult, ...], int]], @@ -274,15 +417,10 @@ def __init__( headers={"User-Agent": "Pullbox/1.0"}, ) normalized_rate_limit = max(int(rate_limit), 1) - normalized_burst_limit = ( - normalized_rate_limit - if burst_limit is None - else max(1, min(int(burst_limit), normalized_rate_limit)) - ) - self._rate_limiter = _TokenBucket( - max_tokens=normalized_burst_limit, - refill_rate=normalized_rate_limit / 3600.0, - ) + normalized_burst_limit = 1 if burst_limit is None else max(1, min(int(burst_limit), 10)) + self._rate_limit = normalized_rate_limit + self._requests_per_second = normalized_burst_limit + self._rate_coordinator: _ComicVineRateCoordinator | None = None @property def name(self) -> str: @@ -296,7 +434,16 @@ async def _request( params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make a rate-limited GET request to the ComicVine API.""" - await self._rate_limiter.acquire() + if self._rate_coordinator is None: + self._rate_coordinator = _rate_coordinator_for( + api_key=self._api_key, + rate_limit=self._rate_limit, + requests_per_second=self._requests_per_second, + ) + await self._rate_coordinator.acquire( + _resource_rate_key(endpoint), + requests_per_second=self._requests_per_second, + ) request_params: dict[str, Any] = { "api_key": self._api_key, @@ -627,27 +774,34 @@ async def get_series(self, provider_id: str) -> SeriesMetadata: data = await self._request(f"/volume/{_VOLUME_PREFIX}-{provider_id}/", params) item: dict[str, Any] = data.get("results", {}) - publisher_data = item.get("publisher") - publisher_name = publisher_data.get("name") if isinstance(publisher_data, dict) else None - - image_data = item.get("image") - cover_url = image_data.get("medium_url") if isinstance(image_data, dict) else None - - title = item.get("name", "Unknown") - - return SeriesMetadata( - provider_id=str(item.get("id", provider_id)), - title=title, - sort_title=_make_sort_title(title), - year_start=_safe_int(item.get("start_year")), - year_end=None, # ComicVine doesn't provide end year directly - status=None, # ComicVine volumes lack an explicit status field - publisher=publisher_name, - description=_strip_html(item.get("description") or item.get("deck")), - cover_url=cover_url, - issue_count=_safe_int(item.get("count_of_issues")), - comicvine_url=item.get("site_detail_url"), + return _series_metadata_from_item(item, fallback_provider_id=provider_id) + + async def get_series_batch(self, provider_ids: Sequence[str]) -> dict[str, SeriesMetadata]: + """Fetch volume profiles in bounded ID-filter batches.""" + normalized = _normalize_bulk_provider_ids(provider_ids) + found: dict[str, SeriesMetadata] = {} + field_list = ( + "id,name,start_year,count_of_issues,publisher,image,description,deck,site_detail_url" ) + for start in range(0, len(normalized), _BULK_RESOURCE_PAGE_SIZE): + batch = normalized[start : start + _BULK_RESOURCE_PAGE_SIZE] + data = await self._request( + "/volumes/", + { + "filter": f"id:{'|'.join(batch)}", + "field_list": field_list, + "limit": _BULK_RESOURCE_PAGE_SIZE, + "offset": 0, + }, + ) + for raw_item in data.get("results", []): + item = raw_item if isinstance(raw_item, dict) else {} + metadata = _series_metadata_from_item(item, fallback_provider_id="") + if metadata.provider_id in batch: + found[metadata.provider_id] = metadata + return { + provider_id: found[provider_id] for provider_id in normalized if provider_id in found + } async def get_issue(self, provider_id: str) -> IssueMetadata: """Get full issue metadata by ComicVine issue ID.""" @@ -665,28 +819,35 @@ async def get_issue(self, provider_id: str) -> IssueMetadata: data = await self._request(f"/issue/{_ISSUE_PREFIX}-{provider_id}/", params) item: dict[str, Any] = data.get("results", {}) - volume_data = item.get("volume") - series_provider_id = str(volume_data["id"]) if isinstance(volume_data, dict) else "" - - image_data = item.get("image") - cover_url = image_data.get("medium_url") if isinstance(image_data, dict) else None - issue_number, issue_number_text = _parse_issue_number_fields(item.get("issue_number")) + return _issue_metadata_from_item(item, fallback_provider_id=provider_id) - return IssueMetadata( - provider_id=str(item.get("id", provider_id)), - series_provider_id=series_provider_id, - issue_number=issue_number, - title=item.get("name"), - description=_strip_html(item.get("description") or item.get("deck")), - release_date=item.get("cover_date"), - store_date=item.get("store_date"), - cover_url=cover_url, - page_count=None, # Not available from ComicVine - comicvine_url=item.get("site_detail_url"), - creators=_extract_creators(item.get("person_credits")), - story_arcs=_extract_story_arcs(item.get("story_arc_credits")), - issue_number_text=issue_number_text, + async def get_issue_batch(self, provider_ids: Sequence[str]) -> dict[str, IssueMetadata]: + """Fetch full issue metadata in bounded ID-filter batches.""" + normalized = _normalize_bulk_provider_ids(provider_ids) + found: dict[str, IssueMetadata] = {} + field_list = ( + "id,volume,issue_number,name,description,deck,cover_date,store_date," + "image,site_detail_url,person_credits,story_arc_credits" ) + for start in range(0, len(normalized), _BULK_RESOURCE_PAGE_SIZE): + batch = normalized[start : start + _BULK_RESOURCE_PAGE_SIZE] + data = await self._request( + "/issues/", + { + "filter": f"id:{'|'.join(batch)}", + "field_list": field_list, + "limit": _BULK_RESOURCE_PAGE_SIZE, + "offset": 0, + }, + ) + for raw_item in data.get("results", []): + item = raw_item if isinstance(raw_item, dict) else {} + metadata = _issue_metadata_from_item(item, fallback_provider_id="") + if metadata.provider_id in batch: + found[metadata.provider_id] = metadata + return { + provider_id: found[provider_id] for provider_id in normalized if provider_id in found + } async def get_issues_for_series(self, series_provider_id: str) -> list[IssueSummary]: """Get all issues for a series (volume), paginating automatically.""" @@ -723,6 +884,40 @@ async def get_issues_for_series(self, series_provider_id: str) -> list[IssueSumm log.debug("comicvine_issues_fetched", count=len(all_issues)) return all_issues + async def get_issue_catalog_batch( + self, + series_provider_ids: Sequence[str], + ) -> dict[str, list[IssueSummary]]: + """Fetch and group issue summaries for multiple volumes.""" + normalized = _normalize_bulk_provider_ids(series_provider_ids) + grouped: dict[str, list[IssueSummary]] = {provider_id: [] for provider_id in normalized} + for start in range(0, len(normalized), _BULK_RESOURCE_PAGE_SIZE): + batch = normalized[start : start + _BULK_RESOURCE_PAGE_SIZE] + offset = 0 + while True: + data = await self._request( + "/issues/", + { + "filter": f"volume:{'|'.join(batch)}", + "field_list": "id,volume,issue_number,name,cover_date,image", + "sort": "issue_number:asc", + "limit": _BULK_RESOURCE_PAGE_SIZE, + "offset": offset, + }, + ) + items = data.get("results", []) + for raw_item in items: + item = raw_item if isinstance(raw_item, dict) else {} + volume = item.get("volume") + volume_id = str(volume.get("id")) if isinstance(volume, dict) else "" + if volume_id in grouped: + grouped[volume_id].append(_issue_summary_from_item(item)) + total = _safe_int(data.get("number_of_total_results")) or 0 + offset += len(items) + if not items or offset >= total: + break + return grouped + async def get_recent_issues_for_series( self, series_provider_id: str, diff --git a/src/pullbox/services/comicvine_persistent_cache.py b/src/pullbox/services/comicvine_persistent_cache.py index 5af349e3..f0747ba6 100644 --- a/src/pullbox/services/comicvine_persistent_cache.py +++ b/src/pullbox/services/comicvine_persistent_cache.py @@ -166,6 +166,52 @@ async def get_series(self, series_provider_id: str) -> SeriesMetadata: _series_metadata_from_payload, ) + async def get_series_batch( + self, + series_provider_ids: list[str], + ) -> dict[str, SeriesMetadata]: + """Resolve series profiles from singular cache rows, batching misses upstream.""" + provider_ids = _ordered_provider_ids(series_provider_ids) + found: dict[str, SeriesMetadata] = {} + missing: list[str] = [] + for provider_id in provider_ids: + request = {"series_provider_id": provider_id} + payload = await self._load_cached_payload("get_series", _cache_key(request)) + if payload is None: + self._stats.misses["get_series"] += 1 + missing.append(provider_id) + else: + self._stats.hits["get_series"] += 1 + found[provider_id] = _series_metadata_from_payload(payload) + + if missing: + started_at = time.monotonic() + batch_fetch = _declared_provider_method(self._provider, "get_series_batch") + if callable(batch_fetch): + fetched = await batch_fetch(missing) + else: + fetched = { + provider_id: await self._provider.get_series(provider_id) + for provider_id in missing + } + self._stats.external_calls["get_series"] += 1 + self._stats.external_duration_ms["get_series"] += (time.monotonic() - started_at) * 1000 + for provider_id in missing: + metadata = fetched.get(provider_id) + if metadata is None: + continue + found[provider_id] = metadata + request = {"series_provider_id": provider_id} + await self._store_payload( + "get_series", + _cache_key(request), + request, + _series_metadata_to_payload(metadata), + ) + return { + provider_id: found[provider_id] for provider_id in provider_ids if provider_id in found + } + async def get_series_cached(self, series_provider_id: str) -> SeriesMetadata | None: """Return cached series metadata without making a provider request.""" request = {"series_provider_id": str(series_provider_id)} @@ -175,6 +221,18 @@ async def get_series_cached(self, series_provider_id: str) -> SeriesMetadata | N self._stats.hits["get_series"] += 1 return _series_metadata_from_payload(payload) + async def refresh_series(self, series_provider_id: str) -> SeriesMetadata: + """Bypass a fresh cache row, then replace it with current provider data.""" + metadata = cast("SeriesMetadata", await self._provider.get_series(series_provider_id)) + request = {"series_provider_id": str(series_provider_id)} + await self._store_payload( + "get_series", + _cache_key(request), + request, + _series_metadata_to_payload(metadata), + ) + return metadata + async def get_issue(self, issue_provider_id: str) -> IssueMetadata: request = {"issue_provider_id": str(issue_provider_id)} return await self._get_or_fetch( @@ -185,6 +243,52 @@ async def get_issue(self, issue_provider_id: str) -> IssueMetadata: _issue_metadata_from_payload, ) + async def get_issue_batch( + self, + issue_provider_ids: list[str], + ) -> dict[str, IssueMetadata]: + """Resolve full issue metadata from singular cache rows and one batch miss path.""" + provider_ids = _ordered_provider_ids(issue_provider_ids) + found: dict[str, IssueMetadata] = {} + missing: list[str] = [] + for provider_id in provider_ids: + request = {"issue_provider_id": provider_id} + payload = await self._load_cached_payload("get_issue", _cache_key(request)) + if payload is None: + self._stats.misses["get_issue"] += 1 + missing.append(provider_id) + else: + self._stats.hits["get_issue"] += 1 + found[provider_id] = _issue_metadata_from_payload(payload) + + if missing: + started_at = time.monotonic() + batch_fetch = _declared_provider_method(self._provider, "get_issue_batch") + if callable(batch_fetch): + fetched = await batch_fetch(missing) + else: + fetched = { + provider_id: await self._provider.get_issue(provider_id) + for provider_id in missing + } + self._stats.external_calls["get_issue"] += 1 + self._stats.external_duration_ms["get_issue"] += (time.monotonic() - started_at) * 1000 + for provider_id in missing: + metadata = fetched.get(provider_id) + if metadata is None: + continue + found[provider_id] = metadata + request = {"issue_provider_id": provider_id} + await self._store_payload( + "get_issue", + _cache_key(request), + request, + _issue_metadata_to_payload(metadata), + ) + return { + provider_id: found[provider_id] for provider_id in provider_ids if provider_id in found + } + async def get_issues_for_series(self, series_provider_id: str) -> list[IssueSummary]: request = {"series_provider_id": str(series_provider_id)} return await self._get_or_fetch( @@ -195,6 +299,82 @@ async def get_issues_for_series(self, series_provider_id: str) -> list[IssueSumm _issue_summaries_from_payload, ) + async def get_issue_catalog_batch( + self, + series_provider_ids: list[str], + ) -> dict[str, list[IssueSummary]]: + """Resolve issue catalogs from singular cache rows, batching missing volumes.""" + provider_ids = _ordered_provider_ids(series_provider_ids) + found: dict[str, list[IssueSummary]] = {} + missing: list[str] = [] + for provider_id in provider_ids: + request = {"series_provider_id": provider_id} + payload = await self._load_cached_payload( + "get_issues_for_series", + _cache_key(request), + ) + if payload is None: + self._stats.misses["get_issues_for_series"] += 1 + missing.append(provider_id) + else: + self._stats.hits["get_issues_for_series"] += 1 + found[provider_id] = _issue_summaries_from_payload(payload) + + if missing: + started_at = time.monotonic() + batch_fetch = _declared_provider_method(self._provider, "get_issue_catalog_batch") + if callable(batch_fetch): + fetched = await batch_fetch(missing) + else: + fetched = { + provider_id: await self._provider.get_issues_for_series(provider_id) + for provider_id in missing + } + self._stats.external_calls["get_issues_for_series"] += 1 + self._stats.external_duration_ms["get_issues_for_series"] += ( + time.monotonic() - started_at + ) * 1000 + for provider_id in missing: + summaries = fetched.get(provider_id) + if summaries is None: + continue + found[provider_id] = summaries + request = {"series_provider_id": provider_id} + await self._store_payload( + "get_issues_for_series", + _cache_key(request), + request, + _issue_summaries_to_payload(summaries), + ) + return { + provider_id: found[provider_id] for provider_id in provider_ids if provider_id in found + } + + async def refresh_issue_catalog(self, series_provider_id: str) -> list[IssueSummary]: + """Bypass a fresh catalog cache row, then replace it atomically.""" + batch_fetch = _declared_provider_method(self._provider, "get_issue_catalog_batch") + if callable(batch_fetch): + summaries = cast( + "list[IssueSummary]", + (await batch_fetch([str(series_provider_id)])).get( + str(series_provider_id), + [], + ), + ) + else: + summaries = cast( + "list[IssueSummary]", + await self._provider.get_issues_for_series(series_provider_id), + ) + request = {"series_provider_id": str(series_provider_id)} + await self._store_payload( + "get_issues_for_series", + _cache_key(request), + request, + _issue_summaries_to_payload(summaries), + ) + return summaries + async def get_issues_for_series_by_numbers( self, series_provider_id: str, @@ -509,6 +689,10 @@ def _cache_key(request: dict[str, Any]) -> str: return hashlib.sha256(canonical.encode("utf-8")).hexdigest() +def _ordered_provider_ids(provider_ids: list[str]) -> list[str]: + return list(dict.fromkeys(str(provider_id) for provider_id in provider_ids)) + + def _clear_inflight( inflight_key: tuple[str, str], completed: asyncio.Task[dict[str, Any]], diff --git a/src/pullbox/services/health_database_checks.py b/src/pullbox/services/health_database_checks.py index a7f4cb1d..d6a343e1 100644 --- a/src/pullbox/services/health_database_checks.py +++ b/src/pullbox/services/health_database_checks.py @@ -24,8 +24,6 @@ _DB_CONNECTION_UNHEALTHY_MS = 1000.0 _DB_QUERY_DEGRADED_MS = 500.0 _DB_QUERY_UNHEALTHY_MS = 1500.0 -_DB_SIZE_DEGRADED_MB = 1024.0 -_DB_SIZE_UNHEALTHY_MB = 2048.0 _DB_BLOAT_DEGRADED_RATIO = 0.15 _DB_BLOAT_UNHEALTHY_RATIO = 0.3 _DB_BLOAT_DEGRADED_MB = 50.0 @@ -233,25 +231,15 @@ async def check_db_size(session: AsyncSession) -> SubCheckOutcome | None: size_mb = size_bytes / (1024 * 1024) - if size_mb > _DB_SIZE_UNHEALTHY_MB: - return SubCheckOutcome( - check_name="database_size", - name="Database size", - status=HealthStatus.UNHEALTHY, - message=f"{size_mb:.0f} MB (threshold: {_DB_SIZE_UNHEALTHY_MB:.0f} MB)", - ) - if size_mb > _DB_SIZE_DEGRADED_MB: - return SubCheckOutcome( - check_name="database_size", - name="Database size", - status=HealthStatus.DEGRADED, - message=f"{size_mb:.0f} MB (threshold: {_DB_SIZE_DEGRADED_MB:.0f} MB)", - ) return SubCheckOutcome( check_name="database_size", name="Database size", status=HealthStatus.HEALTHY, - message=f"{size_mb:.1f} MB", + message=f"{size_mb:.1f} MB (informational)", + details={ + "size_bytes": size_bytes, + "classification": "informational", + }, ) diff --git a/src/pullbox/services/health_service.py b/src/pullbox/services/health_service.py index 84597f7c..b39cbccf 100644 --- a/src/pullbox/services/health_service.py +++ b/src/pullbox/services/health_service.py @@ -148,7 +148,12 @@ async def run_all_checks( for factory, component, check_name in checks: if component == "comicvine" and skip_comicvine: continue - results = await self._safe_run(factory(), component, check_name) + results = await self._safe_run( + factory(), + component, + check_name, + session=session, + ) outcomes.extend(results) await self._persist_outcomes(session, outcomes) @@ -183,7 +188,12 @@ async def run_check( ] factory, check_name = dispatch[component] - outcomes = await self._safe_run(factory(), component, check_name) + outcomes = await self._safe_run( + factory(), + component, + check_name, + session=session, + ) await self._persist_outcomes(session, outcomes) return outcomes @@ -455,6 +465,8 @@ async def _safe_run( coro: Awaitable[CheckOutcome | list[CheckOutcome]], component: str, check_name: str, + *, + session: AsyncSession | None = None, ) -> list[CheckOutcome]: """Run a check coroutine with timeout and exception isolation.""" start = time.perf_counter() @@ -476,6 +488,7 @@ async def _safe_run( return outcomes except TimeoutError: + await self._rollback_failed_check_session(session) elapsed_ms = (time.perf_counter() - start) * 1000 logger.warning("health_check_timeout", component=component, check_name=check_name) return [ @@ -493,6 +506,7 @@ async def _safe_run( ) ] except Exception as exc: + await self._rollback_failed_check_session(session) elapsed_ms = (time.perf_counter() - start) * 1000 logger.exception( "health_check_error", component=component, check_name=check_name, error=str(exc) @@ -511,6 +525,16 @@ async def _safe_run( ) ] + @staticmethod + async def _rollback_failed_check_session(session: AsyncSession | None) -> None: + """Restore a failed check transaction before later checks or persistence.""" + if session is None or session.is_active: + return + try: + await session.rollback() + except Exception: + logger.warning("health_check_session_rollback_failed", exc_info=True) + @staticmethod async def _persist_outcomes( session: AsyncSession, diff --git a/src/pullbox/services/import_catalog_hydration.py b/src/pullbox/services/import_catalog_hydration.py index 1b03b701..2e0a6871 100644 --- a/src/pullbox/services/import_catalog_hydration.py +++ b/src/pullbox/services/import_catalog_hydration.py @@ -11,6 +11,11 @@ from pullbox.core.library_root_resolution import preferred_managed_root_id from pullbox.models.series import IssueCatalogState, Series +from pullbox.services.import_metadata_priority import catalog_metadata_work +from pullbox.services.import_metadata_progress import ( + catalog_hydration_import_job_ids, + track_import_metadata_progress, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -19,6 +24,8 @@ logger = structlog.get_logger(__name__) +COMICVINE_BULK_BATCH_SIZE = 5_000 + catalog_hydration_tasks: set[asyncio.Task[None]] = set() _catalog_hydration_semaphore: asyncio.Semaphore | None = None _catalog_hydration_semaphore_loop: asyncio.AbstractEventLoop | None = None @@ -51,35 +58,31 @@ def schedule_catalog_hydration( series_id: int, search_on_add: bool, ) -> None: - """Queue full catalog hydration after the Step 4 file-placement hot path.""" + """Nudge the singleton catalog drain after the Step 4 file-placement hot path.""" if session_factory is None: return - hydration_methods = _catalog_hydration_methods(series_service) - if hydration_methods is None: + if _catalog_hydration_methods(series_service) is None: + return + if any(not task.done() for task in catalog_hydration_tasks): return - prefetch_comicvine_bundle, add_from_comicvine_prefetched = hydration_methods async def run_hydration() -> None: - async with catalog_hydration_gate(): + _ = series_id, search_on_add + while True: try: - await run_catalog_hydration( + await run_pending_catalog_hydration( session_factory, - series_id=series_id, - search_on_add=search_on_add, - prefetch_comicvine_bundle=prefetch_comicvine_bundle, - add_from_comicvine_prefetched=add_from_comicvine_prefetched, + series_service=series_service, ) except Exception as exc: - await mark_catalog_hydration_failed( - session_factory, - series_id=series_id, - error=str(exc), - ) logger.warning( - "import_catalog_hydration_failed", - series_id=series_id, + "import_catalog_hydration_drain_failed", error=str(exc), ) + return + await asyncio.sleep(0) + if not await load_pending_catalog_hydration(session_factory, limit=1): + return task = asyncio.create_task(run_hydration()) catalog_hydration_tasks.add(task) @@ -100,7 +103,21 @@ async def run_pending_catalog_hydration( pending = await load_pending_catalog_hydration(session_factory, limit=limit) recovered = 0 - async with catalog_hydration_gate(): + job_ids = await catalog_hydration_import_job_ids(session_factory) + async with ( + catalog_metadata_work(len(pending)) as priority, + track_import_metadata_progress(session_factory, job_ids=job_ids), + catalog_hydration_gate(), + ): + batch_methods = _catalog_hydration_batch_methods(series_service) + if batch_methods is not None and pending: + return await _run_pending_catalog_hydration_batch( + session_factory, + pending=pending, + batch_methods=batch_methods, + add_from_comicvine_prefetched=add_from_comicvine_prefetched, + priority=priority, + ) for request in pending: try: hydrated = await run_catalog_hydration( @@ -122,6 +139,8 @@ async def run_pending_catalog_hydration( error=str(exc), ) continue + finally: + await priority.complete_one() if not hydrated: continue @@ -135,6 +154,170 @@ async def run_pending_catalog_hydration( return recovered +def _catalog_hydration_batch_methods( + series_service: Any, +) -> ( + tuple[ + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + ] + | None +): + methods = ( + getattr(series_service, "prefetch_comicvine_profiles", None), + getattr(series_service, "prefetch_comicvine_issue_catalogs", None), + getattr(series_service, "upsert_comicvine_profile", None), + ) + if not all(callable(method) for method in methods): + return None + return methods # type: ignore[return-value] + + +async def _run_pending_catalog_hydration_batch( + session_factory: async_sessionmaker[AsyncSession], + *, + pending: list[PendingCatalogHydration], + batch_methods: tuple[ + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + ], + add_from_comicvine_prefetched: Callable[..., Awaitable[Series]], + priority: Any, +) -> int: + """Persist visible profiles first, then complete issue catalogs in bulk.""" + planned: list[tuple[PendingCatalogHydration, CatalogHydrationPlan]] = [] + for request in pending: + plan = await load_catalog_hydration_plan( + session_factory, + series_id=request.series_id, + search_on_add=request.search_on_add, + only_if_hydrating=True, + ) + if plan is None: + await priority.complete_one() + continue + planned.append((request, plan)) + + comicvine_ids = [plan.comicvine_id for _request, plan in planned] + if not comicvine_ids: + return 0 + + recovered = 0 + for start in range(0, len(planned), COMICVINE_BULK_BATCH_SIZE): + recovered += await _run_planned_catalog_hydration_batch( + session_factory, + planned=planned[start : start + COMICVINE_BULK_BATCH_SIZE], + batch_methods=batch_methods, + add_from_comicvine_prefetched=add_from_comicvine_prefetched, + priority=priority, + ) + return recovered + + +async def _run_planned_catalog_hydration_batch( + session_factory: async_sessionmaker[AsyncSession], + *, + planned: list[tuple[PendingCatalogHydration, CatalogHydrationPlan]], + batch_methods: tuple[ + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + Callable[..., Awaitable[Any]], + ], + add_from_comicvine_prefetched: Callable[..., Awaitable[Series]], + priority: Any, +) -> int: + """Hydrate one provider-safe batch without failing unrelated backlog rows.""" + prefetch_profiles, prefetch_catalogs, upsert_profile = batch_methods + comicvine_ids = [plan.comicvine_id for _request, plan in planned] + + try: + profiles = await prefetch_profiles(comicvine_ids) + except Exception as exc: + for request, _plan in planned: + await mark_catalog_hydration_failed( + session_factory, + series_id=request.series_id, + error=str(exc), + ) + await priority.complete_one() + return 0 + + eligible: list[tuple[PendingCatalogHydration, CatalogHydrationPlan]] = [] + for request, plan in planned: + profile = profiles.get(plan.comicvine_id) + if profile is None: + await mark_catalog_hydration_failed( + session_factory, + series_id=request.series_id, + error="ComicVine did not return the requested series profile", + ) + await priority.complete_one() + continue + try: + async with session_factory() as session: + state = await session.scalar( + sa_select(Series.issue_catalog_state).where(Series.id == request.series_id) + ) + if state != IssueCatalogState.HYDRATING: + await priority.complete_one() + continue + await upsert_profile(session, plan.comicvine_id, profile) + await session.commit() + eligible.append((request, plan)) + except Exception as exc: + await mark_catalog_hydration_failed( + session_factory, + series_id=request.series_id, + error=str(exc), + ) + await priority.complete_one() + + if not eligible: + return 0 + + try: + catalogs = await prefetch_catalogs([plan.comicvine_id for _request, plan in eligible]) + except Exception as exc: + for request, _plan in eligible: + await mark_catalog_hydration_failed( + session_factory, + series_id=request.series_id, + error=str(exc), + ) + await priority.complete_one() + return 0 + + recovered = 0 + for request, plan in eligible: + try: + profile = profiles[plan.comicvine_id] + summaries = catalogs.get(plan.comicvine_id) + if summaries is None: + raise ValueError("ComicVine did not return the requested issue catalog") + persisted = await _persist_catalog_hydration( + session_factory, + series_id=request.series_id, + plan=plan, + series_meta=profile, + issue_summaries=summaries, + add_from_comicvine_prefetched=add_from_comicvine_prefetched, + ) + if persisted: + recovered += 1 + logger.info("import_catalog_hydration_recovered", series_id=request.series_id) + except Exception as exc: + await mark_catalog_hydration_failed( + session_factory, + series_id=request.series_id, + error=str(exc), + ) + finally: + await priority.complete_one() + return recovered + + async def load_pending_catalog_hydration( session_factory: async_sessionmaker[AsyncSession], *, @@ -201,6 +384,7 @@ async def run_catalog_hydration( session_factory, series_id=series_id, search_on_add=search_on_add, + only_if_hydrating=True, ) if plan is None: return False @@ -209,6 +393,27 @@ async def run_catalog_hydration( # the UI and active import writer keep access to the pool while we wait. series_meta, issue_summaries = await prefetch_comicvine_bundle(plan.comicvine_id) + return await _persist_catalog_hydration( + session_factory, + series_id=series_id, + plan=plan, + series_meta=series_meta, + issue_summaries=issue_summaries, + add_from_comicvine_prefetched=add_from_comicvine_prefetched, + ) + + +async def _persist_catalog_hydration( + session_factory: async_sessionmaker[AsyncSession], + *, + series_id: int, + plan: CatalogHydrationPlan, + series_meta: Any, + issue_summaries: list[Any], + add_from_comicvine_prefetched: Callable[..., Awaitable[Series]], +) -> bool: + """Persist one prefetched catalog only while its exact row still needs it.""" + async with session_factory() as hydrate_session: # A cancellation rollback can delete the series while the provider # request is in flight. Revalidate the exact row under the write @@ -219,6 +424,7 @@ async def run_catalog_hydration( .where( Series.id == series_id, Series.comicvine_id == plan.comicvine_id, + Series.issue_catalog_state == IssueCatalogState.HYDRATING, ) .with_for_update() ) @@ -241,11 +447,14 @@ async def load_catalog_hydration_plan( *, series_id: int, search_on_add: bool, + only_if_hydrating: bool = False, ) -> CatalogHydrationPlan | None: async with session_factory() as session: series = await session.get(Series, series_id) if series is None: return None + if only_if_hydrating and series.issue_catalog_state != IssueCatalogState.HYDRATING: + return None if series.comicvine_id is None: msg = "Series has no ComicVine ID" raise ValueError(msg) diff --git a/src/pullbox/services/import_comicinfo_enrichment.py b/src/pullbox/services/import_comicinfo_enrichment.py index 1cd790d0..290021e3 100644 --- a/src/pullbox/services/import_comicinfo_enrichment.py +++ b/src/pullbox/services/import_comicinfo_enrichment.py @@ -25,6 +25,8 @@ from pullbox.models.library import LibraryFile from pullbox.models.series import Series from pullbox.services.import_comicinfo_metadata import is_retryable_provider_error +from pullbox.services.import_metadata_priority import wait_for_comicinfo_turn +from pullbox.services.import_metadata_progress import track_import_metadata_progress if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -32,10 +34,12 @@ logger = structlog.get_logger(__name__) COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY = "comicinfo_enrichment" +COMICVINE_BULK_BATCH_SIZE = 5_000 ImportComicInfoBuildPayload = Callable[..., Awaitable[dict[str, Any]]] ImportComicInfoApply = Callable[[Path, dict[str, Any]], Any] ImportComicInfoLogEvent = Callable[..., Awaitable[None]] +ImportComicInfoPrefetch = Callable[[list[int]], Awaitable[Any]] comicinfo_enrichment_tasks: set[asyncio.Task[None]] = set() _comicinfo_enrichment_semaphore: asyncio.Semaphore | None = None @@ -79,6 +83,7 @@ def schedule_import_comicinfo_enrichment( build_comicinfo_payload: ImportComicInfoBuildPayload, apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, + prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> None: """Queue deferred issue metadata and ComicInfo rewrites after Step 4 completes.""" if session_factory is None: @@ -92,6 +97,7 @@ async def run_enrichment() -> None: build_comicinfo_payload=build_comicinfo_payload, apply_comicinfo=apply_comicinfo, log_event=log_event, + prefetch_issue_metadata=prefetch_issue_metadata, ) except Exception as exc: logger.warning( @@ -111,6 +117,7 @@ async def run_pending_import_comicinfo_enrichment( build_comicinfo_payload: ImportComicInfoBuildPayload, apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, + prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> int: """Refresh deferred ComicInfo metadata for all completed jobs with pending rows.""" pending_job_ids = await _load_pending_import_job_ids(session_factory) @@ -121,6 +128,7 @@ async def run_pending_import_comicinfo_enrichment( build_comicinfo_payload=build_comicinfo_payload, apply_comicinfo=apply_comicinfo, log_event=log_event, + prefetch_issue_metadata=prefetch_issue_metadata, ) return len(pending_job_ids) @@ -132,15 +140,20 @@ async def run_import_comicinfo_enrichment( build_comicinfo_payload: ImportComicInfoBuildPayload, apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, + prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> None: """Refresh deferred ComicInfo metadata for imported files in one import job.""" - async with comicinfo_enrichment_gate(): + async with ( + track_import_metadata_progress(session_factory, job_ids=[job_id]), + comicinfo_enrichment_gate(), + ): await _run_import_comicinfo_enrichment_while_fenced( session_factory, job_id=job_id, build_comicinfo_payload=build_comicinfo_payload, apply_comicinfo=apply_comicinfo, log_event=log_event, + prefetch_issue_metadata=prefetch_issue_metadata, ) @@ -151,12 +164,29 @@ async def _run_import_comicinfo_enrichment_while_fenced( build_comicinfo_payload: ImportComicInfoBuildPayload, apply_comicinfo: ImportComicInfoApply, log_event: ImportComicInfoLogEvent, + prefetch_issue_metadata: ImportComicInfoPrefetch | None = None, ) -> None: """Run one job while holding the process-local filesystem mutation fence.""" if not await _import_job_is_completed(session_factory, job_id=job_id): return pending_ids = await _load_pending_imported_file_ids(session_factory, job_id=job_id) + if prefetch_issue_metadata is not None: + issue_cv_ids = await _load_pending_issue_cv_ids(session_factory, job_id=job_id) + if issue_cv_ids: + for start in range(0, len(issue_cv_ids), COMICVINE_BULK_BATCH_SIZE): + batch = issue_cv_ids[start : start + COMICVINE_BULK_BATCH_SIZE] + await wait_for_comicinfo_turn() + try: + await prefetch_issue_metadata(batch) + except Exception as exc: + logger.warning( + "import_comicinfo_metadata_batch_prefetch_failed", + job_id=job_id, + issue_count=len(batch), + error=str(exc), + ) for imported_file_id in pending_ids: + await wait_for_comicinfo_turn() try: prepared = await _prepare_pending_imported_file_with_retry( session_factory, @@ -237,6 +267,41 @@ async def _load_pending_imported_file_ids( return ids +async def _load_pending_issue_cv_ids( + session_factory: async_sessionmaker[AsyncSession], + *, + job_id: int, +) -> list[int]: + """Load unique provider issue IDs already recorded in pending diagnostics.""" + async with session_factory() as session: + result = await session.execute( + sa_select(ImportedFile.diagnostics) + .join(ImportJob, ImportedFile.import_job_id == ImportJob.id) + .where(ImportedFile.import_job_id == job_id) + .where(ImportJob.status == ImportJobStatus.COMPLETED) + .where(ImportedFile.status == ImportedFileStatus.IMPORTED) + .order_by(ImportedFile.id) + ) + provider_ids: list[int] = [] + seen: set[int] = set() + for diagnostics in result.scalars().all(): + if not _is_pending_comicinfo_enrichment_diagnostics(diagnostics): + continue + details = diagnostics.get(COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY, {}) + raw_provider_id = details.get("issue_cv_id") if isinstance(details, dict) else None + if isinstance(raw_provider_id, bool) or not isinstance(raw_provider_id, (int, str)): + continue + try: + provider_id = int(raw_provider_id) + except (TypeError, ValueError): + continue + if provider_id <= 0 or provider_id in seen: + continue + seen.add(provider_id) + provider_ids.append(provider_id) + return provider_ids + + async def _load_pending_import_job_ids( session_factory: async_sessionmaker[AsyncSession], ) -> list[int]: @@ -329,19 +394,37 @@ async def _prepare_pending_imported_file( if job_status is not ImportJobStatus.COMPLETED: return None - library_file_id = imported_file.library_file_id - if library_file_id is None: - details = imported_file.diagnostics.get(COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY, {}) - if isinstance(details, dict): - library_file_id = details.get("library_file_id") + details_value = imported_file.diagnostics.get(COMICINFO_ENRICHMENT_DIAGNOSTIC_KEY, {}) + details = details_value if isinstance(details_value, dict) else {} + recorded_library_file_id = _recorded_positive_int(details, "library_file_id") + library_file_id = imported_file.library_file_id or recorded_library_file_id if library_file_id is None: raise ValueError("Deferred ComicInfo enrichment is missing library_file_id") + if ( + imported_file.library_file_id is not None + and recorded_library_file_id is not None + and imported_file.library_file_id != recorded_library_file_id + ): + raise ValueError("Deferred ComicInfo target no longer matches its queued library file") library_file = await session.get(LibraryFile, int(library_file_id)) if library_file is None: raise ValueError(f"Library file {library_file_id} no longer exists") - issue_id = imported_file.matched_issue_id or library_file.issue_id + recorded_issue_id = _recorded_positive_int(details, "issue_id") + expected_issue_id = imported_file.matched_issue_id or recorded_issue_id + if expected_issue_id is not None and library_file.issue_id != expected_issue_id: + raise ValueError("Deferred ComicInfo target no longer matches its queued issue") + + recorded_artifact_path = details.get("artifact_path") + if ( + isinstance(recorded_artifact_path, str) + and recorded_artifact_path + and Path(library_file.file_path) != Path(recorded_artifact_path) + ): + raise ValueError("Deferred ComicInfo target no longer matches its queued artifact path") + + issue_id = expected_issue_id or library_file.issue_id if issue_id is None: raise ValueError("Deferred ComicInfo enrichment is missing issue_id") @@ -353,6 +436,9 @@ async def _prepare_pending_imported_file( issue = issue_result.scalars().first() if issue is None: raise ValueError(f"Issue {issue_id} no longer exists") + recorded_issue_cv_id = _recorded_positive_int(details, "issue_cv_id") + if recorded_issue_cv_id is not None and issue.comicvine_id != recorded_issue_cv_id: + raise ValueError("Deferred ComicInfo target no longer matches its queued ComicVine issue") artifact_path = Path(library_file.file_path) payload = await build_comicinfo_payload( @@ -372,6 +458,22 @@ async def _prepare_pending_imported_file( ) +def _recorded_positive_int(details: dict[str, Any], key: str) -> int | None: + value = details.get(key) + if isinstance(value, bool): + return None + if isinstance(value, int): + parsed = value + elif isinstance(value, str): + try: + parsed = int(value) + except ValueError: + return None + else: + return None + return parsed if parsed > 0 else None + + async def _mark_pending_file_complete_with_retry( session_factory: async_sessionmaker[AsyncSession], *, diff --git a/src/pullbox/services/import_confirmation.py b/src/pullbox/services/import_confirmation.py index b69258bf..a30ed809 100644 --- a/src/pullbox/services/import_confirmation.py +++ b/src/pullbox/services/import_confirmation.py @@ -18,6 +18,9 @@ ImportSeriesStatus, ) from pullbox.models.issue import Issue +from pullbox.services.import_job_execution_items import ( + ensure_target_issue_summary_for_import_file, +) from pullbox.services.import_managed_copy_preflight import ( ManagedCopyPreflightError, reopen_review_after_managed_copy_preflight_failure, @@ -208,11 +211,65 @@ async def _load_confirmed_series( item.selected_for_import = True await session.flush() + persisted_item_ids = [item.id for item in persisted_items] + series_ids_with_files = ( + set( + ( + await session.scalars( + sa_select(ImportedFile.import_series_id) + .where(ImportedFile.import_series_id.in_(persisted_item_ids)) + .distinct() + ) + ).all() + ) + if persisted_item_ids + else set() + ) + importable_series_ids = ( + set( + ( + await session.scalars( + sa_select(ImportedFile.import_series_id) + .where( + ImportedFile.import_series_id.in_(persisted_item_ids), + ImportedFile.status.in_( + [ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED] + ), + ) + .distinct() + ) + ).all() + ) + if persisted_item_ids + else set() + ) + conflict_series_ids = ( + set( + ( + await session.scalars( + sa_select(ImportedFile.import_series_id) + .where( + ImportedFile.import_series_id.in_(persisted_item_ids), + ImportedFile.status == ImportedFileStatus.CONFLICT, + ) + .distinct() + ) + ).all() + ) + if persisted_item_ids + else set() + ) + valid_items: list[ImportedSeries] = [] invalid_items: list[ImportedSeries] = [] has_unresolved_conflicts = False for item in persisted_items: - if item.status == ImportSeriesStatus.MATCHED and (item.files_conflict or 0) == 0: + no_persisted_files = item.id not in series_ids_with_files + if item.status == ImportSeriesStatus.MATCHED and ( + item.id in importable_series_ids + or no_persisted_files + or item.id not in conflict_series_ids + ): valid_items.append(item) continue if (item.files_conflict or 0) > 0: @@ -309,6 +366,11 @@ async def _confirm_matched_files( ) ) for matched_file in matched_result.scalars().all(): + if not ensure_target_issue_summary_for_import_file(matched_file): + matched_file.status = ImportedFileStatus.NO_MATCH + matched_file.include_in_import = False + affected_series_ids.add(matched_file.import_series_id) + continue matched_file.status = ImportedFileStatus.CONFIRMED affected_series_ids.add(matched_file.import_series_id) await session.flush() diff --git a/src/pullbox/services/import_counters.py b/src/pullbox/services/import_counters.py index f63b0057..1dc0f3f8 100644 --- a/src/pullbox/services/import_counters.py +++ b/src/pullbox/services/import_counters.py @@ -163,7 +163,7 @@ async def recompute_file_counters( ImportedFileStatus.SAFETY_BLOCKED, 0, ) + job_status_counts.get(ImportedFileStatus.SAFETY_APPROVED, 0) - job.total_files_found = ( + classified_file_count = ( job.total_files_matched + job.total_files_duplicate + job.total_files_already_owned @@ -173,5 +173,10 @@ async def recompute_file_counters( + job.total_files_failed + total_files_safety_blocked ) + job.total_files_found = max( + int(job.total_files_found or 0), + int(job.scan_total_files or 0), + classified_file_count, + ) await session.flush() diff --git a/src/pullbox/services/import_file_execution.py b/src/pullbox/services/import_file_execution.py index ac0f77db..bac55bdd 100644 --- a/src/pullbox/services/import_file_execution.py +++ b/src/pullbox/services/import_file_execution.py @@ -24,6 +24,7 @@ ) from pullbox.core.file_ops import LibraryFileRegistrationOutcome from pullbox.core.file_safety import classify_resource_safety_exception +from pullbox.core.import_resources import bounded_async_map from pullbox.core.issue_numbers import ( issue_number_text_matches_numeric, normalize_issue_number_text, @@ -743,7 +744,6 @@ async def process_import_series_files( ) or 0 ) - semaphore = asyncio.Semaphore(min(effective_worker_count, len(importable_file_ids))) async def locked_record_action( session: AsyncSession, @@ -773,7 +773,7 @@ async def locked_record_action( return action async def process_one_file(imp_file_id: int) -> tuple[int, int]: - async with semaphore, session_factory() as worker_session: + async with session_factory() as worker_session: worker_job = await worker_session.get(type(job), job_id) worker_item = await worker_session.get(type(item), item_id) if worker_job is None or worker_item is None: @@ -809,11 +809,16 @@ async def process_one_file(imp_file_id: int) -> tuple[int, int]: _setup_placeholder_targets=False, ) - results = await asyncio.gather( - *(process_one_file(int(imp_file_id)) for imp_file_id in importable_file_ids) - ) - files_imported = sum(imported for imported, _failed in results) - files_failed = sum(failed for _imported, failed in results) + files_imported = 0 + files_failed = 0 + async with bounded_async_map( + process_one_file, + importable_file_ids, + workers=effective_worker_count, + ) as results: + async for imported, failed in results: + files_imported += imported + files_failed += failed reloaded_item = await session.get(type(item), item_id) if reloaded_item is not None: item = reloaded_item @@ -1227,6 +1232,7 @@ async def report_current_file( "issue_id": resolved_issue.id, "issue_cv_id": resolved_issue.comicvine_id, "library_file_id": library_file.id, + "artifact_path": library_file.file_path, "queued_at": datetime.now(UTC).isoformat(), } imp_file.diagnostics = diagnostics diff --git a/src/pullbox/services/import_file_issue_signals.py b/src/pullbox/services/import_file_issue_signals.py index d8f4724e..f681c748 100644 --- a/src/pullbox/services/import_file_issue_signals.py +++ b/src/pullbox/services/import_file_issue_signals.py @@ -52,6 +52,30 @@ def filename_issue_number(imp_file: ImportedFile) -> float | None: return parsed.issue_number if parsed is not None else None +def comicinfo_issue_number_raw(imp_file: ImportedFile) -> str | float | int | None: + """Return the issue designation cached from ComicInfo.xml during discovery.""" + diagnostics = imp_file.diagnostics if isinstance(imp_file.diagnostics, dict) else {} + source_metadata = diagnostics.get("source_metadata") + comicinfo = source_metadata.get("comicinfo") if isinstance(source_metadata, dict) else None + value = comicinfo.get("number") if isinstance(comicinfo, dict) else None + return value if isinstance(value, str | float | int) else None + + +def comicinfo_issue_number(imp_file: ImportedFile) -> float | None: + """Return a numeric compatibility value from saved ComicInfo.xml evidence.""" + raw_value = comicinfo_issue_number_raw(imp_file) + normalized = normalize_issue_number(raw_value) + if normalized is not None: + return normalized + if not isinstance(raw_value, str): + return None + bracketed_total = re.fullmatch( + r"\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\[\s*\d+\s*\]\s*", + raw_value, + ) + return normalize_issue_number(bracketed_total.group(1)) if bracketed_total else None + + def candidate_issue_number(imp_file: ImportedFile) -> float | None: """Return the best issue-number signal available for target lookup.""" if imp_file.parsed_issue_number is not None: @@ -59,16 +83,35 @@ def candidate_issue_number(imp_file: ImportedFile) -> float | None: parsed_issue_number = filename_issue_number(imp_file) if parsed_issue_number is not None: return parsed_issue_number - return volume_issue_number(imp_file) + volume_number = volume_issue_number(imp_file) + if volume_number is not None: + return volume_number + comicinfo_number = comicinfo_issue_number(imp_file) + if comicinfo_number is not None: + return comicinfo_number + diagnostics = imp_file.diagnostics if isinstance(imp_file.diagnostics, dict) else {} + comicinfo_raw = comicinfo_issue_number_raw(imp_file) + comicinfo_number_missing = comicinfo_raw is None or ( + isinstance(comicinfo_raw, str) and not comicinfo_raw.strip() + ) + if ( + comicinfo_number_missing + and diagnostics.get("source_issue_type") == "one_shot" + and diagnostics.get("issue_count_hint") == 1 + and imp_file.matched_issue_cv_id is not None + ): + return 1.0 + return None def candidate_issue_number_text(imp_file: ImportedFile) -> str | None: """Return a validated exact issue designation when the source preserved one.""" issue_number = candidate_issue_number(imp_file) - if not imp_file.issue_number_raw or issue_number is None: + raw_value = imp_file.issue_number_raw or comicinfo_issue_number_raw(imp_file) + if not raw_value or issue_number is None: return None try: - normalized = normalize_issue_number_text(imp_file.issue_number_raw) + normalized = normalize_issue_number_text(raw_value) except ValueError: return None if not issue_number_text_matches_numeric(issue_number, normalized): diff --git a/src/pullbox/services/import_file_match_outcomes.py b/src/pullbox/services/import_file_match_outcomes.py index 7a4be79a..1772a37b 100644 --- a/src/pullbox/services/import_file_match_outcomes.py +++ b/src/pullbox/services/import_file_match_outcomes.py @@ -8,6 +8,7 @@ from pullbox.core.release_parser import parse_release_title from pullbox.models.import_job import ImportedFileStatus from pullbox.models.issue import IssueType +from pullbox.services.import_file_issue_signals import candidate_issue_number_text from pullbox.services.import_file_match_targets import ( PROVIDER_MISSING_ISSUE_PLACEHOLDER_KIND, PROVIDER_MISSING_ISSUE_PLACEHOLDER_METHOD, @@ -200,6 +201,16 @@ def _target_issue_summary_diagnostics( "cover_url": None, "issue_type": issue_type.value, } + issue_number_text = ( + match_candidate.matched_issue.effective_issue_number_text + if match_candidate.matched_issue is not None + else candidate_issue_number_text(imp_file) + ) + numeric_issue_text = ( + str(int(issue_number)) if float(issue_number).is_integer() else str(float(issue_number)) + ) + if issue_number_text is not None and issue_number_text != numeric_issue_text: + target_summary["issue_number_text"] = issue_number_text return { "target_issue_summary": target_summary, } @@ -318,6 +329,7 @@ def apply_unmatched_file_outcome( ) next_diagnostics = metadata_conflict or { "kind": "duplicate_series_file", + "reason": "duplicate_series_no_importable_target", "target_state": ( "no_importable_targets" if duplicate_merge_profile is not None and not duplicate_merge_profile.actionable @@ -335,6 +347,10 @@ def apply_unmatched_file_outcome( else 0 ), } + next_diagnostics.setdefault( + "reason", + str(next_diagnostics.get("kind") or "duplicate_series_issue_target_not_found"), + ) imp_file.diagnostics = {**existing_diagnostics, **next_diagnostics} informational_only = ( duplicate_merge_profile is not None and not duplicate_merge_profile.actionable @@ -367,9 +383,23 @@ def apply_unmatched_file_outcome( ) imp_file.diagnostics = ( - {**existing_diagnostics, **metadata_conflict} + { + **existing_diagnostics, + **metadata_conflict, + "reason": str( + metadata_conflict.get("reason") + or metadata_conflict.get("kind") + or "issue_target_not_found" + ), + } if metadata_conflict is not None - else existing_diagnostics + else { + **existing_diagnostics, + "reason": "issue_target_not_found", + "rejection_reason": ( + "No issue target matched the available file name and metadata evidence." + ), + } ) return FileMatchLogEvent( name=( diff --git a/src/pullbox/services/import_file_match_results.py b/src/pullbox/services/import_file_match_results.py index 8eafa5c4..9a5ab42b 100644 --- a/src/pullbox/services/import_file_match_results.py +++ b/src/pullbox/services/import_file_match_results.py @@ -83,6 +83,29 @@ def apply_file_match_series_summary( not duplicate_series and imp_series.status == ImportSeriesStatus.MATCHED and trusted_identity_conflict_files + and imp_series.cv_match_method == "mylar3_cv_id" + ): + diagnostics = dict(imp_series.diagnostics or {}) + diagnostics.update( + { + "file_identity_conflict_count": len(trusted_identity_conflict_files), + "file_identity_review_required": True, + "conflicting_files": [ + { + "file_name": imp_file.file_name, + "rejection_reason": dict(imp_file.diagnostics or {}).get( + "rejection_reason" + ), + } + for imp_file in trusted_identity_conflict_files + ], + } + ) + imp_series.diagnostics = diagnostics + elif ( + not duplicate_series + and imp_series.status == ImportSeriesStatus.MATCHED + and trusted_identity_conflict_files ): identity_conflicts: list[dict[str, object]] = [] for imp_file in trusted_identity_conflict_files: diff --git a/src/pullbox/services/import_file_matching.py b/src/pullbox/services/import_file_matching.py index 7d3c878f..e64c6368 100644 --- a/src/pullbox/services/import_file_matching.py +++ b/src/pullbox/services/import_file_matching.py @@ -6,6 +6,7 @@ import tempfile import time from contextlib import suppress +from datetime import UTC, datetime from inspect import isawaitable, iscoroutine from typing import TYPE_CHECKING, Any, NamedTuple @@ -68,14 +69,11 @@ from pullbox.services.import_progress_runtime import ( ScanReviewFileMatchProfile, ScanReviewProgressPlan, - ScanReviewSeriesMatchProfile, current_item_payload, estimate_remaining_work_seconds, - scan_review_analysis_weight, scan_review_file_match_weight, scan_review_file_target_weight, scan_review_progress_pct, - scan_review_series_match_weight, ) from pullbox.services.import_source_metadata import ( import_file_has_deferred_archive_metadata, @@ -89,7 +87,6 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator, Awaitable, Callable - from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession @@ -219,6 +216,7 @@ async def _iter_eligible_import_series( series_ids: list[int] | None, raise_if_cancelled: RaiseIfCancelledFunc, page_size: int, + blocked_only_counts: dict[int, int] | None = None, ) -> AsyncIterator[tuple[int, ImportedSeries, bool]]: after_id = 0 series_index = 0 @@ -233,12 +231,45 @@ async def _iter_eligible_import_series( ) if not page: break + if blocked_only_counts is not None: + blocked_only_counts.clear() + blocked_only_counts.update(await _blocked_only_summary_counts(session, page)) for page_index, item in enumerate(page): yield series_index, item, page_index == len(page) - 1 series_index += 1 after_id = page[-1].id +async def _blocked_only_summary_counts( + session: AsyncSession, series_page: list[ImportedSeries] +) -> dict[int, int]: + """Summarize simple safety-only groups once per bounded series page.""" + ids = [ + item.id + for item in series_page + if item.status == ImportSeriesStatus.MATCHED and item.series_id is None + ] + if not ids: + return {} + needs_detailed_review = sa_or( + ImportedFile.status != ImportedFileStatus.SAFETY_BLOCKED, + ImportedFile.diagnostics["kind"] + .as_string() + .in_(["metadata_conflict", "source_layout_review"]), + ) + rows = await session.execute( + sa_select(ImportedFile.import_series_id, sa_func.count(ImportedFile.id)) + .where(ImportedFile.import_series_id.in_(ids)) + .group_by(ImportedFile.import_series_id) + .having(sa_func.sum(sa_case((needs_detailed_review, 1), else_=0)) == 0) + ) + return {int(series_id): int(count) for series_id, count in rows} + + +def _review_summary_weight(file_count: int) -> float: + return 1.0 + max(file_count, 0) * 0.1 + + async def _load_file_matching_profile_page( session: AsyncSession, *, @@ -334,6 +365,7 @@ async def _build_file_matching_plan( completed_file_phase_units = 0 file_match_weight = 0.0 completed_file_match_weight = 0.0 + summary_weight = 0.0 completed_target_series_ids: set[int] = set() after_id = 0 while True: @@ -349,6 +381,7 @@ async def _build_file_matching_plan( break for profile in profiles: series_count += 1 + summary_weight += _review_summary_weight(profile.file_count) total_file_phase_units += 1 + profile.file_count file_profile = ScanReviewFileMatchProfile( file_count=profile.file_count, @@ -367,28 +400,26 @@ async def _build_file_matching_plan( completed_target_series_ids.add(profile.id) after_id = profiles[-1].id - series_match_profile_count = max( - int(job.series_matched or 0) + int(job.series_no_match or 0), - series_count, - ) - analysis_series_count = max( - int(job.series_found or 0), - series_match_profile_count, - ) - analysis_weight = scan_review_analysis_weight(analysis_series_count) - series_match_weight = series_match_profile_count * scan_review_series_match_weight( - ScanReviewSeriesMatchProfile(direct_match=False) + # Replan only remaining work at the persisted phase boundary. Resolved files + # need no matching, but their summaries and cross-series review still do. + remaining_weight = max(file_match_weight - completed_file_match_weight, 0.0) + remaining_weight += summary_weight + max(1.0, series_count * 0.1) + progress_start = max( + 35, + min(98, int((job.progress_snapshot or {}).get("progress", SCAN_PROGRESS_FILE_MATCH_START))), ) return _FileMatchingPlan( series_count=series_count, total_file_phase_units=total_file_phase_units, completed_file_phase_units=completed_file_phase_units, - completed_file_match_weight=completed_file_match_weight, + completed_file_match_weight=0.0, completed_target_series_ids=frozenset(completed_target_series_ids), progress_plan=ScanReviewProgressPlan( - analysis_weights=(analysis_weight,) if analysis_weight else (), - series_match_weights=(series_match_weight,) if series_match_weight else (), - file_match_weights=(file_match_weight,) if file_match_weight else (), + analysis_weights=(), + series_match_weights=(), + file_match_weights=(remaining_weight,), + progress_start=progress_start, + progress_end=98, ), ) @@ -700,6 +731,8 @@ async def _finalize_import_series_file_groups( ): for cohort in cohorts: await raise_if_cancelled(session, job.id) + if cohort.file_count < 2: + continue files = await _load_file_target_cohort( session, job_id=job.id, @@ -1220,6 +1253,7 @@ async def run_import_file_matching( ) -> None: """Match individual imported files to issue targets for review.""" started_at = time.monotonic() + work_started_at = datetime.now(UTC) last_checkpoint_at = time.monotonic() matching_plan = await _build_file_matching_plan( session, @@ -1281,6 +1315,7 @@ async def run_import_file_matching( scan_review_plan=progress_plan, phase_start=SCAN_PROGRESS_FILE_MATCH_START, phase_end=SCAN_PROGRESS_FILE_MATCH_END, + work_started_at=work_started_at, ) async def emit_file_matching_progress( @@ -1292,9 +1327,9 @@ async def emit_file_matching_progress( effective_file_weight = completed_file_match_weight + ( active_target_weight * max(min(current_progress, 100), 0) / 100 ) - overall_file_progress = round( - (effective_file_weight / max(progress_plan.file_match_weight, 1.0)) * 100 - ) + overall_file_progress = ( + effective_file_weight / max(progress_plan.file_match_weight, 1.0) + ) * 100 kwargs["current_work_unit_progress_pct"] = overall_file_progress await emit_weighted_file_matching_progress(item, 0, **kwargs) @@ -1549,13 +1584,113 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: int(max_duplicate_group_id or 0), ) + async def emit_review_progress(message: str, *, complete: bool = False) -> None: + if progress_callback is None: + return + await emit_progress( + session, + job, + ImportProgressEvent( + job_id=job.id, + status=ImportJobStatus.FILE_MATCHING, + phase="file_matching", + progress=( + 99 + if complete + else scan_review_progress_pct( + progress_plan, completed_weight=completed_file_match_weight + ) + ), + message=message, + estimated_seconds_remaining=( + None + if complete + else estimate_remaining_work_seconds( + work_started_at, + completed_units=completed_file_match_weight, + total_units=progress_plan.total_weight, + ) + ), + **current_item_payload( + kind="scan", stage="file_matching", progress_pct=100 if complete else None + ), + **job_stats(job), + ), + progress_callback, + ) + + async def checkpoint_summary( + item: ImportedSeries, + summary: FileMatchSeriesSummary, + *, + series_idx: int, + page_end: bool, + ) -> None: + nonlocal total_found, total_matched, total_duplicate, total_already_owned + nonlocal total_no_match, total_conflict, last_checkpoint_at, completed_file_match_weight + completed_file_match_weight += _review_summary_weight(summary.found) + total_found += summary.found + total_matched += summary.matched + total_duplicate += summary.duplicate + total_already_owned += summary.already_owned + total_no_match += summary.no_match + total_conflict += summary.conflict + # Inventory is already known; partial summary counts must not shrink it. + job.total_files_found = max(int(job.total_files_found or 0), total_found) + job.total_files_matched = total_matched + job.total_files_duplicate = total_duplicate + job.total_files_already_owned = total_already_owned + job.total_files_no_match = total_no_match + job.total_files_conflict = total_conflict + should_checkpoint = ( + series_idx == 0 + or page_end + or series_idx == matching_plan.series_count - 1 + or time.monotonic() - last_checkpoint_at >= 0.5 + ) + if not should_checkpoint: + return + await raise_if_cancelled(session, job.id) + await session.flush() + await session.commit() + last_checkpoint_at = time.monotonic() + await emit_file_matching_progress( + item, + completed_file_phase_units, + message=f"Prepared file review for {item.raw_series_name}", + current_item_progress_pct=100, + current_work_unit_progress_pct=0, + ) + if progress_callback: + await maybe_slow_item_delay() + + await emit_review_progress("Preparing file matching and review summaries...") + blocked_only_counts: dict[int, int] = {} + blocked_only_series = 0 async for series_idx, imp_series, series_page_end in _iter_eligible_import_series( session, job_id=job.id, series_ids=series_ids, raise_if_cancelled=raise_if_cancelled, page_size=_SERIES_PAGE_SIZE, + blocked_only_counts=blocked_only_counts, ): + if imp_series.id in blocked_only_counts: + count = blocked_only_counts[imp_series.id] + blocked_only_series += 1 + imp_series.files_total = count + imp_series.files_matched = 0 + imp_series.files_duplicate = 0 + imp_series.files_already_owned = 0 + imp_series.files_no_match = 0 + imp_series.files_conflict = 0 + await checkpoint_summary( + imp_series, + FileMatchSeriesSummary(count, 0, 0, 0, 0, 0), + series_idx=series_idx, + page_end=series_page_end, + ) + continue await raise_if_cancelled(session, job.id) duplicate_series = is_duplicate_series(imp_series) @@ -1691,6 +1826,8 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: retained_file_count += len(matched_page) processed_file_count += len(file_page) series_had_processed_files = series_had_processed_files or bool(matched_page) + for split_id in created_split_series_ids: + blocked_only_counts.pop(split_id, None) split_series_ids.update(created_split_series_ids) while len(split_series_ids) >= _SERIES_PAGE_SIZE: split_batch = sorted(split_series_ids)[:_SERIES_PAGE_SIZE] @@ -1732,8 +1869,6 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: raise_if_cancelled=raise_if_cancelled, job_id=job.id, ) - if not series_summary.found: - continue if series_summary.series_invalidated: invalidation_reason = (series_summary.invalidation_diagnostics or {}).get("reason") await log_event( @@ -1750,70 +1885,14 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: diagnostics=series_summary.invalidation_diagnostics, ) - total_found += series_summary.found - total_matched += series_summary.matched - total_duplicate += series_summary.duplicate - total_already_owned += series_summary.already_owned - total_no_match += series_summary.no_match - total_conflict += series_summary.conflict - job.total_files_found = total_found - job.total_files_matched = total_matched - job.total_files_duplicate = total_duplicate - job.total_files_already_owned = total_already_owned - job.total_files_no_match = total_no_match - job.total_files_conflict = total_conflict - - should_checkpoint = ( - (series_idx == 0) - or series_page_end - or series_idx == matching_plan.series_count - 1 - or (time.monotonic() - last_checkpoint_at) >= 0.5 + await checkpoint_summary( + imp_series, series_summary, series_idx=series_idx, page_end=series_page_end ) - if should_checkpoint: - await session.flush() - await session.commit() - last_checkpoint_at = time.monotonic() - - if progress_callback and should_checkpoint: - completed_weight = ( - progress_plan.analysis_weight - + progress_plan.series_match_weight - + completed_file_match_weight - ) - progress = scan_review_progress_pct( - progress_plan, - completed_weight=completed_weight, - ) - await emit_progress( - session, - job, - ImportProgressEvent( - job_id=job.id, - status=ImportJobStatus.FILE_MATCHING, - phase="file_matching", - progress=progress, - message=f"Matched files in {imp_series.raw_series_name}", - current_series=imp_series.raw_series_name, - estimated_seconds_remaining=estimate_remaining_work_seconds( - job.scan_completed_at or job.match_completed_at or job.scan_started_at, - completed_units=completed_weight, - total_units=progress_plan.total_weight, - ), - **current_item_payload( - kind="series", - stage="file_matching", - name=imp_series.raw_series_name, - progress_pct=100, - ), - **job_stats(job), - ), - progress_callback, - ) - await maybe_slow_item_delay() if split_series_ids: await process_split_series_batch(sorted(split_series_ids)) + await emit_review_progress("Finalizing review summaries and cross-series conflicts...") if rebuild_cross_conflicts: conflict_group_counter = await _rebuild_cross_series_conflicts( session, @@ -1870,6 +1949,7 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: files_already_owned=job.total_files_already_owned, files_no_match=job.total_files_no_match, files_conflict=job.total_files_conflict, + blocked_only_series=blocked_only_series, duration_ms=round((time.monotonic() - started_at) * 1000), series_processed=series_processed, files_processed=files_processed, @@ -1879,6 +1959,7 @@ async def process_split_series_batch(batch_ids: list[int]) -> None: deferred_metadata_loads=deferred_metadata_loads, provider_cache_metrics=_provider_cache_metrics(metadata_provider), ) + await emit_review_progress("File review summaries ready", complete=True) def _file_has_deferred_archive_metadata(imp_file: ImportedFile) -> bool: diff --git a/src/pullbox/services/import_file_matching_progress.py b/src/pullbox/services/import_file_matching_progress.py index b5e6a5c0..02ef8e05 100644 --- a/src/pullbox/services/import_file_matching_progress.py +++ b/src/pullbox/services/import_file_matching_progress.py @@ -78,6 +78,7 @@ def build_file_matching_progress_emitter( scan_review_plan: ScanReviewProgressPlan | None = None, phase_start: int = 80, phase_end: int = 99, + work_started_at: datetime | None = None, ) -> Callable[..., Awaitable[None]]: """Build the Step 2 file-matching progress emitter.""" @@ -88,7 +89,7 @@ async def emit_file_matching_progress( message: str, current_item_stage: str = "file_matching", current_item_progress_pct: int | None = None, - current_work_unit_progress_pct: int | None = None, + current_work_unit_progress_pct: int | float | None = None, live_only: bool = False, ) -> None: if progress_callback is None: @@ -121,7 +122,10 @@ async def emit_file_matching_progress( ) estimated_seconds_remaining = ( estimate_remaining_work_seconds( - job.scan_completed_at or job.match_completed_at or job.scan_started_at, + work_started_at + or job.scan_completed_at + or job.match_completed_at + or job.scan_started_at, completed_units=( completed_weight if completed_weight is not None else completed_units ), diff --git a/src/pullbox/services/import_file_resolution.py b/src/pullbox/services/import_file_resolution.py index 17770d6c..498f982e 100644 --- a/src/pullbox/services/import_file_resolution.py +++ b/src/pullbox/services/import_file_resolution.py @@ -33,16 +33,6 @@ async def load_importable_files( files_result = await session.execute(sa_select(ImportedFile).where(*file_filters)) importable_files = list(files_result.scalars().all()) - if not duplicate_mode: - conflict_result = await session.execute( - sa_select(ImportedFile).where( - ImportedFile.import_series_id == item.id, - ImportedFile.status == ImportedFileStatus.CONFLICT, - ImportedFile.is_preferred.is_(True), - ) - ) - importable_files.extend(conflict_result.scalars().all()) - return importable_files diff --git a/src/pullbox/services/import_job_execution.py b/src/pullbox/services/import_job_execution.py index 8a7cce23..fb654e44 100644 --- a/src/pullbox/services/import_job_execution.py +++ b/src/pullbox/services/import_job_execution.py @@ -91,7 +91,7 @@ ImportProgressSettings, current_item_payload, import_group_progress_plan, - weighted_import_progress_pct, + import_work_progress, ) from pullbox.services.import_root_policy_activation import ( RootPolicyActivationConflictError, @@ -343,6 +343,8 @@ def queue_catalog_hydration(series_id: int, search_on_add: bool) -> None: runtime_revision_state=runtime_revision_state, ) + # Execution plans contain only remaining work, including after pause/restart. + work_started_at = datetime.now(UTC) _prime_series_prefetch_window( series_service=series_service, execution_items=execution_items, @@ -356,10 +358,10 @@ def queue_catalog_hydration(series_id: int, search_on_add: bool) -> None: job_id=job_id, job=job, job_started_at=job_started_at, + work_started_at=work_started_at, progress_callback=progress_callback, emit_progress=emit_progress, emit_live_progress=emit_live_progress, - estimate_remaining_seconds=estimate_remaining_seconds, group_progress_plans=group_progress_plans, shared_progress_settings=shared_progress_settings, group_progress_weights=group_progress_weights, @@ -403,9 +405,9 @@ def queue_catalog_hydration(series_id: int, search_on_add: bool) -> None: job_id=job_id, job=job, job_started_at=job_started_at, + work_started_at=work_started_at, progress_callback=progress_callback, progress_session_factory=progress_session_factory, - estimate_remaining_seconds=estimate_remaining_seconds, group_progress_plans=group_progress_plans, shared_progress_settings=shared_progress_settings, group_progress_weights=group_progress_weights, @@ -608,10 +610,10 @@ def queue_catalog_hydration(series_id: int, search_on_add: bool) -> None: item = refreshed_item if progress_callback: - progress = weighted_import_progress_pct( + work_progress = import_work_progress( group_progress_weights, current_group_index=idx, - current_group_progress_pct=100, + current_group_completed_weight=group_progress_weights[idx], ) job.series_imported = imported_count job.series_failed = failed_count @@ -626,15 +628,14 @@ def queue_catalog_hydration(series_id: int, search_on_add: bool) -> None: job_id=job_id, status=ImportJobStatus.IMPORTING, phase="importing", - progress=progress, + progress=work_progress.progress_pct, message=f"Processed {idx + 1}/{len(execution_items)} review groups", current_series=item_raw_series_name, current_series_status=( item.status if item is not None else ImportSeriesStatus.FAILED ), - estimated_seconds_remaining=estimate_remaining_seconds( - job_started_at, - progress, + estimated_seconds_remaining=work_progress.remaining_seconds( + work_started_at ), series_imported=imported_count, series_failed=failed_count, diff --git a/src/pullbox/services/import_job_execution_items.py b/src/pullbox/services/import_job_execution_items.py index 2b493177..2502ae61 100644 --- a/src/pullbox/services/import_job_execution_items.py +++ b/src/pullbox/services/import_job_execution_items.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import suppress from typing import TYPE_CHECKING, Any from sqlalchemy import select as sa_select @@ -14,9 +15,14 @@ ImportJob, ImportSeriesStatus, ) +from pullbox.models.issue import IssueType from pullbox.models.series import Series from pullbox.providers.base import IssueSummary from pullbox.services.import_catalog_hydration import schedule_catalog_hydration +from pullbox.services.import_file_issue_signals import ( + candidate_issue_number, + candidate_issue_number_text, +) from pullbox.services.import_file_resolution import load_importable_files from pullbox.services.import_job_actions import ( build_series_cover_cache_action_payload, @@ -346,14 +352,14 @@ def targeted_issue_summaries_for_import_files(files: list[ImportedFile]) -> list """Build Step 4 issue summaries from review-time file matches.""" summaries: list[IssueSummary] = [] seen_provider_ids: set[str] = set() - seen_numbers: set[float] = set() + seen_issue_keys: set[str] = set() for imp_file in files: + if not ensure_target_issue_summary_for_import_file(imp_file): + continue diagnostics = imp_file.diagnostics if isinstance(imp_file.diagnostics, dict) else {} summary_payload = diagnostics.get("target_issue_summary") issue_number: float | None if not isinstance(summary_payload, dict): - if imp_file.matched_issue_cv_id is not None and imp_file.matched_issue_id is None: - raise ValueError("Import file is missing required target_issue_summary diagnostics") continue provider_id = str(summary_payload.get("provider_id") or "").strip() @@ -365,14 +371,16 @@ def targeted_issue_summaries_for_import_files(files: list[ImportedFile]) -> list release_date = summary_payload.get("release_date") cover_url = summary_payload.get("cover_url") issue_type = str(summary_payload["issue_type"]) + issue_number_text = str(summary_payload.get("issue_number_text") or "").strip() or None if not provider_id or issue_number is None: continue number_key = float(issue_number) - if provider_id in seen_provider_ids or number_key in seen_numbers: + issue_key = issue_number_text or str(number_key) + if provider_id in seen_provider_ids or issue_key in seen_issue_keys: continue seen_provider_ids.add(provider_id) - seen_numbers.add(number_key) + seen_issue_keys.add(issue_key) summaries.append( IssueSummary( provider_id=provider_id, @@ -381,11 +389,65 @@ def targeted_issue_summaries_for_import_files(files: list[ImportedFile]) -> list release_date=str(release_date) if release_date else None, cover_url=str(cover_url) if cover_url else None, issue_type=issue_type, + issue_number_text=issue_number_text, ) ) return summaries +def ensure_target_issue_summary_for_import_file(imp_file: ImportedFile) -> bool: + """Repair the cached issue target needed to create a new series in Step 4.""" + if imp_file.matched_issue_id is not None or imp_file.matched_issue_cv_id is None: + return True + + diagnostics = dict(imp_file.diagnostics or {}) + existing = diagnostics.get("target_issue_summary") + payload = dict(existing) if isinstance(existing, dict) else {} + provider_id = str(payload.get("provider_id") or imp_file.matched_issue_cv_id).strip() + raw_issue_number = payload.get("issue_number") + issue_number: float | None = None + if raw_issue_number is not None: + with suppress(ValueError): + issue_number = float(str(raw_issue_number)) + if issue_number is None: + issue_number = candidate_issue_number(imp_file) + if not provider_id or issue_number is None: + diagnostics.update( + { + "reason": "target_issue_summary_unavailable", + "rejection_reason": ( + "The matched issue target is incomplete and must be reviewed before import." + ), + } + ) + imp_file.diagnostics = diagnostics + return False + + raw_issue_type = payload.get("issue_type") or diagnostics.get("source_issue_type") + try: + issue_type = IssueType(str(raw_issue_type or IssueType.ISSUE.value)) + except ValueError: + issue_type = IssueType.ISSUE + issue_number_text = str(payload.get("issue_number_text") or "").strip() + if not issue_number_text: + issue_number_text = candidate_issue_number_text(imp_file) or "" + repaired = { + "provider_id": provider_id, + "issue_number": issue_number, + "title": payload.get("title"), + "release_date": payload.get("release_date"), + "cover_url": payload.get("cover_url"), + "issue_type": issue_type.value, + } + if issue_number_text: + repaired["issue_number_text"] = issue_number_text + diagnostics["target_issue_summary"] = repaired + diagnostics.pop("reason", None) + diagnostics.pop("rejection_reason", None) + imp_file.diagnostics = diagnostics + return True + + def schedule_catalog_hydration_for_series( session: AsyncSession, *, diff --git a/src/pullbox/services/import_job_execution_progress.py b/src/pullbox/services/import_job_execution_progress.py index f9493cbc..41a26ffb 100644 --- a/src/pullbox/services/import_job_execution_progress.py +++ b/src/pullbox/services/import_job_execution_progress.py @@ -31,10 +31,10 @@ ImportProgressFileProfile, ImportProgressSettings, current_item_payload, - import_group_file_progress_pct, - import_group_metadata_progress_pct, + import_group_file_completed_weight, + import_group_metadata_completed_weight, import_group_progress_plan, - weighted_import_progress_pct, + import_work_progress, ) if TYPE_CHECKING: @@ -44,7 +44,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from pullbox.services.import_job_execution_types import ( - EstimateRemainingFunc, ExecutionItemPlan, RaiseIfCancelledFunc, ReportFileProgressFunc, @@ -206,9 +205,9 @@ def build_report_file_progress_callback( job_id: int, job: ImportJob, job_started_at: datetime | None, + work_started_at: datetime, progress_callback: Callable[[ImportProgressEvent], Awaitable[None]] | None, progress_session_factory: async_sessionmaker[AsyncSession] | None, - estimate_remaining_seconds: EstimateRemainingFunc, group_progress_plans: dict[int, ImportGroupProgressPlan], shared_progress_settings: ImportProgressSettings, group_progress_weights: list[float], @@ -251,15 +250,15 @@ async def report_file_progress( series_id, import_group_progress_plan(shared_progress_settings, []), ) - group_progress_pct = import_group_file_progress_pct( + group_completed_weight = import_group_file_completed_weight( group_plan, file_index=file_index, current_file_pct=current_file_pct, ) - overall_progress = weighted_import_progress_pct( + work_progress = import_work_progress( group_progress_weights, current_group_index=group_index, - current_group_progress_pct=group_progress_pct, + current_group_completed_weight=group_completed_weight, ) loop_now = monotonic_time() emitted_at_value = progress_state.get("emitted_at") @@ -297,7 +296,7 @@ async def report_file_progress( ephemeral_progress=not persist_progress, mode="import", phase="importing", - progress=overall_progress, + progress=work_progress.progress_pct, message=( f"Processing file {file_index}/{max(total_files, 1)} " f"in review group {group_index + 1}/{max(total_groups, 1)}" @@ -313,10 +312,7 @@ async def report_file_progress( current_file_progress_unit=unit, current_series=series_name, current_series_status=ImportSeriesStatus.IMPORTING, - estimated_seconds_remaining=estimate_remaining_seconds( - job_started_at, - overall_progress, - ), + estimated_seconds_remaining=work_progress.remaining_seconds(work_started_at), series_imported=int(stats["series_imported"]), series_failed=int(stats["series_failed"]), series_found=series_found, @@ -361,10 +357,10 @@ def build_series_metadata_progress_emitter( job_id: int, job: ImportJob, job_started_at: datetime | None, + work_started_at: datetime, progress_callback: Callable[[ImportProgressEvent], Awaitable[None]] | None, emit_progress: Callable[..., Awaitable[None]], emit_live_progress: Callable[..., Awaitable[None]], - estimate_remaining_seconds: EstimateRemainingFunc, group_progress_plans: dict[int, ImportGroupProgressPlan], shared_progress_settings: ImportProgressSettings, group_progress_weights: list[float], @@ -392,14 +388,14 @@ async def emit_series_metadata_progress( series_id, import_group_progress_plan(shared_progress_settings, []), ) - group_progress = import_group_metadata_progress_pct( + group_completed_weight = import_group_metadata_completed_weight( group_plan, metadata_progress_pct=current_item_progress_pct, ) - progress = weighted_import_progress_pct( + work_progress = import_work_progress( group_progress_weights, current_group_index=group_index, - current_group_progress_pct=group_progress, + current_group_completed_weight=group_completed_weight, ) current_stats = stats() event = ImportProgressEvent( @@ -407,16 +403,13 @@ async def emit_series_metadata_progress( status=ImportJobStatus.IMPORTING, mode="import", phase="importing", - progress=progress, + progress=work_progress.progress_pct, message=message, current_series_id=series_id, current_series_name=series_name, current_series=series_name, current_series_status=ImportSeriesStatus.IMPORTING, - estimated_seconds_remaining=estimate_remaining_seconds( - job_started_at, - progress, - ), + estimated_seconds_remaining=work_progress.remaining_seconds(work_started_at), series_imported=current_stats["series_imported"], series_failed=current_stats["series_failed"], series_found=series_found, diff --git a/src/pullbox/services/import_logical_groups.py b/src/pullbox/services/import_logical_groups.py index 5842de22..f1cfff15 100644 --- a/src/pullbox/services/import_logical_groups.py +++ b/src/pullbox/services/import_logical_groups.py @@ -208,6 +208,7 @@ async def _mark_library_owned_ambiguous_candidate_conflict( imp_file.diagnostics = { **dict(imp_file.diagnostics or {}), "kind": "series_conflict_file", + "reason": "library_owned_ambiguous_candidate", "target_state": "series_match_conflict", "rejection_reason": ( "A same-year single-issue library item has the same crossover title tokens." diff --git a/src/pullbox/services/import_metadata_priority.py b/src/pullbox/services/import_metadata_priority.py new file mode 100644 index 00000000..77d87325 --- /dev/null +++ b/src/pullbox/services/import_metadata_priority.py @@ -0,0 +1,92 @@ +"""Weighted coordination between catalog hydration and ComicInfo enrichment.""" + +from __future__ import annotations + +import asyncio +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass + +_CATALOGS_PER_COMICINFO_TURN = 3 + + +@dataclass +class _PriorityState: + condition: asyncio.Condition + pending_catalogs: int = 0 + catalog_credits: int = 0 + + +_state: _PriorityState | None = None +_state_loop: asyncio.AbstractEventLoop | None = None + + +def _priority_state() -> _PriorityState: + global _state, _state_loop + loop = asyncio.get_running_loop() + if _state is None or _state_loop is not loop: + _state = _PriorityState(asyncio.Condition()) + _state_loop = loop + return _state + + +def reset_import_metadata_priority() -> None: + """Reset process-local coordination between tests or event-loop restarts.""" + global _state, _state_loop + _state = None + _state_loop = None + + +class CatalogMetadataWork(AbstractAsyncContextManager["CatalogMetadataWork"]): + """Track a known catalog batch and expose per-series completion checkpoints.""" + + def __init__(self, units: int) -> None: + self._units = max(units, 0) + self._remaining = self._units + self._state: _PriorityState | None = None + + async def __aenter__(self) -> CatalogMetadataWork: + self._state = _priority_state() + async with self._state.condition: + if self._state.pending_catalogs == 0: + self._state.catalog_credits = 0 + self._state.pending_catalogs += self._units + self._state.condition.notify_all() + return self + + async def complete_one(self) -> None: + if self._state is None or self._remaining == 0: + return + async with self._state.condition: + self._remaining -= 1 + self._state.pending_catalogs -= 1 + self._state.catalog_credits += 1 + self._state.condition.notify_all() + + async def __aexit__(self, *_exc: object) -> None: + assert self._state is not None + async with self._state.condition: + self._state.pending_catalogs -= self._remaining + self._remaining = 0 + if self._state.pending_catalogs == 0: + self._state.catalog_credits = 0 + self._state.condition.notify_all() + + +def catalog_metadata_work(units: int) -> CatalogMetadataWork: + """Register catalog work before it competes for ComicVine capacity.""" + return CatalogMetadataWork(units) + + +async def wait_for_comicinfo_turn() -> None: + """Give each three catalog completions priority over one new file update.""" + state = _priority_state() + async with state.condition: + await state.condition.wait_for( + lambda: ( + state.pending_catalogs == 0 or state.catalog_credits >= _CATALOGS_PER_COMICINFO_TURN + ) + ) + if state.pending_catalogs == 0: + state.catalog_credits = 0 + else: + state.catalog_credits -= _CATALOGS_PER_COMICINFO_TURN diff --git a/src/pullbox/services/import_metadata_progress.py b/src/pullbox/services/import_metadata_progress.py new file mode 100644 index 00000000..d2a51295 --- /dev/null +++ b/src/pullbox/services/import_metadata_progress.py @@ -0,0 +1,269 @@ +"""Overall import metadata activity, derived from durable catalog and file state.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy import func, select + +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, +) +from pullbox.models.operation_progress import ( + OperationProgress, + OperationProgressState, + OperationProgressTone, + OperationProgressType, +) +from pullbox.models.series import IssueCatalogState, Series +from pullbox.services.operation_progress import ( + OperationProgressMeasure, + OperationProgressUpdate, + publish_operation_progress, +) +from pullbox.services.operation_progress_dispatch import notify_activity_changed + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +logger = structlog.get_logger(__name__) +_REFRESH_SECONDS = 5.0 + + +@dataclass +class _MetadataActivity: + factory: async_sessionmaker[AsyncSession] + job_id: int + workers: int = 0 + task: asyncio.Task[None] | None = None + stop: asyncio.Event = field(default_factory=asyncio.Event) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def refresh(self) -> None: + try: + async with self.lock: + async with self.factory() as session: + update = await build_import_metadata_progress( + session, + job_id=self.job_id, + running=self.workers > 0, + ) + if update is None: + return + existing = await session.scalar( + select(OperationProgress).where( + OperationProgress.operation_type == update.operation_type, + OperationProgress.operation_key == update.operation_key, + ) + ) + if existing is not None and ( + existing.state == update.state + and existing.message == update.message + and existing.overall_current == update.overall.current + and existing.overall_total == update.overall.total + ): + return + await publish_operation_progress(session, update) + await session.commit() + await notify_activity_changed(update) + except Exception: + # Progress failure must never interrupt a catalog fetch or archive write. + logger.exception("import_metadata_activity_update_failed", job_id=self.job_id) + + async def run(self, stop: asyncio.Event) -> None: + while not stop.is_set(): + await self.refresh() + with suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=_REFRESH_SECONDS) + + +_activities: dict[tuple[asyncio.AbstractEventLoop, int], _MetadataActivity] = {} + + +@asynccontextmanager +async def track_import_metadata_progress( + factory: async_sessionmaker[AsyncSession], + *, + job_ids: list[int], +) -> AsyncIterator[None]: + """Share one periodic summary across both metadata lanes for each import.""" + loop = asyncio.get_running_loop() + tracked = [] + for job_id in set(job_ids): + key = (loop, job_id) + activity = _activities.setdefault(key, _MetadataActivity(factory, job_id)) + activity.workers += 1 + if activity.task is None: + activity.stop = asyncio.Event() + activity.task = asyncio.create_task(activity.run(activity.stop)) + tracked.append((key, activity)) + try: + yield + finally: + for key, activity in tracked: + activity.workers -= 1 + if activity.workers: + continue + task, activity.task = activity.task, None + activity.stop.set() + if task is not None: + await task + await activity.refresh() + if activity.workers == 0: + _activities.pop(key, None) + + +async def catalog_hydration_import_job_ids( + factory: async_sessionmaker[AsyncSession], + *, + series_id: int | None = None, +) -> list[int]: + """Find import owners without expanding large series lists into SQL parameters.""" + try: + async with factory() as session: + statement = ( + select(ImportedSeries.import_job_id) + .join(ImportJob, ImportJob.id == ImportedSeries.import_job_id) + .join(Series, Series.id == ImportedSeries.series_id) + .where(ImportJob.status.in_([ImportJobStatus.IMPORTING, ImportJobStatus.COMPLETED])) + .distinct() + ) + if series_id is not None: + statement = statement.where(Series.id == series_id) + else: + statement = statement.where( + Series.issue_catalog_state == IssueCatalogState.HYDRATING + ) + return list((await session.scalars(statement)).all()) + except Exception: + # Activity ownership is observability only and cannot block real metadata work. + logger.exception("import_metadata_owner_lookup_failed", series_id=series_id) + return [] + + +async def build_import_metadata_progress( + session: AsyncSession, + *, + job_id: int, + running: bool, +) -> OperationProgressUpdate | None: + """Count finished work, not individual-file bytes or elapsed-time guesses.""" + job = await session.get(ImportJob, job_id) + if job is None: + return None + key = f"metadata:{job_id}" + if job.status != ImportJobStatus.COMPLETED: + existing = await session.scalar( + select(OperationProgress.id).where( + OperationProgress.operation_type == OperationProgressType.IMPORT, + OperationProgress.operation_key == key, + ) + ) + if existing is None: + return None + return OperationProgressUpdate( + operation_type=OperationProgressType.IMPORT, + operation_key=key, + revision=None, + state=OperationProgressState.CANCELLED, + phase="metadata_sync", + title="Import metadata sync", + message="Metadata sync stopped because the import is no longer complete.", + source_label=f"Import #{job_id}", + group_key="import_metadata", + ) + + series_ids = select(ImportedSeries.series_id).where(ImportedSeries.import_job_id == job_id) + catalog_rows = await session.execute( + select(Series.issue_catalog_state, Series.metadata_source, func.count()) + .where(Series.id.in_(series_ids), Series.comicvine_id.isnot(None)) + .group_by(Series.issue_catalog_state, Series.metadata_source) + ) + catalog_groups = list(catalog_rows.all()) + catalogs: dict[IssueCatalogState, int] = {} + profile_done = 0 + for catalog_state, metadata_source, count in catalog_groups: + catalogs[catalog_state] = catalogs.get(catalog_state, 0) + count + if metadata_source == "comicvine" or catalog_state == IssueCatalogState.COMPLETE: + profile_done += count + status = ImportedFile.diagnostics["comicinfo_enrichment"]["status"].as_string() + file_rows = await session.execute( + select(status, func.count()) + .where( + ImportedFile.import_job_id == job_id, + ImportedFile.status == ImportedFileStatus.IMPORTED, + status.in_(["pending", "complete", "failed"]), + ) + .group_by(status) + ) + files = {state: count for state, count in file_rows.all()} + catalog_total = sum(catalogs.values()) + file_total = sum(files.values()) + total = catalog_total + file_total + if not total: + return None + catalog_done = catalogs.get(IssueCatalogState.COMPLETE, 0) + file_done = files.get("complete", 0) + failed = catalogs.get(IssueCatalogState.FAILED, 0) + files.get("failed", 0) + finished = catalog_done + file_done + failed + pending = total - finished + state = OperationProgressState.RUNNING + tone = OperationProgressTone.INFO + attention = False + summary = ( + f"Series details: {profile_done:,} of {catalog_total:,}. " + f"Issue catalogs: {catalog_done:,} of {catalog_total:,}. " + f"ComicInfo files: {file_done:,} of {file_total:,}." + ) + if not pending: + state = OperationProgressState.FAILED if failed else OperationProgressState.COMPLETED + tone = OperationProgressTone.WARNING if failed else OperationProgressTone.SUCCESS + attention = bool(failed) + summary = ( + f"Metadata sync finished with {failed:,} failed updates. " + if failed + else "Metadata sync complete. " + ) + summary + elif not running: + state = OperationProgressState.PAUSED + tone = OperationProgressTone.WARNING + attention = True + summary = "Metadata sync paused. Check import logs for provider or file errors. " + summary + else: + summary = "Syncing metadata in the background. " + summary + return OperationProgressUpdate( + operation_type=OperationProgressType.IMPORT, + operation_key=key, + revision=None, + state=state, + phase="metadata_sync", + title="Import metadata sync", + message=summary, + source_label=f"Import #{job_id}", + group_key="import_metadata", + detail_url="/import?tab=history", + tone=tone, + attention_required=attention, + overall=OperationProgressMeasure(current=finished, total=total, unit="updates"), + detail_snapshot={ + "job_id": job_id, + "profiles_complete": profile_done, + "profiles_total": catalog_total, + "catalogs_complete": catalog_done, + "catalogs_total": catalog_total, + "files_complete": file_done, + "files_total": file_total, + "failed": failed, + "pending": pending, + }, + ) diff --git a/src/pullbox/services/import_mylar_scan_progress.py b/src/pullbox/services/import_mylar_scan_progress.py index 12e7137b..55692f29 100644 --- a/src/pullbox/services/import_mylar_scan_progress.py +++ b/src/pullbox/services/import_mylar_scan_progress.py @@ -3,12 +3,14 @@ from __future__ import annotations import time -from dataclasses import dataclass +from dataclasses import dataclass, field +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING from pullbox.models.import_job import ImportJobStatus from pullbox.schemas.import_job import ImportProgressEvent +from pullbox.services import import_progress_runtime from pullbox.services.import_counters import job_stats from pullbox.services.import_workflow_state import ( SCAN_PROGRESS_MATERIALIZE_END, @@ -36,6 +38,8 @@ class MylarScanProgress: last_report_at: float = 0.0 checked_files: int = 0 batch_files: int = 0 + work_started_at: datetime = field(default_factory=lambda: datetime.now(UTC), repr=False) + estimated_seconds_remaining: int | None = field(default=None, init=False) async def report_safety(self, completed: int, total: int, file_path: str) -> None: self.checked_files = completed @@ -70,6 +74,7 @@ async def _publish(self, completed: int, total: int, file_path: str, message: st phase="scanning", progress=progress, message=message, + estimated_seconds_remaining=self.estimated_seconds_remaining, current_item_kind="scan", current_item_stage="scanning", current_item_stage_label="Checking Mylar file batch", @@ -82,6 +87,16 @@ async def _publish(self, completed: int, total: int, file_path: str, message: st async def checkpoint_page(self) -> None: self.source_completed = self.source_page_end + if self.source_total > 0 and self.source_completed >= self.source_total: + self.estimated_seconds_remaining = 0 + else: + self.estimated_seconds_remaining = ( + import_progress_runtime.estimate_remaining_work_seconds( + self.work_started_at, + completed_units=self.source_completed, + total_units=self.source_total, + ) + ) # A single source page can emit extra Annual cohorts. Measure overall # progress using source rows, not the number of resulting review groups. await self._publish( diff --git a/src/pullbox/services/import_orphans.py b/src/pullbox/services/import_orphans.py index fd2f17f8..85799c55 100644 --- a/src/pullbox/services/import_orphans.py +++ b/src/pullbox/services/import_orphans.py @@ -21,6 +21,10 @@ from pullbox.services.import_counters import recompute_file_counters, recompute_series_counters from pullbox.services.import_file_resolution import load_issue_lookup_for_series from pullbox.services.import_job_actions import build_series_created_action_payload +from pullbox.services.import_job_execution_items import ( + ensure_target_issue_summary_for_import_file, +) +from pullbox.services.import_review_recheck import prepare_retryable_failed_sources_for_retry if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -530,6 +534,8 @@ async def retry_failed_series( if job.status != ImportJobStatus.COMPLETED: raise ValidationError(f"Job must be in COMPLETED state to retry (current: {job.status})") + source_recheck = await prepare_retryable_failed_sources_for_retry(session, job) + result = await session.execute( sa_select(ImportedSeries).where( ImportedSeries.import_job_id == job_id, @@ -539,7 +545,7 @@ async def retry_failed_series( failed_items = list(result.scalars().all()) failed_file_result = await session.execute( - sa_select(ImportedSeries) + sa_select(ImportedSeries, ImportedFile) .join(ImportedFile, ImportedFile.import_series_id == ImportedSeries.id) .where( ImportedSeries.import_job_id == job_id, @@ -551,13 +557,28 @@ async def retry_failed_series( ), ImportedFile.status == ImportedFileStatus.FAILED, ) - .distinct() ) - failed_file_items = list(failed_file_result.scalars().all()) + failed_file_items = [ + item + for item, imp_file in failed_file_result.all() + if not isinstance(dict(imp_file.diagnostics or {}).get("source_revalidation"), dict) + ] retry_items_by_id = {item.id: item for item in [*failed_items, *failed_file_items]} retry_items = list(retry_items_by_id.values()) if not retry_items: + if source_recheck["files_checked"] > 0: + await session.flush() + await log_event( + session, + job_id, + "WARNING", + "import_retry_failed_source_revalidation_blocked", + message="No failed files passed source revalidation", + source_files_checked=source_recheck["files_checked"], + source_files_blocked=source_recheck["blocked_files"], + ) + return job, 0 raise ValidationError("No failed series or files to retry") retry_series_ids = [item.id for item in retry_items] @@ -572,12 +593,34 @@ async def retry_failed_series( ImportedFile.status == ImportedFileStatus.FAILED, ) ) - failed_files = list(failed_files_result.scalars().all()) + failed_files = [ + imp_file + for imp_file in failed_files_result.scalars().all() + if not isinstance(dict(imp_file.diagnostics or {}).get("source_revalidation"), dict) + ] for imp_file in failed_files: imp_file.status = ImportedFileStatus.CONFIRMED imp_file.include_in_import = True imp_file.error_message = None + retry_file_result = await session.execute( + sa_select(ImportedFile).where( + ImportedFile.import_series_id.in_(retry_series_ids), + ImportedFile.status.in_([ImportedFileStatus.MATCHED, ImportedFileStatus.CONFIRMED]), + ) + ) + repaired_target_count = 0 + for imp_file in retry_file_result.scalars().all(): + had_summary = isinstance(dict(imp_file.diagnostics or {}).get("target_issue_summary"), dict) + if ensure_target_issue_summary_for_import_file(imp_file): + if not had_summary and isinstance( + dict(imp_file.diagnostics or {}).get("target_issue_summary"), dict + ): + repaired_target_count += 1 + continue + imp_file.status = ImportedFileStatus.NO_MATCH + imp_file.include_in_import = False + count = len(retry_items) job.status = ImportJobStatus.IMPORTING await recompute_file_counters(session, job, series_ids=retry_series_ids) @@ -592,6 +635,7 @@ async def retry_failed_series( message=f"Retrying {count} failed import item{'s' if count != 1 else ''}", retry_count=count, retry_file_count=len(failed_files), + repaired_target_count=repaired_target_count, ) return job, count diff --git a/src/pullbox/services/import_progress_runtime.py b/src/pullbox/services/import_progress_runtime.py index 7ccc41d9..b7b3c6da 100644 --- a/src/pullbox/services/import_progress_runtime.py +++ b/src/pullbox/services/import_progress_runtime.py @@ -117,6 +117,8 @@ class ScanReviewProgressPlan: analysis_weights: tuple[float, ...] series_match_weights: tuple[float, ...] file_match_weights: tuple[float, ...] + progress_start: int = _SCAN_REVIEW_PROGRESS_START + progress_end: int = _SCAN_REVIEW_PROGRESS_END @property def analysis_weight(self) -> float: @@ -171,6 +173,27 @@ def total_weight(self) -> float: ) +@dataclass(frozen=True, slots=True) +class ImportWorkProgress: + """Keep measured work precise even when its display percentage rounds to zero.""" + + completed_weight: float + total_weight: float + + @property + def progress_pct(self) -> int: + if self.total_weight <= 0: + return 0 + return max(0, min(round(self.completed_weight / self.total_weight * 100), 99)) + + def remaining_seconds(self, started_at: datetime | None) -> int | None: + return estimate_remaining_work_seconds( + started_at, + completed_units=self.completed_weight, + total_units=self.total_weight, + ) + + def phase_range(phase: str) -> tuple[int, int]: """Return the canonical bounded progress range for a workflow phase.""" return _PHASE_RANGES.get(phase, (0, 100)) @@ -336,10 +359,8 @@ def scan_review_completed_weight( def scan_review_progress_pct(plan: ScanReviewProgressPlan, *, completed_weight: float) -> int: """Scale weighted Step 2 review-prep work into the canonical 35-99 range.""" fraction = min(max(completed_weight / plan.total_weight, 0.0), 1.0) - progress = _SCAN_REVIEW_PROGRESS_START + ( - (_SCAN_REVIEW_PROGRESS_END - _SCAN_REVIEW_PROGRESS_START) * fraction - ) - return max(_SCAN_REVIEW_PROGRESS_START, min(round(progress), _SCAN_REVIEW_PROGRESS_END)) + progress = plan.progress_start + ((plan.progress_end - plan.progress_start) * fraction) + return max(plan.progress_start, min(round(progress), plan.progress_end)) def _weighted_completed( @@ -399,10 +420,21 @@ def import_group_metadata_progress_pct( metadata_progress_pct: int | float, ) -> int: """Return current group progress while series metadata is being prepared.""" - completed = plan.metadata_weight * (_clamped_pct(metadata_progress_pct) / 100) + completed = import_group_metadata_completed_weight( + plan, metadata_progress_pct=metadata_progress_pct + ) return _clamped_pct((completed / plan.total_weight) * 100) +def import_group_metadata_completed_weight( + plan: ImportGroupProgressPlan, + *, + metadata_progress_pct: int | float, +) -> float: + """Measure metadata work without rounding its share of the group.""" + return plan.metadata_weight * (_clamped_pct(metadata_progress_pct) / 100) + + def import_group_file_progress_pct( plan: ImportGroupProgressPlan, *, @@ -410,6 +442,19 @@ def import_group_file_progress_pct( current_file_pct: int | float, ) -> int: """Return current group progress while an importable file is being processed.""" + completed = import_group_file_completed_weight( + plan, file_index=file_index, current_file_pct=current_file_pct + ) + return _clamped_pct((completed / plan.total_weight) * 100) + + +def import_group_file_completed_weight( + plan: ImportGroupProgressPlan, + *, + file_index: int, + current_file_pct: int | float, +) -> float: + """Measure completed file work without rounding its share of the group.""" completed = plan.metadata_weight safe_index = max(file_index, 1) for idx, (_file_id, weight) in enumerate(plan.file_weights, start=1): @@ -419,7 +464,7 @@ def import_group_file_progress_pct( if idx == safe_index: completed += weight * (_clamped_pct(current_file_pct) / 100) break - return _clamped_pct((completed / plan.total_weight) * 100) + return completed def weighted_import_progress_pct( @@ -429,16 +474,31 @@ def weighted_import_progress_pct( current_group_progress_pct: int | float, ) -> int: """Return whole-job Step 4 progress across weighted review groups.""" - if not group_weights: - return 0 safe_index = max(current_group_index, 0) - total_weight = max(sum(max(weight, 1.0) for weight in group_weights), 1.0) + current_weight = max(group_weights[safe_index], 1.0) if safe_index < len(group_weights) else 0.0 + return import_work_progress( + group_weights, + current_group_index=safe_index, + current_group_completed_weight=current_weight + * (_clamped_pct(current_group_progress_pct) / 100), + ).progress_pct + + +def import_work_progress( + group_weights: list[float] | tuple[float, ...], + *, + current_group_index: int, + current_group_completed_weight: float, +) -> ImportWorkProgress: + """Use one unrounded work position for both the display and Step 4 ETA.""" + safe_index = max(current_group_index, 0) + total_weight = sum(max(weight, 1.0) for weight in group_weights) completed = sum(max(weight, 1.0) for weight in group_weights[:safe_index]) if safe_index < len(group_weights): - completed += max(group_weights[safe_index], 1.0) * ( - _clamped_pct(current_group_progress_pct) / 100 + completed += min( + max(current_group_completed_weight, 0.0), max(group_weights[safe_index], 1.0) ) - return max(0, min(round((completed / total_weight) * 100), 99)) + return ImportWorkProgress(completed_weight=completed, total_weight=total_weight) def _clamped_pct(value: int | float | None) -> int: diff --git a/src/pullbox/services/import_review_actions.py b/src/pullbox/services/import_review_actions.py index ddae7f5e..1b276a8a 100644 --- a/src/pullbox/services/import_review_actions.py +++ b/src/pullbox/services/import_review_actions.py @@ -507,6 +507,7 @@ async def _unmatch_series_target( imp_file.diagnostics = { **existing_diagnostics, "kind": "file_no_match", + "reason": reason, "target_state": "needs_series_match", "rejection_reason": rejection_reason, "previous_match": previous_file_match, diff --git a/src/pullbox/services/import_review_recheck.py b/src/pullbox/services/import_review_recheck.py index dd264ac4..f99feaa9 100644 --- a/src/pullbox/services/import_review_recheck.py +++ b/src/pullbox/services/import_review_recheck.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from sqlalchemy import exists, or_, select +from sqlalchemy import exists, func, or_, select from pullbox.core.exceptions import NotFoundError, ValidationError from pullbox.core.file_safety import ( @@ -29,11 +29,14 @@ ImportedFile, ImportedFileStatus, ImportedSeries, + ImportFileHandlingMode, ImportJob, ImportJobLog, ImportJobStatus, ImportSeriesStatus, + ImportSourceType, ) +from pullbox.models.library import LibraryRoot from pullbox.services.import_content_inspection import inspect_import_content from pullbox.services.import_safety_diagnostics import build_import_safety_diagnostics from pullbox.services.import_series_match_state import clear_auto_cv_match_fields @@ -190,6 +193,370 @@ def _apply_file( file.diagnostics = diagnostics +def _apply_completed_file_recheck( + file: ImportedFile, + metadata: SourceMetadata, + content: dict[str, Any], + signature: dict[str, int | str], + *, + reviewed_series_cv_id: int | None, +) -> bool: + """Refresh source evidence without changing the saved import decision.""" + diagnostics = dict(file.diagnostics or {}) + previous_signature = dict(file.source_signature or {}) + source = {**metadata.diagnostics, **content} + block = source.pop("file_safety", None) + identity_conflicts = source.get("identity_conflicts") + saved_target_conflicts = _saved_target_identity_conflicts( + file, + metadata, + reviewed_series_cv_id=reviewed_series_cv_id, + ) + if block is None and isinstance(identity_conflicts, list) and identity_conflicts: + block = build_import_safety_diagnostics( + "The current source identity conflicts with the file reviewed during import.", + kind="source_revalidation", + code="source_identity_changed", + source="completed_import_recheck", + overrideable_hint=False, + ) + if block is None and saved_target_conflicts: + block = build_import_safety_diagnostics( + "The replacement source does not match the issue reviewed during import.", + kind="source_revalidation", + code="source_identity_changed", + source="completed_import_recheck", + overrideable_hint=False, + ) + block["identity_conflicts"] = saved_target_conflicts + checked_at = datetime.now(UTC).isoformat() + if isinstance(block, dict): + diagnostics["source_revalidation"] = { + **block, + "kind": "source_revalidation", + "source": "completed_import_recheck", + } + diagnostics["source_recheck"] = { + "checked_at": checked_at, + "ready_for_retry": False, + } + file.error_message = str(block.get("sanitized_reason") or block.get("reason")) + file.diagnostics = diagnostics + return False + + diagnostics.pop("source_revalidation", None) + diagnostics.update( + { + "source_metadata": source, + "source_issue_type": metadata.issue_type.value, + "comicvine_series_id": metadata.comicvine_series_id, + "metadata_signals": {key: value.value for key, value in metadata.signals.items()}, + "source_recheck": { + "checked_at": checked_at, + "ready_for_retry": True, + }, + } + ) + file.parsed_series = metadata.series_name + file.parsed_issue_number = metadata.issue_number + file.parsed_year = metadata.year + file.comicvine_issue_id = metadata.comicvine_issue_id + file.has_comicinfo = bool(source.get("has_comicinfo")) + refreshed_signature = dict(signature) + reference_root_id = previous_signature.get("mylar_reference_root_id") + if ( + isinstance(reference_root_id, int) + and not isinstance(reference_root_id, bool) + and reference_root_id > 0 + ): + refreshed_signature["mylar_reference_root_id"] = reference_root_id + file.source_signature = refreshed_signature + file.file_size = int(signature["size"]) + file.error_message = "Source rechecked and ready to retry." + file.diagnostics = diagnostics + return True + + +def _saved_target_identity_conflicts( + file: ImportedFile, + metadata: SourceMetadata, + *, + reviewed_series_cv_id: int | None, +) -> list[dict[str, int | float | str]]: + """Compare fresh replacement evidence with the immutable reviewed target.""" + diagnostics = dict(file.diagnostics or {}) + raw_summary = diagnostics.get("target_issue_summary") + summary = raw_summary if isinstance(raw_summary, dict) else {} + conflicts: list[dict[str, int | float | str]] = [] + + target_issue_cv_id = file.matched_issue_cv_id + if target_issue_cv_id is None: + try: + target_issue_cv_id = int(str(summary.get("provider_id") or "")) + except ValueError: + target_issue_cv_id = None + if ( + target_issue_cv_id is not None + and metadata.comicvine_issue_id is not None + and metadata.comicvine_issue_id != target_issue_cv_id + ): + conflicts.append( + { + "field": "comicvine_issue_id", + "reviewed": target_issue_cv_id, + "source": metadata.comicvine_issue_id, + } + ) + + raw_target_number = summary.get("issue_number", file.parsed_issue_number) + try: + target_issue_number = float(str(raw_target_number)) + except (TypeError, ValueError): + target_issue_number = None + if ( + target_issue_number is not None + and metadata.issue_number is not None + and abs(metadata.issue_number - target_issue_number) >= 0.0001 + ): + conflicts.append( + { + "field": "issue_number", + "reviewed": target_issue_number, + "source": metadata.issue_number, + } + ) + + if ( + reviewed_series_cv_id is not None + and metadata.comicvine_series_id is not None + and metadata.comicvine_series_id != reviewed_series_cv_id + ): + conflicts.append( + { + "field": "comicvine_series_id", + "reviewed": reviewed_series_cv_id, + "source": metadata.comicvine_series_id, + } + ) + return conflicts + + +async def _retry_source_roots( + session: AsyncSession, + job: ImportJob, +) -> list[Path]: + """Recover only source boundaries already approved by the saved import.""" + candidates: list[Path] = [] + if job.source_type is ImportSourceType.FILESYSTEM: + candidates.append(Path(job.source_path)) + else: + candidates.extend(Path(value) for value in dict(job.mylar3_path_map or {}).values()) + signature_rows = await session.scalars( + select(ImportedFile.source_signature).where( + ImportedFile.import_job_id == job.id, + ImportedFile.status == ImportedFileStatus.FAILED, + ImportedFile.diagnostics["source_revalidation"]["retryable"].as_boolean().is_(True), + ) + ) + root_ids = { + root_id + for signature in signature_rows.all() + if isinstance(signature, dict) + and isinstance( + root_id := signature.get("mylar_reference_root_id"), + int, + ) + and not isinstance(root_id, bool) + and root_id > 0 + } + if root_ids: + root_query = select(LibraryRoot).where( + LibraryRoot.id.in_(root_ids), + LibraryRoot.enabled.is_(True), + ) + if job.file_handling_mode is ImportFileHandlingMode.IN_PLACE: + root_query = root_query.where(LibraryRoot.allow_referenced_registrations.is_(True)) + candidates.extend(Path(root.path) for root in (await session.scalars(root_query)).all()) + + roots: list[Path] = [] + seen: set[tuple[str, str]] = set() + for candidate in candidates: + try: + lexical = candidate.expanduser().absolute() + resolved = resolve_preview_source(candidate) + except (OSError, RuntimeError, ValueError): + continue + key = (str(lexical), str(resolved)) + if resolved.parent == resolved or key in seen: + continue + seen.add(key) + roots.append(lexical) + return roots + + +async def prepare_retryable_failed_sources_for_retry( + session: AsyncSession, + job: ImportJob, +) -> dict[str, int]: + """Revalidate changed failed sources as part of the in-app retry action.""" + retryable_count = int( + await session.scalar( + select(func.count(ImportedFile.id)).where( + ImportedFile.import_job_id == job.id, + ImportedFile.status == ImportedFileStatus.FAILED, + ImportedFile.diagnostics["source_revalidation"]["retryable"].as_boolean().is_(True), + ) + ) + or 0 + ) + if retryable_count == 0: + return { + "files_checked": 0, + "files_prepared": 0, + "blocked_files": 0, + "skipped_files": 0, + } + + roots = await _retry_source_roots(session, job) + if not roots: + raise ValidationError( + "No failed files are safe to retry because their approved source root is unavailable." + ) + return await prepare_completed_import_file_recheck( + session, + job.id, + source_roots=roots, + apply=True, + accept_replaced_files=True, + ) + + +async def prepare_completed_import_file_recheck( + session: AsyncSession, + job_id: int, + *, + source_roots: list[Path], + apply: bool = False, + series_ids: list[int] | None = None, + accept_replaced_files: bool = False, +) -> dict[str, int]: + """Recheck only retryable failed sources from an otherwise completed import.""" + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if job.status != ImportJobStatus.COMPLETED or job.control_request != ImportControlRequest.NONE: + raise ValidationError("Job must be idle in COMPLETED before a failed-file recheck") + if not source_roots: + raise ValidationError("At least one explicit source root is required") + roots = [(path.expanduser().absolute(), resolve_preview_source(path)) for path in source_roots] + if any(not real.is_dir() or real.parent == real for _, real in roots): + raise ValidationError("Source roots must be specific existing directories") + + block_dangerous = await is_dangerous_file_blocking_enabled(session) + max_archive_size = await get_archive_size_limit_bytes(session) + report = { + "files_checked": 0, + "files_prepared": 0, + "blocked_files": 0, + "skipped_files": 0, + } + sidecars: dict[str, dict[str, Any]] = {} + cursor = 0 + while True: + query = ( + select(ImportedFile, ImportedSeries) + .join(ImportedSeries, ImportedSeries.id == ImportedFile.import_series_id) + .where( + ImportedFile.import_job_id == job_id, + ImportedFile.status == ImportedFileStatus.FAILED, + ImportedFile.id > cursor, + ImportedFile.diagnostics["source_revalidation"]["retryable"].as_boolean().is_(True), + ) + .order_by(ImportedFile.id) + .limit(250) + ) + if series_ids: + query = query.where(ImportedFile.import_series_id.in_(series_ids)) + rows = list((await session.execute(query)).all()) + if not rows: + break + for imported_file, imported_series in rows: + cursor = int(imported_file.id) + metadata, content, signature = await asyncio.to_thread( + inspect_review_source, + Path(imported_file.file_path), + source_metadata_for_import_file(imported_series, imported_file), + dict(imported_file.source_signature or {}), + roots=roots, + block_dangerous=block_dangerous, + max_archive_size=max_archive_size, + accept_replaced_files=accept_replaced_files, + sidecars=sidecars, + ) + report["files_checked"] += 1 + blocked = "file_safety" in content + report["blocked_files"] += int(blocked) + report["files_prepared"] += int(not blocked) + if apply: + _apply_completed_file_recheck( + imported_file, + metadata, + content, + signature, + reviewed_series_cv_id=imported_series.cv_id, + ) + if apply: + await session.flush() + + if apply and report["files_checked"]: + session.add( + ImportJobLog( + import_job_id=job.id, + level="INFO", + event="import_failed_sources_rechecked", + message=( + f"Rechecked {report['files_checked']} failed source file(s); " + f"{report['files_prepared']} are ready for retry." + ), + data={**report, "prepared_at": datetime.now(UTC).isoformat()}, + ) + ) + await session.flush() + return report + + +async def prepare_import_recheck( + session: AsyncSession, + job_id: int, + *, + source_roots: list[Path], + apply: bool = False, + series_ids: list[int] | None = None, + accept_replaced_files: bool = False, +) -> dict[str, int]: + """Dispatch a source recheck without widening the saved job's safe scope.""" + job = await session.get(ImportJob, job_id) + if job is None: + raise NotFoundError("ImportJob", job_id) + if job.status == ImportJobStatus.COMPLETED: + return await prepare_completed_import_file_recheck( + session, + job_id, + source_roots=source_roots, + apply=apply, + series_ids=series_ids, + accept_replaced_files=accept_replaced_files, + ) + return await prepare_review_recheck( + session, + job_id, + source_roots=source_roots, + apply=apply, + series_ids=series_ids, + accept_replaced_files=accept_replaced_files, + ) + + async def prepare_review_recheck( session: AsyncSession, job_id: int, diff --git a/src/pullbox/services/import_rollback_state.py b/src/pullbox/services/import_rollback_state.py index 916c0262..ac0d4df8 100644 --- a/src/pullbox/services/import_rollback_state.py +++ b/src/pullbox/services/import_rollback_state.py @@ -188,6 +188,13 @@ def _restore_imported_file_state( imp_file.status = ImportedFileStatus.MATCHED else: imp_file.status = ImportedFileStatus.NO_MATCH + if imp_file.status == ImportedFileStatus.NO_MATCH: + diagnostics.setdefault("reason", "rollback_restored_unmatched") + diagnostics.setdefault( + "rejection_reason", + "This file returned to unresolved review after the import was rolled back.", + ) + imp_file.diagnostics = diagnostics imp_file.include_in_import = False imp_file.library_file_id = None imp_file.error_message = None diff --git a/src/pullbox/services/import_safety_diagnostics.py b/src/pullbox/services/import_safety_diagnostics.py index 212a5ccd..94edbca3 100644 --- a/src/pullbox/services/import_safety_diagnostics.py +++ b/src/pullbox/services/import_safety_diagnostics.py @@ -138,6 +138,7 @@ class ImportSafetyCategory(enum.StrEnum): "source_unavailable", "source_root_unconfirmed", "source_root_changed", + "source_identity_changed", } ) diff --git a/src/pullbox/services/import_scan_helpers.py b/src/pullbox/services/import_scan_helpers.py index a3e5a3d4..69c08a4c 100644 --- a/src/pullbox/services/import_scan_helpers.py +++ b/src/pullbox/services/import_scan_helpers.py @@ -3,11 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +import time +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager from dataclasses import asdict +from functools import partial from pathlib import Path from typing import TYPE_CHECKING +import structlog from sqlalchemy import delete as sa_delete from sqlalchemy import select as sa_select @@ -19,6 +23,7 @@ is_dangerous_file_blocking_enabled, run_safety_checks, ) +from pullbox.core.import_resources import bounded_thread_map, detect_import_resources from pullbox.core.source_metadata import archive_entry_issue_hint_from_names from pullbox.models.import_job import ImportedFile, ImportedSeries, ImportJob from pullbox.models.story_arc_import import ImportedStoryArc, ImportedStoryArcEntry @@ -37,6 +42,7 @@ Awaitable[FileSafetyInspection | None], ] FileSafetyProgress = Callable[[int, int, str], Awaitable[None]] +logger = structlog.get_logger(__name__) async def reset_scan_artifacts(session: AsyncSession, job: ImportJob) -> None: @@ -75,23 +81,61 @@ async def reset_scan_artifacts(session: AsyncSession, job: ImportJob) -> None: await session.flush() -async def _build_default_file_safety_check(session: AsyncSession) -> FileSafetyCheck: - """Build a per-batch file safety checker with immutable config values.""" +InspectionResult = tuple[ + str, FileSafetyInspection | None, dict[str, object], FileSafetyError | None +] + + +@asynccontextmanager +async def _inspection_results( + session: AsyncSession, + paths: list[str], + *, + check_file_safety: FileSafetyCheck | None, + workers: int, +) -> AsyncIterator[AsyncIterator[InspectionResult]]: + if check_file_safety is not None: + + async def serial_results() -> AsyncGenerator[InspectionResult, None]: + # Injected checkers may use the caller's database session. + for path in paths: + try: + inspection = await check_file_safety(session, Path(path)) + content = ( + await asyncio.to_thread(inspect_import_content, Path(path), inspection) + if inspection is not None + else {} + ) + yield path, inspection, content, None + except FileSafetyError as exc: + yield path, None, {}, exc + + iterator = serial_results() + try: + yield iterator + finally: + await iterator.aclose() + return + block_dangerous = await is_dangerous_file_blocking_enabled(session) max_archive_size = await get_archive_size_limit_bytes(session) - async def _check_file_safety( - _session: AsyncSession, - path: Path, - ) -> FileSafetyInspection: - return await asyncio.to_thread( - run_safety_checks, - path, - block_dangerous=block_dangerous, - max_archive_size=max_archive_size, - ) + def inspect(path: str) -> InspectionResult: + try: + inspection = run_safety_checks( + Path(path), + block_dangerous=block_dangerous, + max_archive_size=max_archive_size, + ) + content = ( + inspect_import_content(Path(path), inspection) if inspection is not None else {} + ) + return path, inspection, content, None + except FileSafetyError as exc: + return path, None, {}, exc - return _check_file_safety + async with bounded_thread_map(inspect, paths, workers=workers) as results: + yield results async def validate_discovered_files_safety( @@ -100,12 +144,9 @@ async def validate_discovered_files_safety( *, check_file_safety: FileSafetyCheck | None = None, progress_callback: FileSafetyProgress | None = None, + worker_count: int = 0, ) -> None: """Run safety checks once per unique discovered source file.""" - effective_check_file_safety = check_file_safety - if effective_check_file_safety is None: - effective_check_file_safety = await _build_default_file_safety_check(session) - files_by_path: dict[str, list[DiscoveredFile]] = {} for discovered in discovered_list: for discovered_file in discovered.files: @@ -117,80 +158,103 @@ async def validate_discovered_files_safety( total = len(files_by_path) if progress_callback is not None: await progress_callback(0, total, "") - for completed, (file_path, discovered_files) in enumerate(files_by_path.items(), 1): - try: - inspection = await effective_check_file_safety(session, Path(file_path)) - content_diagnostics = ( - await asyncio.to_thread(inspect_import_content, Path(file_path), inspection) - if inspection is not None - else {} - ) - except FileSafetyError as exc: - resource_block = classify_resource_safety_exception(exc) - safety_block = build_import_safety_diagnostics( - exc.reason, - details=exc.details, - kind=(resource_block.kind if resource_block is not None else None), - source=(resource_block.source if resource_block is not None else "file_safety"), - overrideable_hint=( - resource_block.overrideable if resource_block is not None else False - ), - ) - for discovered_file in discovered_files: - metadata_diagnostics = dict(discovered_file.metadata_diagnostics) - metadata_diagnostics["file_safety"] = safety_block - discovered_file.metadata_diagnostics = metadata_diagnostics - else: - if inspection is None: - continue - for discovered_file in discovered_files: - discovered_file.metadata_diagnostics = { - **discovered_file.metadata_diagnostics, - **content_diagnostics, - } - archive_report = next( - ( - report - for report in inspection.archives - if report.archive_path == Path(file_path) - ), - None, - ) - if archive_report is None: - continue - for discovered_file in discovered_files: - metadata_diagnostics = dict(discovered_file.metadata_diagnostics) - if archive_report.comicinfo_entry_count == 0: - archive_hint = archive_entry_issue_hint_from_names( - list(archive_report.entry_names), - expected_series_name=discovered_file.parsed_series, - ) - metadata_diagnostics.update( - { - "archive_metadata_loaded": True, - "archive_metadata_deferred": False, - "archive_entry_issue_hint_checked": True, - "has_comicinfo": False, - } - ) - if archive_hint is not None: - metadata_diagnostics["archive_entry_issue_hint"] = dict(archive_hint) + resources = await asyncio.to_thread(detect_import_resources) + workers = ( + resources.inspection_workers(requested=worker_count) if check_file_safety is None else 1 + ) + started_at = time.monotonic() + completed = 0 + async with _inspection_results( + session, + list(files_by_path), + check_file_safety=check_file_safety, + workers=workers, + ) as results: + async for file_path, inspection, content_diagnostics, error in results: + completed += 1 + discovered_files = files_by_path[file_path] + try: + if error is not None: + raise error + except FileSafetyError as exc: + resource_block = classify_resource_safety_exception(exc) + safety_block = build_import_safety_diagnostics( + exc.reason, + details=exc.details, + kind=(resource_block.kind if resource_block is not None else None), + source=(resource_block.source if resource_block is not None else "file_safety"), + overrideable_hint=( + resource_block.overrideable if resource_block is not None else False + ), + ) + for discovered_file in discovered_files: + metadata_diagnostics = dict(discovered_file.metadata_diagnostics) + metadata_diagnostics["file_safety"] = safety_block discovered_file.metadata_diagnostics = metadata_diagnostics + else: + if inspection is None: continue - archive_evidence: dict[str, object] = { - "member_index_scanned": True, - "comicinfo_entry_count": archive_report.comicinfo_entry_count, - } - if archive_report.comicinfo_entry is not None: - archive_evidence["comicinfo_entry"] = archive_report.comicinfo_entry - if archive_report.comicinfo is not None: - archive_evidence["comicinfo"] = asdict(archive_report.comicinfo) - if archive_report.comicinfo_error is not None: - archive_evidence["comicinfo_error"] = archive_report.comicinfo_error - metadata_diagnostics["archive_member_evidence"] = archive_evidence - discovered_file.metadata_diagnostics = metadata_diagnostics - finally: - if progress_callback is not None: - await progress_callback(completed, total, file_path) + for discovered_file in discovered_files: + discovered_file.metadata_diagnostics = { + **discovered_file.metadata_diagnostics, + **content_diagnostics, + } + archive_report = next( + ( + report + for report in inspection.archives + if report.archive_path == Path(file_path) + ), + None, + ) + if archive_report is None: + continue + for discovered_file in discovered_files: + metadata_diagnostics = dict(discovered_file.metadata_diagnostics) + if archive_report.comicinfo_entry_count == 0: + async with bounded_thread_map( + partial( + archive_entry_issue_hint_from_names, + expected_series_name=discovered_file.parsed_series, + ), + [list(archive_report.entry_names)], + workers=1, + ) as hints: + archive_hint = await anext(hints) + metadata_diagnostics.update( + { + "archive_metadata_loaded": True, + "archive_metadata_deferred": False, + "archive_entry_issue_hint_checked": True, + "has_comicinfo": False, + } + ) + if archive_hint is not None: + metadata_diagnostics["archive_entry_issue_hint"] = dict(archive_hint) + discovered_file.metadata_diagnostics = metadata_diagnostics + continue + archive_evidence: dict[str, object] = { + "member_index_scanned": True, + "comicinfo_entry_count": archive_report.comicinfo_entry_count, + } + if archive_report.comicinfo_entry is not None: + archive_evidence["comicinfo_entry"] = archive_report.comicinfo_entry + if archive_report.comicinfo is not None: + archive_evidence["comicinfo"] = asdict(archive_report.comicinfo) + if archive_report.comicinfo_error is not None: + archive_evidence["comicinfo_error"] = archive_report.comicinfo_error + metadata_diagnostics["archive_member_evidence"] = archive_evidence + discovered_file.metadata_diagnostics = metadata_diagnostics + finally: + if progress_callback is not None: + await progress_callback(completed, total, file_path) await asyncio.to_thread(reconcile_discovered_mylar_paths, discovered_list) + logger.info( + "import_archive_inspection_batch", + files_checked=completed, + inspection_workers=workers, + effective_cpus=resources.cpu_count, + available_memory_mib=resources.available_memory_bytes // (1024**2), + duration_ms=round((time.monotonic() - started_at) * 1000), + ) diff --git a/src/pullbox/services/import_scan_materialization.py b/src/pullbox/services/import_scan_materialization.py index 52b4942b..40cc50a4 100644 --- a/src/pullbox/services/import_scan_materialization.py +++ b/src/pullbox/services/import_scan_materialization.py @@ -5,6 +5,8 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING +from sqlalchemy import insert + from pullbox.models.import_job import ( ImportedFile, ImportedFileStatus, @@ -97,6 +99,7 @@ async def materialize_discovered_scan_results( await session.flush() total_files = 0 + file_rows: list[dict[str, object]] = [] for discovered, series_item in series_pairs: series_file_count = 0 for df in discovered.files: @@ -123,11 +126,12 @@ async def materialize_discovered_scan_results( diagnostics.update( { "kind": "source_layout_review", + "reason": "selected_layout_no_match", "rejection_reason": _SOURCE_LAYOUT_REVIEW_MESSAGE, } ) error_message = safety_block.get("reason") if isinstance(safety_block, dict) else None - file_item = ImportedFile( + file_item = dict( import_job_id=job.id, import_series_id=series_item.id, file_path=df.file_path, @@ -148,10 +152,15 @@ async def materialize_discovered_scan_results( error_message=error_message, diagnostics=diagnostics, ) - session.add(file_item) + file_rows.append(file_item) + if len(file_rows) >= 500: + await session.execute(insert(ImportedFile), file_rows) + file_rows.clear() series_file_count += 1 series_item.files_total = series_file_count total_files += series_file_count + if file_rows: + await session.execute(insert(ImportedFile), file_rows) if total_files: await session.flush() diff --git a/src/pullbox/services/import_scan_pipeline.py b/src/pullbox/services/import_scan_pipeline.py index 53a7521c..9a7291ef 100644 --- a/src/pullbox/services/import_scan_pipeline.py +++ b/src/pullbox/services/import_scan_pipeline.py @@ -89,6 +89,16 @@ PhaseProgressFunc = Callable[[int, int, int, int], int] +def _scan_item_percentage(phase: str, progress: int, current_series: str | None) -> int: + # Named discovery events describe a completed cohort, not overall work. + if current_series: + return 100 + if phase == "scanning": + span = SCAN_PROGRESS_MATERIALIZE_END - SCAN_PROGRESS_MATERIALIZE_START + return max(0, min(100, round((progress - SCAN_PROGRESS_MATERIALIZE_START) * 100 / span))) + return 0 + + def _scan_failure_message(exc: Exception) -> str: """Return a user-facing import scan failure message.""" if is_sqlite_locked_error(exc): @@ -250,19 +260,12 @@ async def emit_scan_progress( message=message, current_series=current_series, current_series_status=current_series_status, - estimated_seconds_remaining=estimate_remaining_seconds( - ( - job.import_started_at - if status == ImportJobStatus.IMPORTING - else job.scan_started_at - ), - progress, - ), + estimated_seconds_remaining=None, **current_item_payload( kind="series" if current_series else "scan", stage=phase, name=current_series, - progress_pct=progress, + progress_pct=_scan_item_percentage(phase, progress, current_series), ), **job_stats(job), ), @@ -294,19 +297,12 @@ async def forward_live_scan_progress( message=message, current_series=current_series, current_series_status=current_series_status, - estimated_seconds_remaining=estimate_remaining_seconds( - ( - job.import_started_at - if status == ImportJobStatus.IMPORTING - else job.scan_started_at - ), - progress, - ), + estimated_seconds_remaining=None, **current_item_payload( kind="series" if current_series else "scan", stage=phase, name=current_series, - progress_pct=progress, + progress_pct=_scan_item_percentage(phase, progress, current_series), ), **job_stats(job), ), @@ -489,16 +485,17 @@ async def check_folder_staging_cancellation() -> None: job_id=job_id, status=ImportJobStatus.FILE_MATCHING, phase="file_matching", - progress=SCAN_PROGRESS_FILE_MATCH_START, - message="Matching files to issues...", - estimated_seconds_remaining=estimate_remaining_seconds( - job.scan_started_at, - SCAN_PROGRESS_FILE_MATCH_START, + progress=int( + (job.progress_snapshot or {}).get( + "progress", SCAN_PROGRESS_FILE_MATCH_START + ) ), + message="Matching files to issues...", + estimated_seconds_remaining=None, **current_item_payload( kind="scan", stage="file_matching", - progress_pct=SCAN_PROGRESS_FILE_MATCH_START, + progress_pct=0, ), **job_stats(job), ), @@ -782,6 +779,7 @@ async def persist_series_page(raw_page: tuple[Any, ...]) -> None: else scan_progress.source_completed + len(page) ) await scan_progress.report_safety(0, sum(len(item.files) for item in page), "") + inspection_started_at = time.monotonic() if in_place: await asyncio.to_thread( validate_mylar_in_place_files, @@ -793,7 +791,12 @@ async def persist_series_page(raw_page: tuple[Any, ...]) -> None: session, page, progress_callback=scan_progress.report_safety ) if materialize_discovered_scan_results is not None: + persistence_started_at = time.monotonic() await materialize_discovered_scan_results(session, job, page) + else: + persistence_started_at = time.monotonic() + persistence_duration_ms = round((time.monotonic() - persistence_started_at) * 1000) + inspection_duration_ms = round((persistence_started_at - inspection_started_at) * 1000) job.scan_completed_at = None for series in page: @@ -822,6 +825,8 @@ async def persist_series_page(raw_page: tuple[Any, ...]) -> None: message=f"Prepared {series_count} series and {file_count} file records from Mylar.", series_found=series_count, files_found=file_count, + inspection_duration_ms=inspection_duration_ms, + persistence_duration_ms=persistence_duration_ms, duration_ms=round((time.monotonic() - page_started_at) * 1000), ) await scan_progress.checkpoint_page() @@ -982,6 +987,7 @@ async def flush_scan_batch(*, force: bool = False) -> None: batch_to_flush = list(batch) batch.clear() last_batch_flush_at = now + inspection_started_at = time.monotonic() if validate_discovered_files_safety is not None: batch_for_validation: list[DiscoveredSeries] = [] @@ -1018,6 +1024,7 @@ async def flush_scan_batch(*, force: bool = False) -> None: if batch_for_validation: await validate_discovered_files_safety(session, batch_for_validation) + persistence_started_at = time.monotonic() if materialize_discovered_scan_results is not None: await materialize_discovered_scan_results(session, job, batch_to_flush) # The shared materializer also supports one-shot callers and sets @@ -1035,6 +1042,10 @@ async def flush_scan_batch(*, force: bool = False) -> None: message=f"Discovered {len(batch_to_flush)} series ({series_found} total so far)", series_found_in_batch=len(batch_to_flush), total_series_found=series_found, + inspection_duration_ms=round( + (persistence_started_at - inspection_started_at) * 1000 + ), + persistence_duration_ms=round((time.monotonic() - persistence_started_at) * 1000), sample_series=[item.raw_series_name for item in batch_to_flush[:5]], ) diff --git a/src/pullbox/services/import_series_matching.py b/src/pullbox/services/import_series_matching.py index 718aff22..ccd03875 100644 --- a/src/pullbox/services/import_series_matching.py +++ b/src/pullbox/services/import_series_matching.py @@ -73,6 +73,41 @@ def _filesystem_source_identity_evaluation( """Classify a filesystem series from trusted local identity only.""" normalized_query = NameMatcher.normalize(source_metadata.series_name or item.raw_series_name) identity_conflicts = source_metadata.diagnostics.get("identity_conflicts") + source_signal = source_metadata.signals.get("comicvine_series_id") + cv_id = source_metadata.comicvine_series_id or item.cv_id + match_method = item.cv_match_method + if match_method not in _DIRECT_CV_ID_MATCH_METHODS: + match_method = ( + { + MetadataSignal.MYLAR3: "mylar3_cv_id", + MetadataSignal.COMICINFO: "comicinfo_cv_id", + MetadataSignal.SIDECAR: "comicinfo_cv_id", + MetadataSignal.PULLBOX_FOLDER: "folder_cv_id", + }.get(source_signal) + if source_signal is not None + else None + ) + + # A Mylar database row is authoritative for series ownership during a Mylar + # migration. Conflicting file/sidecar evidence is retained for file-level + # review rather than invalidating the already-known series identity. + if cv_id is not None and match_method == "mylar3_cv_id": + evaluation = known_cv_id_evaluation_from_source( + int(cv_id), + match_method=match_method, + raw_name=item.raw_series_name, + raw_year=item.raw_year, + normalized_query=normalized_query, + source_metadata=source_metadata, + match_threshold=match_threshold, + ) + if isinstance(identity_conflicts, list) and identity_conflicts: + evaluation.diagnostics["identity_conflicts"] = [ + dict(conflict) for conflict in identity_conflicts if isinstance(conflict, dict) + ] + evaluation.diagnostics["file_identity_review_required"] = True + return evaluation + if isinstance(identity_conflicts, list) and identity_conflicts: return ComicVineMatchEvaluation( match=None, @@ -91,20 +126,6 @@ def _filesystem_source_identity_evaluation( }, ) - cv_id = source_metadata.comicvine_series_id or item.cv_id - match_method = item.cv_match_method - if match_method not in _DIRECT_CV_ID_MATCH_METHODS: - signal = source_metadata.signals.get("comicvine_series_id") - match_method = ( - { - MetadataSignal.MYLAR3: "mylar3_cv_id", - MetadataSignal.COMICINFO: "comicinfo_cv_id", - MetadataSignal.SIDECAR: "comicinfo_cv_id", - MetadataSignal.PULLBOX_FOLDER: "folder_cv_id", - }.get(signal) - if signal is not None - else None - ) if cv_id is not None and match_method in _DIRECT_CV_ID_MATCH_METHODS: return known_cv_id_evaluation_from_source( int(cv_id), @@ -466,6 +487,10 @@ async def _mark_pending_files_no_match( for imp_file in pending_files: diagnostics = dict(imp_file.diagnostics or {}) diagnostics.setdefault("kind", "series_no_match_file") + diagnostics.setdefault( + "reason", + str(dict(item.diagnostics or {}).get("reason") or "series_no_match"), + ) local_identity_missing = ( dict(item.diagnostics or {}).get("reason") == "trusted_source_identity_missing" ) @@ -987,6 +1012,15 @@ async def _rebucket_collection_volume_subtitle_series_with_progress( progress_callback, ) await maybe_slow_item_delay() + elif progress_callback: + # Completing the displayed series must not wait for a DB batch. + await emit_matching_progress( + item, + idx, + message=f"Completed series review {idx + 1}/{total_items}.", + current_item_progress_pct=100, + live_only=True, + ) processed_items = idx + 1 if deferred_count: diff --git a/src/pullbox/services/import_service.py b/src/pullbox/services/import_service.py index 11fcbc39..18b39a9e 100644 --- a/src/pullbox/services/import_service.py +++ b/src/pullbox/services/import_service.py @@ -345,7 +345,10 @@ async def _validate_discovered_files_safety( ) -> None: """Run safety checks on discovered source files before review/import.""" await validate_import_discovered_files_safety( - session, discovered_list, progress_callback=progress_callback + session, + discovered_list, + progress_callback=progress_callback, + worker_count=self._settings.import_scan_worker_count, ) def _build_scan_metadata_provider(self, session: AsyncSession) -> CachedImportMetadataProvider: @@ -758,6 +761,7 @@ def schedule_comicinfo_enrichment( build_comicinfo_payload=self._build_comicinfo_payload_for_issue, apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, + prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, ) def schedule_story_arc_sync(self) -> None: @@ -776,6 +780,7 @@ async def recover_pending_comicinfo_enrichment( build_comicinfo_payload=self._build_comicinfo_payload_for_issue, apply_comicinfo=self._apply_comicinfo_to_imported_artifact, log_event=self._log_event, + prefetch_issue_metadata=self._metadata_service.prefetch_issue_metadata_batch, ) async def recover_pending_catalog_hydration( diff --git a/src/pullbox/services/import_workflow_state.py b/src/pullbox/services/import_workflow_state.py index 81a61efb..ece4f31d 100644 --- a/src/pullbox/services/import_workflow_state.py +++ b/src/pullbox/services/import_workflow_state.py @@ -673,7 +673,9 @@ def apply_progress_event_contract( if event.current_item_stage_label is None: event.current_item_stage_label = stage_label(event.current_item_stage) - if event.current_item_progress_pct is None: + if event.current_item_kind == "scan" and event.current_item_stage == "inventory": + event.current_item_progress_pct = None + elif event.current_item_progress_pct is None: if event.current_file_progress_pct is not None: event.current_item_progress_pct = event.current_file_progress_pct elif event.current_item_kind is not None: diff --git a/src/pullbox/services/metadata_service.py b/src/pullbox/services/metadata_service.py index afc9bc90..08a9c3be 100644 --- a/src/pullbox/services/metadata_service.py +++ b/src/pullbox/services/metadata_service.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import contextlib from datetime import UTC, date, datetime, timedelta from pathlib import Path # noqa: TC003 — used at runtime via parameter values @@ -40,7 +41,7 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession - from pullbox.providers.base import IssueSummary + from pullbox.providers.base import IssueMetadata, IssueSummary from pullbox.providers.metadata.comicvine import ComicVineProvider logger = structlog.get_logger(__name__) @@ -248,6 +249,26 @@ async def get_series_metadata( except ComicVineError as exc: raise _provider_error_from_comicvine(exc) from exc + async def get_series_metadata_batch( + self, + comicvine_ids: list[int], + ) -> dict[int, SeriesMetadata]: + """Fetch multiple provider series profiles through the optional bulk contract.""" + batch_fetch = getattr(type(self._provider), "get_series_batch", None) + try: + if callable(batch_fetch): + result = await batch_fetch( + self._provider, + [str(comicvine_id) for comicvine_id in comicvine_ids], + ) + return {int(provider_id): metadata for provider_id, metadata in result.items()} + metadata = await asyncio.gather( + *(self.get_series_metadata(comicvine_id) for comicvine_id in comicvine_ids) + ) + return dict(zip(comicvine_ids, metadata, strict=True)) + except ComicVineError as exc: + raise _provider_error_from_comicvine(exc) from exc + async def get_cached_series_metadata( self, comicvine_id: int, @@ -272,6 +293,29 @@ async def get_issue_summaries_for_series( except ComicVineError as exc: raise _provider_error_from_comicvine(exc) from exc + async def get_issue_catalog_batch( + self, + comicvine_ids: list[int], + ) -> dict[int, list[IssueSummary]]: + """Fetch multiple full issue catalogs through the optional bulk contract.""" + batch_fetch = getattr(type(self._provider), "get_issue_catalog_batch", None) + try: + if callable(batch_fetch): + result = await batch_fetch( + self._provider, + [str(comicvine_id) for comicvine_id in comicvine_ids], + ) + return {int(provider_id): summaries for provider_id, summaries in result.items()} + catalogs = await asyncio.gather( + *( + self.get_issue_summaries_for_series(comicvine_id) + for comicvine_id in comicvine_ids + ) + ) + return dict(zip(comicvine_ids, catalogs, strict=True)) + except ComicVineError as exc: + raise _provider_error_from_comicvine(exc) from exc + async def get_recent_issue_summaries_for_series( self, comicvine_id: int, @@ -503,6 +547,23 @@ async def fetch_issue( return issue + async def prefetch_issue_metadata_batch( + self, + comicvine_ids: list[int], + ) -> dict[int, IssueMetadata]: + """Warm full issue metadata cache in bulk without holding a DB session.""" + batch_fetch = getattr(type(self._provider), "get_issue_batch", None) + if not callable(batch_fetch) or not comicvine_ids: + return {} + try: + result = await batch_fetch( + self._provider, + [str(comicvine_id) for comicvine_id in comicvine_ids], + ) + except ComicVineError as exc: + raise _provider_error_from_comicvine(exc) from exc + return {int(provider_id): metadata for provider_id, metadata in result.items()} + async def _sync_issue_creators( self, session: AsyncSession, @@ -878,6 +939,17 @@ async def refresh_series( if not series.comicvine_id: raise ProviderError("comicvine", "Series has no ComicVine ID") + if force: + refresh_series = getattr(type(self._provider), "refresh_series", None) + refresh_catalog = getattr(type(self._provider), "refresh_issue_catalog", None) + try: + if callable(refresh_series): + await refresh_series(self._provider, str(series.comicvine_id)) + if callable(refresh_catalog): + await refresh_catalog(self._provider, str(series.comicvine_id)) + except ComicVineError as exc: + raise _provider_error_from_comicvine(exc) from exc + # noinspection PyTypeChecker series = await self.fetch_series(session, series.comicvine_id) await self.fetch_issues_for_series(session, series.id) diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py index 5775ef6a..f1e9d065 100644 --- a/src/pullbox/services/series_service.py +++ b/src/pullbox/services/series_service.py @@ -376,6 +376,31 @@ async def prefetch_comicvine_bundle( ) return series_meta, issue_summaries + async def prefetch_comicvine_profiles( + self, + comicvine_ids: list[int], + ) -> dict[int, SeriesMetadata]: + """Fetch provider profiles in bulk before the slower issue-catalog pass.""" + return await self._metadata.get_series_metadata_batch(comicvine_ids) + + async def prefetch_comicvine_issue_catalogs( + self, + comicvine_ids: list[int], + ) -> dict[int, list[IssueSummary]]: + """Fetch and group issue catalogs for multiple imported series.""" + return await self._metadata.get_issue_catalog_batch(comicvine_ids) + + async def upsert_comicvine_profile( + self, + session: AsyncSession, + comicvine_id: int, + series_meta: SeriesMetadata, + ) -> Series: + """Persist visible profile fields without claiming catalog completion.""" + series = await self._metadata.upsert_series_metadata(session, comicvine_id, series_meta) + await session.flush() + return series + async def add_from_comicvine_prefetched( self, session: AsyncSession, diff --git a/src/pullbox/tasks/download_task.py b/src/pullbox/tasks/download_task.py index 1474d44e..1ec3e495 100644 --- a/src/pullbox/tasks/download_task.py +++ b/src/pullbox/tasks/download_task.py @@ -21,6 +21,7 @@ from pullbox.composition.events import build_domain_event_bus from pullbox.composition.providers import register_download_clients +from pullbox.core.sqlite_lock import run_sqlite_transaction_with_retry from pullbox.database import get_session_factory from pullbox.models.download import DownloadClientType from pullbox.models.issue import Issue, IssueStatus @@ -336,11 +337,12 @@ async def monitor_downloads() -> None: event_logger=logger, ) - # ── Phase 3: Write — apply updates and run throttled recovery checks ── - async with factory() as session: - try: - if updates: - apply_result = await _apply_monitor_updates( + # ── Phase 3: Write — short, independently retryable DB transactions ── + if updates: + async with factory() as session: + + async def apply_updates() -> _download_monitor_apply.MonitorApplyResult: + return await _apply_monitor_updates( session, updates, first_active_observed_at=_first_active_observed_at, @@ -350,19 +352,32 @@ async def monitor_downloads() -> None: event_logger=logger, publish_progress=project_download_operation_progress, ) - completed = apply_result.completed - failed = apply_result.failed - # Throttle expensive recovery checks to ~every 30s - if recovery_due: - retried += await _process_retry_pending(factory, download_svc) - recovered = await _recover_orphaned_downloads(session) - _last_recovery_check = recovery_checked_at + apply_result = await run_sqlite_transaction_with_retry( + session, + apply_updates, + event_name="download_monitor_write", + logger=logger, + ) + completed = apply_result.completed + failed = apply_result.failed - await session.commit() - except Exception: - await session.rollback() - raise + # Throttle expensive recovery checks to ~every 30s. Network retries run + # outside this transaction; only the local orphan-recovery write is retried. + if recovery_due: + retried += await _process_retry_pending(factory, download_svc) + async with factory() as session: + + async def recover_orphans() -> int: + return await _recover_orphaned_downloads(session) + + recovered = await run_sqlite_transaction_with_retry( + session, + recover_orphans, + event_name="download_orphan_recovery", + logger=logger, + ) + _last_recovery_check = recovery_checked_at duration_ms = (time.monotonic() - start) * 1000 log_kwargs = { diff --git a/src/pullbox/tasks/import_orphan_recovery_task.py b/src/pullbox/tasks/import_orphan_recovery_task.py index aa52b44b..44e5491f 100644 --- a/src/pullbox/tasks/import_orphan_recovery_task.py +++ b/src/pullbox/tasks/import_orphan_recovery_task.py @@ -130,7 +130,7 @@ async def progress_callback( current=current, total=total, ) - _set_orphan_recovery_state( + progress = _set_orphan_recovery_state( imported_series_id, state="running", message=( @@ -147,12 +147,14 @@ async def progress_callback( file_index=file_index, total_files=total_files, ) + await _queue_orphan_recovery_progress(progress) - completed = _set_orphan_recovery_state( + preparing = _set_orphan_recovery_state( imported_series_id, state="running", message=f"Preparing recovery for {item.cv_title or item.raw_series_name}...", ) + await _queue_orphan_recovery_progress(preparing) payload = await service.recover_orphan( session, imported_series_id, @@ -160,7 +162,7 @@ async def progress_callback( progress_callback=progress_callback, ) await session.commit() - _set_orphan_recovery_state( + completed = _set_orphan_recovery_state( imported_series_id, state="completed", message=( diff --git a/src/pullbox/ui/import_results_context.py b/src/pullbox/ui/import_results_context.py index 1b40e8da..7d4a7451 100644 --- a/src/pullbox/ui/import_results_context.py +++ b/src/pullbox/ui/import_results_context.py @@ -570,6 +570,17 @@ async def load_import_results_context( file_status_counts.get(ImportedFileStatus.FAILED.value, 0), job.total_files_failed or 0, ) + failed_files = ( + await _load_files_for_status(session, job_id, ImportedFileStatus.FAILED) + if files_failed > 0 + else [] + ) + source_changed_files = sum( + 1 + for imported_file in failed_files + if dict(dict(imported_file.diagnostics or {}).get("source_revalidation") or {}).get("code") + == "source_changed" + ) files_safety_blocked = file_status_counts.get( ImportedFileStatus.SAFETY_BLOCKED.value, 0, @@ -620,11 +631,8 @@ async def load_import_results_context( "catalog_sync_attention_count": len(catalog_sync_series), "catalog_sync_series": catalog_sync_series, "files_failed": files_failed, - "failed_files": ( - await _load_files_for_status(session, job_id, ImportedFileStatus.FAILED) - if files_failed > 0 - else [] - ), + "source_changed_files": source_changed_files, + "failed_files": failed_files, "files_safety_blocked": files_safety_blocked, "safety_blocked_files": ( await _load_files_for_status( diff --git a/src/pullbox/ui/static/css/input.css b/src/pullbox/ui/static/css/input.css index 0f4f11fb..e3dbf55e 100644 --- a/src/pullbox/ui/static/css/input.css +++ b/src/pullbox/ui/static/css/input.css @@ -8483,12 +8483,14 @@ input[type="range"].range-pb::-moz-range-thumb { from { transform: translateX(-130%); } to { transform: translateX(280%); } } -.downloads-progress-fill.is-indeterminate { +.downloads-progress-fill.is-indeterminate, +.app-progress-fill.is-indeterminate { animation: downloads-progress-indeterminate 1.25s ease-in-out infinite; will-change: transform; } @media (prefers-reduced-motion: reduce) { - .downloads-progress-fill.is-indeterminate { + .downloads-progress-fill.is-indeterminate, + .app-progress-fill.is-indeterminate { animation: none; opacity: 0.7; transform: translateX(60%); diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css index d216ad53..93aac805 100644 --- a/src/pullbox/ui/static/css/tailwind.css +++ b/src/pullbox/ui/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[9rem_minmax\(0\,1fr\)\]{grid-template-columns:9rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-sec);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:DM Sans,sans-serif;line-height:1.5}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root,[data-theme=dark]{--pb-surface-app:#101824;--pb-surface-shell:#141e2c;--pb-surface-card:#172231;--pb-surface-raised:#1d2a3d;--pb-surface-input:#132030;--pb-surface-overlay:#070b12b8;--pb-surface-selected:#8fb9ee29;--pb-text-primary:#f7f1e8;--pb-text-secondary:#cbd5e1;--pb-text-tertiary:#9aaaba;--pb-text-inverse:#101824;--pb-border-subtle:#cbd5e11f;--pb-border-default:#cbd5e133;--pb-border-strong:#cbd5e152;--pb-interactive:#8fb9ee;--pb-interactive-hover:#7da9df;--pb-interactive-active:#6a96c8;--pb-interactive-selected:#8fb9ee2e;--pb-brand:#c6a17b;--pb-brand-muted:#c6a17b29;--pb-status-success:#68b88b;--pb-status-warning:#d7a15b;--pb-status-danger:#e38473;--pb-status-info:#8fb9ee;--pb-focus-ring:#8fb9ee47;--pb-focus-outline:#8fb9ee85;--pb-selection:#8fb9ee38;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #070b1238, 0 10px 24px #070b1229;--pb-shadow-2:0 2px 6px #070b1247, 0 18px 40px #070b1238;--pb-shadow-overlay:0 20px 56px #00000075;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root,[data-theme=dark]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 16%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 22%, transparent)}}:root,[data-theme=dark]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 28%, transparent)}}:root,[data-theme=dark]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}:root,[data-theme=dark]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 18%, transparent)}}:root,[data-theme=dark]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 20%, transparent)}}:root,[data-theme=dark]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 18%, transparent)}}:root,[data-theme=dark]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 18%, transparent)}}:root,[data-theme=dark]{--pb-purple:#a88bda;--pb-purple-dim:#a88bda2e;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root,[data-theme=dark]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-bg-card-hover) 68%, transparent)}}:root,[data-theme=dark]{--pb-table-row-hover:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){:root,[data-theme=dark]{--pb-table-row-hover:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}:root,[data-theme=dark]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-page-footer-clearance:1.5rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}[data-theme=light]{--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}[data-theme=light]{--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}[data-theme=light]{--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}[data-theme=light]{--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}[data-theme=light]{--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}[data-theme=light]{--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}[data-theme=light]{--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}[data-theme=light]{--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}[data-theme=light]{--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}[data-theme=light]{--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}[data-theme=light]{--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}[data-theme=light]{--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}[data-theme=light]{--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){[data-theme=light]{--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}[data-theme=light]{--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}@media (prefers-color-scheme:light){:root:not([data-theme]){--pb-surface-app:#f6f1e8;--pb-surface-shell:#fbf6ee;--pb-surface-card:#fffcf7;--pb-surface-raised:#fff;--pb-surface-input:#fffdfa;--pb-surface-overlay:#1a16136b;--pb-surface-selected:#2f5e8c1f;--pb-text-primary:#1e1a17;--pb-text-secondary:#51473d;--pb-text-tertiary:#74675b;--pb-text-inverse:#f7f1e8;--pb-border-subtle:#51473d24;--pb-border-default:#51473d33;--pb-border-strong:#51473d57;--pb-interactive:#2f5e8c;--pb-interactive-hover:#274f76;--pb-interactive-active:#21435f;--pb-interactive-selected:#2f5e8c24;--pb-brand:#855e3d;--pb-brand-muted:#8c68471f;--pb-status-success:#2e6a4f;--pb-status-warning:#8a5a1f;--pb-status-danger:#b24432;--pb-status-info:#2f5e8c;--pb-focus-ring:#2f5e8c42;--pb-focus-outline:#2f5e8cb8;--pb-selection:#2f5e8c33;--pb-shadow-0:none;--pb-shadow-1:0 1px 2px #1a161314, 0 10px 24px #1a16130f;--pb-shadow-2:0 2px 6px #1a16131a, 0 18px 40px #1a161314;--pb-shadow-overlay:0 20px 56px #1a161333;--pb-brand-hover:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-hover:color-mix(in srgb, var(--pb-brand) 84%, black)}}:root:not([data-theme]){--pb-brand-dim:var(--pb-brand-muted);--pb-brand-border:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-border:color-mix(in srgb, var(--pb-brand) 28%, transparent)}}:root:not([data-theme]){--pb-brand-signal:var(--pb-status-warning);--pb-brand-signal-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-dim:color-mix(in srgb, var(--pb-status-warning) 12%, transparent)}}:root:not([data-theme]){--pb-brand-signal-hover:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-hover:color-mix(in srgb, var(--pb-status-warning) 18%, transparent)}}:root:not([data-theme]){--pb-brand-signal-border:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-brand-signal-border:color-mix(in srgb, var(--pb-status-warning) 24%, transparent)}}:root:not([data-theme]){--pb-interactive-dim:var(--pb-interactive-selected);--pb-interactive-border:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-interactive-border:color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}:root:not([data-theme]){--pb-success:var(--pb-status-success);--pb-success-dim:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-success-dim:color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}:root:not([data-theme]){--pb-warning:var(--pb-status-warning);--pb-warning-dim:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-warning-dim:color-mix(in srgb, var(--pb-status-warning) 14%, transparent)}}:root:not([data-theme]){--pb-error:var(--pb-status-danger);--pb-error-dim:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-error-dim:color-mix(in srgb, var(--pb-status-danger) 12%, transparent)}}:root:not([data-theme]){--pb-info:var(--pb-status-info);--pb-info-dim:var(--pb-status-info)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-info-dim:color-mix(in srgb, var(--pb-status-info) 12%, transparent)}}:root:not([data-theme]){--pb-purple:#7b5c91;--pb-purple-dim:#7b5c9124;--pb-bg-base:var(--pb-surface-app);--pb-bg-surface:var(--pb-surface-shell);--pb-bg-card:var(--pb-surface-card);--pb-bg-card-hover:var(--pb-surface-card)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-bg-card-hover:color-mix(in srgb, var(--pb-surface-card) 72%, var(--pb-surface-raised))}}:root:not([data-theme]){--pb-bg-input:var(--pb-surface-input);--pb-bg-overlay:var(--pb-surface-overlay);--pb-table-row-stripe:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-stripe:color-mix(in srgb, var(--pb-surface-shell) 40%, transparent)}}:root:not([data-theme]){--pb-table-row-hover:var(--pb-surface-shell)}@supports (color:color-mix(in lab, red, red)){:root:not([data-theme]){--pb-table-row-hover:color-mix(in srgb, var(--pb-surface-shell) 58%, transparent)}}:root:not([data-theme]){--pb-table-cell-x:1.25rem;--pb-table-cell-x-tight:1rem;--pb-text-dim:var(--pb-text-tertiary);--pb-border:var(--pb-border-default);--pb-border-hover:var(--pb-border-strong)}}html,body{overscroll-behavior:none;overflow:hidden}:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring);border-radius:4px}::selection{background-color:var(--pb-selection)}:where(a[href]:not([aria-disabled=true]),button:not(:disabled):not([aria-disabled=true]),input[type=button]:not(:disabled),input[type=submit]:not(:disabled),input[type=reset]:not(:disabled),input[type=checkbox]:not(:disabled),input[type=radio]:not(:disabled),input[type=file]:not(:disabled),input[type=image]:not(:disabled),select:not(:disabled),summary,[role=button]:not([aria-disabled=true]),[role=link]:not([aria-disabled=true]),label:has(:is(input[type=checkbox],input[type=radio],input[type=file]):not(:disabled))){cursor:pointer}:where(button:disabled,input:disabled,select:disabled,[aria-disabled=true]){cursor:not-allowed}}@layer components{.control-size-sm,.btn-sm,.chip-btn-sm,.icon-btn-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.625rem;--pb-control-search-padding-left:2rem;--pb-control-search-padding-right:1.875rem;--pb-control-clear-right:.5rem}.control-size-md,.btn-md,.chip-btn,.icon-btn,.dropdown-select,.search-field,.btn-primary,.btn-warning,.btn-ghost,.btn-danger{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.75rem;--pb-control-panel-item-py:.5rem;--pb-control-search-icon-left:.75rem;--pb-control-search-padding-left:2.5rem;--pb-control-search-padding-right:2.25rem;--pb-control-clear-right:.625rem}.control-size-lg,.btn-lg,.chip-btn-lg,.icon-btn-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem;--pb-control-panel-item-px:.875rem;--pb-control-panel-item-py:.625rem;--pb-control-search-icon-left:.875rem;--pb-control-search-padding-left:2.75rem;--pb-control-search-padding-right:2.5rem;--pb-control-clear-right:.75rem}.btn-primary.btn-sm,.btn-warning.btn-sm,.btn-ghost.btn-sm,.btn-danger.btn-sm,.btn-primary.control-size-sm,.btn-warning.control-size-sm,.btn-ghost.control-size-sm,.btn-danger.control-size-sm{--pb-control-min-height:2rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.4375rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:.875rem}.btn-primary.btn-md,.btn-warning.btn-md,.btn-ghost.btn-md,.btn-danger.btn-md,.btn-primary.control-size-md,.btn-warning.control-size-md,.btn-ghost.control-size-md,.btn-danger.control-size-md{--pb-control-min-height:2.5rem;--pb-control-radius:.5rem;--pb-control-gap:.5rem;--pb-control-px:.75rem;--pb-control-py:.5rem;--pb-control-font-size:.875rem;--pb-control-line-height:1.25rem;--pb-control-icon-size:1rem}.btn-primary.btn-lg,.btn-warning.btn-lg,.btn-ghost.btn-lg,.btn-danger.btn-lg,.btn-primary.control-size-lg,.btn-warning.control-size-lg,.btn-ghost.control-size-lg,.btn-danger.control-size-lg{--pb-control-min-height:2.875rem;--pb-control-radius:.625rem;--pb-control-gap:.625rem;--pb-control-px:.875rem;--pb-control-py:.625rem;--pb-control-font-size:.9375rem;--pb-control-line-height:1.375rem;--pb-control-icon-size:1rem}.btn-primary{background-color:var(--pb-interactive);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--pb-interactive-hover)}}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-ghost{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:10px;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.btn-ghost:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.btn-ghost:disabled{cursor:not-allowed;opacity:.5}.btn-ghost{min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning{--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;justify-content:center;align-items:center;display:inline-flex}.btn-warning:disabled{cursor:not-allowed;opacity:.5}.btn-warning{background-color:var(--pb-warning);color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 86%, var(--pb-text-primary))}}.btn-danger{justify-content:center;align-items:center;gap:var(--pb-control-gap);background-color:var(--pb-error);--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-weight:var(--font-weight-medium);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:10px;display:inline-flex}@media (hover:hover){.btn-danger:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-danger{color:var(--pb-text-inverse);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.pill,.badge-success,.badge-warning,.badge-error,.badge-info,.badge-neutral,.badge-muted,.badge-purple{justify-content:center;align-items:center;gap:var(--spacing);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);text-align:center;--tw-font-weight:var(--font-weight-medium);font-family:DM Sans,sans-serif;font-size:11px;font-weight:var(--font-weight-medium);border-radius:3.40282e38px;display:inline-flex}.pill-success,.badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.pill-warning,.badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.pill-error,.badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.pill-info,.badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.pill-neutral,.badge-neutral{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.pill-muted,.badge-muted{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim)}.pill-purple,.badge-purple{background-color:var(--pb-purple-dim);color:var(--pb-purple)}.count-badge{min-width:1.25rem;min-height:1.25rem;padding-inline:calc(var(--spacing) * 1.5);--tw-leading:1;--tw-font-weight:var(--font-weight-bold);font-size:10px;line-height:1;font-weight:var(--font-weight-bold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}.count-badge-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.count-badge-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.count-badge-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.count-badge-error{background-color:var(--pb-error-dim);color:var(--pb-error)}.count-badge-neutral{background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}.table-shell{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card)}.table-shell-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.table-shell-header-comfortable{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.table-shell-content{padding:calc(var(--spacing) * 5)}.table-shell-content-comfortable{padding:calc(var(--spacing) * 6)}.table-toolbar{gap:calc(var(--spacing) * 3);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:64rem){.table-toolbar{flex-direction:row;justify-content:space-between;align-items:flex-end}}:where(.table-toolbar-title-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.table-toolbar-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.table-toolbar-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-title-compact{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.table-toolbar-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.table-toolbar-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.table-header-row{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.75rem;display:grid}.table-header-aside{justify-self:end}.table-summary{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));flex-wrap:wrap;display:flex}.table-scroll{overflow-x:auto}.table-base{border-collapse:collapse;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-head{text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-secondary);text-transform:uppercase}.table-head-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.table-head-cell{padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-head-cell-tight{padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-head-cell-right{text-align:right}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.table-body-striped>.table-row:nth-child(2n){background-color:var(--pb-table-row-stripe)}.table-row{color:var(--pb-text-secondary)}.table-row-hover>.table-row{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.table-row-hover>.table-row:hover{background-color:var(--pb-table-row-hover)}.table-row-hover>.table-row-detail{transition-property:none}.table-row-hover>.table-row-detail:hover{background-color:#0000}.table-cell{padding-block:calc(var(--spacing) * 3);padding-left:var(--pb-table-cell-x);padding-right:var(--pb-table-cell-x)}.table-cell-tight{padding-block:calc(var(--spacing) * 2.5);padding-left:var(--pb-table-cell-x-tight);padding-right:var(--pb-table-cell-x-tight)}.table-cell-right{text-align:right}.table-cell-muted{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary)}.table-cell-nowrap{white-space:nowrap}.table-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.table-empty{padding:calc(var(--spacing) * 8);text-align:center}.table-empty-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.chip-btn{border-radius:var(--radius-lg);border-style:var(--tw-border-style);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;justify-content:center;align-items:center;display:inline-flex}.chip-btn:disabled{cursor:not-allowed;opacity:.5}.chip-btn{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.chip-btn-pill{padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.2em;letter-spacing:.2em;text-transform:uppercase;border-radius:9999px}.chip-btn-neutral{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary)}@media (hover:hover){.chip-btn-neutral:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.chip-btn-selected{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected{border-color:color-mix(in srgb, var(--pb-info) 26%, transparent)}}.chip-btn-selected:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-selected:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-selected:hover{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.chip-btn-info{color:var(--pb-info);background-color:var(--pb-info-dim);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info{border-color:color-mix(in srgb, var(--pb-info) 24%, transparent)}}.chip-btn-info:hover{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{background-color:color-mix(in srgb, var(--pb-info) 20%, var(--pb-bg-card))}}.chip-btn-info:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.chip-btn-info:hover{border-color:color-mix(in srgb, var(--pb-info) 32%, transparent)}}.chip-btn-success{color:var(--pb-success);background-color:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success{border-color:color-mix(in srgb, var(--pb-success) 24%, transparent)}}.chip-btn-success:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{background-color:color-mix(in srgb, var(--pb-success) 20%, var(--pb-bg-card))}}.chip-btn-success:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.chip-btn-success:hover{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.chip-btn-warning{color:var(--pb-warning);background-color:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.chip-btn-warning:hover{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{background-color:color-mix(in srgb, var(--pb-warning) 20%, var(--pb-bg-card))}}.chip-btn-warning:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.chip-btn-warning:hover{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.chip-btn-error{color:var(--pb-error);background-color:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.chip-btn-error:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{background-color:color-mix(in srgb, var(--pb-error) 20%, var(--pb-bg-card))}}.chip-btn-error:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.chip-btn-error:hover{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:14px}.card-hover{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:14px}@media (hover:hover){.card-hover:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}}.auth-side-panel{border-right:1px solid var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-surface) 0%, var(--pb-bg-surface) 42%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.auth-side-panel{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-surface) 90%, var(--pb-brand) 10%) 0%, var(--pb-bg-surface) 42%, color-mix(in srgb, var(--pb-bg-card) 78%, var(--pb-interactive) 22%) 100%)}}.auth-side-panel:before,.auth-side-panel:after{content:"";pointer-events:none;filter:blur(12px);position:absolute;inset:auto}.auth-side-panel:before{background:radial-gradient(circle, var(--pb-brand) 0%, transparent 64%);width:24rem;height:24rem;top:-10%;left:-8%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:before{background:radial-gradient(circle, color-mix(in srgb, var(--pb-brand) 26%, transparent) 0%, transparent 64%)}}.auth-side-panel:after{background:radial-gradient(circle, var(--pb-interactive) 0%, transparent 66%);width:26rem;height:26rem;bottom:-14%;right:-12%}@supports (color:color-mix(in lab, red, red)){.auth-side-panel:after{background:radial-gradient(circle, color-mix(in srgb, var(--pb-interactive) 22%, transparent) 0%, transparent 66%)}}.auth-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);border-radius:1.75rem;position:relative;overflow:hidden}.auth-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.auth-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.auth-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.auth-summary-card-emphasis{border-color:var(--pb-brand-border);background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.auth-summary-card-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.auth-kicker{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.login-brand-mobile{justify-content:center;display:flex}.login-hero-stack{flex-direction:column;gap:1rem;max-width:29rem;padding-bottom:.25rem;display:flex}.login-hero-headline{letter-spacing:-.02em;color:var(--pb-text-primary);text-wrap:balance;padding-bottom:.1em;font-family:Syne,sans-serif;font-size:clamp(2.3rem,3.1vw,3.35rem);font-weight:800;line-height:1.08}.login-hero-copy{max-width:25rem;color:var(--pb-text-secondary);padding-bottom:.08em;font-size:.95rem;line-height:1.72}.login-side-footer{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.625rem 1.25rem;padding-bottom:.08em;font-family:JetBrains Mono,monospace;font-size:.68rem;display:flex}.login-side-footer-item{align-items:center;gap:.45rem;display:inline-flex}.login-side-footer-dot{background:var(--pb-success);width:.4rem;height:.4rem;box-shadow:0 0 4px var(--pb-success);border-radius:9999px}@supports (color:color-mix(in lab, red, red)){.login-side-footer-dot{box-shadow:0 0 4px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.login-side-footer-dot{flex-shrink:0}.login-card-title{letter-spacing:-.02em;color:var(--pb-text-primary);padding-bottom:.08em;font-family:Syne,sans-serif;font-size:1.9rem;font-weight:800;line-height:1.08}.login-card-subtitle{color:var(--pb-text-secondary);padding-bottom:.05em;font-size:.875rem;line-height:1.62}.login-card-hint{color:var(--pb-text-tertiary);padding-bottom:.04em;font-size:.75rem;line-height:1.6}.floating-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-overlay);overflow:hidden}.add-series-header-metrics{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.add-series-results-shell{position:relative}.add-series-results-loading{z-index:12;background:var(--pb-bg-base);border-radius:.875rem;justify-content:center;align-items:flex-start;padding:1.25rem;display:flex;position:absolute;inset:0}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading{background:color-mix(in srgb, var(--pb-bg-base) 55%, transparent)}}.add-series-results-loading{-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);visibility:hidden;pointer-events:none}.add-series-results-loading.htmx-request{opacity:1;visibility:visible}.add-series-results-loading-card{border:1px solid var(--pb-border-default);background:var(--pb-bg-card);border-radius:.875rem;align-items:center;gap:.75rem;max-width:24rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-results-loading-card{background:color-mix(in srgb, var(--pb-bg-card) 94%, transparent)}}.add-series-results-loading-card{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-1)}.add-series-results-loading-card svg{color:var(--pb-interactive);flex-shrink:0}.add-series-results-loading-title{font-size:.82rem;font-weight:800;line-height:1.2}.add-series-results-loading-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.74rem;line-height:1.35}.add-series-preview-notice{border:1px solid var(--pb-interactive);border-radius:.875rem;flex-wrap:wrap;align-items:center;gap:.875rem;padding:.875rem 1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{border:1px solid color-mix(in srgb, var(--pb-interactive) 32%, var(--pb-border-default))}}.add-series-preview-notice{background:linear-gradient(135deg, var(--pb-interactive), transparent 70%), var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-interactive) 12%, transparent), transparent 70%), var(--pb-bg-card)}}.add-series-preview-notice{color:var(--pb-text-primary);box-shadow:var(--pb-shadow-0)}.add-series-preview-notice-icon{background:var(--pb-interactive);border-radius:999px;flex:none;justify-content:center;align-items:center;width:2rem;height:2rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.add-series-preview-notice-icon{background:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.add-series-preview-notice-icon{color:var(--pb-interactive)}.add-series-preview-notice-body{flex:auto;min-width:0}.add-series-preview-notice-title{letter-spacing:.08em;text-transform:uppercase;font-size:.78rem;font-weight:850;line-height:1.2}.add-series-preview-notice-copy{color:var(--pb-text-secondary);margin-top:.125rem;font-size:.8rem;line-height:1.45}.add-series-preview-notice-action{white-space:nowrap;flex:none;margin-left:auto}@media (max-width:640px){.add-series-preview-notice{align-items:flex-start}.add-series-preview-notice-action{justify-content:center;width:100%;margin-left:0}}.add-series-results-list{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.add-series-result-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;align-items:flex-start;gap:.875rem;padding:.875rem;transition:border-color .14s,background-color .14s;display:flex}.add-series-result-card:hover{border-color:var(--pb-border-strong);background:var(--pb-surface-selected)}@supports (color:color-mix(in lab, red, red)){.add-series-result-card:hover{background:color-mix(in srgb, var(--pb-surface-selected) 35%, var(--pb-bg-card))}}.add-series-result-card-static:hover{border-color:var(--pb-border-subtle);background:var(--pb-bg-card)}.add-series-result-cover{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);width:3.5rem;min-width:3.5rem;color:var(--pb-text-tertiary);border-radius:.625rem;overflow:hidden}.add-series-result-cover-empty{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.add-series-result-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.875rem;display:flex}.add-series-result-title{color:var(--pb-text-primary);font-size:.92rem;font-weight:600;line-height:1.25}.add-series-result-meta{color:var(--pb-text-secondary);flex-wrap:wrap;gap:.25rem .75rem;margin-top:.25rem;font-size:.78rem;display:flex}.add-series-result-description{color:var(--pb-text-tertiary);-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:.375rem;font-size:.78rem;line-height:1.55;display:-webkit-box;overflow:hidden}.add-series-result-actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.add-series-result-add{white-space:nowrap;flex-shrink:0}.add-series-empty-state{text-align:center;border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:.875rem;padding:2.5rem 1.25rem}.add-series-empty-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.9rem;font-weight:700}.add-series-empty-copy{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.82rem;line-height:1.55}.add-series-modal-title{color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1rem;font-weight:800;line-height:1.1}.add-series-modal-subtitle{color:var(--pb-text-tertiary);font-size:.78rem}.add-series-modal-section-label{border-bottom:1px solid var(--pb-border-subtle);text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.add-series-modal-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.add-series-modal-row-label{color:var(--pb-text-secondary);font-size:.85rem;font-weight:500}.add-series-modal-preview{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-shell);color:var(--pb-text-secondary);border-radius:.625rem;padding:.625rem .75rem;font-family:JetBrains Mono,monospace;font-size:.72rem;line-height:1.5}.add-series-root-display.input-pb:disabled,.add-series-root-display.input-pb[readonly]{opacity:1;color:var(--pb-text-primary);background:var(--pb-surface-input);cursor:default}.media-result-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);display:flex}.media-result-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.media-result-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.media-result-card:hover{box-shadow:var(--pb-shadow-2)}.media-result-card-static:hover{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1)}.media-result-cover{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);flex-shrink:0;width:4.75rem;overflow:hidden}.media-result-cover-lg{width:6.25rem}.media-result-cover-inner{aspect-ratio:2/3;width:100%;height:100%}.media-result-empty{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.media-result-meta{margin-top:var(--spacing);align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.media-result-divider{color:var(--pb-text-dim)}.match-file-card{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}@media (min-width:64rem){.match-file-card{flex-direction:row;align-items:flex-start}}.match-file-card{box-shadow:var(--pb-shadow-1)}.match-file-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.match-file-card:hover{box-shadow:var(--pb-shadow-2)}.match-file-format{height:calc(var(--spacing) * 14);width:calc(var(--spacing) * 14);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.18em;letter-spacing:.18em;text-transform:uppercase;border-width:1px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.match-file-format-cbz{color:var(--pb-interactive);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.match-file-format-cbz{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbz{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.match-file-format-cbr{color:var(--pb-purple);border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{border-color:color-mix(in srgb, var(--pb-purple) 24%, transparent)}}.match-file-format-cbr{background-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.match-file-format-cbr{background-color:color-mix(in srgb, var(--pb-purple) 12%, transparent)}}.match-file-format-pdf{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.match-file-format-pdf{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.match-file-format-pdf{background-color:color-mix(in srgb, var(--pb-warning) 12%, transparent)}}.match-file-format-other{color:var(--pb-text-secondary);border-color:var(--pb-border);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.match-file-format-other{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.match-file-card-actions{gap:calc(var(--spacing) * 2);flex-direction:column;width:100%;display:flex}@media (min-width:64rem){.match-file-card-actions{align-items:flex-end;width:auto;min-width:11rem}}.library-series-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;height:100%;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);flex-direction:column;display:flex;overflow:hidden}.library-series-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 56%, var(--pb-bg-card))}}.library-series-card:hover{box-shadow:var(--pb-shadow-2)}.library-series-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.library-series-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-series-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.library-series-cover-frame{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background:linear-gradient(160deg, var(--pb-bg-card-hover) 0%, var(--pb-bg-card) 100%);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-series-cover-frame{background:linear-gradient(160deg, color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 82%, transparent) 100%)}}.library-series-cover-frame-compact{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}.library-series-placeholder{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);width:100%;height:100%;color:var(--pb-text-dim);flex-direction:column;display:flex}.library-series-meta{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.library-series-progress-track{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-series-progress-track{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.series-grid-card-meta{container-type:inline-size}.series-grid-card-publisher-year{grid-template-columns:8rem minmax(0,1fr);align-items:start;gap:.25rem .5rem;display:grid}.series-grid-card-publisher{overflow-wrap:anywhere;min-width:0}.series-grid-card-year{text-align:right;justify-self:end}.series-grid-card-progress-track{background-color:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.series-grid-card-progress-track{background-color:color-mix(in srgb, var(--pb-text-dim) 72%, var(--pb-bg-card) 28%)}}@container (max-width:11rem){.series-grid-card-publisher-year{grid-template-columns:8rem}.series-grid-card-year{text-align:left;justify-self:start}}.admin-workspace-page{gap:calc(var(--spacing) * 4);flex-direction:column;min-height:0;display:flex}.admin-workspace-header{padding-block:0}.admin-workspace-header-copy{max-width:var(--container-3xl);min-width:0}.admin-workspace-tag-row{gap:calc(var(--spacing) * 2);padding-top:var(--spacing);flex-wrap:wrap;display:flex}.admin-workspace-header-aside{min-width:0}.admin-workspace-summary-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.admin-workspace-body{gap:calc(var(--spacing) * 7);display:grid}@media (min-width:80rem){.admin-workspace-body{grid-template-columns:10.75rem minmax(0,1fr);align-items:flex-start}}.admin-workspace-body{padding-bottom:var(--pb-page-footer-clearance)}.admin-workspace-rail{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:80rem){.admin-workspace-rail{top:calc(var(--spacing) * 24);position:sticky}}:where(.admin-workspace-rail-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-rail-links{gap:var(--spacing);flex-direction:column;display:flex}.admin-workspace-content{flex:1;min-width:0}:where(.admin-workspace-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.admin-workspace-content{width:min(100%,58rem)}.section-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);overflow:hidden}.admin-workspace-content .space-y-6>.section-card:not(:has(~.section-card)){margin-bottom:0!important}.section-card-visible{overflow:visible}.system-version-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);flex-direction:column;padding-block:1.125rem;display:flex;position:relative;overflow:hidden}@media (min-width:64rem){.system-version-banner{flex-direction:row;justify-content:space-between;align-items:center}}.system-version-banner{box-shadow:var(--pb-shadow-1)}.system-version-banner:before{content:"";background:var(--pb-brand);width:3px;position:absolute;top:0;bottom:0;left:0}.system-version-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.system-version-banner-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);background:var(--pb-brand-muted);color:var(--pb-brand);border-radius:10px;flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-version-banner-title{letter-spacing:-.02em;color:var(--pb-text-primary);flex-wrap:wrap;align-items:center;gap:.625rem;font-family:Bricolage Grotesque,sans-serif;font-size:1.125rem;font-weight:800;display:flex}.system-version-banner-meta{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.system-version-banner-meta span{font-family:JetBrains Mono,monospace;font-size:.6875rem}.system-version-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}:where(.system-detail-list>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.system-detail-list{border-color:var(--pb-border-subtle)}.system-detail-row{justify-content:space-between;align-items:baseline;gap:calc(var(--spacing) * 5);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2.5);display:flex}.system-detail-label{color:var(--pb-text-secondary);flex-shrink:0;font-size:13px}.system-detail-value{color:var(--pb-text-primary);text-align:right;word-break:break-word;font-family:JetBrains Mono,monospace;font-size:.78125rem}.system-detail-value-brand{color:var(--pb-brand)}.system-registry-filename{--tw-leading:calc(var(--spacing) * 5);font-family:JetBrains Mono,Fira Code,monospace;font-size:.8125rem;line-height:calc(var(--spacing) * 5);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.system-links-grid{gap:calc(var(--spacing) * 2);padding:calc(var(--spacing) * 5);grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));display:grid}.system-link-card{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2.5);--tw-font-weight:var(--font-weight-medium);font-size:12.5px;font-weight:var(--font-weight-medium);color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.system-link-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 20%, transparent)}}.system-link-card:hover{border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover)}.system-link-card svg{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0}.system-link-card-arrow{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);color:var(--pb-text-dim);margin-left:auto}.stat-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.stat-card-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.stat-card-value{margin-top:calc(var(--spacing) * 3);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.stat-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.info-panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.info-panel-muted{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.info-panel-muted{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.dashboard-briefing-shell{border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-2);background:radial-gradient(circle at top right, var(--pb-interactive) 0%, transparent 38%), radial-gradient(circle at left bottom, var(--pb-brand) 0%, transparent 34%), var(--pb-bg-card);position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell{background:radial-gradient(circle at top right, color-mix(in srgb, var(--pb-interactive) 14%, transparent) 0%, transparent 38%), radial-gradient(circle at left bottom, color-mix(in srgb, var(--pb-brand) 10%, transparent) 0%, transparent 34%), var(--pb-bg-card)}}.dashboard-briefing-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 44%, transparent), color-mix(in srgb, var(--pb-interactive) 44%, transparent), transparent)}}.dashboard-briefing-meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}@media (min-width:64rem){.dashboard-briefing-meta{max-width:13rem}}.dashboard-briefing-meta{box-shadow:var(--pb-shadow-1);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.dashboard-briefing-meta{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 42%, transparent)}}.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{align-items:center;gap:var(--spacing);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.16em;letter-spacing:.16em;text-transform:uppercase;border-width:1px;border-color:currentColor;border-radius:3.40282e38px;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.dashboard-status-pill,.dashboard-priority-badge,.dashboard-watch-tag,.dashboard-scorecard-delta{border-color:color-mix(in srgb, currentColor 20%, transparent)}}.dashboard-priority-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.dashboard-priority-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-card-v2{align-items:flex-start;gap:calc(var(--spacing) * 4);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:14px;grid-template-columns:1fr auto;transition:box-shadow .14s;display:grid;position:relative;overflow:hidden}.dashboard-priority-card-v2:before{content:"";background-color:#0000;width:6px;position:absolute;inset:0 auto 0 0}.dashboard-priority-card-v2:hover{box-shadow:var(--pb-shadow-2)}.dashboard-priority-card-v2-tone-pill-error:before{background-color:var(--pb-error)}.dashboard-priority-card-v2-tone-pill-warning:before{background-color:var(--pb-warning)}.dashboard-priority-card-v2-tone-pill-info:before{background-color:var(--pb-info)}.dashboard-priority-card-v2-tone-pill-success:before{background-color:var(--pb-success)}.dashboard-priority-card-v2-tone-pill-neutral:before,.dashboard-priority-card-v2-tone-pill-muted:before{background-color:var(--pb-text-dim)}.dashboard-priority-fact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-base);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:inline-flex}.dashboard-priority-card,.dashboard-decision-card{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px}.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, var(--pb-error) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-critical,.dashboard-decision-card-critical{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, var(--pb-warning) 0%, var(--pb-bg-card-hover) 100%)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-high,.dashboard-decision-card-high{background:linear-gradient(135deg, color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card)) 0%, color-mix(in srgb, var(--pb-bg-card-hover) 92%, transparent) 100%)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-watch,.dashboard-decision-card-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-healthy,.dashboard-decision-card-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-priority-card-info,.dashboard-decision-card-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-priority-facts{margin-top:calc(var(--spacing) * 5);gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.dashboard-priority-facts{grid-template-columns:repeat(3,minmax(0,1fr))}}.dashboard-priority-facts dt{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-priority-facts dd{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-primary)}.dashboard-quiet-note{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-success);background:var(--pb-success);display:flex}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{background:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.dashboard-quiet-note{border:1px solid var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-quiet-note{border:1px solid color-mix(in srgb, var(--pb-success) 18%, transparent)}}.dashboard-pulse-tile{padding-block:calc(var(--spacing) * 2.5);flex-direction:column;align-items:flex-start;display:flex}.dashboard-pulse-value{margin-top:var(--spacing);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.dashboard-pulse-tile-right{border-left-style:var(--tw-border-style);border-left-width:1px;border-color:var(--pb-border);padding-left:calc(var(--spacing) * 3)}.dashboard-pulse-tile-bottom{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-top:calc(var(--spacing) * 2.5)}.dashboard-scoreboard-attention-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.dashboard-scoreboard-attention-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dashboard-scorecard{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-width:0;height:100%;padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-width:1px;flex-direction:column;justify-content:space-between;display:flex}.dashboard-scorecard-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.dashboard-scorecard-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-critical{background-color:color-mix(in srgb, var(--pb-error) 8%, var(--pb-bg-card))}}.dashboard-scorecard-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.dashboard-scorecard-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-high{background-color:color-mix(in srgb, var(--pb-warning) 9%, var(--pb-bg-card))}}.dashboard-scorecard-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.dashboard-scorecard-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-watch{background-color:color-mix(in srgb, var(--pb-warning) 7%, var(--pb-bg-card))}}.dashboard-scorecard-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{border-color:color-mix(in srgb, var(--pb-success) 20%, transparent)}}.dashboard-scorecard-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-healthy{background-color:color-mix(in srgb, var(--pb-success) 7%, var(--pb-bg-card))}}.dashboard-scorecard-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.dashboard-scorecard-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-scorecard-info{background-color:color-mix(in srgb, var(--pb-info) 7%, var(--pb-bg-card))}}.dashboard-scorecard-title{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.dashboard-scorecard-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.dashboard-scorecard-value-watch{color:var(--pb-status-warning)}.dashboard-scorecard-value-critical{color:var(--pb-status-danger)}.dashboard-scorecard-copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.dashboard-healthy-strip{align-items:center;gap:calc(var(--spacing) * 2);column-gap:calc(var(--spacing) * 6);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);flex-wrap:wrap;display:flex}.dashboard-healthy-strip-label{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-success);display:flex}.dashboard-healthy-metric{align-items:baseline;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);font-size:.82rem;display:flex}.dashboard-healthy-metric strong{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--pb-text-primary)}.dashboard-inline-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.dashboard-inline-link:hover{color:var(--pb-interactive-hover)}.dashboard-download-row{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3.5);grid-template-columns:1fr 100px 80px;display:grid}.dashboard-watch-item,.dashboard-exception-row{border-radius:var(--radius-2xl)}.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{border-color:color-mix(in srgb, var(--pb-error) 20%, transparent)}}.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-critical,.dashboard-exception-row-critical{background-color:color-mix(in srgb, var(--pb-error) 7%, var(--pb-bg-card))}}.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-high,.dashboard-exception-row-high{background-color:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{border-color:color-mix(in srgb, var(--pb-warning) 16%, transparent)}}.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-watch,.dashboard-exception-row-watch{background-color:color-mix(in srgb, var(--pb-warning) 6%, var(--pb-bg-card))}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{border-color:color-mix(in srgb, var(--pb-success) 16%, transparent)}}.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-healthy,.dashboard-exception-row-healthy{background-color:color-mix(in srgb, var(--pb-success) 6%, var(--pb-bg-card))}}.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{border-color:color-mix(in srgb, var(--pb-info) 16%, transparent)}}.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-watch-item-info,.dashboard-exception-row-info{background-color:color-mix(in srgb, var(--pb-info) 6%, var(--pb-bg-card))}}.dashboard-mission-page{gap:calc(var(--spacing) * 4);width:100%;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;display:flex}.dashboard-mission-control{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.dashboard-mission-control__summary{align-items:flex-start;gap:calc(var(--spacing) * 6);flex-wrap:wrap;flex:1;min-width:0;display:flex}.dashboard-mission-control__title-block{min-width:0}.dashboard-mission-control__title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.5rem;font-weight:800;line-height:1}.dashboard-mission-control__title span{color:var(--pb-brand)}.dashboard-mission-control__subtitle{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.dashboard-mission-control__gauges{align-items:flex-end;gap:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}.dashboard-mission-control__gauges-spacer{flex:none;width:224px;min-width:224px;min-height:72px}.dashboard-gauge{text-align:center;min-width:64px}.dashboard-gauge__ring{width:56px;height:56px;margin-inline:auto;position:relative}.dashboard-gauge__ring svg{width:56px;height:56px;transform:rotate(-90deg)}.dashboard-gauge__track{fill:none;stroke:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.dashboard-gauge__track{stroke:color-mix(in srgb, var(--pb-text-dim) 14%, transparent)}}.dashboard-gauge__track{stroke-width:4.5px}.dashboard-gauge__fill{fill:none;stroke-width:4.5px;stroke-linecap:round}.dashboard-gauge__value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.dashboard-gauge__label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);margin-top:.2rem;font-size:.52rem;font-weight:600;display:block}.dashboard-gauge--success .dashboard-gauge__fill,.dashboard-gauge--success .dashboard-gauge__value{stroke:var(--pb-success);color:var(--pb-success)}.dashboard-gauge--info .dashboard-gauge__fill,.dashboard-gauge--info .dashboard-gauge__value{stroke:var(--pb-info);color:var(--pb-info)}.dashboard-gauge--warning .dashboard-gauge__fill,.dashboard-gauge--warning .dashboard-gauge__value{stroke:var(--pb-warning);color:var(--pb-warning)}.dashboard-gauge--danger .dashboard-gauge__fill,.dashboard-gauge--danger .dashboard-gauge__value{stroke:var(--pb-error);color:var(--pb-error)}.dashboard-gauge--neutral .dashboard-gauge__fill,.dashboard-gauge--neutral .dashboard-gauge__value{stroke:var(--pb-text-dim);color:var(--pb-text-dim)}.dashboard-scoreboard{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:.5rem;display:grid}.dashboard-scoreboard__item{gap:calc(var(--spacing) * .5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);min-width:0;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-direction:column;display:flex}.dashboard-scoreboard__label,.dashboard-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-scoreboard__value{color:var(--pb-text);font-family:JetBrains Mono,monospace;font-size:1.05rem;font-weight:700}.dashboard-scoreboard__delta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.66rem}:where(.dashboard-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.dashboard-table-wrap,.dashboard-activity-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:12px;overflow-x:auto}.dashboard-table{border-collapse:collapse;width:100%}.dashboard-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .9rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700}.dashboard-table th.c,.dashboard-table td.c{text-align:center}.dashboard-table th.r,.dashboard-table td.r{text-align:right}.dashboard-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6rem .9rem;font-size:.82rem;transition:background-color .12s}.dashboard-table tbody tr:last-child td{border-bottom:0}.dashboard-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.dashboard-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.dashboard-table__status-col{width:40px}.dashboard-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.dashboard-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.75rem}.table-mono-value,td.table-mono-value,.dashboard-table__mono,td.dashboard-table__mono,.health-table__mono,td.health-table__mono,.series-mission-control-owned,.series-mission-control-type,.utility-tool-table-mono,td.utility-tool-table-mono,.downloads-mono-cell,td.downloads-mono-cell{color:var(--pb-text-secondary);font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace;font-size:.75rem;font-weight:400;line-height:1.2}.series-mission-control-reading{color:var(--pb-interactive);white-space:nowrap;margin-top:.22rem;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:600;line-height:1.2;display:block}.dashboard-table__link{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-table__link:hover{color:var(--pb-interactive-hover)}.dashboard-led{vertical-align:middle;border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.dashboard-led--sys{border:1px solid var(--pb-text);width:10px;height:10px}@supports (color:color-mix(in lab, red, red)){.dashboard-led--sys{border:1px solid color-mix(in srgb, var(--pb-text) 10%, transparent)}}.dashboard-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.dashboard-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.dashboard-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.dashboard-led--blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.dashboard-led--blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.dashboard-led--off{background:var(--pb-text-dim);opacity:.4}.dashboard-progress{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.dashboard-progress__track{height:calc(var(--spacing) * 1.5);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background:var(--pb-text-dim);flex:1;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__track{background:color-mix(in srgb, var(--pb-text-dim) 10%, transparent)}}.dashboard-progress__fill{height:100%;position:relative}.dashboard-progress__fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.dashboard-progress__fill--green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.dashboard-progress__fill--blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.dashboard-progress__fill--blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.dashboard-progress__fill--pulse{opacity:.6;width:100%;animation:1.6s ease-in-out infinite pulse}.dashboard-progress__percent,.dashboard-activity-row__time,.dashboard-footer-strip{font-family:JetBrains Mono,monospace}.dashboard-progress__percent{text-align:right;min-width:32px;color:var(--pb-text-sec);font-size:.66rem;font-weight:600}.dashboard-activity-card{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2)}.dashboard-activity-row{align-items:flex-start;gap:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-bottom:1px solid var(--pb-border-subtle);display:flex}.dashboard-activity-row:last-child{border-bottom:0}.dashboard-activity-row__dot{margin-top:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;flex-shrink:0}.dashboard-activity-row__dot--acquired{background:var(--pb-success)}.dashboard-activity-row__dot--imported{background:var(--pb-brand)}.dashboard-activity-row__dot--failed{background:var(--pb-error)}.dashboard-activity-row__body{flex:1}.dashboard-activity-row__summary{--tw-leading:calc(var(--spacing) * 6);font-size:.82rem;line-height:calc(var(--spacing) * 6);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.dashboard-activity-row__detail{--tw-leading:calc(var(--spacing) * 5);font-size:.78rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary)}.dashboard-activity-row__meta{margin-top:var(--spacing);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.dashboard-activity-row__time{color:var(--pb-text-dim);font-size:.66rem}.dashboard-activity-row__link{--tw-font-weight:var(--font-weight-semibold);font-size:.68rem;font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.dashboard-activity-row__link:hover{color:var(--pb-interactive-hover)}.dashboard-activity-card__empty{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-dim)}.dashboard-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);color:var(--pb-text-dim);box-shadow:var(--pb-shadow-1);border-radius:10px;flex-wrap:wrap;font-size:.72rem;display:flex}.dashboard-footer-strip strong{color:var(--pb-text);font-weight:600}.library-page-shell{flex:1;width:100%;min-height:0;overflow:hidden}.library-format-pills{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.library-scoreboard__item{text-decoration:none;transition:background-color .12s}.library-scoreboard__item--link:hover{background-color:var(--pb-bg-card-hover)}.library-scoreboard__value--info{color:var(--pb-info)}.library-scoreboard__value--success{color:var(--pb-success)}.library-scoreboard__value--warning{color:var(--pb-warning)}.library-matching-banner{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;border-color:var(--pb-warning);border-radius:12px;display:flex}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{border-color:color-mix(in srgb, var(--pb-warning) 26%, transparent)}}.library-matching-banner{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-matching-banner{background:color-mix(in srgb, var(--pb-warning) 8%, var(--pb-bg-card))}}.library-matching-banner{box-shadow:var(--pb-shadow-1)}.library-matching-banner__body{align-items:center;column-gap:calc(var(--spacing) * 2);row-gap:var(--spacing);flex-wrap:wrap;min-width:0;display:flex}.library-matching-banner__title{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-matching-banner__copy{color:var(--pb-text-secondary)}.library-browser{--library-browser-header-height:calc(1.5rem + .75rem + 1px);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-height:0;box-shadow:var(--pb-shadow-1);border-radius:14px;flex:1 1 0;grid-template-columns:minmax(220px,260px) minmax(0,1fr);display:grid;overflow:hidden}.library-browser__tree{overscroll-behavior:contain;z-index:2;border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-direction:column;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__tree-list{min-height:0;padding-block:var(--spacing);overscroll-behavior:contain;flex:1;overflow-y:auto}.library-browser__tree-header,.library-browser__toolbar{min-height:var(--library-browser-header-height);height:var(--library-browser-header-height)}.library-browser__tree-header{padding-inline:calc(var(--spacing) * 3);z-index:3;letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);align-items:center;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-header{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__tree-node{min-height:calc(var(--spacing) * 6);border-radius:var(--radius-md);min-width:0;padding-inline:calc(var(--spacing) * 1.5);padding-block:var(--spacing);color:var(--pb-text-secondary);flex:1;align-items:center;font-size:.73rem;font-weight:400;line-height:1.2;text-decoration:none;display:flex}.library-browser__tree-node--active{background:var(--pb-interactive-dim);color:var(--pb-interactive);font-weight:500}.library-browser__tree-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim);flex-shrink:0;transition:transform .14s,color .14s}.library-browser__tree-icon--folder{color:var(--pb-brand)}.library-browser__tree-icon--expanded{color:var(--pb-interactive);transform:rotate(90deg)}.library-browser__tree-item{padding-left:calc(var(--library-tree-level,0) * .8rem)}.library-browser__tree-row{min-height:calc(var(--spacing) * 6);align-items:center;gap:var(--spacing);min-width:0;padding-inline:calc(var(--spacing) * 2);display:flex}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser__tree-row--context .library-browser__tree-node,.library-browser__tree-row--context .library-browser__tree-toggle,.library-browser__tree-row--context .library-browser__tree-leaf{color:var(--pb-interactive)}.library-browser__tree-toggle,.library-browser__tree-leaf{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);color:var(--pb-text-dim);flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.library-browser__tree-toggle{border-radius:4px;transition:background-color .12s,color .12s}.library-browser__tree-toggle:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}:where(.library-browser__tree-children>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}.library-browser__tree-name{flex:1;min-width:0;display:block}.library-browser__panel{z-index:1;flex-direction:column;min-width:0;min-height:0;display:flex;position:relative;overflow:hidden}.library-browser__toolbar{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 3);border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-shrink:0;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser__toolbar{background:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.library-browser__toolbar-main{align-items:center;gap:calc(var(--spacing) * 1.5);flex:1;min-width:0;display:flex}.library-browser__up-btn{height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);border-radius:var(--radius-xs);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;text-decoration:none;display:inline-flex}.library-browser__up-btn:hover{background:var(--pb-bg-card-hover);color:var(--pb-text)}.library-browser__up-btn.is-disabled{pointer-events:none;opacity:.4}.library-browser__path-block{flex:1;align-items:center;min-width:0;display:flex}.library-browser__path{color:var(--pb-text-sec);white-space:nowrap;text-overflow:ellipsis;font-family:JetBrains Mono,monospace;font-size:.56rem;line-height:1.15;overflow:hidden}.library-browser__table-wrap{overscroll-behavior:contain;min-height:0;box-shadow:none;background:0 0;border:0;border-radius:0;flex:1;position:relative;overflow:auto}.library-browser-table{min-width:760px}.library-browser-table th{padding:.4rem .75rem;font-size:.54rem;font-weight:600}.library-browser-table td{padding:.5rem .75rem;font-size:.76rem;font-weight:400;line-height:1.3}.library-browser__table-row--file,.library-browser__table-row--file td,.library-browser__table-row--file .library-browser__name-label{cursor:pointer}.library-browser__table-row--context{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser__table-row--context{background:color-mix(in srgb, var(--pb-interactive) 8%, transparent)}}.library-context-menu{border-radius:var(--radius-xl);border-style:var(--tw-border-style);min-width:15.5rem;padding-block:calc(var(--spacing) * 1.5);z-index:80;border-width:1px;border-color:var(--pb-border);position:fixed;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-context-menu{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-context-menu{background:linear-gradient(180deg, var(--pb-bg-card) 0%, var(--pb-bg-surface) 100%)}@supports (color:color-mix(in lab, red, red)){.library-context-menu{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-card) 94%, transparent) 0%, color-mix(in srgb, var(--pb-bg-surface) 97%, transparent) 100%)}}.library-context-menu{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 18px 48px #0f172a42,0 6px 18px #0f172a29}.library-context-menu__group{display:contents}.library-context-menu__item{align-items:center;gap:calc(var(--spacing) * 3);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));color:var(--pb-text);display:flex}.library-context-menu__item:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item:hover{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-context-menu__item:hover{color:var(--pb-interactive)}.library-context-menu__item--danger:hover{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-context-menu__item--danger:hover{background:color-mix(in srgb, var(--pb-error) 11%, transparent)}}.library-context-menu__item--danger:hover{color:var(--pb-error)}.library-context-menu__icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);color:currentColor;flex-shrink:0}.library-context-menu__sep{margin-block:var(--spacing);background:var(--pb-border-subtle);height:1px}@supports (color:color-mix(in lab, red, red)){.library-context-menu__sep{background:color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.modal-panel.library-browser-modal{border-radius:18px;width:min(92vw,48rem);max-height:min(86vh,52rem);overflow:hidden}.modal-panel.library-browser-modal--properties{width:min(84vw,28rem)}.modal-panel.library-browser-modal--compact,.modal-panel.library-delete-modal{width:min(92vw,34rem)}.library-delete-modal__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);border-bottom-width:1px;border-color:var(--pb-border);display:flex}.library-delete-modal__eyebrow{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-dim);font-size:11px;font-weight:500}.library-delete-modal__title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-delete-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}:where(.library-delete-modal__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.library-delete-modal__body{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4)}.library-delete-modal__copy{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-delete-modal__impact{border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{border-color:color-mix(in srgb, var(--pb-border-hover) 88%, transparent)}}.library-delete-modal__impact{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__impact{background:color-mix(in srgb, var(--pb-bg-surface) 40%, transparent)}}.library-delete-modal__impact .library-browser-modal__details-table{margin-top:0}.series-delete-modal__options{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.series-delete-modal__option{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);display:flex}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.series-delete-modal__option{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.series-delete-modal__option{background:color-mix(in srgb, var(--pb-bg-surface) 82%, transparent)}}.series-delete-modal__checkbox{margin-top:calc(var(--spacing) * .5);border-color:var(--pb-border-hover);background:var(--pb-bg-card-hover);color:var(--pb-error);border-radius:.25rem}.series-delete-modal__checkbox:focus{--tw-ring-color:var(--pb-error);--tw-ring-offset-width:0px}.series-delete-modal__option-copy{min-width:0}.series-delete-modal__option-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.series-delete-modal__option-text{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);line-height:1.5}.library-delete-modal__footer{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);border-top-width:1px;border-color:var(--pb-border);background:var(--pb-bg-card);display:flex}@supports (color:color-mix(in lab, red, red)){.library-delete-modal__footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__header{align-items:flex-start;gap:calc(var(--spacing) * 4);background:linear-gradient(180deg, var(--pb-bg-surface) 0%, var(--pb-bg-card) 100%)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__header{background:linear-gradient(180deg, color-mix(in srgb, var(--pb-bg-surface) 95%, transparent) 0%, color-mix(in srgb, var(--pb-bg-card) 96%, transparent) 100%)}}.library-browser-modal--properties .library-browser-modal__header,.library-browser-modal--properties .library-browser-modal__body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.library-browser-modal--properties .modal-footer{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);justify-content:flex-end}.library-browser-modal--form .library-browser-modal__body{padding-block:0;padding-inline:0}.library-browser-modal--form .modal-footer{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3)}.library-browser-modal .modal-footer{justify-content:flex-end;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);background:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal .modal-footer{background:color-mix(in srgb, var(--pb-bg-card) 80%, transparent)}}.library-browser-modal__title-block{align-items:flex-start;gap:calc(var(--spacing) * 3);min-width:0;display:flex}.library-browser-modal__icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);color:var(--pb-interactive);border-width:1px;border-color:var(--pb-interactive);flex-shrink:0;justify-content:center;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{border-color:color-mix(in srgb, var(--pb-interactive) 24%, transparent)}}.library-browser-modal__icon{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.library-browser-modal__icon svg{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5)}.library-browser-modal__icon--warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{border-color:color-mix(in srgb, var(--pb-warning) 24%, transparent)}}.library-browser-modal__icon--warning{background:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--warning{background:color-mix(in srgb, var(--pb-warning) 10%, transparent)}}.library-browser-modal__icon--danger{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__icon--danger{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__icon--danger{background:color-mix(in srgb, var(--pb-error) 10%, transparent)}}.library-browser-modal--properties .library-browser-modal__icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-xl)}.library-browser-modal--properties .library-browser-modal__icon svg{width:1.125rem;height:1.125rem}.library-browser-modal__title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser-modal__subtitle{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.library-browser-modal__close{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.library-browser-modal__close:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 88%, transparent)}}.library-browser-modal__close:hover{color:var(--pb-text)}.library-browser-modal__close:focus-visible{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__close:focus-visible{outline:2px solid color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.library-browser-modal__close:focus-visible{outline-offset:2px}.library-browser-modal__body{min-height:0}.library-browser-modal__form{flex-direction:column;flex:1;min-height:0;display:flex}.library-browser-modal__content{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-label{margin-bottom:calc(var(--spacing) * 2);padding-bottom:calc(var(--spacing) * 2);letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);border-bottom:1px solid var(--pb-border-subtle);font-family:Syne,sans-serif;font-size:.64rem;font-weight:700;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-label{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__section{padding-bottom:var(--spacing)}.library-browser-modal__details-table{border-collapse:collapse;table-layout:fixed;width:100%;margin-top:.625rem;font-size:.8rem}.library-browser-modal__details-table--flush{margin-top:0}.library-browser-modal__detail-label{vertical-align:top;width:6.875rem;color:var(--pb-text-dim);font-size:inherit;text-align:left;padding:.25rem .75rem .25rem 0;font-weight:500;line-height:1.35}.library-browser-modal__detail-value{vertical-align:top;min-width:0;color:var(--pb-text);font-size:inherit;overflow-wrap:anywhere;padding:.25rem 0;font-weight:500;line-height:1.4}.library-browser-modal__detail-value--mono{color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__loading{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 6);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{border-color:color-mix(in srgb, var(--pb-border) 86%, transparent)}}.library-browser-modal__loading{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__loading{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stats{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(8.5rem,1fr));display:grid}.library-browser-modal__stat{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__stat{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__stat{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__stat-label,.library-browser-modal__field-label{letter-spacing:.11em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.library-browser-modal__stat-value,.library-browser-modal__preview-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);overflow-wrap:anywhere;font-weight:500;line-height:1.35}.library-browser-modal__meta{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__meta{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__meta-row{gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);grid-template-columns:minmax(8rem,10rem) minmax(0,1fr);display:grid}.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid var(--pb-border-subtle)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__meta-row+.library-browser-modal__meta-row{border-top:1px solid color-mix(in srgb, var(--pb-border-subtle) 84%, transparent)}}.library-browser-modal__meta-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-dim);letter-spacing:.08em;text-transform:uppercase}.library-browser-modal__meta-value{min-width:0;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__storage{border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__storage{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage{background:color-mix(in srgb, var(--pb-bg-surface) 93%, transparent)}}.library-browser-modal__storage-head{margin-bottom:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.library-browser-modal__storage-bar{height:calc(var(--spacing) * 2.5);background:var(--pb-bg-card-hover);border-radius:3.40282e38px;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-bar{background:color-mix(in srgb, var(--pb-bg-card-hover) 72%, transparent)}}.library-browser-modal__storage-fill{background:linear-gradient(90deg, var(--pb-interactive) 0%, var(--pb-brand) 100%);border-radius:3.40282e38px;height:100%;display:block}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__storage-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 82%, white) 0%, color-mix(in srgb, var(--pb-brand) 74%, white) 100%)}}.library-browser-modal__field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.library-browser-modal__settings-rows{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border)}.library-browser-modal__note{padding-inline:calc(var(--spacing) * 6)}.library-browser-modal__body-inset{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__body-inset--section{padding-bottom:0}.library-browser-modal__body-inset--compact-top{padding-top:calc(var(--spacing) * 3)}.library-browser-modal__body-inset--roomy-bottom{padding-bottom:calc(var(--spacing) * 4)}.library-browser-modal__section-stack{gap:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);flex-direction:column;display:flex}.library-browser-modal__section-stack--delete{gap:calc(var(--spacing) * 4)}.library-browser-modal__section-panel{flex-direction:column;display:flex}.library-browser-modal__section-heading-row{padding-inline:calc(var(--spacing) * 6);padding-bottom:calc(var(--spacing) * 3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding-top:0}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__section-heading-row{border-bottom:1px solid color-mix(in srgb, var(--pb-border-subtle) 80%, transparent)}}.library-browser-modal__preview-block{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);border-width:1px;border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-block{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-block{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser-modal__preview-block{overflow-wrap:anywhere;line-height:1.45}.library-browser-modal__preview-block--mono{font-family:JetBrains Mono,monospace;font-size:.76rem}.library-browser-modal__detail-input{width:100%;min-height:2.5rem;font-size:.88rem;display:block}.library-browser-modal__helper,.library-browser-modal__context-value{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-browser-modal__context-value--mono{color:var(--pb-text-sec);overflow-wrap:anywhere;font-family:JetBrains Mono,monospace;font-size:.76rem;line-height:1.45}.library-browser-modal__error{border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-error);border-width:1px;border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{border-color:color-mix(in srgb, var(--pb-error) 24%, transparent)}}.library-browser-modal__error{background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__error{background:color-mix(in srgb, var(--pb-error) 8%, transparent)}}.library-browser-modal__actions{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser-modal__preview-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));display:grid}.library-browser-modal__preview-card{justify-content:space-between;gap:calc(var(--spacing) * 2);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);min-height:5.25rem;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);border-width:1px;border-color:var(--pb-border);flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{border-color:color-mix(in srgb, var(--pb-border) 88%, transparent)}}.library-browser-modal__preview-card{background:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.library-browser-modal__preview-card{background:color-mix(in srgb, var(--pb-bg-surface) 94%, transparent)}}.library-browser__name{align-items:center;gap:calc(var(--spacing) * 2);display:flex}.library-browser__name-icon{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);flex-shrink:0}.library-browser__name-icon--folder{color:var(--pb-brand)}.library-browser__name-icon--file{color:var(--pb-text-dim)}.library-browser__name-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.76rem;font-weight:400;overflow:hidden}.library-browser__name-label--folder{color:var(--pb-text-primary);font-weight:500;text-decoration:none}.library-browser__name-label--folder:hover{color:var(--pb-interactive)}.library-browser__empty{height:100%;min-height:280px;padding-inline:calc(var(--spacing) * 6);text-align:center;flex-direction:column;justify-content:center;align-items:center;display:flex}.library-browser__empty-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);color:var(--pb-text-dim)}.library-browser__empty-title{margin-top:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.library-browser__empty-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.library-empty-shell{align-items:flex-start;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 8);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;display:flex}.library-empty-shell__icon-wrap{height:calc(var(--spacing) * 16);width:calc(var(--spacing) * 16);background-color:var(--pb-bg-card-hover);color:var(--pb-text-dim);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.library-empty-shell__icon{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8)}:where(.library-empty-shell__body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.library-empty-shell__copy{max-width:var(--container-2xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.library-empty-shell__actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}@media (max-width:1024px){.library-browser{flex:none;grid-template-columns:1fr;height:auto;min-height:520px}.library-browser__tree{border-right:0;border-bottom:1px solid var(--pb-border);max-height:220px}}.health-section__label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);margin-block:.625rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:block}.health-component-card__sub,.health-detail-card__stat-value,.health-history-table td{font-family:JetBrains Mono,monospace}:where(.health-section>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}.health-component-grid{gap:calc(var(--spacing) * 3);grid-template-columns:repeat(auto-fit,minmax(280px,1fr));display:grid}.health-component-card{cursor:pointer;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-width:1px;border-radius:14px;flex-direction:column;height:100%;display:flex}.health-component-card:hover{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-component-card:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, var(--pb-bg-card))}}.health-component-card:hover{border-color:var(--pb-border-strong)}.health-component-card--success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-component-card--success{border-color:color-mix(in srgb, var(--pb-success) 22%, var(--pb-border))}}.health-component-card--warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-component-card--warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, var(--pb-border))}}.health-component-card--danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-component-card--danger{border-color:color-mix(in srgb, var(--pb-error) 22%, var(--pb-border))}}.health-component-card--neutral{border-color:var(--pb-border)}.health-component-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.health-component-card__name{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text);font-family:Syne,sans-serif;font-size:.84rem;font-weight:700}.health-component-card__stats{align-items:flex-start;gap:calc(var(--spacing) * 2);grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.health-detail-card__stats{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.health-detail-card__stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.health-detail-card__stats{grid-template-columns:repeat(4,minmax(0,1fr))}}.health-component-card__stat,.health-detail-card__stat{border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-width:1px;border-color:var(--pb-border-subtle);border-radius:10px;flex-direction:column;gap:0;display:flex}.health-component-card__stat{background:var(--pb-bg-base)}.health-detail-card__stat{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);background:var(--pb-bg-base)}.health-component-card__stat-label,.health-detail-card__stat-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.46rem;font-weight:700}.health-component-card__stat-value,.health-detail-card__stat-value{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);display:block}.health-component-card__stat-value{white-space:pre-line;line-height:1.25}.health-component-card__stat-value--danger,.health-detail-card__stat-value--danger{color:var(--pb-error)}.health-component-card__stat-value--warning,.health-detail-card__stat-value--warning{color:var(--pb-warning)}.health-component-card__message{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary);min-height:2.5rem}.health-component-card__sub{color:var(--pb-text-dim);margin-top:auto;font-size:.68rem}:where(.health-detail-shell>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-back{align-items:center;gap:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.health-detail-back:hover{color:var(--pb-interactive-hover)}:where(.health-detail-card>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.health-detail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px}.health-detail-card__header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.health-detail-card__title-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;min-width:0;display:flex}.health-detail-card__title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1rem;font-weight:800}:where(.health-check-list>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.health-check-row{align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);border-radius:10px;grid-template-columns:8px minmax(120px,140px) minmax(0,1fr) auto;display:grid}.health-check-row--database-metric{grid-template-columns:8px minmax(160px,180px) minmax(0,1fr) auto}.health-check-row__name{text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary);justify-self:flex-start}.health-check-row__message{min-width:0;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim);flex:1}.health-check-list--database-metrics .health-check-row__name,.health-check-list--database-metrics .health-check-row__message{text-align:left;justify-self:flex-start}.health-check-list--database-metrics .health-led{justify-self:center}.health-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);border-radius:12px;overflow-x:auto}.health-table{border-collapse:collapse;width:100%}.health-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);background:var(--pb-bg-shell);border-bottom:1px solid var(--pb-border);padding:.45rem .85rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.health-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text-sec);padding:.55rem .85rem;font-size:.76rem}.health-table tbody tr:last-child td{border-bottom:0}.health-table tbody tr:hover td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.health-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.health-table th.c,.health-table td.c{text-align:center}.health-table th.r,.health-table td.r{text-align:right}.health-table__status-col{width:40px}.health-table__name{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.health-table__detail{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.health-led{border-radius:999px;flex-shrink:0;width:8px;height:8px;display:inline-block}.health-led--header{width:10px;height:10px}.health-led--green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.health-led--green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.health-led--amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.health-led--amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.health-led--red{background:var(--pb-error);box-shadow:0 0 5px var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.health-led--red{box-shadow:0 0 5px color-mix(in srgb, var(--pb-error) 35%, transparent)}}.health-led--off{background:var(--pb-text-dim);opacity:.4}.health-footer-strip{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;font-family:JetBrains Mono,monospace;font-size:.72rem;display:flex}.health-footer-strip strong{color:var(--pb-text);font-weight:600}.step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1)}.step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-info);background-color:var(--pb-info);border-radius:3.40282e38px;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.step-badge{background-color:color-mix(in srgb, var(--pb-info) 14%, transparent)}}.step-badge{border:1px solid var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.step-badge{border:1px solid color-mix(in srgb, var(--pb-info) 28%, transparent)}}.action-card{align-items:flex-start;gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-bg-card-hover);display:flex}@supports (color:color-mix(in lab, red, red)){.action-card{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.action-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.action-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.action-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.action-card-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.action-card-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-launch-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);flex-direction:column;transition-duration:.15s;display:flex}.utility-launch-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-launch-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 82%, transparent)}}.utility-launch-card:hover{box-shadow:var(--pb-shadow-2)}.utility-launch-icon-brand{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-launch-icon-interactive{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-launch-icon-info{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-launch-icon-success{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-launch-icon-warning{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-launch-kicker{margin-top:calc(var(--spacing) * 4);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-launch-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-launch-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-launch-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 4);margin-top:auto;display:flex}.utility-launch-tag{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-launch-link{align-items:center;gap:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-interactive);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.utility-launch-link:hover{color:var(--pb-interactive-hover)}.utility-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.utility-hero-card{padding-inline:calc(var(--spacing) * 7)}}.utility-hero-card{box-shadow:var(--pb-shadow-2)}.utility-top-link{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-top-link:hover{border-color:var(--pb-border-hover);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-top-link:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-top-link-muted{color:var(--pb-text-dim)}.utility-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.utility-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.utility-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.utility-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.utility-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.utility-hero-title{margin-top:calc(var(--spacing) * 2);font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-copy{margin-top:calc(var(--spacing) * 2);max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-highlights{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:48rem){.utility-hero-highlights{grid-template-columns:repeat(3,minmax(0,1fr))}}.utility-hero-highlight{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-highlight-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-highlight-value{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-highlight-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-actions{gap:calc(var(--spacing) * 3);display:grid}.utility-hero-action{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.utility-hero-action:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.utility-hero-action-primary{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{border-color:color-mix(in srgb, var(--pb-interactive) 26%, transparent)}}.utility-hero-action-primary{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.utility-hero-action-primary{background-color:color-mix(in srgb, var(--pb-interactive) 8%, var(--pb-bg-card))}}.utility-hero-action-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-action-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.utility-hero-action-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utility-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.utility-hero-notes{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.utility-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.utility-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.utility-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.utility-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.utilities-page{width:100%;max-width:1100px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.utilities-shell{width:100%}:where(.utilities-content>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utilities-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-copy{align-items:flex-start;gap:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.utilities-header-title{letter-spacing:.04em;text-transform:uppercase;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.utilities-header-title span{color:var(--pb-brand)}.utilities-header-subtitle{color:var(--pb-text-dim);margin-top:4px;font-size:.78rem;font-weight:400;line-height:1.5}.utilities-gauges{align-items:flex-end;gap:calc(var(--spacing) * 3.5);display:flex}.utilities-gauge{text-align:center}.utilities-gauge-ring{height:calc(var(--spacing) * 12);width:calc(var(--spacing) * 12);border-style:var(--tw-border-style);background-color:var(--pb-bg-card);border-width:4px;border-radius:3.40282e38px;justify-content:center;align-items:center;margin-inline:auto;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring{background-color:color-mix(in srgb, var(--pb-bg-card) 70%, var(--pb-bg-base))}}.utilities-gauge-ring-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-success{border-color:color-mix(in srgb, var(--pb-success) 60%, var(--pb-border))}}.utilities-gauge-ring-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-info{border-color:color-mix(in srgb, var(--pb-info) 60%, var(--pb-border))}}.utilities-gauge-ring-ink{border-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-gauge-ring-ink{border-color:color-mix(in srgb, var(--pb-text) 22%, var(--pb-border))}}.utilities-gauge-value{font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.utilities-gauge-label{--tw-font-weight:var(--font-weight-medium);font-size:.52rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;display:block}.utilities-tabs{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);border-radius:10px;align-self:center;display:inline-flex;overflow:hidden}.utilities-tab{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.78rem;font-weight:var(--font-weight-bold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.utilities-tab+.utilities-tab{border-left:1px solid var(--pb-border)}.utilities-tab:hover{color:var(--pb-text);background-color:var(--pb-text)}@supports (color:color-mix(in lab, red, red)){.utilities-tab:hover{background-color:color-mix(in srgb, var(--pb-text) 3%, transparent)}}.utilities-tab.is-active{background-color:var(--pb-interactive);color:var(--pb-text-inverse)}.utilities-section-label{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-bottom:calc(var(--spacing) * 1.5);--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.62rem;font-weight:var(--font-weight-bold);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-tool-grid{grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.75rem;display:grid}.utilities-tool-grid-wide{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}.utility-launch-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);height:100%;padding:calc(var(--spacing) * 5);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;box-shadow:var(--pb-shadow-1);border-radius:14px;flex-direction:column;transition-duration:.15s;display:flex;overflow:hidden}.utility-launch-card:hover{border-color:var(--pb-border-strong);box-shadow:var(--pb-shadow-2);background-color:var(--pb-bg-card);transform:translateY(-2px)}.utility-launch-header{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-arrow{color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.utility-launch-card:hover .utility-launch-arrow{color:var(--pb-interactive);transform:translate(2px)}.utility-launch-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-launch-title{margin-top:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:.9rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.04em;letter-spacing:.04em;color:var(--pb-text-primary);text-transform:uppercase}.utility-launch-copy{margin-top:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);flex:1;font-size:.78rem;font-weight:400;line-height:1.5}.utility-launch-footer{margin-top:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.utility-launch-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-family:JetBrains Mono,monospace;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);text-transform:uppercase;letter-spacing:.06em;align-items:center;font-size:.6rem;display:inline-flex}.utility-tool-page{width:100%;max-width:1100px;margin-inline:auto}:where(.utility-tool-header-block>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-back-link{align-items:center;gap:var(--spacing);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:.82rem;display:inline-flex}@media (hover:hover){.utility-tool-back-link:hover{color:var(--pb-text-primary)}}.utility-tool-header-shell{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:14px;flex-wrap:wrap;display:flex}.utility-tool-header-left{align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.utility-tool-header-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:10px;justify-content:center;align-items:center;display:flex}.utility-tool-header-icon-converter{background-color:var(--pb-interactive-dim);color:var(--pb-interactive)}.utility-tool-header-icon-mass-convert{background-color:var(--pb-success-dim);color:var(--pb-success)}.utility-tool-header-icon-integrity{background-color:var(--pb-info-dim);color:var(--pb-info)}.utility-tool-header-icon-rename,.utility-tool-header-icon-db-check{background-color:var(--pb-warning-dim);color:var(--pb-warning)}.utility-tool-header-icon-export{background-color:var(--pb-brand-dim);color:var(--pb-brand)}.utility-tool-header-title{--tw-font-weight:var(--font-weight-extrabold);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:var(--font-weight-extrabold);--tw-tracking:.03em;letter-spacing:.03em;color:var(--pb-text-primary);text-transform:uppercase}.utility-tool-header-title span{color:var(--pb-brand)}.utility-tool-header-subtitle{margin-top:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.utility-tool-header-tag{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:var(--font-weight-semibold);--tw-tracking:.08em;letter-spacing:.08em;color:var(--pb-text-dim);text-transform:uppercase;align-items:center;display:inline-flex}.system-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.system-hero-card{padding-inline:calc(var(--spacing) * 7)}}.system-hero-card{box-shadow:var(--pb-shadow-2)}.system-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 42%, transparent), transparent)}}.system-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.system-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.system-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.system-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-hero-aside{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-hero-aside{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-hero-aside{grid-template-columns:repeat(1,minmax(0,1fr))}}.system-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.system-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 20%, transparent)}}.system-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.system-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.system-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.system-shell-body{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.system-shell-body{grid-template-columns:280px minmax(0,1fr)}}@media (min-width:96rem){.system-shell-body{grid-template-columns:300px minmax(0,1fr)}}.system-rail-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.system-rail-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}.system-rail-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.system-rail-title{margin-top:var(--spacing);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-rail-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}:where(.system-rail-links>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.system-rail-links{padding:calc(var(--spacing) * 3)}.system-rail-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.system-rail-footer{background-color:color-mix(in srgb, var(--pb-bg-card) 55%, transparent)}}.system-tab-card{align-items:flex-start;gap:calc(var(--spacing) * 3);border-radius:var(--radius-2xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;border-width:1px;transition-duration:.15s;display:flex}.system-tab-card-inactive{border-color:var(--pb-border);background-color:var(--pb-bg-surface)}.system-tab-card-inactive:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-inactive:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 58%, transparent)}}.system-tab-card-inactive:hover{box-shadow:var(--pb-shadow-1)}.system-tab-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 30%, transparent)}}.system-tab-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.system-tab-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.system-tab-card-active{box-shadow:var(--pb-shadow-1)}.system-tab-icon{margin-top:calc(var(--spacing) * .5);height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-2xl);flex-shrink:0;justify-content:center;align-items:center;display:flex}.system-tab-copy{flex:1;min-width:0}.system-tab-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.system-tab-description{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5);color:var(--pb-text-secondary);display:block}.system-tab-arrow{margin-top:var(--spacing);color:var(--pb-text-dim);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}.system-tab-card:hover .system-tab-arrow,.system-tab-card-active .system-tab-arrow{color:var(--pb-interactive);transform:translate(.125rem)}.system-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.system-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.system-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.system-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.system-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.system-focus-meta{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim);flex-wrap:wrap;display:flex}.system-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.system-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.system-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.system-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.review-hero-card{padding-inline:calc(var(--spacing) * 7)}}.review-hero-card{box-shadow:var(--pb-shadow-2)}.review-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-info), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-info) 42%, transparent), transparent)}}.review-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.review-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.review-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.review-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.review-hero-note-emphasis{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-info) 20%, transparent)}}.review-hero-note-emphasis{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.review-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.review-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.review-workspace-tabs{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.review-workspace-tabs{padding-inline:calc(var(--spacing) * 5)}}.review-workspace-tabs{box-shadow:var(--pb-shadow-1)}.review-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{border-color:color-mix(in srgb, var(--pb-info) 34%, transparent)}}.review-tab-chip-active{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.review-tab-chip-active{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.review-tab-chip-active{box-shadow:var(--pb-shadow-1)}.review-tab-chip-inactive{color:var(--pb-text-dim)}.review-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.review-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.review-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.review-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.review-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.review-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.review-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.review-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.review-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}.review-note-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.workflow-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.workflow-hero-card{padding-inline:calc(var(--spacing) * 7)}}.workflow-hero-card{box-shadow:var(--pb-shadow-2)}.workflow-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-interactive) 40%, transparent), transparent)}}.workflow-hero-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.workflow-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.workflow-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.workflow-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.workflow-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.workflow-hero-note-emphasis{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-interactive) 20%, transparent)}}.workflow-hero-note-emphasis{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-hero-note-copy{margin-top:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.series-page-shell{gap:calc(var(--spacing) * 6);flex-direction:column;min-height:100%;display:flex}.series-hero-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.series-hero-card{padding-inline:calc(var(--spacing) * 7)}}.series-hero-card{box-shadow:var(--pb-shadow-2)}.series-hero-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), var(--pb-interactive), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.series-hero-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), color-mix(in srgb, var(--pb-interactive) 34%, transparent), transparent)}}.series-hero-grid{gap:calc(var(--spacing) * 5);display:grid}@media (min-width:96rem){.series-hero-grid{grid-template-columns:minmax(0,1.45fr) minmax(320px,.8fr);align-items:flex-start}}.series-hero-eyebrow{--tw-font-weight:var(--font-weight-semibold);font-size:11px;font-weight:var(--font-weight-semibold);--tw-tracking:.24em;letter-spacing:.24em;color:var(--pb-brand);text-transform:uppercase}.series-hero-title{font-family:Bricolage Grotesque,sans-serif;font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--pb-text-primary)}.series-hero-copy{max-width:var(--container-3xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7);color:var(--pb-text-secondary)}.series-hero-notes{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.series-hero-notes{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:96rem){.series-hero-notes{grid-template-columns:repeat(1,minmax(0,1fr))}}.series-hero-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1)}.series-hero-note-emphasis{border-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{border-color:color-mix(in srgb, var(--pb-brand) 22%, transparent)}}.series-hero-note-emphasis{background-color:var(--pb-brand)}@supports (color:color-mix(in lab, red, red)){.series-hero-note-emphasis{background-color:color-mix(in srgb, var(--pb-brand) 8%, var(--pb-bg-card))}}.series-hero-note-kicker{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.16em;letter-spacing:.16em;color:var(--pb-text-dim);text-transform:uppercase}.series-hero-note-title{margin-top:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-hero-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.page-context-back-link{align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-back-link:hover{color:var(--pb-text-primary)}.page-context-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.page-context-grid{grid-template-columns:minmax(0,1.35fr) minmax(280px,.9fr);align-items:flex-start}}.page-context-breadcrumbs{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);flex-wrap:wrap;display:flex}.page-context-link{align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}.page-context-link:hover{color:var(--pb-text-primary)}.page-context-separator{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);color:var(--pb-text-dim)}.page-context-summary{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.page-context-chip-row{gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.page-context-note{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);padding:calc(var(--spacing) * 4);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.page-context-note{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 40%, transparent)}}.page-context-note-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.page-context-note-copy{margin-top:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);border-radius:1.75rem;position:relative;overflow:hidden}@media (min-width:40rem){.detail-hero-shell{padding-inline:calc(var(--spacing) * 7)}}.detail-hero-shell{box-shadow:var(--pb-shadow-2)}.detail-hero-shell:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.detail-hero-shell:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 40%, transparent), transparent)}}.detail-hero-grid{gap:calc(var(--spacing) * 6);display:grid}@media (min-width:80rem){.detail-hero-grid{grid-template-columns:220px minmax(0,1fr) 320px}}.detail-cover-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card-hover);padding:calc(var(--spacing) * 4);box-shadow:var(--pb-shadow-1);border-radius:1.5rem}.detail-summary-panel{min-width:0}:where(.detail-summary-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.detail-header-stack>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-status-row{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.detail-body-copy{max-width:56rem}.detail-stat-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-stat-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.detail-info-grid{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.detail-info-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.detail-info-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}:where(.detail-aside>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.detail-action-panel{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.25rem}.series-domain-page{width:100%;max-width:1400px;padding-bottom:var(--pb-page-footer-clearance);margin-inline:auto}.series-domain-breadcrumb-row{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.series-domain-back-link{color:var(--pb-interactive);align-items:center;gap:.25rem;font-size:.82rem;transition:color .14s;display:inline-flex}.series-domain-back-link:hover{color:var(--pb-interactive-hover)}.series-domain-breadcrumbs{color:var(--pb-text-tertiary);flex-wrap:wrap;align-items:center;gap:.35rem;font-size:.82rem;display:flex}.series-domain-breadcrumbs a{color:inherit}.series-domain-breadcrumbs a:hover{color:var(--pb-interactive)}.series-domain-breadcrumb-sep{font-size:.7rem}.series-domain-breadcrumbs .current{color:var(--pb-text-primary);font-weight:600}.detail-hero-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 6);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.detail-hero-shell:before{display:none}.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(13rem,16rem);align-items:start;gap:1.25rem;display:grid}.story-arc-detail-hero-inner{grid-template-columns:130px minmax(0,1fr) minmax(18rem,19rem)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr) minmax(13rem,16rem)}.series-domain-cover-column{flex-shrink:0;width:130px}.issue-domain-cover-column{width:120px}.series-domain-cover-frame{aspect-ratio:2/3;border:1px solid var(--pb-border-default);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);color:var(--pb-text-tertiary);border-radius:12px;justify-content:center;align-items:center;transition:box-shadow .2s;display:flex;overflow:hidden}.series-domain-cover-frame:hover{box-shadow:var(--pb-shadow-2)}.issue-domain-cover-frame{width:120px}.series-domain-cover-image{object-fit:cover;object-position:top;cursor:pointer;width:100%;height:100%}.series-domain-cover-placeholder{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.series-domain-hero-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);overflow-wrap:anywhere;font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1.05}.series-domain-hero-title span{color:var(--pb-brand)}.series-domain-hero-title-link{color:inherit;text-underline-offset:.18em;text-decoration:none;text-decoration-thickness:2px;transition:color .14s,text-decoration-color .14s;display:inline}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{color:var(--pb-interactive);text-decoration-line:underline;-webkit-text-decoration-color:var(--pb-interactive);-webkit-text-decoration-color:var(--pb-interactive);text-decoration-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent);text-decoration-color:color-mix(in srgb, var(--pb-interactive) 58%, transparent)}}.series-domain-hero-title-link:hover,.series-domain-hero-title-link:focus-visible{outline:none}.series-domain-hero-title-sm{font-size:1.4rem}.series-domain-hero-description{max-width:35rem;color:var(--pb-text-secondary);margin-top:.5rem;font-size:.85rem;line-height:1.7}.series-domain-hero-subtitle{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.88rem}.series-domain-status-row{flex-wrap:wrap;align-items:center;gap:.375rem;margin-top:.75rem;display:flex}.series-domain-led{border-radius:999px;width:8px;height:8px;display:inline-block}.series-domain-led-on{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success), 0 0 10px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-domain-led-on{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent), 0 0 10px color-mix(in srgb, var(--pb-status-success) 12%, transparent)}}.series-domain-led-off{background:var(--pb-text-tertiary);opacity:.35}.app-progress{align-items:center;gap:.625rem;display:flex}.app-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.app-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.app-progress-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.app-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.app-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.app-progress-fill{position:relative}.app-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.app-progress-fill--interactive{background:linear-gradient(90deg, var(--pb-interactive), var(--pb-interactive))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--interactive{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-interactive) 45%, transparent), var(--pb-interactive))}}.app-progress-fill--error{background:linear-gradient(90deg, var(--pb-error), var(--pb-error))}@supports (color:color-mix(in lab, red, red)){.app-progress-fill--error{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-error) 45%, transparent), var(--pb-error))}}.app-progress-value{text-align:right;min-width:40px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.82rem;font-weight:700}.app-progress-value--interactive{color:var(--pb-interactive)}.app-progress-value--error{color:var(--pb-error)}.app-progress-value-stack{flex-direction:column;align-items:flex-end;line-height:1.05;display:flex}.app-progress-value-secondary{letter-spacing:.01em;color:var(--pb-text-dim);white-space:nowrap;font-family:DM Sans,sans-serif;font-size:.62rem;font-weight:600}.series-domain-info-grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.625rem;margin-top:1rem;display:grid}.series-domain-info-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.75rem .875rem}.series-domain-info-box-wide{grid-column:span 2}.series-domain-info-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-info-value{color:var(--pb-text-secondary);margin-top:.25rem;font-size:.85rem}.series-domain-path{word-break:break-all;font-size:.75rem}.series-domain-actions-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1.125rem 1.5rem}.series-domain-actions-inner{flex-wrap:wrap;align-items:center;gap:1rem;display:flex}.series-domain-actions-panel{border-left:1px solid var(--pb-border-subtle);flex-direction:column;gap:.75rem;min-width:0;padding-left:1.25rem;display:flex}.series-domain-actions-panel-wide{min-width:18rem}.series-domain-actions-title{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-primary);white-space:nowrap;font-family:Syne,sans-serif;font-size:.88rem;font-weight:800}.series-domain-actions-title span{color:var(--pb-brand)}.series-domain-actions-divider{background:var(--pb-border-default);align-self:stretch;width:1px}.series-domain-actions-buttons{flex-wrap:wrap;flex:1;align-items:center;gap:.5rem;display:flex}.series-domain-actions-panel .series-domain-actions-buttons{flex-direction:column;flex:initial;align-items:center;gap:.55rem}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button,.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{width:100%}.series-domain-actions-panel .series-domain-actions-buttons>a,.series-domain-actions-panel .series-domain-actions-buttons>button{gap:var(--pb-control-gap);justify-content:center}.series-domain-actions-panel .series-domain-actions-buttons .series-domain-inline-toggle{justify-content:space-between}.series-domain-action-form{width:100%}.series-domain-action-form>button{justify-content:center;width:100%}.series-domain-inline-toggle{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;align-items:center;gap:.75rem;height:40px;padding:0 .75rem;display:inline-flex}.series-domain-inline-toggle-label{color:var(--pb-text-primary);font-size:.82rem;font-weight:600}.series-domain-section-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;padding:1rem 1.25rem}.series-domain-section-title{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:.82rem;font-weight:700}:where(.series-domain-alt-panel>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.series-domain-alt-title{text-transform:uppercase;letter-spacing:.12em;color:var(--pb-text-dim);font-family:Syne,sans-serif;font-size:.72rem;font-weight:700}.series-domain-alt-strip{flex-direction:column;align-items:flex-start;gap:.5rem;display:flex}.series-domain-alt-list{flex-wrap:wrap;align-items:flex-start;gap:.35rem;min-height:0;display:flex}.series-domain-alt-pill{gap:.35rem}.series-domain-alt-pill-remove{color:var(--pb-text-tertiary);margin-left:.1rem;transition:color .14s}.series-domain-alt-pill-remove:hover{color:var(--pb-status-danger)}.series-domain-alt-form{flex-wrap:wrap;align-items:center;gap:.5rem;width:100%;display:flex}.series-domain-alt-input{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);width:180px;color:var(--pb-text-primary);border-radius:8px;padding:.45rem .75rem;font-size:.78rem}.series-domain-alt-input:focus{border-color:var(--pb-interactive);outline:none}.series-domain-alt-add{padding:.3rem .75rem;font-size:.78rem}.series-domain-issues-wrap{overflow:visible}.series-domain-issues-card{padding-top:.875rem}.series-domain-issues-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.series-domain-issues-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-domain-issues-title{color:var(--pb-text-primary);font-size:.88rem;font-weight:700}.series-domain-issues-summary{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.series-domain-issues-summary .pill{font-size:.62rem}.series-domain-issues-select.dropdown-select{--pb-control-min-height:34px;--pb-control-font-size:.78rem;width:auto}.series-domain-issues-select .dropdown-select-trigger{background:var(--pb-surface-input);padding-block:.4rem;padding-inline:.75rem .65rem}.series-domain-issues-progress{align-items:center;gap:.625rem;margin-top:.9rem;display:flex}.series-domain-issues-progress-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-domain-issues-progress-track{border:1px solid var(--pb-border-subtle);overflow:hidden}.series-domain-issues-progress-fill{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success));height:100%}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-progress-fill{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-domain-issues-progress-label{text-align:right;min-width:36px;color:var(--pb-status-success);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:700}.series-domain-issues-table-wrap{margin-top:.875rem;overflow-x:auto}.series-domain-issues-table{border-collapse:collapse;width:100%}.series-domain-issues-table th,.series-domain-issues-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.75rem .875rem}.series-domain-issues-table thead th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-domain-issues-table tbody tr:hover td{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.series-domain-issues-table tbody tr:hover td{background:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.series-domain-table-head.r{text-align:right}.series-domain-issue-num{white-space:nowrap;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.84rem}.series-domain-issue-title-cell{min-width:240px;color:var(--pb-text-secondary);font-size:.84rem}.series-domain-issue-title-link{color:var(--pb-text-primary);transition:color .14s;display:block}.series-domain-issue-title-link:hover{color:var(--pb-interactive)}.series-domain-issue-date{white-space:nowrap;color:var(--pb-text-tertiary);font-size:.8rem}.series-domain-issue-reading{min-width:9.5rem;color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.7rem}.series-domain-issue-reading-mobile{color:var(--pb-interactive);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.65rem}.series-domain-reading-progress{gap:.28rem;max-width:8.5rem;display:grid}.series-domain-reading-progress-track{background:var(--pb-border-default);border-radius:999px;height:3px;display:block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-progress-track{background:color-mix(in srgb, var(--pb-border-default) 68%, transparent)}}.series-domain-reading-progress-fill{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.series-domain-reading-queue{color:var(--pb-text-tertiary);margin-top:.3rem;font-family:DM Sans,sans-serif;font-size:.62rem;display:block}.series-domain-issue-status{white-space:nowrap}.series-domain-issue-actions-cell{text-align:right}.series-domain-issue-actions{align-items:center;gap:.25rem;display:inline-flex}.series-domain-issue-action-btn{width:28px;height:28px;color:var(--pb-text-tertiary);background:0 0;border:1px solid #0000;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-domain-issue-action-btn:hover{color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-issue-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-domain-reading-menu{z-index:60;border:1px solid var(--pb-border-default);background:var(--pb-surface-raised);min-width:10.5rem;box-shadow:var(--pb-shadow-2);border-radius:10px;display:grid;position:absolute;top:calc(100% + .3rem);right:0;overflow:hidden}.series-domain-reading-menu button{text-align:left;min-height:2.5rem;color:var(--pb-text-secondary);padding:.55rem .75rem;font-size:.72rem;font-weight:600}.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{color:var(--pb-text-primary);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-domain-reading-menu button:hover,.series-domain-reading-menu button:focus-visible{background:color-mix(in srgb, var(--pb-interactive) 9%, transparent)}}.series-domain-table-footer{margin-top:.75rem}.series-domain-copy-block{color:var(--pb-text-secondary);margin-top:.75rem}.issue-domain-stat-strip{grid-template-columns:repeat(4,minmax(0,1fr));gap:.625rem;margin-top:1rem;display:grid}.issue-domain-stat-box{background:var(--pb-surface-shell);border:1px solid var(--pb-border-subtle);border-radius:12px;padding:.8rem .9rem}.issue-domain-stat-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.issue-domain-stat-value{color:var(--pb-text-primary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.85rem}.issue-domain-stat-value a{color:var(--pb-interactive)}.issue-domain-creators-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-creator-card{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:10px;padding:.8rem .9rem}.issue-domain-creator-name{color:var(--pb-text-primary);font-size:.85rem;font-weight:600}.issue-domain-creator-role{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.2rem;font-size:.68rem}.issue-domain-file-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;margin-top:.75rem;display:grid}.issue-domain-file-name{font-size:.78rem}.issue-domain-file-path-box{background:var(--pb-surface-app);border:1px solid var(--pb-border-subtle);border-radius:12px;justify-content:space-between;align-items:flex-start;gap:.75rem;margin-top:.75rem;padding:.875rem 1rem;display:flex}.issue-domain-file-path{word-break:break-all;color:var(--pb-text-secondary);margin-top:.3rem;font-family:JetBrains Mono,monospace;font-size:.75rem;line-height:1.7}.issue-domain-copy-btn{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);width:30px;height:30px;color:var(--pb-text-tertiary);border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.issue-domain-copy-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.series-domain-telemetry-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);box-shadow:var(--pb-shadow-1);border-radius:12px;flex-wrap:wrap;align-items:center;gap:.35rem 1rem;padding:.65rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-domain-telemetry-strip strong{color:var(--pb-text-primary);font-weight:700}.series-page-shell{gap:calc(var(--spacing) * 4);flex-direction:column;display:flex}.series-registry-header{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;display:flex}.series-registry-header-left{flex-wrap:wrap;align-items:flex-start;gap:1.5rem;display:flex}.series-registry-title{text-transform:uppercase;letter-spacing:.04em;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.series-registry-title span{color:var(--pb-brand)}.series-registry-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.series-registry-gauges{align-items:flex-end;gap:1rem;display:flex}.series-registry-gauge{text-align:center}.series-registry-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.series-registry-gauge-ring svg{transform:rotate(-90deg)}.series-registry-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-registry-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.series-registry-gauge-bg{stroke-width:4.5px}.series-registry-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-registry-gauge-fill-success{stroke:var(--pb-status-success)}.series-registry-gauge-fill-info{stroke:var(--pb-status-info)}.series-registry-gauge-fill-warning{stroke:var(--pb-status-warning)}.series-registry-gauge-fill-danger{stroke:var(--pb-status-danger)}.series-registry-gauge-fill-default{stroke:var(--pb-text-secondary)}.series-registry-gauge-value{justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.series-registry-gauge-label{text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.series-registry-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-toolbar-shell{z-index:20;position:sticky;top:0}.series-toolbar-frame{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);overflow:visible}.series-toolbar-body{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.series-toolbar-browse{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-primary{flex-wrap:wrap;flex:1;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.series-toolbar-actions{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.series-toolbar-field{flex-shrink:0;display:block}.series-toolbar-label{text-transform:uppercase;letter-spacing:.08em;color:var(--pb-text-tertiary);margin-bottom:.25rem;font-size:.68rem;font-weight:700;display:block}.series-registry-search{width:220px;max-width:100%}.add-series-search-field{width:275px}.series-registry-search.search-field{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:38px;box-shadow:none;border-radius:8px}.series-registry-search .search-field-input{font-size:.82rem}.series-registry-dropdown.dropdown-select{--pb-control-min-height:38px;--pb-control-font-size:.78rem;--pb-control-radius:8px;width:fit-content;max-width:100%}.series-registry-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);box-shadow:none;padding-inline:.75rem}.series-registry-dropdown .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:500}.series-registry-dropdown .dropdown-select-panel{border-radius:10px}.series-toolbar-view-block{flex-direction:column;display:inline-flex}.series-view-toggle{border:1px solid var(--pb-border-default);background:0 0;border-radius:8px;display:inline-flex;overflow:visible}.series-view-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.series-view-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.series-view-toggle-item{width:38px;height:34px;color:var(--pb-text-tertiary);background:0 0;border:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-view-toggle-item+.series-view-toggle-item{border-left:1px solid var(--pb-border-default)}.series-view-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.series-view-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.series-view-toggle-item:hover{color:var(--pb-text-primary)}html[data-series-view=list] .series-view-toggle-item[data-series-view-option=list],html[data-series-view=grid] .series-view-toggle-item[data-series-view-option=grid],html[data-story-arc-view=list] .series-view-toggle-item[data-story-arc-view-option=list],html[data-story-arc-view=grid] .series-view-toggle-item[data-story-arc-view-option=grid],html[data-whats-new-view=list] .whats-new-view-toggle-item[data-whats-new-view-option=list],html[data-whats-new-view=compact] .whats-new-view-toggle-item[data-whats-new-view-option=compact]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-pane-toggle{background:var(--pb-surface-input)}.whats-new-pane-toggle-item{min-height:34px;color:var(--pb-text-tertiary);letter-spacing:.06em;text-transform:uppercase;background:0 0;border:none;justify-content:center;align-items:center;padding:0 .875rem;font-size:.78rem;font-weight:700;transition:all .14s;display:inline-flex}.whats-new-pane-toggle-item:first-child{border-top-left-radius:7px;border-bottom-left-radius:7px}.whats-new-pane-toggle-item:last-child{border-top-right-radius:7px;border-bottom-right-radius:7px}.whats-new-pane-toggle-item+.whats-new-pane-toggle-item{border-left:1px solid var(--pb-border-default)}.whats-new-pane-toggle-item:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.whats-new-pane-toggle-item:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.whats-new-pane-toggle-item:hover{color:var(--pb-text-primary)}.whats-new-pane-toggle-item.is-active,.whats-new-pane-toggle-item[aria-current=page]{background:var(--pb-interactive);color:var(--pb-text-inverse)}.whats-new-title-block{min-height:4.55rem}.whats-new-cache-badge{min-height:34px;color:var(--pb-text-inverse);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:#1f6f4a}.whats-new-cache-badge-error{background:#9f2f24}.whats-new-week-nav{border:1px solid var(--pb-border-default);background:var(--pb-surface-input);border-radius:10px;justify-content:center;justify-self:center;align-items:center;gap:.375rem;width:fit-content;max-width:min(100%,34rem);margin-inline:auto;padding:.375rem;display:inline-flex}.whats-new-week-nav-button{border:1px solid var(--pb-border-default);background:var(--pb-surface-card);min-width:32px;min-height:32px;color:var(--pb-text-secondary);border-radius:8px;justify-content:center;align-items:center;font-size:.9rem;font-weight:800;transition:all .14s;display:inline-flex}.whats-new-week-nav-button:hover{border-color:var(--pb-border-hover);background:var(--pb-interactive-selected);color:var(--pb-text-primary)}.whats-new-week-nav-button.is-disabled{opacity:.45;cursor:not-allowed}.whats-new-week-dropdown.dropdown-select{--pb-control-min-height:32px;--pb-control-font-size:.78rem;--pb-control-radius:8px;flex:0 auto;width:fit-content;max-width:min(52vw,14rem)}.whats-new-week-dropdown .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-card);min-width:9.5rem;max-width:14rem;box-shadow:none;padding-inline:.625rem}.whats-new-week-dropdown .dropdown-select-panel{border-radius:10px;min-width:11.5rem;max-width:min(90vw,16rem)}.whats-new-week-dropdown .dropdown-select-trigger-label,.whats-new-week-dropdown .dropdown-select-option-label{font-weight:700}.whats-new-week-position{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding-inline:.25rem .375rem;font-size:.68rem;font-weight:700}@media (max-width:640px){.whats-new-week-nav{max-width:100%}.whats-new-week-dropdown.dropdown-select{max-width:min(58vw,13rem)}.whats-new-week-position{text-align:center;width:100%}}.whats-new-results-stack,.whats-new-panel{gap:1rem;display:grid}.whats-new-stale-banner{border:1px solid var(--pb-status-warning);justify-content:space-between;align-items:center;gap:1rem;display:flex}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{border:1px solid color-mix(in srgb, var(--pb-status-warning) 40%, transparent)}}.whats-new-stale-banner{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.whats-new-stale-banner{background:color-mix(in srgb, var(--pb-status-warning) 10%, transparent)}}.whats-new-stale-banner{color:var(--pb-text-primary);border-radius:12px;margin-bottom:1rem;padding:.75rem 1rem;font-size:.82rem}.whats-new-stale-copy{min-width:0}.whats-new-stale-actions{flex-direction:column;flex:none;align-items:flex-end;gap:.375rem;display:flex}.whats-new-stale-message{max-width:24rem;color:var(--pb-text-secondary);text-align:right;font-size:.72rem;line-height:1.35}@media (max-width:640px){.whats-new-stale-banner{flex-direction:column;align-items:stretch}.whats-new-stale-actions{align-items:flex-start}.whats-new-stale-message{text-align:left}}.whats-new-release-summary{margin-bottom:0}.whats-new-release-table-slot{min-width:0}html[data-whats-new-view=compact] .whats-new-release-cover,html[data-whats-new-view=compact] .whats-new-pulls-col{display:none}html[data-whats-new-view=compact] .whats-new-release-title-cell{gap:0}.select-btn{border:1px solid var(--pb-border-default);min-height:38px;color:var(--pb-text-secondary);background:0 0;border-radius:8px;justify-content:center;align-items:center;gap:.375rem;padding:.375rem .875rem;font-family:DM Sans,sans-serif;font-size:.78rem;font-weight:600;transition:all .14s;display:inline-flex}.select-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.select-btn:disabled{cursor:not-allowed;opacity:.45}.select-btn-success{color:var(--pb-status-success);border-color:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{border-color:color-mix(in srgb, var(--pb-status-success) 30%, transparent)}}.select-btn-success{background:var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.select-btn-success{background:color-mix(in srgb, var(--pb-status-success) 8%, transparent)}}.select-btn-warning{color:var(--pb-status-warning);border-color:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{border-color:color-mix(in srgb, var(--pb-status-warning) 30%, transparent)}}.select-btn-warning{background:var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.select-btn-warning{background:color-mix(in srgb, var(--pb-status-warning) 8%, transparent)}}.select-btn-danger{color:var(--pb-status-danger);border-color:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{border-color:color-mix(in srgb, var(--pb-status-danger) 30%, transparent)}}.select-btn-danger{background:var(--pb-status-danger)}@supports (color:color-mix(in lab, red, red)){.select-btn-danger{background:color-mix(in srgb, var(--pb-status-danger) 8%, transparent)}}.series-selection-shell{flex-direction:column;gap:.625rem;display:flex}.series-selection-inline{align-items:center;display:inline-flex}.series-selection-count{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.78rem;font-weight:600}.series-selection-controls-row{flex-wrap:wrap;justify-content:space-between;gap:.75rem;display:flex}.series-selection-bulk,.series-selection-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.series-results-shell{gap:calc(var(--spacing) * 4);min-height:16rem;padding-bottom:var(--pb-page-footer-clearance);flex-direction:column;flex:none;display:flex}.series-results-body-shell{min-height:14rem}:where(:is(.series-mission-control-shell,.series-collector-wall-shell)>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.series-mission-control-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:auto hidden}.series-mission-control-table{border-collapse:collapse;width:100%}.series-mission-control-table th{text-align:left;text-transform:uppercase;letter-spacing:.1em;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:2px solid var(--pb-border-default);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.series-mission-control-table td{border-bottom:1px solid var(--pb-border-subtle);padding:.6875rem .875rem;font-size:.82rem;transition:background .1s}.series-mission-control-table tbody tr:last-child td{border-bottom:none}.series-mission-control-table tbody tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.series-mission-control-table th.c,.series-mission-control-table td.c{text-align:center}.series-mission-control-table th.r,.series-mission-control-table td.r{text-align:right}.series-mission-control-row-selected td{background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-row-selected td{background:color-mix(in srgb, var(--pb-interactive) 10%, transparent)}}.series-led{border-radius:50%;width:8px;height:8px;display:inline-block}.series-led-green{background:var(--pb-status-success);box-shadow:0 0 5px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.series-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.series-led-amber{background:var(--pb-status-warning);box-shadow:0 0 5px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.series-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.series-led-off{background:var(--pb-text-tertiary);opacity:.35}.series-mission-control-name{color:var(--pb-text-primary);font-weight:700;transition:color .14s}.series-mission-control-name:hover{color:var(--pb-interactive)}.series-mission-control-publisher{color:var(--pb-text-tertiary);margin-top:1px;font-size:.72rem}.series-mission-control-year{color:var(--pb-text-tertiary);font-size:.78rem}.series-mission-control-bar-cell{width:220px}.series-mission-control-bar{align-items:center;gap:.5rem;display:flex}.series-mission-control-bar-track{background:var(--pb-text-secondary);border-radius:2px;flex:1;height:8px}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.series-mission-control-bar-track{border:1px solid var(--pb-border-subtle);position:relative;overflow:hidden}.series-mission-control-bar-fill{height:100%;position:relative}.series-mission-control-bar-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}.series-mission-control-bar-fill-green{background:linear-gradient(90deg, var(--pb-status-success), var(--pb-status-success))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-success) 45%, transparent), var(--pb-status-success))}}.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, var(--pb-status-warning), var(--pb-status-warning))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-warning) 45%, transparent), var(--pb-status-warning))}}.series-mission-control-bar-fill-red{background:linear-gradient(90deg, var(--pb-status-danger), var(--pb-status-danger))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-red{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-danger) 45%, transparent), var(--pb-status-danger))}}.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, var(--pb-status-info), var(--pb-status-info))}@supports (color:color-mix(in lab, red, red)){.series-mission-control-bar-fill-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-status-info) 45%, transparent), var(--pb-status-info))}}.series-mission-control-bar-pct{text-align:right;font-variant-numeric:tabular-nums;min-width:36px;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700}.series-mission-control-bar-pct-green{color:var(--pb-status-success)}.series-mission-control-bar-pct-amber{color:var(--pb-status-warning)}.series-mission-control-bar-pct-red{color:var(--pb-status-danger)}.series-mission-control-bar-pct-blue{color:var(--pb-status-info)}.series-mission-control-actions{justify-content:flex-end;gap:.25rem;display:flex}.series-mission-control-action-btn{border:1px solid var(--pb-border-subtle);width:30px;height:30px;color:var(--pb-text-tertiary);background:0 0;border-radius:8px;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.series-mission-control-action-btn:hover{border-color:var(--pb-interactive);color:var(--pb-interactive);background:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-mission-control-action-btn:hover{background:color-mix(in srgb, var(--pb-interactive) 4%, transparent)}}.series-mission-control-action-btn.disabled{opacity:.25;pointer-events:none}.series-mission-control-action-btn svg{width:14px;height:14px}.series-mission-control-footer{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);color:var(--pb-text-tertiary);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.series-mission-control-footer strong{color:var(--pb-text-primary);font-weight:600}.series-collector-wall-grid{grid-template-columns:repeat(auto-fill,minmax(172px,1fr));gap:24px 18px;display:grid}.series-wall-card{min-width:0;transition:transform .22s;position:relative}.series-wall-card:hover{transform:translateY(-5px)}.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card-selected .series-wall-cover-wrap{outline:2px solid color-mix(in srgb, var(--pb-interactive) 28%, transparent)}}.series-wall-card-selected .series-wall-cover-wrap{outline-offset:2px}.series-wall-cover-wrap{aspect-ratio:2/3;border:1px solid var(--pb-border-subtle);background:var(--pb-surface-card);box-shadow:var(--pb-shadow-1);border-radius:14px;transition:box-shadow .22s;position:relative;overflow:hidden}.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.series-wall-card:hover .series-wall-cover-wrap{box-shadow:var(--pb-shadow-2), 0 0 20px color-mix(in srgb, var(--pb-interactive) 6%, transparent)}}.series-wall-cover-placeholder{width:100%;height:100%;color:var(--pb-text-tertiary);background:linear-gradient(155deg, var(--pb-surface-card) 0%, var(--pb-surface-app) 100%);justify-content:center;align-items:center;display:flex}.series-monitor-badge{width:1.5rem;height:1.5rem;color:var(--pb-text-inverse);background:var(--pb-brand-signal);border:2px solid var(--pb-surface-card);border-radius:999px;flex:none;justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.series-monitor-badge{border:2px solid color-mix(in srgb, var(--pb-surface-card) 92%, white)}}.series-monitor-badge{box-shadow:0 0 0 2px #0000006b,0 10px 22px #00000057}.series-monitor-badge svg{flex:none;width:.9rem;height:.9rem}.series-monitor-badge.is-paused{color:var(--pb-text-secondary);background:var(--pb-surface-raised);border-color:var(--pb-border-default);box-shadow:0 0 0 1px var(--pb-border-subtle)}.series-wall-monitor-dot{z-index:3;position:absolute;top:9px;left:9px}.story-arc-wall-review{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:999px;justify-content:center;align-items:center;gap:.2rem;min-width:1.75rem;height:1.75rem;padding-inline:.38rem;display:inline-flex;position:absolute;top:9px;right:9px}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.story-arc-wall-review{box-shadow:var(--pb-shadow-1);font-family:JetBrains Mono,monospace;font-size:.65rem;font-weight:700}.story-arc-wall-review svg{width:.8rem;height:.8rem}.story-arc-wall-review-warning{color:var(--pb-warning);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-warning{border-color:color-mix(in srgb, var(--pb-warning) 48%, transparent)}}.story-arc-wall-review-error{color:var(--pb-error);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-wall-review-error{border-color:color-mix(in srgb, var(--pb-error) 48%, transparent)}}.story-arc-review-indicator{border:1px solid var(--pb-border-default);border-radius:999px;justify-content:center;align-items:center;gap:.28rem;min-width:2.5rem;min-height:1.75rem;padding-inline:.45rem;font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700;display:inline-flex}.story-arc-review-indicator svg{width:.82rem;height:.82rem}.story-arc-review-indicator-success{color:var(--pb-success);background:var(--pb-success-dim);border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-success{border-color:color-mix(in srgb, var(--pb-success) 32%, transparent)}}.story-arc-review-indicator-warning{color:var(--pb-warning);background:var(--pb-warning-dim);border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-warning{border-color:color-mix(in srgb, var(--pb-warning) 32%, transparent)}}.story-arc-review-indicator-error{color:var(--pb-error);background:var(--pb-error-dim);border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.story-arc-review-indicator-error{border-color:color-mix(in srgb, var(--pb-error) 32%, transparent)}}.series-wall-catalog-state-badge{z-index:4;border:1px solid var(--pb-warning);background:var(--pb-warning);border-radius:999px;justify-content:center;align-items:center;max-width:calc(100% - 18px);display:inline-flex;position:absolute;top:46px;right:9px}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge{background:color-mix(in srgb, var(--pb-warning) 92%, black)}}.series-wall-catalog-state-badge{color:var(--pb-text-inverse);letter-spacing:.04em;text-align:center;text-transform:uppercase;padding:.2rem .5rem;font-size:.58rem;font-weight:800;line-height:1.15;box-shadow:0 8px 18px #00000057}.series-wall-catalog-state-badge.is-failed{border-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.series-wall-catalog-state-badge.is-failed{background:color-mix(in srgb, var(--pb-error) 92%, black)}}.pull-list-monitor-toggle{width:28px;height:28px;box-shadow:none;cursor:pointer;border-width:1px;border-radius:6px;transition:background-color .14s,border-color .14s,color .14s,box-shadow .14s}.pull-list-monitor-toggle svg{width:12px;height:12px}.pull-list-monitor-toggle:hover{border-color:var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{border-color:color-mix(in srgb, var(--pb-brand-signal) 56%, transparent)}}.pull-list-monitor-toggle:hover{background:var(--pb-brand-signal);color:var(--pb-text-inverse);box-shadow:0 0 0 2px var(--pb-brand-signal)}@supports (color:color-mix(in lab, red, red)){.pull-list-monitor-toggle:hover{box-shadow:0 0 0 2px color-mix(in srgb, var(--pb-brand-signal) 14%, transparent)}}.pull-list-monitor-toggle:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.series-wall-selection-control{z-index:4;border:1px solid var(--pb-border-default);background:var(--pb-surface-card);border-radius:10px;position:absolute;top:10px;right:10px}@supports (color:color-mix(in lab, red, red)){.series-wall-selection-control{background:color-mix(in srgb, var(--pb-surface-card) 92%, transparent)}}.series-wall-selection-control{box-shadow:var(--pb-shadow-1);padding:.25rem}.series-wall-ring{z-index:2;width:44px;height:44px;position:absolute;bottom:-6px;right:-6px}.series-wall-ring svg{transform:rotate(-90deg)}.series-wall-ring-bg{fill:none;stroke:var(--pb-surface-card);stroke-width:3.5px}.series-wall-ring-fill{fill:none;stroke-width:3.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.series-wall-ring-fill-green{stroke:var(--pb-status-success)}.series-wall-ring-fill-amber{stroke:var(--pb-status-warning)}.series-wall-ring-fill-red{stroke:var(--pb-status-danger)}.series-wall-ring-center{background:var(--pb-surface-card);color:var(--pb-text-primary);border-radius:999px;justify-content:center;align-items:center;margin:5px;font-family:JetBrains Mono,monospace;font-size:.58rem;font-weight:700;display:flex;position:absolute;inset:0}.series-wall-overlay{--series-wall-overlay-edge:#fff;z-index:3;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:inset 0 0 0 4.5px var(--series-wall-overlay-edge);opacity:0;pointer-events:none;background:#1e1a17e0;border-radius:18px;flex-direction:column;justify-content:flex-end;padding:.875rem;transition:opacity .2s;display:flex;position:absolute;inset:-4px}[data-theme=light] .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}@media (prefers-color-scheme:light){:root:not([data-theme]) .series-wall-overlay{--series-wall-overlay-edge:#1e1a17}}.series-wall-card:hover .series-wall-overlay{opacity:1}.series-wall-overlay-title{color:var(--pb-text-primary);font-size:.82rem;font-weight:700;line-height:1.3}.series-wall-overlay-meta{color:var(--pb-text-tertiary);gap:.25rem;margin-top:.45rem;font-size:.68rem;display:grid}.series-wall-overlay-meta-line,.series-wall-overlay-stat{grid-template-columns:minmax(4.25rem,max-content) minmax(0,1fr);align-items:baseline;column-gap:.5rem;line-height:1.25;display:grid}.series-wall-overlay-meta-label{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;font-family:JetBrains Mono,monospace;font-size:.56rem;font-weight:700}.series-wall-overlay-meta-value{overflow-wrap:anywhere;min-width:0;color:var(--pb-text-secondary)}.series-wall-overlay-stats{color:var(--pb-text-secondary);gap:.25rem;margin-top:.35rem;font-size:.68rem;display:grid}.series-wall-overlay-stats strong{color:var(--pb-text-primary);font-weight:600}.series-wall-overlay-actions{pointer-events:auto;gap:6px;margin-top:10px;display:flex}.series-wall-overlay-btn{color:var(--pb-text-secondary);text-align:center;background:#ffffff0f;border:1px solid #cbd5e140;border-radius:8px;flex:1;justify-content:center;padding:7px;font-family:DM Sans,sans-serif;font-size:.72rem;font-weight:600;transition:all .14s;display:inline-flex}.series-wall-overlay-btn:hover{background:var(--pb-interactive);color:var(--pb-text-primary);border-color:var(--pb-interactive)}.series-wall-card-title{text-overflow:ellipsis;white-space:nowrap;color:var(--pb-text-primary);margin-top:10px;font-size:.82rem;font-weight:600;line-height:1.3;display:block;overflow:hidden}.series-empty-state{justify-content:center;align-items:center;gap:calc(var(--spacing) * 3);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;box-shadow:var(--pb-shadow-1);border-radius:1.25rem;flex-direction:column;display:flex}.series-empty-state-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.series-empty-state-copy{max-width:var(--container-xl);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}@media (max-width:1180px){.series-domain-hero-inner{grid-template-columns:130px minmax(0,1fr)}.issue-domain-hero-inner{grid-template-columns:120px minmax(0,1fr)}.series-domain-actions-panel{border-top:1px solid var(--pb-border-subtle);border-left:0;grid-column:1/-1;padding-top:1rem;padding-left:0}.series-domain-actions-panel .series-domain-actions-buttons{grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));align-items:stretch;display:grid}}@media (max-width:900px){.issue-domain-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.issue-domain-file-grid{grid-template-columns:1fr}}@media (max-width:640px){.detail-hero-shell,.series-domain-actions-card,.series-domain-section-card{padding:1rem}.series-domain-cover-column,.issue-domain-cover-column,.issue-domain-cover-frame{width:104px}.series-domain-hero-inner,.issue-domain-hero-inner{grid-template-columns:104px minmax(0,1fr)}.series-domain-hero-title{font-size:1.2rem}.series-domain-actions-inner{align-items:flex-start}.series-domain-actions-divider{display:none}.series-domain-actions-panel .series-domain-actions-buttons,.issue-domain-stat-strip{grid-template-columns:1fr}.series-domain-info-box-wide{grid-column:auto}.series-domain-issues-toolbar{flex-direction:column;align-items:stretch}}[data-series-page][data-series-toolbar-mode=browse] [data-testid=series-select-toolbar],[data-series-page][data-series-toolbar-mode=select] [data-testid=series-browse-toolbar]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-testid=series-select-toolbar]{display:flex}[data-select-toolbar-page][data-toolbar-mode=browse] [data-select-toolbar],[data-select-toolbar-page][data-toolbar-mode=select] [data-browse-toolbar]{display:none}[data-select-toolbar-page][data-toolbar-mode=select] [data-select-toolbar]{display:flex}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-cell]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-cell]{display:table-cell}[data-series-page][data-series-toolbar-mode=browse] [data-series-selection-control]{display:none}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control]{display:block}[data-series-page][data-series-toolbar-mode=select] [data-series-selection-control=grid]{display:inline-flex}#page-footer-dock:empty{display:none}body:has(#page-footer-dock:not(:empty)) #content{padding-bottom:var(--pb-page-footer-clearance)}body:has(#page-footer-dock:not(:empty)) #content:has(.admin-workspace-page,.dashboard-mission-page,.downloads-view,.series-domain-page,.series-results-shell,.utilities-page){padding-bottom:0}#content:has(.admin-workspace-page){overflow-anchor:none}.page-dock-inner{--page-dock-height-status:2rem;--page-dock-height-pagination:2.5rem;max-width:85rem;height:var(--page-dock-height-status);flex-wrap:nowrap;justify-content:space-between;align-items:center;gap:.75rem;margin:0 auto;padding:0 1rem;display:flex;overflow:hidden}.page-dock-inner:has(.page-dock-pagination){height:var(--page-dock-height-pagination)}.page-footer-clearance{height:var(--pb-page-footer-clearance);flex:0 0 var(--pb-page-footer-clearance);pointer-events:none}.page-dock-inner-status-only{justify-content:flex-end}.page-dock-pagination{flex:none}.page-dock-pagination nav{gap:.125rem}.page-dock-pagination nav>a,.page-dock-pagination nav>button,.page-dock-pagination nav>span{text-align:center;border-radius:.375rem;min-width:1.75rem;padding:.25rem .625rem;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600;line-height:1}.page-dock-pagination nav>a,.page-dock-pagination nav>button{appearance:none;color:var(--pb-text-secondary);cursor:pointer;background:0 0;border:0;margin:0}.page-dock-pagination nav>span.bg-pb-interactive{border-radius:.375rem}.page-dock-status{min-width:0;color:var(--pb-text-tertiary);scrollbar-width:none;flex-wrap:nowrap;align-items:center;gap:1rem;font-family:JetBrains Mono,monospace;font-size:.65rem;display:flex;overflow-x:auto}.page-dock-status::-webkit-scrollbar{display:none}.page-dock-status-item{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.page-dock-status-value{color:var(--pb-text-primary);font-weight:600}.page-dock-status-label{text-transform:uppercase;letter-spacing:.04em}.page-dock-led{border-radius:999px;width:6px;height:6px;margin-right:2px;display:inline-block}.page-dock-led-green{background:var(--pb-status-success);box-shadow:0 0 4px var(--pb-status-success)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-green{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-success) 35%, transparent)}}.page-dock-led-amber{background:var(--pb-status-warning);box-shadow:0 0 4px var(--pb-status-warning)}@supports (color:color-mix(in lab, red, red)){.page-dock-led-amber{box-shadow:0 0 4px color-mix(in srgb, var(--pb-status-warning) 35%, transparent)}}.page-dock-led-off{background:var(--pb-text-tertiary);opacity:.35}@media (max-width:767px){.page-dock-inner{padding-inline:0}.page-dock-status{gap:.75rem}}.workflow-tabs-shell{align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-radius:1.25rem;flex-wrap:wrap;display:flex}@media (min-width:40rem){.workflow-tabs-shell{padding-inline:calc(var(--spacing) * 5)}}.workflow-tabs-shell{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary);border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{border-color:color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.workflow-tab-chip-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-tab-chip-active{background-color:color-mix(in srgb, var(--pb-interactive) 12%, var(--pb-bg-card))}}.workflow-tab-chip-active{box-shadow:var(--pb-shadow-1)}.workflow-tab-chip-inactive{color:var(--pb-text-dim)}.workflow-focus-card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 5);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;position:relative;overflow:hidden}.workflow-focus-card:before{content:"";background:linear-gradient(90deg, transparent, var(--pb-brand), transparent);height:1px;position:absolute;inset:0 0 auto}@supports (color:color-mix(in lab, red, red)){.workflow-focus-card:before{background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--pb-brand) 34%, transparent), transparent)}}.workflow-focus-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:80rem){.workflow-focus-grid{grid-template-columns:minmax(0,1.35fr) minmax(320px,.95fr);align-items:flex-start}}.workflow-focus-chip{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.workflow-focus-actions{gap:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.workflow-focus-actions{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:80rem){.workflow-focus-actions{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(.workflow-shell-region>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.workflow-shell-region{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 4);border-radius:1.5rem;overflow:visible}@media (min-width:40rem){.workflow-shell-region{padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 5)}}.workflow-shell-region{box-shadow:var(--pb-shadow-1)}.utility-workspace-shell{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.5rem;overflow:hidden}.utility-workspace-shell-visible{overflow:visible}:where(.utility-workspace-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.utility-workspace-body{padding:calc(var(--spacing) * 6)}@media (min-width:40rem){.utility-workspace-body{padding:calc(var(--spacing) * 7)}}:where(.utility-tool-body>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.utility-tool-body{padding:calc(var(--spacing) * 5)}@media (min-width:40rem){.utility-tool-body{padding:calc(var(--spacing) * 5)}}.utility-workspace-shell .info-panel{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-base);box-shadow:none}.utility-workspace-shell .info-panel-muted{background-color:var(--pb-bg-base)}.utility-empty-state{border-radius:var(--radius-xl);border-style:var(--tw-border-style);--tw-border-style:dashed;border-style:dashed;border-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 8);text-align:center;background-color:var(--pb-bg-base)}.utility-path-pill{align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);display:flex}.utility-token{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);border-radius:3.40282e38px;align-items:center;display:inline-flex}.utility-step-grid{gap:calc(var(--spacing) * 3);display:grid}.utility-step-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);width:100%;padding:calc(var(--spacing) * 4);display:block}.utility-step-card-inline{align-items:center;gap:.75rem;display:flex}.utility-step-card-locked{opacity:.78}.utility-step-num{color:var(--pb-text-tertiary);min-width:1.25rem;font-family:JetBrains Mono,monospace;font-size:.62rem;font-weight:700}.utility-step-name{color:var(--pb-text-primary);flex:auto;min-width:0;font-size:.82rem;font-weight:600}.utility-step-tag{color:var(--pb-text-tertiary);white-space:nowrap;font-family:JetBrains Mono,monospace;font-size:.62rem}.utility-step-card-inline .utility-step-num,.utility-step-card-inline .utility-step-tag{color:var(--pb-text-secondary)}.utility-step-check{flex-shrink:0;margin-top:0}.utility-scan-mode-grid{gap:calc(var(--spacing) * 2);display:grid}@media (min-width:40rem){.utility-scan-mode-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-scan-mode{justify-content:center;align-items:flex-start;gap:var(--spacing);border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card);min-height:72px;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;display:flex}.utility-scan-mode:hover{border-color:var(--pb-border-hover)}.utility-scan-mode-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-scan-mode-label{--tw-font-weight:var(--font-weight-semibold);font-size:.82rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utility-scan-mode-copy{--tw-leading:calc(var(--spacing) * 5);font-size:.72rem;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.utility-scope-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-scope-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:.5rem;justify-content:center;align-items:center;padding:.4375rem 1rem;font-size:.78rem;font-weight:600;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-scope-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-scope-chip-active{background:var(--pb-interactive);color:var(--pb-text-inverse);border-color:var(--pb-interactive)}.utility-scope-chip-active:hover{color:var(--pb-text-inverse)}.utility-export-chip-row{flex-wrap:wrap;gap:.375rem;display:flex}.utility-export-chip{border:1px solid var(--pb-border-default);color:var(--pb-text-secondary);background:0 0;border-radius:999px;justify-content:center;align-items:center;padding:.4375rem .875rem;font-family:DM Sans,sans-serif;font-size:.76rem;font-weight:500;line-height:1.2;transition:border-color .14s,background-color .14s,color .14s;display:inline-flex}.utility-export-chip:hover{border-color:var(--pb-interactive);color:var(--pb-interactive)}.utility-export-chip-active{border-color:var(--pb-interactive);background:var(--pb-interactive);color:var(--pb-text-inverse)}.utility-export-chip-active:hover{color:var(--pb-text-inverse)}.utility-tool-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-bottom:.5rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.utility-tool-section-row{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.utility-tool-field-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700;display:block}.utility-export-group-grid{gap:.75rem;display:grid}@media (min-width:1024px){.utility-export-group-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-group-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:12px;flex-direction:column;gap:.5rem;padding:.875rem;display:flex}.utility-export-group-title{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-group-fields{flex-direction:column;gap:.375rem;display:flex}.utility-export-group-fields .utility-step-card{padding:.5rem .75rem}.utility-export-field-label{color:var(--pb-text-primary);font-size:.8rem;font-weight:500;line-height:1.25}.utility-export-field-meta{justify-content:space-between;align-items:baseline;gap:.875rem;width:100%;min-width:0;display:flex}.utility-export-field-sub{color:var(--pb-text-tertiary);text-align:right;white-space:nowrap;text-overflow:ellipsis;flex:0 auto;min-width:0;font-family:JetBrains Mono,monospace;font-size:.66rem;line-height:1.2;overflow:hidden}.utility-export-options-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:1rem}.utility-export-inline-label{color:var(--pb-text-primary);font-size:.72rem;font-weight:600}.utility-export-multi-grid{gap:.5rem;display:grid}@media (min-width:640px){.utility-export-multi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-grid{gap:.625rem;display:grid}@media (min-width:640px){.utility-export-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}.utility-export-summary-card{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);border-radius:10px;padding:.875rem 1rem}.utility-export-summary-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);font-family:Syne,sans-serif;font-size:.58rem;font-weight:700}.utility-export-summary-value{color:var(--pb-text-primary);margin-top:.35rem;font-family:JetBrains Mono,monospace;font-size:.92rem;font-weight:600}.utility-tool-select.dropdown-select{--pb-control-font-size:.82rem;--pb-control-line-height:1.25rem;--pb-control-radius:8px;width:100%}.utility-tool-select .dropdown-select-trigger{border-color:var(--pb-border-default);background:var(--pb-surface-input);min-height:0;box-shadow:none;padding:.5rem .75rem}.utility-tool-select .dropdown-select-trigger-label{color:var(--pb-text-primary);font-weight:400}.utility-tool-select .dropdown-select-chevron{color:var(--pb-text-secondary)}.utility-tool-output-field{border:1px solid var(--pb-border-subtle);background:var(--pb-surface-app);color:var(--pb-text-secondary);border-radius:8px;padding:.5rem .75rem;font-family:DM Sans,sans-serif;font-size:.82rem;line-height:1.25rem}.utility-tool-browse-button{gap:.375rem;min-height:0;padding:.375rem .875rem;font-size:.78rem;font-weight:600;line-height:1.5}.utility-tool-browse-button svg{width:.875rem;height:.875rem}.utility-tool-table-wrap{border:1px solid var(--pb-border-subtle);border-radius:10px;overflow:hidden}.utility-tool-table{border-collapse:collapse;width:100%}.utility-tool-table th{text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);background:var(--pb-surface-shell);border-bottom:1px solid var(--pb-border-default);padding:.4375rem .75rem;font-family:Syne,sans-serif;font-size:.52rem;font-weight:700}.utility-tool-table th.r,.utility-tool-table td.r{text-align:right}.utility-tool-table td{color:var(--pb-text-secondary);border-bottom:1px solid var(--pb-border-subtle);padding:.5rem .75rem;font-size:.78rem}.utility-tool-table tbody tr:last-child td{border-bottom:none}.utility-tool-table tbody tr:hover td{background:var(--pb-surface-selected)}.utility-tool-table-output{color:var(--pb-status-success)}.utility-tool-action-footer{border-top:1px solid var(--pb-border-subtle);justify-content:flex-end;align-items:center;gap:.625rem;padding:.75rem 0 0;display:flex}.utility-tool-action-footer .settings-footer-actions{gap:.625rem;margin-left:auto}.utility-tool-action-footer .btn-ghost,.utility-tool-action-footer .btn-primary{min-height:0;padding:.5625rem 1.125rem;font-size:.82rem;line-height:1.25rem}.utility-tool-action-footer-card{margin-top:.25rem}.utility-step-card-active{border-color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.utility-step-card-static{border-color:var(--pb-border-subtle);background-color:var(--pb-bg-card)}.utility-empty-state-compact{padding-top:1rem;padding-bottom:1rem}.utility-template-table-type{color:var(--pb-text-primary);font-size:.78rem;font-weight:600}.utility-template-meta-token{color:var(--pb-text-tertiary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utility-template-meta-token strong{color:var(--pb-text-secondary);font-weight:600}.utilities-queue-table-wrap{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:14px;overflow:hidden}.utilities-queue-table{border-collapse:collapse;width:100%}.utilities-queue-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-surface);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-bold);font-family:Syne,sans-serif;font-size:.58rem;font-weight:var(--font-weight-bold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--pb-text-dim);text-transform:uppercase}.utilities-queue-table th.c,.utilities-queue-table td.c{text-align:center}.utilities-queue-table th.r,.utilities-queue-table td.r{text-align:right}.utilities-queue-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border-subtle);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);font-size:.82rem}.utilities-queue-table tbody:last-child tr:last-child td{border-bottom:0}.utilities-queue-row:hover td{background-color:var(--pb-surface-selected)}.utilities-queue-row>td{vertical-align:middle}.utilities-queue-led{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);background-color:var(--pb-text-dim);border-radius:3.40282e38px;display:inline-block}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led{background-color:color-mix(in srgb, var(--pb-text-dim) 45%, transparent)}}.utilities-queue-led-green{background-color:var(--pb-success);box-shadow:0 0 8px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-green{box-shadow:0 0 8px color-mix(in srgb, var(--pb-success) 30%, transparent)}}.utilities-queue-led-amber{background-color:var(--pb-warning);box-shadow:0 0 8px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-amber{box-shadow:0 0 8px color-mix(in srgb, var(--pb-warning) 28%, transparent)}}.utilities-queue-led-blue{background-color:var(--pb-info);box-shadow:0 0 8px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-led-blue{box-shadow:0 0 8px color-mix(in srgb, var(--pb-info) 28%, transparent)}}.utilities-queue-job-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-primary)}.utilities-queue-job-type{margin-top:calc(var(--spacing) * .5);color:var(--pb-text-dim);font-size:.72rem}.utilities-queue-job-meta{margin-top:var(--spacing);color:var(--pb-text-secondary);font-size:.68rem}.utilities-queue-items{--tw-font-weight:var(--font-weight-semibold);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:var(--font-weight-semibold);color:var(--pb-text-secondary)}.utilities-queue-progress-cell .series-mission-control-bar{gap:.625rem}.utilities-queue-progress-cell .series-mission-control-bar-track{min-width:120px}.utilities-queue-time{color:var(--pb-text-secondary);font-family:JetBrains Mono,monospace;font-size:.72rem}.utilities-queue-actions{justify-content:flex-end;align-items:center;gap:calc(var(--spacing) * 1.5);display:flex}.utilities-queue-act-btn{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.utilities-queue-act-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text);background-color:var(--pb-bg-card-hover)}.utilities-queue-act-btn-danger{color:var(--pb-error)}.utilities-queue-act-btn-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-act-btn-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 36%, transparent)}}.utilities-queue-act-btn-danger:hover{background-color:var(--pb-error-dim);color:var(--pb-error)}.utilities-queue-detail-cell{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.utilities-queue-detail-cell{background-color:color-mix(in srgb, var(--pb-bg-surface) 72%, transparent)}}.utilities-queue-detail-summary{align-items:center;column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--pb-text-dim);flex-wrap:wrap;font-size:.72rem;display:flex}.utilities-queue-detail-summary strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.utilities-queue-detail-label{--tw-tracking:.08em;letter-spacing:.08em;text-transform:uppercase}.utilities-queue-empty{justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 12);text-align:center;flex-direction:column;display:flex}.utilities-queue-empty-compact{padding-block:calc(var(--spacing) * 8)}.utilities-queue-empty-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary)}.utilities-queue-empty-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.queue-job-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1);overflow:hidden}.queue-job-card-expanded{border-color:var(--pb-border-hover);box-shadow:var(--pb-shadow-2)}.queue-progress-track{height:calc(var(--spacing) * 1.5);background-color:var(--pb-bg-card-hover);border-radius:3.40282e38px;width:100%;overflow:hidden}.queue-progress-fill{height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;background-color:var(--pb-success);border-radius:3.40282e38px;transition-duration:.5s}.queue-progress-fill-paused{background-color:var(--pb-warning)}.selection-card{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));box-shadow:var(--pb-shadow-1)}.selection-card:not(.selection-card-active):hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.selection-card:not(.selection-card-active):hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.selection-card-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{border-color:color-mix(in srgb, var(--pb-interactive) 42%, transparent)}}.selection-card-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, var(--pb-bg-card))}}.selection-card-active{box-shadow:var(--pb-shadow-2)}.selection-card-icon{height:calc(var(--spacing) * 11);width:calc(var(--spacing) * 11);border-radius:var(--radius-xl);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-shrink:0;justify-content:center;align-items:center;display:flex}.selection-card-icon-active{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.selection-card-icon-active{background-color:color-mix(in srgb, var(--pb-interactive) 14%, transparent)}}.settings-theme-choice{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3)}.settings-theme-choice-icon{height:calc(var(--spacing) * 9);width:calc(var(--spacing) * 9);border-radius:var(--radius-lg)}.settings-media-preview-panel{background-color:var(--pb-surface-selected);border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel{border-color:color-mix(in srgb, var(--pb-border-hover) 85%, transparent)}}.settings-media-preview-panel{overflow-wrap:anywhere;min-height:4.625rem;transition:opacity .16s,border-color .16s}.settings-media-preview-panel.is-loading{opacity:.68;border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.settings-media-preview-panel.is-loading{border-color:color-mix(in srgb, var(--pb-interactive) 30%, var(--pb-border-hover))}}.workflow-step{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 3);box-shadow:var(--pb-shadow-1)}.workflow-step-active{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active{background-color:color-mix(in srgb, var(--pb-interactive) 10%, var(--pb-bg-card))}}.workflow-step-complete{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.workflow-step-badge{height:calc(var(--spacing) * 8);width:calc(var(--spacing) * 8);border-style:var(--tw-border-style);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);border-width:1px;border-color:var(--pb-border-hover);color:var(--pb-text-secondary);background-color:var(--pb-bg-card);border-radius:3.40282e38px;justify-content:center;align-items:center;display:flex}.workflow-step-active .workflow-step-badge{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-interactive) 32%, transparent)}}.workflow-step-active .workflow-step-badge{color:var(--pb-interactive);background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.workflow-step-active .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-interactive) 12%, transparent)}}.workflow-step-complete .workflow-step-badge{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{border-color:color-mix(in srgb, var(--pb-success) 28%, transparent)}}.workflow-step-complete .workflow-step-badge{color:var(--pb-success);background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.workflow-step-complete .workflow-step-badge{background-color:color-mix(in srgb, var(--pb-success) 12%, transparent)}}.workflow-step-label{--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);--tw-tracking:.12em;letter-spacing:.12em;color:var(--pb-text-dim);text-transform:uppercase}.workflow-step-title{margin-top:var(--spacing);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.checklist-item{align-items:center;gap:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-secondary);display:flex}.section-body{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4)}.section-header{gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-direction:column;display:flex}@media (min-width:40rem){.section-header{flex-direction:row;justify-content:space-between;align-items:flex-start}}.section-header-copy{min-width:0}:where(.section-header-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.section-header-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.section-eyebrow{--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-dim);text-transform:uppercase}.section-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:.14em;letter-spacing:.14em;color:var(--pb-text-primary);text-transform:uppercase}.section-title-plain{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.section-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.settings-connection-list{gap:calc(var(--spacing) * 3);flex-direction:column;display:flex}.settings-connection-card{cursor:pointer;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding:calc(var(--spacing) * 5);text-align:left;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.settings-connection-card:hover{border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.settings-connection-card-muted{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card-muted{background-color:color-mix(in srgb, var(--pb-bg-surface) 70%, transparent)}}.settings-connection-card-muted{opacity:.78}.settings-connection-card .downloads-action-btn.is-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger{border-color:color-mix(in srgb, var(--pb-error) 28%, var(--pb-border-subtle))}}.settings-connection-card .downloads-action-btn.is-danger{background:var(--pb-error-dim);color:var(--pb-error)}.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 44%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{background:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.settings-connection-card .downloads-action-btn.is-danger:hover{background:color-mix(in srgb, var(--pb-error-dim) 86%, transparent)}}.settings-connection-card .downloads-action-btn.is-danger:hover{color:var(--pb-error)}.admin-nav-link{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;border-color:#0000;display:flex}.admin-nav-link:hover{color:var(--pb-text-secondary);background-color:var(--pb-surface-selected)}.admin-nav-link-active{color:var(--pb-interactive);background-color:var(--pb-surface-selected)}.admin-nav-link-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0;justify-content:center;align-items:center;display:flex}.admin-nav-link-active .admin-nav-link-icon{color:var(--pb-interactive)}.admin-nav-link-copy{min-width:0}.admin-nav-link-title{text-overflow:ellipsis;white-space:nowrap;--tw-font-weight:var(--font-weight-medium);font-size:.8125rem;font-weight:var(--font-weight-medium);overflow:hidden}:where(.settings-rows>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--pb-border)}.settings-row{gap:calc(var(--spacing) * 4);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);grid-template-columns:repeat(1,minmax(0,1fr));display:grid}@media (min-width:40rem){.settings-row{grid-template-columns:220px minmax(0,1fr);align-items:flex-start}}.settings-row-meta{padding-top:var(--spacing)}.settings-row-label{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--pb-text-secondary);display:block}.settings-row-help{margin-top:calc(var(--spacing) * .5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);color:var(--pb-text-dim)}.settings-row-content{min-width:0}.settings-rows-align-end .settings-row-content{flex-direction:column;align-items:flex-end;display:flex}.settings-footer{gap:calc(var(--spacing) * 3);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);flex-direction:column;display:flex}@media (min-width:40rem){.settings-footer{flex-direction:row;justify-content:space-between;align-items:center}}.settings-footer{background-color:var(--pb-bg-surface);overflow-anchor:none}@media (min-width:40rem){.settings-footer-end{justify-content:flex-end}}.settings-footer-copy{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--pb-text-dim)}.settings-footer-actions{align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.settings-footer-actions .btn-primary,.settings-footer-actions .btn-ghost,.settings-footer-actions .btn-danger,.settings-footer-actions .btn-warning{min-height:calc(var(--spacing) * 10);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.field-note{margin-top:calc(var(--spacing) * 1.5);align-items:flex-start;gap:calc(var(--spacing) * 1.5);--tw-leading:calc(var(--spacing) * 5);font-size:11px;line-height:calc(var(--spacing) * 5);display:flex}.field-note code{color:var(--pb-text-secondary)}.field-note-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0;width:.75rem;height:.75rem}.field-note-warning{color:var(--pb-warning)}.field-note-info{color:var(--pb-info)}.field-note-danger{color:var(--pb-error)}.field-note-success{color:var(--pb-success)}.alert-banner{gap:calc(var(--spacing) * 4);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);border-width:1px;flex-direction:column;display:flex}@media (min-width:40rem){.alert-banner{flex-direction:row;justify-content:space-between;align-items:flex-start}}.alert-banner{box-shadow:var(--pb-shadow-1)}.alert-banner-main{align-items:flex-start;gap:calc(var(--spacing) * 4);min-width:0;display:flex}.alert-banner-icon{height:calc(var(--spacing) * 10);width:calc(var(--spacing) * 10);border-radius:var(--radius-lg);flex-shrink:0;justify-content:center;align-items:center;display:flex}.alert-banner-copy{min-width:0}:where(.alert-banner-copy>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}.alert-banner-title{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--pb-text-primary)}.alert-banner-description{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6);color:var(--pb-text-secondary)}.alert-banner-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;flex-shrink:0;display:flex}.alert-banner-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{background-color:color-mix(in srgb, var(--pb-info) 10%, var(--pb-bg-card))}}.alert-banner-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info{border-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.alert-banner-info .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-info) 18%, transparent)}}.alert-banner-info .alert-banner-icon{color:var(--pb-info)}.alert-banner-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{background-color:color-mix(in srgb, var(--pb-warning) 10%, var(--pb-bg-card))}}.alert-banner-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning{border-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.alert-banner-warning .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-warning) 18%, transparent)}}.alert-banner-warning .alert-banner-icon{color:var(--pb-warning)}.alert-banner-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{background-color:color-mix(in srgb, var(--pb-error) 10%, var(--pb-bg-card))}}.alert-banner-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger{border-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.alert-banner-danger .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-error) 18%, transparent)}}.alert-banner-danger .alert-banner-icon{color:var(--pb-error)}.alert-banner-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{background-color:color-mix(in srgb, var(--pb-success) 10%, var(--pb-bg-card))}}.alert-banner-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success{border-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.alert-banner-success .alert-banner-icon{background-color:color-mix(in srgb, var(--pb-success) 18%, transparent)}}.alert-banner-success .alert-banner-icon{color:var(--pb-success)}.modal-shell{z-index:50;padding:calc(var(--spacing) * 4);padding-top:calc(var(--spacing) * 16);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.modal-backdrop{z-index:0;background-color:var(--pb-bg-overlay);--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);position:fixed;inset:0}.modal-panel{z-index:10;border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);width:100%;box-shadow:var(--pb-shadow-overlay);flex-direction:column;display:flex;position:relative;overflow:hidden}.modal-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);justify-content:space-between;align-items:center;display:flex}.modal-body{flex:1;overflow-y:auto}.modal-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--pb-border);background-color:var(--pb-bg-card);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 4);flex-shrink:0;justify-content:space-between;align-items:center;display:flex}.donation-modal-shell{align-items:flex-start;padding-top:4.75rem}.donation-modal-panel{border-radius:1.5rem;width:min(100vw - 2rem,56rem);max-height:calc(100dvh - 6rem)}.donation-modal-header{border-bottom-color:var(--pb-border-subtle);background:radial-gradient(circle at top left, var(--pb-brand-dim), transparent 18rem), var(--pb-bg-card);align-items:flex-start;gap:1rem}.donation-modal-kicker{letter-spacing:.18em;text-transform:uppercase;color:var(--pb-brand);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:700}.donation-modal-title{color:var(--pb-text);letter-spacing:-.03em;margin-top:.2rem;font-family:Syne,sans-serif;font-size:clamp(1.35rem,2.5vw,2rem);font-weight:800;line-height:1.05}.donation-modal-copy{max-width:38rem;color:var(--pb-text-sec);margin-top:.45rem;font-size:.9rem;line-height:1.55}.donation-modal-body{background:linear-gradient(135deg, transparent, var(--pb-surface-selected)), var(--pb-bg-surface);gap:1rem;padding:1rem;display:grid}.donation-option-card{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);box-shadow:var(--pb-shadow-1);border-radius:1.25rem;grid-template-columns:minmax(0,1fr) 10.5rem;align-items:stretch;gap:1rem;padding:1rem;display:grid}.donation-option-card--coffee{border-color:#fd0}@supports (color:color-mix(in lab, red, red)){.donation-option-card--coffee{border-color:color-mix(in srgb, #fd0 38%, var(--pb-border-subtle))}}.donation-option-card--liberapay{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-card--liberapay{border-color:color-mix(in srgb, var(--pb-success) 32%, var(--pb-border-subtle))}}.donation-option-main{flex-direction:column;justify-content:space-between;gap:1rem;min-width:0;display:flex}.donation-option-eyebrow{letter-spacing:.16em;text-transform:uppercase;color:var(--pb-text-sec);font-family:JetBrains Mono,monospace;font-size:.64rem;font-weight:700}.donation-option-title{color:var(--pb-text);margin-top:.25rem;font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800}.donation-option-copy{max-width:28rem;color:var(--pb-text-sec);margin-top:.35rem;font-size:.82rem;line-height:1.5}.donation-option-button{border-radius:999px;justify-content:center;align-items:center;width:fit-content;padding:.62rem 1rem;font-size:.82rem;font-weight:800;line-height:1;text-decoration:none;transition:transform .16s,box-shadow .16s,filter .16s;display:inline-flex}.donation-option-button:hover,.donation-option-button:focus-visible{filter:brightness(1.02);transform:translateY(-1px)}.donation-option-button:focus-visible{outline:2px solid var(--pb-focus-outline);outline-offset:3px}.donation-option-button--coffee{color:#16130a;background:#fd0;box-shadow:0 10px 20px #fd03}.donation-option-button--liberapay{background:var(--pb-success);color:#f7f1e8;box-shadow:0 10px 20px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.donation-option-button--liberapay{box-shadow:0 10px 20px color-mix(in srgb, var(--pb-success) 20%, transparent)}}[data-theme=dark] .donation-option-button--liberapay{color:#16130a}.donation-qr-frame{border:1px solid var(--pb-border-subtle);background:#fff;border-radius:1rem;place-items:center;min-height:10.5rem;padding:.625rem;display:grid}.donation-qr{object-fit:contain;width:9.25rem;height:9.25rem}@media (max-width:720px){.donation-modal-shell{padding-top:3.25rem}.donation-modal-panel{max-height:calc(100dvh - 4rem)}.donation-option-card{grid-template-columns:1fr}.donation-option-button{width:100%}}.issue-search-modal-panel{border-radius:1.25rem;width:min(100vw - 2rem,78rem);max-width:none;max-height:90vh}.issue-search-modal-header{border-bottom-color:var(--pb-border-subtle);align-items:flex-start;padding:1.125rem 1.5rem}.issue-search-modal-title{letter-spacing:.03em;text-transform:uppercase;color:var(--pb-text);font-family:Syne,sans-serif;font-size:1.05rem;font-weight:800}.issue-search-modal-title span{color:var(--pb-brand)}.issue-search-modal-subtitle{color:var(--pb-text-dim);margin-top:.1875rem;font-size:.78rem}.issue-search-modal-stats{flex-wrap:wrap;gap:.375rem;margin-top:.5rem;display:flex}.issue-search-modal-close{border-radius:var(--radius-lg);--tw-border-style:none;color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-style:none;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.issue-search-modal-close:hover{color:var(--pb-text-primary)}}.issue-search-modal-close{width:1.875rem;height:1.875rem}.issue-search-modal-close:hover{background:var(--pb-surface-selected)}.issue-search-modal-body{padding:0}.issue-search-loading-state{min-height:24rem}.issue-search-loading-title{color:var(--pb-text);font-size:.82rem;font-weight:600}.issue-search-loading-copy{color:var(--pb-text-dim);font-size:.72rem}.issue-search-dc-status{border-top:1px solid var(--pb-border-subtle);color:var(--pb-text-dim);align-items:center;gap:.625rem;padding:.75rem 1rem;font-size:.78rem;display:flex}.issue-search-dc-results{border-top:1px solid var(--pb-border-subtle)}.issue-search-dc-results-heading{align-items:center;gap:.5rem;padding:.75rem 1rem;display:flex}.issue-search-modal-footer{border-top-color:var(--pb-border-subtle);justify-content:flex-end;padding:.75rem 1.5rem}.issue-search-modal-footer-meta{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.68rem}.issue-search-modal-footer-meta:empty{display:none}.issue-search-modal-footer-meta strong{color:var(--pb-text);font-weight:700}.issue-search-modal-footer-close{margin-left:auto}.inline-alert{align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border-width:1px;display:flex}.inline-alert-icon{margin-top:calc(var(--spacing) * .5);flex-shrink:0}.inline-alert-danger{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{background-color:color-mix(in srgb, var(--pb-error) 12%, var(--pb-bg-card))}}.inline-alert-danger{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.inline-alert-danger{border-color:color-mix(in srgb, var(--pb-error) 22%, transparent)}}.inline-alert-danger{color:var(--pb-error)}.inline-alert-warning{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{background-color:color-mix(in srgb, var(--pb-warning) 12%, var(--pb-bg-card))}}.inline-alert-warning{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.inline-alert-warning{border-color:color-mix(in srgb, var(--pb-warning) 22%, transparent)}}.inline-alert-warning{color:var(--pb-warning)}.inline-alert-info{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{background-color:color-mix(in srgb, var(--pb-info) 12%, var(--pb-bg-card))}}.inline-alert-info{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.inline-alert-info{border-color:color-mix(in srgb, var(--pb-info) 22%, transparent)}}.inline-alert-info{color:var(--pb-info)}.inline-alert-success{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{background-color:color-mix(in srgb, var(--pb-success) 12%, var(--pb-bg-card))}}.inline-alert-success{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.inline-alert-success{border-color:color-mix(in srgb, var(--pb-success) 22%, transparent)}}.inline-alert-success{color:var(--pb-success)}.field-invalid{border-color:var(--pb-error)!important}@supports (color:color-mix(in lab, red, red)){.field-invalid{border-color:color-mix(in srgb, var(--pb-error) 52%, transparent)!important}}.icon-btn{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.icon-btn:hover{border-color:var(--pb-border-strong);color:var(--pb-text-primary)}}.icon-btn:disabled{cursor:not-allowed;opacity:.5}.icon-btn{min-width:var(--pb-control-min-height);width:var(--pb-control-min-height);min-height:var(--pb-control-min-height);border-radius:var(--pb-control-radius);padding:0}.toggle-switch{cursor:pointer;background-color:var(--pb-bg-card-hover);width:2.25rem;height:1.25rem;box-shadow:inset 0 0 0 1px var(--pb-border-hover);border-radius:999px;flex-shrink:0;transition:background-color .18s,box-shadow .18s;display:inline-flex;position:relative}label:has(>.toggle-input+.toggle-switch){position:relative}.toggle-input{clip:rect(0, 0, 0, 0);clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.toggle-input-overlay{opacity:0;clip:auto;clip-path:none;white-space:normal;z-index:1;width:100%;height:100%;margin:0;inset:0}.toggle-switch:after{content:"";background-color:var(--pb-brand);width:1rem;height:1rem;box-shadow:0 1px 2px var(--pb-text-primary);border-radius:999px;position:absolute;top:2px;left:2px}@supports (color:color-mix(in lab, red, red)){.toggle-switch:after{box-shadow:0 1px 2px color-mix(in srgb, var(--pb-text-primary) 14%, transparent)}}.toggle-switch:after{transition:transform .18s}.peer:checked+.toggle-switch{background-color:var(--pb-interactive);box-shadow:inset 0 0 0 1px var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.peer:checked+.toggle-switch{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--pb-interactive) 34%, transparent)}}.peer:checked+.toggle-switch:after{background-color:var(--pb-surface-shell);transform:translate(1rem)}.peer:focus-visible+.toggle-switch{outline:2px solid var(--pb-focus-outline);outline-offset:3px;box-shadow:0 0 0 4px var(--pb-focus-ring)}.strength-segment{height:calc(var(--spacing) * 1.5);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;border-radius:3.40282e38px;flex:1;transition-duration:.3s}.strength-segment-neutral{background-color:var(--pb-bg-card-hover)}.strength-segment-danger{background-color:var(--pb-error)}.strength-segment-warning{background-color:var(--pb-warning)}.strength-segment-success{background-color:var(--pb-success)}.input-pb{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.input-pb::placeholder{color:var(--pb-text-dim)}.input-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.input-pb:focus{outline-offset:2px;outline:2px solid #0000}}.checkbox-pb{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);color:var(--pb-interactive);border-radius:.25rem}.checkbox-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.select-pb{appearance:none;border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-input);width:100%;padding-block:calc(var(--spacing) * 2);padding-right:calc(var(--spacing) * 8);padding-left:calc(var(--spacing) * 3);font-family:DM Sans,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-primary);border-radius:10px}.select-pb:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.select-pb:focus{outline-offset:2px;outline:2px solid #0000}}.select-pb{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%237A8599' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.25rem}.dropdown-select{min-width:0;position:relative}.dropdown-select-trigger{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;display:flex}@media (hover:hover){.dropdown-select-trigger:hover{border-color:var(--pb-border-strong)}}.dropdown-select-trigger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.dropdown-select-trigger:focus{outline-offset:2px;outline:2px solid #0000}}.dropdown-select-trigger:disabled{cursor:not-allowed;opacity:.6}.dropdown-select-trigger{gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-px);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius)}.dropdown-select-trigger-label{text-overflow:ellipsis;white-space:nowrap;text-align:left;flex:1;min-width:0;overflow:hidden}.dropdown-select-chevron{color:var(--pb-text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.dropdown-select-panel{z-index:80;max-height:calc(var(--spacing) * 60);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:fixed;top:0;left:0;overflow-y:auto}@supports (color:color-mix(in lab, red, red)){.dropdown-select-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-select-panel{max-height:var(--pb-dropdown-panel-max-height,15rem);will-change:top, left}.dropdown-select-panel[data-ready=false]{visibility:hidden;pointer-events:none}.dropdown-select-option{text-align:left;width:100%;color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-content:space-between;align-items:center;gap:var(--pb-control-gap);min-height:var(--pb-control-min-height);padding-inline:var(--pb-control-panel-item-px);padding-block:var(--pb-control-panel-item-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);display:flex}.dropdown-select-option-label{text-align:left;white-space:nowrap;overflow-wrap:normal;flex:1;min-width:0}.import-reconcile-issue-dropdown .dropdown-select-trigger{align-items:flex-start;height:auto}.import-reconcile-issue-dropdown .dropdown-select-trigger-label{white-space:normal;overflow-wrap:anywhere;text-overflow:clip;overflow:visible}.dropdown-select-panel-wrap .dropdown-select-option{align-items:flex-start}.dropdown-select-panel-wrap .dropdown-select-option-label{white-space:normal;overflow-wrap:anywhere;word-break:normal}.dropdown-select-option:hover,.dropdown-select-option-active{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.dropdown-select-option-active{transition-duration:0s}.dropdown-select-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary);font-weight:500}.dropdown-select-option-check{width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);flex-shrink:0}.direct-provider-uri-control{position:relative}.direct-provider-uri-input{padding-right:calc(var(--spacing) * 10);font-family:JetBrains Mono,Fira Code,monospace}.direct-provider-uri-toggle{width:calc(var(--spacing) * 10);border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inset-block:0;justify-content:center;align-items:center;display:flex;position:absolute;right:0}@media (hover:hover){.direct-provider-uri-toggle:hover{color:var(--pb-text-primary)}}.direct-provider-uri-toggle:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-toggle:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-toggle:focus{--tw-ring-inset:inset}.direct-provider-uri-options{z-index:90;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);padding-block:var(--spacing);--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0003;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.direct-provider-uri-options{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.direct-provider-uri-option{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:flex}@media (hover:hover){.direct-provider-uri-option:hover{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}}.direct-provider-uri-option:focus{background-color:var(--pb-info-dim);color:var(--pb-text-primary);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.direct-provider-uri-option:focus{outline-offset:2px;outline:2px solid #0000}}.direct-provider-uri-option-selected{background-color:var(--pb-info-dim);color:var(--pb-text-primary)}.search-field{min-width:0;position:relative}.search-field-icon{pointer-events:none;z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);color:var(--pb-text-secondary);top:50%;left:var(--pb-control-search-icon-left);width:var(--pb-control-icon-size);height:var(--pb-control-icon-size);position:absolute}.search-field-input{border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card-hover);width:100%;color:var(--pb-text-primary)}.search-field-input::placeholder{color:var(--pb-text-secondary)}.search-field-input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--pb-interactive);--tw-outline-style:none;border-color:#0000;outline-style:none}@media (forced-colors:active){.search-field-input:focus{outline-offset:2px;outline:2px solid #0000}}.search-field-input{min-height:var(--pb-control-min-height);padding-inline-start:var(--pb-control-search-padding-left);padding-inline-end:var(--pb-control-search-padding-right);padding-block:var(--pb-control-py);font-size:var(--pb-control-font-size);line-height:var(--pb-control-line-height);border-radius:var(--pb-control-radius);appearance:none;background-image:none}.search-field-editor{white-space:nowrap;min-height:42px;overflow:hidden}.search-field-editor[data-empty=true]:before{content:attr(data-placeholder);color:var(--pb-text-secondary)}.search-field-input::-webkit-search-decoration{appearance:none;display:none}.search-field-input::-webkit-search-cancel-button{appearance:none;display:none}.search-field-input::-webkit-search-results-button{appearance:none;display:none}.search-field-input::-webkit-search-results-decoration{appearance:none;display:none}.search-field-input::-ms-clear{width:0;height:0;display:none}.search-field-input::-ms-reveal{width:0;height:0;display:none}.search-field-clear{z-index:10;--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);padding:calc(var(--spacing) * .5);color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:.25rem;position:absolute;top:50%}@media (hover:hover){.search-field-clear:hover{color:var(--pb-text-primary)}}.search-field-clear{right:var(--pb-control-clear-right)}.search-history-panel{z-index:30;margin-top:var(--spacing);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--pb-border-hover);background-color:var(--pb-bg-card);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:#0006;position:absolute;top:100%;left:0;right:0;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-panel{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.search-history-panel-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--pb-border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);justify-content:space-between;align-items:center;display:flex}.search-history-panel-title{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--pb-text-dim);text-transform:uppercase}.search-history-panel-clear{color:var(--pb-text-dim);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));font-size:10px}@media (hover:hover){.search-history-panel-clear:hover{color:var(--pb-error)}}.search-history-list{max-height:280px;padding-block:var(--spacing);overflow-y:auto}.search-history-item{align-items:center;display:flex}.search-history-item-button{align-items:center;gap:calc(var(--spacing) * 2.5);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:left;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--pb-text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:1;display:flex}@media (hover:hover){.search-history-item-button:hover{color:var(--pb-text-primary)}}.search-history-item-button:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.search-history-item-button:hover{background-color:color-mix(in srgb, var(--pb-bg-card-hover) 60%, transparent)}}.search-history-item-remove{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);color:var(--pb-text-dim);opacity:0;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.search-history-item-remove:is(:where(.group):hover *){opacity:1}.search-history-item-remove:hover{color:var(--pb-error)}}.touch-target{min-width:44px;min-height:44px}.progress-track{height:var(--spacing);background-color:var(--pb-border);border-radius:3.40282e38px}.progress-fill{background-color:var(--pb-success);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.progress-fill-partial{background-color:var(--pb-interactive);height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}.callout-brand{align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-xl);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 4);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:var(--pb-brand-signal-dim);border-width:1px;border-color:var(--pb-brand-signal-border);display:flex}.callout-brand:hover{background-color:var(--pb-brand-signal-hover);border-color:var(--pb-brand-signal-border)}.callout-brand-icon{border-radius:var(--radius-lg);padding:calc(var(--spacing) * 2);background-color:var(--pb-brand-signal-dim);color:var(--pb-brand-signal);flex-shrink:0}.callout-brand-title{color:var(--pb-brand-signal)}.callout-brand-body{color:var(--pb-brand-signal);opacity:.7}.callout-brand-chevron{color:var(--pb-brand-signal);opacity:.5}}@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.-top-3{top:calc(var(--spacing) * -3)}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.-right-1{right:calc(var(--spacing) * -1)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.left-0{left:0}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[60\]{z-index:60}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.col-span-2{grid-column:span 2/span 2}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.\!mt-0{margin-top:0!important}.\!mt-2{margin-top:calc(var(--spacing) * 2)!important}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.\!mb-0{margin-bottom:0!important}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[140px\]{margin-left:140px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-4\.5{height:calc(var(--spacing) * 4.5)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-96{height:calc(var(--spacing) * 96)}.h-\[18px\]{height:18px}.h-\[100dvh\]{height:100dvh}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[62vh\]{max-height:62vh}.max-h-\[70vh\]{max-height:70vh}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[95vh\]{max-height:95vh}.max-h-\[calc\(100vh-5rem\)\]{max-height:calc(100vh - 5rem)}.\!min-h-8{min-height:calc(var(--spacing) * 8)!important}.\!min-h-10{min-height:calc(var(--spacing) * 10)!important}.min-h-0{min-height:0}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-56{min-height:calc(var(--spacing) * 56)}.min-h-\[36px\]{min-height:36px}.min-h-\[200px\]{min-height:200px}.\!w-8{width:calc(var(--spacing) * 8)!important}.\!w-10{width:calc(var(--spacing) * 10)!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-4\.5{width:calc(var(--spacing) * 4.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[10\%\]{width:10%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[18px\]{width:18px}.w-\[22rem\]{width:22rem}.w-\[24\%\]{width:24%}.w-\[26\%\]{width:26%}.w-\[28px\]{width:28px}.w-\[28rem\]{width:28rem}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[52px\]{width:52px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[68px\]{width:68px}.w-\[72px\]{width:72px}.w-\[76px\]{width:76px}.w-\[80px\]{width:80px}.w-\[82px\]{width:82px}.w-\[84px\]{width:84px}.w-\[88px\]{width:88px}.w-\[90px\]{width:90px}.w-\[92px\]{width:92px}.w-\[96px\]{width:96px}.w-\[98px\]{width:98px}.w-\[100px\]{width:100px}.w-\[104px\]{width:104px}.w-\[108px\]{width:108px}.w-\[110px\]{width:110px}.w-\[112px\]{width:112px}.w-\[116px\]{width:116px}.w-\[118px\]{width:118px}.w-\[120px\]{width:120px}.w-\[128px\]{width:128px}.w-\[136px\]{width:136px}.w-\[140px\]{width:140px}.w-\[148px\]{width:148px}.w-\[152px\]{width:152px}.w-\[156px\]{width:156px}.w-\[168px\]{width:168px}.w-\[180px\]{width:180px}.w-\[240px\]{width:240px}.w-auto{width:auto}.w-full{width:100%}.max-w-0{max-width:0}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[140px\]{max-width:140px}.max-w-\[150px\]{max-width:150px}.max-w-\[160px\]{max-width:160px}.max-w-\[220px\]{max-width:220px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[300px\]{max-width:300px}.max-w-\[320px\]{max-width:320px}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-7{min-width:calc(var(--spacing) * 7)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-\[11\.5rem\]{min-width:11.5rem}.min-w-\[20px\]{min-width:20px}.min-w-\[48rem\]{min-width:48rem}.min-w-\[54rem\]{min-width:54rem}.min-w-\[60px\]{min-width:60px}.min-w-\[440px\]{min-width:440px}.min-w-\[620px\]{min-width:620px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[880px\]{min-width:880px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1040px\]{min-width:1040px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.table-fixed{table-layout:fixed}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-pb-border>:not(:last-child)){border-color:var(--pb-border)}:where(.divide-pb-border-subtle>:not(:last-child)){border-color:var(--pb-border-subtle)}:where(.divide-pb-border\/40>:not(:last-child)){border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-pb-border\/40>:not(:last-child)){border-color:color-mix(in oklab, var(--pb-border) 40%, transparent)}}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.\!rounded-none{border-radius:0!important}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[var\(--radius-card\)\]{border-radius:var(--radius-card)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-\[3px\]{border-style:var(--tw-border-style);border-width:3px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-pb-warning\/35{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.\!border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)!important}}.border-current\/15{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/15{border-color:color-mix(in oklab, currentcolor 15%, transparent)}}.border-pb-border{border-color:var(--pb-border)}.border-pb-border-hover,.border-pb-border-hover\/50{border-color:var(--pb-border-hover)}@supports (color:color-mix(in lab, red, red)){.border-pb-border-hover\/50{border-color:color-mix(in oklab, var(--pb-border-hover) 50%, transparent)}}.border-pb-border-subtle{border-color:var(--pb-border-subtle)}.border-pb-border\/50{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/50{border-color:color-mix(in oklab, var(--pb-border) 50%, transparent)}}.border-pb-border\/70{border-color:var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.border-pb-border\/70{border-color:color-mix(in oklab, var(--pb-border) 70%, transparent)}}.border-pb-error,.border-pb-error\/30{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/30{border-color:color-mix(in oklab, var(--pb-error) 30%, transparent)}}.border-pb-error\/35{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/35{border-color:color-mix(in oklab, var(--pb-error) 35%, transparent)}}.border-pb-error\/40{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.border-pb-error\/40{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.border-pb-info\/30{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/30{border-color:color-mix(in oklab, var(--pb-info) 30%, transparent)}}.border-pb-info\/40{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.border-pb-info\/40{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.border-pb-interactive,.border-pb-interactive\/25{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/25{border-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.border-pb-interactive\/30{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/30{border-color:color-mix(in oklab, var(--pb-interactive) 30%, transparent)}}.border-pb-interactive\/40{border-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.border-pb-interactive\/40{border-color:color-mix(in oklab, var(--pb-interactive) 40%, transparent)}}.border-pb-success\/30{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/30{border-color:color-mix(in oklab, var(--pb-success) 30%, transparent)}}.border-pb-success\/35{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/35{border-color:color-mix(in oklab, var(--pb-success) 35%, transparent)}}.border-pb-success\/40{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.border-pb-success\/40{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.border-pb-surface{border-color:var(--pb-bg-surface)}.border-pb-warning\/20{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/20{border-color:color-mix(in oklab, var(--pb-warning) 20%, transparent)}}.border-pb-warning\/25{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/25{border-color:color-mix(in oklab, var(--pb-warning) 25%, transparent)}}.border-pb-warning\/30{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/30{border-color:color-mix(in oklab, var(--pb-warning) 30%, transparent)}}.border-pb-warning\/35{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/35{border-color:color-mix(in oklab, var(--pb-warning) 35%, transparent)}}.border-pb-warning\/40{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/40{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.border-pb-warning\/50{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.border-pb-warning\/50{border-color:color-mix(in oklab, var(--pb-warning) 50%, transparent)}}.border-transparent{border-color:#0000}.border-t-pb-interactive{border-top-color:var(--pb-interactive)}.\!bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)!important}@supports (color:color-mix(in lab, red, red)){.\!bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)!important}}.\!bg-transparent{background-color:#0000!important}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-pb-base{background-color:var(--pb-bg-base)}.bg-pb-card{background-color:var(--pb-bg-card)}.bg-pb-card-hover,.bg-pb-card-hover\/20{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/20{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.bg-pb-card-hover\/25{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/25{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 25%, transparent)}}.bg-pb-card-hover\/35{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/35{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 35%, transparent)}}.bg-pb-card-hover\/40{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/40{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 40%, transparent)}}.bg-pb-card-hover\/45{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/45{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.bg-pb-card-hover\/50{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/50{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 50%, transparent)}}.bg-pb-card-hover\/60{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card-hover\/60{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 60%, transparent)}}.bg-pb-card\/40{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/40{background-color:color-mix(in oklab, var(--pb-bg-card) 40%, transparent)}}.bg-pb-card\/50{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/50{background-color:color-mix(in oklab, var(--pb-bg-card) 50%, transparent)}}.bg-pb-card\/60{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/60{background-color:color-mix(in oklab, var(--pb-bg-card) 60%, transparent)}}.bg-pb-card\/70{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/70{background-color:color-mix(in oklab, var(--pb-bg-card) 70%, transparent)}}.bg-pb-card\/80{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/80{background-color:color-mix(in oklab, var(--pb-bg-card) 80%, transparent)}}.bg-pb-card\/85{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/85{background-color:color-mix(in oklab, var(--pb-bg-card) 85%, transparent)}}.bg-pb-card\/95{background-color:var(--pb-bg-card)}@supports (color:color-mix(in lab, red, red)){.bg-pb-card\/95{background-color:color-mix(in oklab, var(--pb-bg-card) 95%, transparent)}}.bg-pb-error{background-color:var(--pb-error)}.bg-pb-error-dim,.bg-pb-error-dim\/70{background-color:var(--pb-error-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error-dim\/70{background-color:color-mix(in oklab, var(--pb-error-dim) 70%, transparent)}}.bg-pb-error\/10{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/10{background-color:color-mix(in oklab, var(--pb-error) 10%, transparent)}}.bg-pb-error\/15{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.bg-pb-error\/15{background-color:color-mix(in oklab, var(--pb-error) 15%, transparent)}}.bg-pb-info{background-color:var(--pb-info)}.bg-pb-info-dim{background-color:var(--pb-info-dim)}.bg-pb-info\/10{background-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.bg-pb-info\/10{background-color:color-mix(in oklab, var(--pb-info) 10%, transparent)}}.bg-pb-input{background-color:var(--pb-bg-input)}.bg-pb-interactive{background-color:var(--pb-interactive)}.bg-pb-interactive-dim{background-color:var(--pb-interactive-dim)}.bg-pb-interactive\/15{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.bg-pb-interactive\/15{background-color:color-mix(in oklab, var(--pb-interactive) 15%, transparent)}}.bg-pb-overlay,.bg-pb-overlay\/45{background-color:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.bg-pb-overlay\/45{background-color:color-mix(in oklab, var(--pb-bg-overlay) 45%, transparent)}}.bg-pb-purple-dim{background-color:var(--pb-purple-dim)}.bg-pb-success{background-color:var(--pb-success)}.bg-pb-success-dim,.bg-pb-success-dim\/70{background-color:var(--pb-success-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success-dim\/70{background-color:color-mix(in oklab, var(--pb-success-dim) 70%, transparent)}}.bg-pb-success\/10{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/10{background-color:color-mix(in oklab, var(--pb-success) 10%, transparent)}}.bg-pb-success\/15{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/15{background-color:color-mix(in oklab, var(--pb-success) 15%, transparent)}}.bg-pb-surface,.bg-pb-surface\/30{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/30{background-color:color-mix(in oklab, var(--pb-bg-surface) 30%, transparent)}}.bg-pb-surface\/40{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/40{background-color:color-mix(in oklab, var(--pb-bg-surface) 40%, transparent)}}.bg-pb-surface\/50{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/50{background-color:color-mix(in oklab, var(--pb-bg-surface) 50%, transparent)}}.bg-pb-surface\/60{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/60{background-color:color-mix(in oklab, var(--pb-bg-surface) 60%, transparent)}}.bg-pb-surface\/80{background-color:var(--pb-bg-surface)}@supports (color:color-mix(in lab, red, red)){.bg-pb-surface\/80{background-color:color-mix(in oklab, var(--pb-bg-surface) 80%, transparent)}}.bg-pb-text-dim{background-color:var(--pb-text-dim)}.bg-pb-warning{background-color:var(--pb-warning)}.bg-pb-warning-dim,.bg-pb-warning-dim\/45{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/45{background-color:color-mix(in oklab, var(--pb-warning-dim) 45%, transparent)}}.bg-pb-warning\/5{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/5{background-color:color-mix(in oklab, var(--pb-warning) 5%, transparent)}}.bg-pb-warning\/10{background-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning\/10{background-color:color-mix(in oklab, var(--pb-warning) 10%, transparent)}}.bg-slate-950\/70{background-color:#020618b3}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/70{background-color:color-mix(in oklab, var(--color-slate-950) 70%, transparent)}}.bg-transparent{background-color:#0000}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.\!p-0{padding:0!important}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-0{padding-inline:0!important}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.\!px-3{padding-inline:calc(var(--spacing) * 3)!important}.\!px-4{padding-inline:calc(var(--spacing) * 4)!important}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.\!py-0{padding-block:0!important}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.\!py-2{padding-block:calc(var(--spacing) * 2)!important}.\!py-2\.5{padding-block:calc(var(--spacing) * 2.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-\[3px\]{padding-block:3px}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pr-1{padding-right:var(--spacing)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.\!pb-0{padding-bottom:0!important}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.\!text-left{text-align:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-display{font-family:Bricolage Grotesque,sans-serif}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.font-sans{font-family:DM Sans,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.78rem\]{font-size:.78rem}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.88rem\]{font-size:.88rem}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.6\]{--tw-leading:1.6;line-height:1.6}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.14em\]{--tw-tracking:.14em;letter-spacing:.14em}.tracking-\[0\.16em\]{--tw-tracking:.16em;letter-spacing:.16em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-\[0\.22em\]{--tw-tracking:.22em;letter-spacing:.22em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-pb-text{color:var(--pb-text-primary)!important}.\!text-pb-warning{color:var(--pb-warning)!important}.text-pb-border{color:var(--pb-border)}.text-pb-brand{color:var(--pb-brand)}.text-pb-error{color:var(--pb-error)}.text-pb-info{color:var(--pb-info)}.text-pb-interactive{color:var(--pb-interactive)}.text-pb-purple{color:var(--pb-purple)}.text-pb-success{color:var(--pb-success)}.text-pb-text{color:var(--pb-text-primary)}.text-pb-text-dim{color:var(--pb-text-dim)}.text-pb-text-inverse{color:var(--pb-text-inverse)}.text-pb-text-sec{color:var(--pb-text-secondary)}.text-pb-warning,.text-pb-warning\/70{color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.text-pb-warning\/70{color:color-mix(in oklab, var(--pb-warning) 70%, transparent)}}.text-red-300{color:var(--color-red-300)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.accent-pb-interactive{accent-color:var(--pb-interactive)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-\[1px\]{--tw-backdrop-blur:blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.delay-75{transition-delay:75ms}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:border-pb-text-sec:is(:where(.group):hover *){border-color:var(--pb-text-secondary)}.group-hover\:text-pb-interactive:is(:where(.group):hover *){color:var(--pb-interactive)}.group-hover\:text-pb-text:is(:where(.group):hover *){color:var(--pb-text-primary)}.group-hover\:text-pb-text-sec:is(:where(.group):hover *){color:var(--pb-text-secondary)}.group-hover\:opacity-40:is(:where(.group):hover *){opacity:.4}}.placeholder\:text-pb-text-dim::placeholder{color:var(--pb-text-dim)}.placeholder\:text-pb-text-sec::placeholder{color:var(--pb-text-secondary)}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:\!border-pb-warning\/45:hover{border-color:var(--pb-warning)!important}@supports (color:color-mix(in lab, red, red)){.hover\:\!border-pb-warning\/45:hover{border-color:color-mix(in oklab, var(--pb-warning) 45%, transparent)!important}}.hover\:border-pb-border-hover:hover{border-color:var(--pb-border-hover)}.hover\:border-pb-border-strong:hover{border-color:var(--pb-border-strong)}.hover\:border-pb-error\/40:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-error\/40:hover{border-color:color-mix(in oklab, var(--pb-error) 40%, transparent)}}.hover\:border-pb-info\/40:hover{border-color:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-info\/40:hover{border-color:color-mix(in oklab, var(--pb-info) 40%, transparent)}}.hover\:border-pb-purple\/40:hover{border-color:var(--pb-purple)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-purple\/40:hover{border-color:color-mix(in oklab, var(--pb-purple) 40%, transparent)}}.hover\:border-pb-success\/40:hover{border-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-success\/40:hover{border-color:color-mix(in oklab, var(--pb-success) 40%, transparent)}}.hover\:border-pb-warning\/40:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.hover\:border-pb-warning\/40:hover{border-color:color-mix(in oklab, var(--pb-warning) 40%, transparent)}}.hover\:\!bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)!important}.hover\:\!bg-transparent:hover{background-color:#0000!important}.hover\:bg-pb-card:hover{background-color:var(--pb-bg-card)}.hover\:bg-pb-card-hover:hover,.hover\:bg-pb-card-hover\/20:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/20:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 20%, transparent)}}.hover\:bg-pb-card-hover\/45:hover{background-color:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-card-hover\/45:hover{background-color:color-mix(in oklab, var(--pb-bg-card-hover) 45%, transparent)}}.hover\:bg-pb-error-dim:hover{background-color:var(--pb-error-dim)}.hover\:bg-pb-error\/25:hover{background-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-error\/25:hover{background-color:color-mix(in oklab, var(--pb-error) 25%, transparent)}}.hover\:bg-pb-info-dim:hover{background-color:var(--pb-info-dim)}.hover\:bg-pb-interactive-hover:hover{background-color:var(--pb-interactive-hover)}.hover\:bg-pb-interactive\/10:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/10:hover{background-color:color-mix(in oklab, var(--pb-interactive) 10%, transparent)}}.hover\:bg-pb-interactive\/25:hover{background-color:var(--pb-interactive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-interactive\/25:hover{background-color:color-mix(in oklab, var(--pb-interactive) 25%, transparent)}}.hover\:bg-pb-purple-dim:hover{background-color:var(--pb-purple-dim)}.hover\:bg-pb-success-dim:hover{background-color:var(--pb-success-dim)}.hover\:bg-pb-success\/25:hover{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-pb-success\/25:hover{background-color:color-mix(in oklab, var(--pb-success) 25%, transparent)}}.hover\:bg-pb-warning-dim:hover{background-color:var(--pb-warning-dim)}.hover\:\!text-pb-text:hover{color:var(--pb-text-primary)!important}.hover\:\!text-pb-warning:hover{color:var(--pb-warning)!important}.hover\:text-pb-error:hover{color:var(--pb-error)}.hover\:text-pb-interactive:hover{color:var(--pb-interactive)}.hover\:text-pb-interactive-hover:hover{color:var(--pb-interactive-hover)}.hover\:text-pb-text:hover{color:var(--pb-text-primary)}.hover\:text-pb-text-sec:hover{color:var(--pb-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-95:hover{opacity:.95}.hover\:opacity-100:hover{opacity:1}.hover\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-pb-border-hover:focus{--tw-ring-color:var(--pb-border-hover)}.focus\:ring-pb-error:focus{--tw-ring-color:var(--pb-error)}.focus\:ring-pb-interactive:focus{--tw-ring-color:var(--pb-interactive)}.focus\:ring-pb-warning:focus{--tw-ring-color:var(--pb-warning)}.focus\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-pb-card:focus{--tw-ring-offset-color:var(--pb-bg-card)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:cursor-wait:disabled{cursor:wait}.disabled\:opacity-20:disabled{opacity:.2}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-100:disabled{opacity:1}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-44{width:calc(var(--spacing) * 44)}.sm\:w-48{width:calc(var(--spacing) * 48)}.sm\:w-56{width:calc(var(--spacing) * 56)}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-\[760px\]{min-width:760px}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[8rem_minmax\(0\,1fr\)\]{grid-template-columns:8rem minmax(0,1fr)}.sm\:grid-cols-\[9rem_minmax\(0\,1fr\)\]{grid-template-columns:9rem minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.4fr\)_auto\]{grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto}.sm\:grid-cols-\[minmax\(0\,3fr\)_minmax\(0\,2fr\)\]{grid-template-columns:minmax(0,3fr) minmax(0,2fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:pt-5{padding-top:calc(var(--spacing) * 5)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:pt-7{padding-top:calc(var(--spacing) * 7)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto_1fr_auto\]{grid-template-columns:1fr auto 1fr auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-center{justify-content:center}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-auto{width:auto}.lg\:max-w-\[11rem\]{max-width:11rem}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-\[920px\]{min-width:920px}.lg\:min-w-\[980px\]{min-width:980px}.lg\:min-w-\[1060px\]{min-width:1060px}.lg\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,1\.1fr\)_minmax\(280px\,0\.9fr\)\]{grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr)}.lg\:grid-cols-\[minmax\(0\,1\.15fr\)_minmax\(280px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr)}.lg\:grid-cols-\[minmax\(0\,1\.25fr\)_minmax\(280px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(16rem\,0\.65fr\)\]{grid-template-columns:minmax(0,1fr) minmax(16rem,.65fr)}.lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(18rem\,24rem\)\]{grid-template-columns:minmax(0,1fr) minmax(18rem,24rem)}.lg\:grid-cols-\[minmax\(0\,280px\)_minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,280px) minmax(0,1fr)}.lg\:grid-cols-\[minmax\(12rem\,0\.7fr\)_minmax\(18rem\,1\.3fr\)\]{grid-template-columns:minmax(12rem,.7fr) minmax(18rem,1.3fr)}.lg\:grid-cols-\[repeat\(5\,minmax\(0\,1fr\)\)\]{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:text-right{text-align:right}}@media (min-width:80rem){.xl\:col-span-3{grid-column:span 3/span 3}.xl\:w-32{width:calc(var(--spacing) * 32)}.xl\:w-48{width:calc(var(--spacing) * 48)}.xl\:w-\[34rem\]{width:34rem}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:min-w-\[30rem\]{min-width:30rem}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.xl\:grid-cols-\[minmax\(0\,1\.4fr\)_minmax\(320px\,0\.8fr\)\]{grid-template-columns:minmax(0,1.4fr) minmax(320px,.8fr)}.xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(17rem\,1fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(17rem,1fr)}.xl\:grid-cols-\[minmax\(0\,1\.55fr\)_minmax\(320px\,0\.85fr\)\]{grid-template-columns:minmax(0,1.55fr) minmax(320px,.85fr)}.xl\:flex-row{flex-direction:row}.xl\:items-end{align-items:flex-end}.xl\:items-start{align-items:flex-start}.xl\:justify-between{justify-content:space-between}.xl\:justify-end{justify-content:flex-end}}@media (min-width:96rem){.\32 xl\:grid-cols-\[minmax\(0\,1\.45fr\)_minmax\(320px\,0\.75fr\)\]{grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr)}}}@font-face{font-family:Bricolage Grotesque;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/bricolage-grotesque-800.woff2)format("woff2")}@font-face{font-family:DM Sans;font-style:normal;font-weight:300 700;font-display:swap;src:url(/static/fonts/dm-sans-variable.woff2)format("woff2")}@font-face{font-family:Syne;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/syne-400.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/syne-500.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/syne-600.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/syne-700.ttf)format("truetype")}@font-face{font-family:Syne;font-style:normal;font-weight:800;font-display:swap;src:url(/static/fonts/syne-800.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/jetbrains-mono-400.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/static/fonts/jetbrains-mono-500.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/jetbrains-mono-600.ttf)format("truetype")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/jetbrains-mono-700.ttf)format("truetype")}.no-transitions,.no-transitions *,.no-transitions :before,.no-transitions :after,.boot-no-transitions,.boot-no-transitions *,.boot-no-transitions :before,.boot-no-transitions :after{transition-duration:0s!important}.safe-area-pb{padding-bottom:env(safe-area-inset-bottom,0px)}.skip-link{z-index:100;background:var(--pb-brand);color:var(--pb-text-inverse);border-radius:0 0 .5rem .5rem;padding:.5rem 1rem;font-size:.875rem;font-weight:600;text-decoration:none;transition:top .15s ease-out;position:absolute;top:-100%;left:1rem}.skip-link:focus{outline:2px solid var(--pb-interactive);outline-offset:2px;top:0}input[type=range].range-pb{appearance:none;background:var(--pb-border-hover);cursor:pointer;border-radius:9999px;outline:none;height:6px}input[type=range].range-pb::-webkit-slider-thumb{appearance:none;background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}input[type=range].range-pb::-moz-range-thumb{background:var(--pb-interactive);border:2px solid var(--pb-bg-card);width:18px;height:18px;box-shadow:var(--pb-shadow-1);cursor:pointer;border-radius:50%}.scrollbar-hidden{scrollbar-width:none;-ms-overflow-style:none}.scrollbar-hidden::-webkit-scrollbar{display:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--pb-bg-surface)}::-webkit-scrollbar-thumb{background:var(--pb-border-strong);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--pb-text-dim)}*{scrollbar-width:thin;scrollbar-color:var(--pb-border-strong) var(--pb-bg-surface)}.htmx-indicator{opacity:0;transition:opacity .2s ease-in}.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}#content[data-page-swap-phase=leaving],#content[data-page-swap-phase=entering]{pointer-events:none}#content[data-detail-history-hidden=true]{opacity:0;pointer-events:none}@media (prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}#content{transition:none}}@keyframes toast-enter{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toast-exit{0%{opacity:1;transform:translate(0)}to{opacity:0;transform:translate(100%)}}.toast-enter{animation:.3s ease-out forwards toast-enter}.toast-exit{animation:.3s ease-in forwards toast-exit}.sidebar-transition{transition:width .2s ease-in-out,transform .2s ease-in-out}.app-sidebar-backdrop{background:var(--pb-bg-overlay)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-backdrop{background:color-mix(in srgb, var(--pb-bg-overlay) 96%, transparent)}}.app-sidebar-shell{border-right:1px solid var(--pb-border);background:var(--pb-bg-surface);box-shadow:none;overflow:visible}.app-sidebar-brand{background:0 0;padding:1rem 1rem .75rem}.app-sidebar-brand-collapsed{background:0 0;justify-content:center;padding:1rem 0 .75rem;display:flex}.app-sidebar-brand-copy-collapsed{display:none!important}.app-sidebar-brand-card{align-items:center;gap:.75rem;padding:0;transition:color .18s,transform .18s;display:flex}.app-sidebar-brand-card-collapsed{justify-content:center;width:auto;min-width:0;min-height:0;padding:0}.app-sidebar-brand:hover .app-sidebar-brand-card{transform:translateY(-1px)}.app-sidebar-brand-collapsed:hover .app-sidebar-brand-card{transform:none}.app-sidebar-brand-mark{flex-shrink:0;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:flex}.app-sidebar-brand-copy{white-space:nowrap;flex-direction:column;min-width:0;display:flex;overflow:hidden}.app-sidebar-brand-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.1rem;font-weight:800;line-height:1}.app-sidebar-brand-version{color:var(--pb-text-tertiary);margin-top:2px;font-family:JetBrains Mono,monospace;font-size:.58rem}.app-sidebar-nav{padding:.25rem .625rem 1rem}.app-sidebar-nav-collapsed{padding:.25rem 0 1rem;overflow:visible!important}.app-sidebar-link{color:var(--pb-text-secondary);border:0;border-radius:.5rem;align-items:center;gap:.625rem;margin:1px 0;padding:.4375rem .625rem;font-size:.82rem;font-weight:500;transition:background-color .18s,color .18s,transform .18s;display:flex;position:relative}.app-sidebar-link-collapsed{justify-content:center;gap:0;width:2.75rem;margin-inline:auto;padding:.5rem}.app-sidebar-link-copy-collapsed{display:none!important}.app-sidebar-link:hover{background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.app-sidebar-link:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.app-sidebar-link:hover{color:var(--pb-text-primary)}.app-sidebar-link-collapsed:hover{transform:none}.app-sidebar-link-active{background:var(--pb-selected);color:var(--pb-interactive);font-weight:600}.app-sidebar-link-active:before{content:"";background:var(--pb-interactive);border-radius:0 2px 2px 0;width:3px;position:absolute;top:6px;bottom:6px;left:0}.app-sidebar-link-icon{width:1.25rem;height:1.25rem;color:inherit;flex-shrink:0;justify-content:center;align-items:center;transition:color .18s;display:flex}.app-sidebar-link-copy{flex:1;justify-content:space-between;align-items:center;gap:.75rem;min-width:0;display:flex}.app-sidebar-link-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.82rem;font-weight:inherit;overflow:hidden}.app-sidebar-badge-slot{flex-shrink:0;justify-content:flex-end;align-items:center;min-width:1.125rem;display:flex}.app-sidebar-badge-slot .count-badge{border-radius:6px;min-width:18px;min-height:auto;padding:1px 6px;font-family:JetBrains Mono,monospace;font-size:.6rem;font-weight:700;line-height:1.1}.app-sidebar-link-collapsed .app-sidebar-badge-slot{min-width:0;position:absolute;top:2px;right:2px}.app-sidebar-link-collapsed .app-sidebar-badge-slot .count-badge{border-radius:4px;min-width:14px;padding:0 4px;font-size:.52rem}.app-sidebar-section{margin:1rem 0 0}.app-sidebar-section-collapsed{width:3rem;margin:.75rem auto .375rem}.app-sidebar-section-label{letter-spacing:.14em;text-transform:uppercase;color:var(--pb-text-tertiary);align-items:center;gap:.375rem;margin-bottom:.25rem;padding:0 .5rem;font-family:Syne,sans-serif;font-size:.54rem;font-weight:700;display:flex}.app-sidebar-section-label-collapsed{display:none!important}.app-sidebar-section-label:after{content:"";background:var(--pb-border-subtle);flex:auto;height:1px}.app-sidebar-section-rule{border-color:var(--pb-border-subtle);width:1.5rem;margin:.5rem auto 0}.app-sidebar-toggle{z-index:70;border:1px solid var(--pb-border-hover);background:var(--pb-bg-card);width:1.75rem;height:1.75rem;color:var(--pb-text-secondary);pointer-events:auto;box-shadow:var(--pb-shadow-1);border-radius:999px;justify-content:center;align-items:center;transition:border-color .18s,background-color .18s,color .18s,box-shadow .18s;position:absolute;top:72px;right:-.75rem}.app-sidebar-toggle[data-tip]{position:absolute}.app-sidebar-toggle:hover{border-color:var(--pb-interactive);background:var(--pb-bg-card);color:var(--pb-interactive);box-shadow:var(--pb-shadow-2)}[data-tip]{--pb-tooltip-max-width:min(360px, calc(100vw - 32px), calc(100dvw - 32px));position:relative}.tooltips-overlay-enabled [data-tip]:hover:after,.tooltips-overlay-enabled [data-tip]:focus-visible:after{content:none;display:none}[data-tip]:hover:after,[data-tip]:focus-visible:after{content:attr(data-tip);width:max-content;max-width:var(--pb-tooltip-max-width);background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);z-index:50;pointer-events:none;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;animation:.15s ease-out tip-in;position:absolute;bottom:calc(100% + 6px);left:0}.app-tooltip-host{z-index:140;pointer-events:none;position:fixed;inset:0}.app-tooltip-overlay{width:max-content;max-width:var(--pb-tooltip-max-width,min(360px, calc(100vw - 32px), calc(100dvw - 32px)));background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);opacity:0;visibility:hidden;will-change:left, top, opacity;border-radius:8px;padding:6px 10px;font-size:11px;line-height:1.4;transition:opacity .12s ease-out,visibility .12s ease-out;position:absolute;top:0;left:0;transform:translate(0)}.app-tooltip-host[data-visible=true] .app-tooltip-overlay{opacity:1;visibility:visible}[data-search-field-clear][data-tip]{position:absolute}[data-tip-pos=left]:hover:after,[data-tip-pos=left]:focus-visible:after{animation:.15s ease-out tip-in-left;inset:50% calc(100% + 6px) auto auto;transform:translateY(-50%)}[data-tip-pos=right]:hover:after,[data-tip-pos=right]:focus-visible:after{animation:.15s ease-out tip-in-right;inset:50% auto auto calc(100% + 6px);transform:translateY(-50%)}[data-tip-pos=bottom]:hover:after,[data-tip-pos=bottom]:focus-visible:after{animation:.15s ease-out tip-in-bottom;top:calc(100% + 6px);bottom:auto;left:0}[data-tip-size=wide]:hover:after,[data-tip-size=wide]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(480px, calc(100vw - 32px), calc(100dvw - 32px)))}[data-tip-size=narrow]:hover:after,[data-tip-size=narrow]:focus-visible:after{max-width:var(--pb-tooltip-max-width,min(220px, calc(100vw - 32px), calc(100dvw - 32px)))}.app-header-icon-tip:hover:after,.app-header-icon-tip:focus-visible:after{text-align:center;min-width:7.25rem}.tooltip-wrap{position:relative}.tooltip-panel{z-index:50;visibility:hidden;opacity:0;pointer-events:none;background:var(--pb-bg-card);border:1px solid var(--pb-border-hover);width:max-content;max-width:360px;color:var(--pb-text-primary);white-space:normal;overflow-wrap:break-word;box-shadow:var(--pb-shadow-overlay);border-radius:.5rem;padding:.5rem .75rem;font-size:.75rem;line-height:1.4;transition:opacity .15s ease-out,transform .15s ease-out,visibility .15s ease-out;position:absolute}.tooltip-wrap:hover>.tooltip-panel,.tooltip-wrap:focus-within>.tooltip-panel{visibility:visible;opacity:1}.tooltip-panel-top{bottom:calc(100% + .5rem);left:0;transform:translateY(4px)}.tooltip-wrap:hover>.tooltip-panel-top,.tooltip-wrap:focus-within>.tooltip-panel-top{transform:translateY(0)}.tooltip-panel-left{top:50%;right:calc(100% + .5rem);transform:translate(4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-left,.tooltip-wrap:focus-within>.tooltip-panel-left{transform:translateY(-50%)}.tooltip-panel-right{top:50%;left:calc(100% + .5rem);transform:translate(-4px,-50%)}.tooltip-wrap:hover>.tooltip-panel-right,.tooltip-wrap:focus-within>.tooltip-panel-right{transform:translateY(-50%)}.tooltip-panel-bottom{top:calc(100% + .5rem);left:0;transform:translateY(-4px)}.tooltip-wrap:hover>.tooltip-panel-bottom,.tooltip-wrap:focus-within>.tooltip-panel-bottom{transform:translateY(0)}.tooltip-panel-nowrap{white-space:nowrap}.tooltip-panel-wide{max-width:500px}@keyframes tip-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes tip-in-left{0%{opacity:0;transform:translate(4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-right{0%{opacity:0;transform:translate(-4px,-50%)}to{opacity:1;transform:translateY(-50%)}}@keyframes tip-in-bottom{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.log-terminal{contain:inline-size;background:var(--pb-bg-surface);width:100%;min-width:0;max-width:100%;overflow-x:hidden}@supports (color:color-mix(in lab, red, red)){.log-terminal{background:color-mix(in srgb, var(--pb-bg-surface) 82%, var(--pb-bg-base))}}.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, var(--pb-border-subtle) 2px, var(--pb-border-subtle) 4px)}@supports (color:color-mix(in lab, red, red)){.log-terminal{background-image:repeating-linear-gradient(0deg, transparent, transparent 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 2px, color-mix(in srgb, var(--pb-border-subtle) 32%, transparent) 4px)}}.log-line{box-sizing:border-box;border-left:3px solid #0000;width:100%;min-width:0;max-width:100%;overflow:hidden}.log-line:hover{background:var(--pb-bg-card-hover)}@supports (color:color-mix(in lab, red, red)){.log-line:hover{background:color-mix(in srgb, var(--pb-bg-card-hover) 36%, transparent)}}.log-line[data-level=error],.log-line[data-level=critical]{border-left-color:var(--pb-error);background:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.log-line[data-level=error],.log-line[data-level=critical]{background:color-mix(in srgb, var(--pb-error) 12%, transparent)}}.log-line[data-level=warning]{border-left-color:var(--pb-warning)}.log-line[data-level=info]{border-left-color:var(--pb-info)}.log-line[data-level=debug]{border-left-color:var(--pb-text-dim)}.log-line.expanded{background:var(--pb-bg-card-hover)!important}@supports (color:color-mix(in lab, red, red)){.log-line.expanded{background:color-mix(in srgb, var(--pb-bg-card-hover) 54%, transparent)!important}}.log-detail{box-sizing:border-box;max-width:calc(100% - 140px);overflow:hidden}.log-path{overflow-wrap:anywhere}.log-badge{letter-spacing:.08em;text-transform:uppercase;text-align:center;border-radius:3px;min-width:38px;padding:1px 5px;font-size:9px;font-weight:700;display:inline-block}.badge-debug{color:var(--pb-text-dim);background:var(--pb-text-dim)}@supports (color:color-mix(in lab, red, red)){.badge-debug{background:color-mix(in srgb, var(--pb-text-dim) 16%, transparent)}}.badge-info{color:var(--pb-info);background:var(--pb-info-dim)}.badge-warning{color:var(--pb-warning);background:var(--pb-warning-dim)}.badge-error{color:var(--pb-error);background:var(--pb-error-dim)}.badge-critical{color:var(--pb-text-inverse);background:var(--pb-error)}.live-dot{background:var(--pb-success);border-radius:50%;width:6px;height:6px;animation:2s ease-in-out infinite live-pulse}@keyframes live-pulse{0%,to{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb, var(--pb-success) 42%, transparent)}50%{opacity:.6;box-shadow:0 0 6px 2px color-mix(in srgb, var(--pb-success) 24%, transparent)}}.json-key{color:var(--pb-info)}.json-str{color:var(--pb-success)}.json-num{color:var(--pb-warning)}.json-bool{color:var(--pb-purple)}.json-null{color:var(--pb-text-dim)}.downloads-page-shell{width:100%}.downloads-view{width:100%;max-width:none;padding:0 0 var(--pb-page-footer-clearance);margin:0}.downloads-header{display:block}.downloads-header-main{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.downloads-header-summary{flex-wrap:wrap;flex:auto;align-items:flex-start;gap:1.5rem;min-width:0;display:flex}.downloads-header-copy{min-width:0}.downloads-header-actions{flex:none;justify-content:flex-end;align-items:flex-start;margin-left:auto;display:flex}.downloads-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text-primary);font-family:Syne,sans-serif;font-size:1.6rem;font-weight:800;line-height:1}.downloads-title span{color:var(--pb-brand)}.downloads-subtitle{color:var(--pb-text-tertiary);letter-spacing:.01em;margin-top:.25rem;font-size:.78rem}.downloads-gauges{align-items:flex-end;gap:1rem;display:flex}.downloads-gauge{text-align:center}.downloads-gauge-ring{width:56px;height:56px;margin:0 auto;position:relative}.downloads-gauge-ring svg{transform:rotate(-90deg)}.downloads-gauge-bg{fill:none;stroke:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-gauge-bg{stroke:color-mix(in srgb, var(--pb-text-secondary) 10%, transparent)}}.downloads-gauge-bg{stroke-width:4.5px}.downloads-gauge-fill{fill:none;stroke-width:4.5px;stroke-linecap:round;transition:stroke-dashoffset .6s}.downloads-gauge-fill-success{stroke:var(--pb-status-success)}.downloads-gauge-fill-info{stroke:var(--pb-status-info)}.downloads-gauge-fill-warning{stroke:var(--pb-status-warning)}.downloads-gauge-fill-error{stroke:var(--pb-status-danger)}.downloads-gauge-fill-muted{stroke:var(--pb-text-dim)}.downloads-gauge-value{color:var(--pb-text-primary);justify-content:center;align-items:center;font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:700;display:flex;position:absolute;inset:0}.downloads-gauge-value-success{color:var(--pb-status-success)}.downloads-gauge-value-info{color:var(--pb-status-info)}.downloads-gauge-value-warning{color:var(--pb-status-warning)}.downloads-gauge-value-error{color:var(--pb-status-danger)}.downloads-gauge-value-muted{color:var(--pb-text-dim)}.downloads-gauge-label{letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-tertiary);margin-top:.25rem;font-size:.6rem;font-weight:600}.downloads-tab-rail{border:1px solid var(--pb-border);background:0 0;border-radius:10px;align-self:flex-start;display:inline-flex;overflow:hidden}.downloads-tab-btn{letter-spacing:.06em;text-transform:uppercase;color:var(--pb-text-dim);background:0 0;border:0;padding:.5rem 1.25rem;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;transition:background-color .14s,color .14s}.downloads-tab-btn+.downloads-tab-btn{border-left:1px solid var(--pb-border)}.downloads-tab-btn:hover{color:var(--pb-text);background:var(--pb-text-secondary)}@supports (color:color-mix(in lab, red, red)){.downloads-tab-btn:hover{background:color-mix(in srgb, var(--pb-text-secondary) 4%, transparent)}}.downloads-tab-btn.is-active{background:var(--pb-interactive);color:var(--pb-text-inverse)}.downloads-panel-stack{gap:1.25rem;margin-top:.875rem;display:grid}.import-history-page-shell{margin-top:0}.downloads-section{gap:.625rem;display:grid}.downloads-section-label{border-bottom:1px solid var(--pb-border-subtle);letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding-bottom:.375rem;font-family:Syne,sans-serif;font-size:.62rem;font-weight:700}.downloads-table-wrap{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);border-radius:14px;position:relative;overflow:visible;box-shadow:0 1px 3px #1e1a170f,0 1px 2px #1e1a170a}.downloads-table-wrap.is-clipped{overflow:hidden}.downloads-table{border-collapse:collapse;width:100%}.downloads-table th{border-bottom:2px solid var(--pb-border);background:var(--pb-bg-surface);text-align:left;letter-spacing:.1em;text-transform:uppercase;color:var(--pb-text-dim);padding:.5625rem .875rem;font-family:Syne,sans-serif;font-size:.6rem;font-weight:700}.downloads-table td{border-bottom:1px solid var(--pb-border-subtle);color:var(--pb-text);vertical-align:middle;padding:.6875rem .875rem;font-size:.82rem;transition:background-color .1s}.downloads-table tbody:last-child tr:last-child td{border-bottom:0}.downloads-table tr:not(.table-detail-row):hover td{background:var(--pb-surface-selected)}.downloads-table th.is-center,.downloads-table td.is-center{text-align:center}.downloads-table th.is-right,.downloads-table td.is-right{text-align:right}.issue-search-results-table-wrap{max-height:62vh;overflow-y:auto}.issue-search-results-table thead th{z-index:1;position:sticky;top:0}.issue-search-sort-indicator{color:var(--pb-interactive)}.issue-search-release-link{color:var(--pb-text);transition:color .14s}.issue-search-release-link:hover{color:var(--pb-interactive)}.issue-search-release-title{text-overflow:ellipsis;white-space:nowrap;max-width:320px;color:inherit;font-weight:600;display:block;overflow:hidden}.issue-search-release-title-muted{color:var(--pb-text-dim)}.issue-search-release-match{color:var(--pb-text-dim);margin-top:.125rem;font-size:.64rem}.issue-search-action-row{justify-content:flex-end;align-items:center;gap:.25rem;display:inline-flex}.issue-search-rejected-row td{opacity:.45}.issue-search-rejected-label{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.64rem}.issue-search-empty-state{text-align:center;padding:2.5rem 1.25rem}.issue-search-empty-icon{width:2.25rem;height:2.25rem;color:var(--pb-text-dim);margin-inline:auto}.issue-search-empty-title{color:var(--pb-text);margin-top:.625rem;font-size:.85rem;font-weight:600}.issue-search-empty-copy{color:var(--pb-text-sec);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text);font-weight:600;display:block}.downloads-release-meta{color:var(--pb-text-dim);margin-top:.2rem;font-size:.72rem}.downloads-issue-link{color:var(--pb-interactive);transition:color .14s}.downloads-issue-link:hover{color:var(--pb-interactive-hover)}.table-mono-dim,td.table-mono-dim,.downloads-mono-dim-cell,td.downloads-mono-dim-cell{font-family:JetBrains Mono,monospace;font-size:.72rem}.table-mono-dim{color:var(--pb-text-dim)}.downloads-table td.downloads-mono-cell,.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-cell,.downloads-table .downloads-mono-dim-cell{font-variant-numeric:tabular-nums;font-family:JetBrains Mono,monospace}.downloads-table td.downloads-mono-cell,.downloads-table .downloads-mono-cell{color:var(--pb-text-secondary);font-size:.75rem;font-weight:400;line-height:1.2}.downloads-table td.downloads-mono-dim-cell,.downloads-table .downloads-mono-dim-cell{color:var(--pb-text-dim);font-size:.72rem}.downloads-muted-text{color:var(--pb-text-dim)}.downloads-progress-cell{align-items:center;gap:.375rem;display:inline-flex}.downloads-progress-track{border:1px solid var(--pb-border-subtle);background:var(--pb-text-secondary);border-radius:2px;width:100px;height:6px;display:inline-block;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.downloads-progress-track{background:color-mix(in srgb, var(--pb-text-secondary) 6%, transparent)}}.downloads-progress-track{vertical-align:middle}.downloads-progress-fill{height:100%;display:block;position:relative}.downloads-progress-fill.is-blue{background:linear-gradient(90deg, var(--pb-info), var(--pb-info))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-blue{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-info) 45%, transparent), var(--pb-info))}}.downloads-progress-fill.is-amber{background:linear-gradient(90deg, var(--pb-warning), var(--pb-warning))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-amber{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-warning) 45%, transparent), var(--pb-warning))}}.downloads-progress-fill.is-green{background:linear-gradient(90deg, var(--pb-success), var(--pb-success))}@supports (color:color-mix(in lab, red, red)){.downloads-progress-fill.is-green{background:linear-gradient(90deg, color-mix(in srgb, var(--pb-success) 45%, transparent), var(--pb-success))}}.downloads-progress-fill:after{content:"";background:repeating-linear-gradient(90deg,#0000 0 2px,#ffffff1f 2px 4px);position:absolute;inset:0}@keyframes downloads-progress-indeterminate{0%{transform:translate(-130%)}to{transform:translate(280%)}}.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{will-change:transform;animation:1.25s ease-in-out infinite downloads-progress-indeterminate}@media (prefers-reduced-motion:reduce){.downloads-progress-fill.is-indeterminate,.app-progress-fill.is-indeterminate{opacity:.7;animation:none;transform:translate(60%)}}.downloads-progress-pct{color:var(--pb-info);font-family:JetBrains Mono,monospace;font-size:.68rem;font-weight:600}.downloads-progress-pct-warning{color:var(--pb-warning)}.downloads-progress-pct-success{color:var(--pb-success)}.downloads-progress-pct-muted{color:var(--pb-text-dim)}.downloads-led{border-radius:999px;width:8px;height:8px;display:inline-block}.downloads-led-green{background:var(--pb-success);box-shadow:0 0 5px var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.downloads-led-green{box-shadow:0 0 5px color-mix(in srgb, var(--pb-success) 35%, transparent)}}.downloads-led-blue{background:var(--pb-info);box-shadow:0 0 5px var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-led-blue{box-shadow:0 0 5px color-mix(in srgb, var(--pb-info) 35%, transparent)}}.downloads-led-amber{background:var(--pb-warning);box-shadow:0 0 5px var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-led-amber{box-shadow:0 0 5px color-mix(in srgb, var(--pb-warning) 35%, transparent)}}.downloads-led-off{background:var(--pb-text-dim);opacity:.35}.downloads-action-group{align-items:center;gap:.25rem;display:inline-flex}@media (hover:hover) and (pointer:fine){.downloads-action-group.is-hover-reveal{opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;transform:translateY(2px)}.downloads-table tbody tr:hover .downloads-action-group.is-hover-reveal,.downloads-table tbody tr:focus-within .downloads-action-group.is-hover-reveal{opacity:1;pointer-events:auto;transform:translateY(0)}}.downloads-action-btn{border:1px solid var(--pb-border-subtle);width:28px;height:28px;color:var(--pb-text-dim);background:0 0;border-radius:6px;justify-content:center;align-items:center;transition:border-color .14s,color .14s,background-color .14s;display:inline-flex}.downloads-action-btn svg{width:12px;height:12px}.import-history-action-group{gap:.375rem}.import-history-action-btn{border-radius:8px;flex-shrink:0;width:30px;height:30px}.downloads-action-btn .import-history-action-icon{shape-rendering:geometricprecision;flex-shrink:0;width:12px;height:12px;overflow:visible}.downloads-action-btn .import-history-action-icon--detail{width:14px;height:14px}.downloads-action-btn:hover{border-color:var(--pb-interactive);background:var(--pb-info)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn:hover{background:color-mix(in srgb, var(--pb-info) 10%, transparent)}}.downloads-action-btn:hover{color:var(--pb-interactive)}.downloads-action-btn.is-danger:hover{border-color:var(--pb-error)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-danger:hover{border-color:color-mix(in srgb, var(--pb-error) 40%, transparent)}}.downloads-action-btn.is-danger:hover{background:var(--pb-error-dim);color:var(--pb-error)}.downloads-action-btn.is-warn:hover{border-color:var(--pb-warning)}@supports (color:color-mix(in lab, red, red)){.downloads-action-btn.is-warn:hover{border-color:color-mix(in srgb, var(--pb-warning) 40%, transparent)}}.downloads-action-btn.is-warn:hover{background:var(--pb-warning-dim);color:var(--pb-warning)}.downloads-empty-state{text-align:center;padding:2.5rem 1.5rem}.downloads-empty-state.is-compact{padding:1.5rem 1.25rem}.downloads-empty-state.is-history{padding:2rem 1.5rem}.downloads-empty-icon{width:40px;height:40px;color:var(--pb-text-dim);margin:0 auto}.downloads-empty-title{letter-spacing:.04em;text-transform:uppercase;color:var(--pb-text);margin-top:.75rem;font-family:Syne,sans-serif;font-size:.88rem;font-weight:700}.downloads-empty-copy{color:var(--pb-text-dim);margin-top:.4rem;font-size:.78rem}.intervention-bulk-bar{border-top:1px solid var(--pb-border-subtle);border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-info-bg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}@supports (color:color-mix(in lab, red, red)){.intervention-bulk-bar{background:color-mix(in srgb, var(--pb-info-bg) 40%, var(--pb-bg-surface))}}.intervention-bulk-left{flex-wrap:wrap;align-items:center;gap:.625rem;display:inline-flex}.intervention-bulk-count{color:var(--pb-text-dim);font-family:JetBrains Mono,monospace;font-size:.72rem}.intervention-bulk-actions{flex-wrap:wrap;align-items:center;gap:.5rem;display:inline-flex}.downloads-footer-strip{border:1px solid var(--pb-border-subtle);background:var(--pb-bg-card);color:var(--pb-text-dim);border-radius:10px;flex-wrap:wrap;align-items:center;gap:.375rem 1.5rem;margin-top:1.25rem;padding:.625rem 1rem;font-family:JetBrains Mono,monospace;font-size:.7rem;display:flex}.downloads-footer-strip strong{color:var(--pb-text);font-weight:600}.downloads-history-toolbar{z-index:2;border:0;border-bottom:1px solid var(--pb-border);border-top-left-radius:inherit;border-top-right-radius:inherit;background:var(--pb-bg-surface);box-shadow:none;border-bottom-right-radius:0;border-bottom-left-radius:0;position:relative}.downloads-toolbar{border-bottom:1px solid var(--pb-border);background:var(--pb-bg-surface);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;padding:.625rem .875rem;display:flex}.downloads-history-toolbar>.downloads-toolbar{border-top-left-radius:inherit;border-top-right-radius:inherit;border-bottom-right-radius:0;border-bottom-left-radius:0}.downloads-toolbar-left{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.downloads-toolbar-search,.downloads-toolbar-select{border:1px solid var(--pb-border);background:var(--pb-bg-card);height:30px;color:var(--pb-text);border-radius:8px;outline:none;font-size:.78rem}.downloads-toolbar-search{width:200px;padding:0 .625rem}.downloads-toolbar-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2374675B' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-position:right 8px center;background-repeat:no-repeat;padding:0 2rem 0 .625rem}[data-theme=dark] .downloads-toolbar-select{background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239AAABA' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}.downloads-toolbar-search:focus,.downloads-toolbar-select:focus{border-color:var(--pb-interactive)}.downloads-clear-btn{background:var(--pb-error);color:var(--pb-text-inverse);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:.3125rem;padding:.375rem .75rem;font-size:.75rem;font-weight:600;transition:opacity .14s;display:inline-flex}.downloads-clear-btn svg{width:12px;height:12px}.downloads-clear-btn:hover{opacity:.92}.downloads-sort-btn{width:100%;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;text-align:inherit;text-transform:inherit;justify-content:flex-start;align-items:center;gap:.25rem;transition:color .14s;display:inline-flex}.import-review-cv-year-header{width:5.75rem;min-width:5.75rem}.import-review-cv-year-header .downloads-sort-btn{white-space:nowrap}.import-review-cv-year-header .downloads-sort-chevron{flex-shrink:0}.downloads-table th.is-center .downloads-sort-btn{text-align:center;justify-content:center}.downloads-table th.is-right .downloads-sort-btn{text-align:right;justify-content:flex-end}.downloads-sort-btn:hover,.downloads-sort-btn.is-active{color:var(--pb-text)}.downloads-sort-chevron{opacity:.35;width:12px;height:12px}.downloads-sort-btn.is-active .downloads-sort-chevron{opacity:1}.downloads-sort-chevron.is-desc{transform:rotate(180deg)}.downloads-error-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-error-dim);padding:.5rem .875rem}@supports (color:color-mix(in lab, red, red)){.downloads-error-row td{background:color-mix(in srgb, var(--pb-error-dim) 50%, var(--pb-bg-card))}}.downloads-error-row td{color:var(--pb-error);font-size:.75rem}.downloads-error-content{align-items:flex-start;gap:.5rem;display:flex}.downloads-error-content svg{flex-shrink:0;width:14px;height:14px;margin-top:1px}.search-history-detail-row td{border-bottom:1px solid var(--pb-border-subtle);background:var(--pb-bg-surface);padding:.75rem .875rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-row td{background:color-mix(in srgb, var(--pb-bg-surface) 70%, var(--pb-bg-card))}}.search-history-detail-card{border:1px solid var(--pb-border)}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{border:1px solid color-mix(in srgb, var(--pb-border) 78%, transparent)}}.search-history-detail-card{background:var(--pb-bg-card-hover);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-detail-card{background:color-mix(in srgb, var(--pb-bg-card-hover) 55%, transparent)}}.search-history-detail-card{padding:.625rem .75rem}.search-history-detail-label{letter-spacing:.12em;text-transform:uppercase;color:var(--pb-text-dim);font-size:10px;font-weight:600}.search-history-diagnostics-shell{border:1px solid var(--pb-warning);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{border:1px solid color-mix(in srgb, var(--pb-warning) 30%, transparent)}}.search-history-diagnostics-shell{background:var(--pb-warning-dim);border-radius:.75rem}@supports (color:color-mix(in lab, red, red)){.search-history-diagnostics-shell{background:color-mix(in srgb, var(--pb-warning-dim) 35%, transparent)}}.downloads-pagination{border-top:1px solid var(--pb-border-subtle);padding:.5rem .875rem}@media (max-width:1024px){.downloads-header{gap:1rem}.downloads-table-wrap{overflow-x:auto}.downloads-table-wrap.is-clipped{overflow:auto hidden}.downloads-table{min-width:760px}}@media (max-width:640px){.downloads-header-main{gap:.875rem}.downloads-gauges{gap:.625rem}.downloads-toolbar-search{width:100%}}.comic-reader{--pb-reader-canvas:#070b12;--pb-reader-surface:#0d141ef0;--pb-reader-surface-strong:#0d141efa;--pb-reader-text:#f7f1e8;--pb-reader-text-muted:#cbd5e1;--pb-reader-text-dim:#9aaaba;--pb-reader-interactive:#8fb9ee;--pb-reader-danger:#e38473;--pb-reader-warning:#f2c98d;background:var(--pb-reader-canvas);width:100vw;max-width:none;height:100dvh;max-height:none;color:var(--pb-reader-text);border:0;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.comic-reader::backdrop{background:var(--pb-reader-canvas)}.comic-reader__shell{background:var(--pb-reader-canvas);width:100%;min-width:0;height:100%;min-height:0;color:var(--pb-reader-text);grid-template-rows:auto minmax(0,1fr) auto;font-family:DM Sans,sans-serif;display:grid;overflow:hidden}.comic-reader__topbar,.comic-reader__controls{z-index:20;background:var(--pb-reader-surface);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-color:#cbd5e129;transition:opacity .14s;position:relative}.comic-reader__topbar{min-height:58px;padding:calc(.45rem + env(safe-area-inset-top,0px)) max(.75rem, env(safe-area-inset-right,0px)) .45rem max(.75rem, env(safe-area-inset-left,0px));border-bottom-width:1px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:.75rem;display:grid}.comic-reader__identity{min-width:0}.comic-reader__identity h2,.comic-reader__identity p{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.comic-reader__identity h2{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:.95rem;font-weight:800;line-height:1.2}.comic-reader__identity p{color:var(--pb-reader-text-dim);margin:.1rem 0 0;font-size:.72rem;line-height:1.2}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions,.comic-reader__help-heading{align-items:center;display:flex}.comic-reader__top-actions,.comic-reader__issue-navigation,.comic-reader__navigation,.comic-reader__sizing,.comic-reader__state-actions{gap:.4rem}.comic-reader__issue-navigation{min-width:0}.comic-reader__issue-navigation .comic-reader__button{gap:.35rem}.comic-reader__button,.comic-reader__zoom-label,.comic-reader__fit-select,.comic-reader__page-jump input{min-height:44px;color:var(--pb-reader-text);font:inherit;background:#f7f1e812;border:1px solid #cbd5e138;border-radius:.55rem}.comic-reader__button,.comic-reader__zoom-label{white-space:nowrap;cursor:pointer;justify-content:center;align-items:center;min-width:44px;padding:.55rem .7rem;font-size:.76rem;font-weight:700;line-height:1;display:inline-flex}.comic-reader__button:hover,.comic-reader__zoom-label:hover,.comic-reader__button[aria-pressed=true]{background:#8fb9ee29;border-color:#8fb9ee8c}.comic-reader__button:focus-visible,.comic-reader__zoom-label:focus-visible,.comic-reader__fit-select:focus-visible,.comic-reader__page-jump input:focus-visible,.comic-reader__viewport:focus-visible{outline:2px solid var(--pb-reader-interactive);outline-offset:-2px}.comic-reader__button:disabled{cursor:default;opacity:.38}.comic-reader__button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:19px;height:19px}.comic-reader__button--prominent{background:#8fb9ee1f;border-color:#8fb9ee61}.comic-reader__viewport{z-index:1;overscroll-behavior:contain;background:var(--pb-reader-canvas);scrollbar-color:#9aaaba8c transparent;touch-action:pan-y pinch-zoom;min-width:0;min-height:0;position:relative;overflow:auto}.comic-reader__viewport.is-pannable{touch-action:auto}.comic-reader__page-stage{justify-content:center;align-items:center;width:100%;min-width:100%;height:100%;min-height:100%;padding:.75rem;display:flex}.comic-reader__page{z-index:1;object-fit:contain;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;flex:none;max-width:none;max-height:none;display:block;position:relative;box-shadow:0 8px 30px #00000061}.comic-reader__page--page{width:auto;max-width:100%;height:auto;max-height:100%}.comic-reader__page--width{align-self:flex-start;width:100%;height:auto}.comic-reader__page--height{width:auto;height:100%}.comic-reader__page--actual{align-self:flex-start;width:auto;height:auto}.comic-reader__state,.comic-reader__page-busy{z-index:8;text-align:center;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.comic-reader__state{width:min(90vw,32rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #cbd5e133;border-radius:.9rem;flex-direction:column;gap:.5rem;padding:1.5rem;box-shadow:0 20px 56px #00000075}.comic-reader__state strong{color:var(--pb-reader-text);font-size:1rem}.comic-reader__state--error{border-color:var(--pb-reader-danger)}@supports (color:color-mix(in lab, red, red)){.comic-reader__state--error{border-color:color-mix(in srgb, var(--pb-reader-danger) 45%, transparent)}}.comic-reader__state-actions{flex-wrap:wrap;justify-content:center;margin-top:.5rem}.comic-reader__completion{z-index:9;right:max(.75rem, env(safe-area-inset-right,0px));bottom:max(.75rem, env(safe-area-inset-bottom,0px));width:min(24rem,100% - 1.5rem);color:var(--pb-reader-text-muted);background:#0d141ef5;border:1px solid #8fb9ee6b;border-radius:.8rem;padding:.85rem;position:absolute;box-shadow:0 16px 42px #0000006b}.comic-reader__completion strong{color:var(--pb-reader-text);font-family:Bricolage Grotesque,sans-serif;font-size:1rem;display:block}.comic-reader__completion p{margin:.35rem 0 0;font-size:.78rem}.comic-reader__page-busy{color:var(--pb-reader-text-muted);background:#0d141ee6;border-radius:999px;padding:.45rem .7rem;font-size:.72rem;top:1rem;transform:translate(-50%)}.comic-reader__spinner{border:2px solid #8fb9ee40;border-top-color:var(--pb-reader-interactive);border-radius:999px;width:1.5rem;height:1.5rem;animation:.7s linear infinite comic-reader-spin}@keyframes comic-reader-spin{to{transform:rotate(360deg)}}.comic-reader__tap-zone{z-index:4;background:0 0;border:0;margin:0;padding:0;position:absolute;top:0;bottom:0}.comic-reader__tap-zone--left{width:30%;left:0}.comic-reader__tap-zone--center{width:40%;left:30%}.comic-reader__tap-zone--right{width:30%;right:0}.comic-reader__controls{min-width:0;padding:.45rem max(.75rem, env(safe-area-inset-right,0px)) calc(.45rem + env(safe-area-inset-bottom,0px)) max(.75rem, env(safe-area-inset-left,0px));border-top-width:1px;justify-content:space-between;align-items:center;gap:.75rem;display:flex}.comic-reader__navigation,.comic-reader__sizing{min-width:0;position:relative}.comic-reader__page-jump{color:var(--pb-reader-text-dim);white-space:nowrap;align-items:center;gap:.35rem;font-size:.75rem;display:inline-flex}.comic-reader__page-jump input{text-align:center;appearance:textfield;width:3.4rem;padding:.4rem}.comic-reader__page-jump input::-webkit-outer-spin-button{appearance:none;margin:0}.comic-reader__page-jump input::-webkit-inner-spin-button{appearance:none;margin:0}.comic-reader__fit-select{color-scheme:dark;max-width:8.5rem;padding:0 1.9rem 0 .65rem;display:none}.comic-reader__zoom-label{min-width:3.75rem;color:var(--pb-reader-text-muted);padding-inline:.45rem}.comic-reader__input-error{border:1px solid var(--pb-reader-danger);width:max-content;max-width:min(20rem,100vw - 1.5rem);padding:.35rem .5rem;position:absolute;bottom:calc(100% + .35rem);left:50%;transform:translate(-50%)}@supports (color:color-mix(in lab, red, red)){.comic-reader__input-error{border:1px solid color-mix(in srgb, var(--pb-reader-danger) 42%, transparent)}}.comic-reader__input-error{background:var(--pb-reader-surface-strong);color:var(--pb-reader-danger);border-radius:.45rem;font-size:.7rem}.comic-reader__help{z-index:30;background:var(--pb-reader-surface-strong);border:1px solid #8fb9ee66;border-radius:.9rem;width:min(92vw,34rem);max-height:min(78dvh,38rem);padding:1rem;position:absolute;top:50%;left:50%;overflow:auto;transform:translate(-50%,-50%);box-shadow:0 20px 56px #0009}.comic-reader__help-heading{justify-content:space-between;gap:1rem}.comic-reader__help h3{color:var(--pb-reader-text);margin:0;font-family:Bricolage Grotesque,sans-serif;font-size:1rem}.comic-reader__help dl{gap:.15rem;margin:.75rem 0 0;display:grid}.comic-reader__help dl>div{border-top:1px solid #cbd5e11f;grid-template-columns:minmax(7.5rem,.7fr) minmax(0,1.3fr);gap:.75rem;padding:.45rem 0;display:grid}.comic-reader__help dt{color:var(--pb-reader-interactive);font-family:JetBrains Mono,monospace;font-size:.72rem;font-weight:600}.comic-reader__help dd{color:var(--pb-reader-text-muted);margin:0;font-size:.75rem}.comic-reader__save-status{z-index:40;right:max(.75rem, env(safe-area-inset-right,0px));bottom:calc(4.1rem + env(safe-area-inset-bottom,0px));max-width:min(22rem,100vw - 1.5rem);color:var(--pb-reader-warning);background:#231b11f5;border:1px solid #d7a15b66;border-radius:.55rem;margin:0;padding:.45rem .65rem;font-size:.72rem;position:absolute}.comic-reader__shell.is-controls-hidden .comic-reader__topbar,.comic-reader__shell.is-controls-hidden .comic-reader__controls{visibility:hidden;opacity:0;pointer-events:none}@media (max-width:900px){.comic-reader__issue-navigation .comic-reader__button span{clip:rect(0, 0, 0, 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.comic-reader__fit-page,.comic-reader__fit-wide{display:none}.comic-reader__fit-select{display:block}}@media (max-width:600px){.comic-reader__topbar{grid-template-columns:auto minmax(0,1fr) auto;gap:.5rem;min-height:54px}.comic-reader__issue-navigation{grid-area:2/1/auto/-1;justify-content:center}.comic-reader__top-actions{grid-area:1/3}.comic-reader__desktop-action{display:none}.comic-reader__controls{flex-wrap:wrap;justify-content:center;gap:.35rem .6rem}.comic-reader__navigation,.comic-reader__sizing{justify-content:center}.comic-reader__page-stage{padding:.4rem}.comic-reader__help dl>div{grid-template-columns:1fr;gap:.15rem}}@media (max-height:480px){.comic-reader__topbar,.comic-reader__controls{padding-top:.25rem;padding-bottom:.25rem}.comic-reader__topbar{min-height:48px}}@media (prefers-reduced-motion:reduce){.comic-reader__topbar,.comic-reader__controls{transition:none;visibility:visible!important;opacity:1!important;pointer-events:auto!important}.comic-reader__spinner{animation-duration:1.4s}}.reading-workspace{flex-direction:column;gap:1rem;display:flex}.reading-workspace-header,.dashboard-reading-shelf-header{justify-content:space-between;align-items:flex-end;gap:1rem;display:flex}.reading-page-size{align-items:center;gap:.65rem;display:flex}.reading-tabs{border-bottom:1px solid var(--pb-border);gap:.4rem;display:flex;overflow-x:auto}.reading-tab{color:var(--pb-text-tertiary);letter-spacing:.08em;text-transform:uppercase;flex:none;padding:.65rem .85rem;font-family:Syne,sans-serif;font-size:.7rem;font-weight:700;position:relative}.reading-tab:after{content:"";background:0 0;border-radius:999px;height:2px;position:absolute;bottom:-1px;left:.65rem;right:.65rem}.reading-tab:hover,.reading-tab:focus-visible,.reading-tab.is-active{color:var(--pb-interactive)}.reading-tab.is-active:after{background:var(--pb-interactive)}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.85rem;display:grid}.dashboard-reading-card-grid{grid-template-columns:repeat(4,minmax(0,1fr))}.reading-card{border:1px solid var(--pb-border);background:linear-gradient(145deg, var(--pb-interactive), transparent 42%), var(--pb-bg-card);border-radius:12px;grid-template-columns:92px minmax(0,1fr);min-width:0;display:grid;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.reading-card{background:linear-gradient(145deg, color-mix(in srgb, var(--pb-interactive) 5%, transparent), transparent 42%), var(--pb-bg-card)}}.reading-card{box-shadow:var(--pb-shadow-1)}.reading-card:not(.reading-card-dashboard){min-height:15.25rem}.reading-card-cover-link,.reading-card-cover-frame{min-height:138px;display:block}.reading-card-cover-frame{border-right:1px solid var(--pb-border-subtle);background:var(--pb-bg-shell);height:100%;position:relative;overflow:hidden}.reading-card-cover{object-fit:cover;width:100%;height:100%}.reading-card-cover-placeholder{width:100%;height:100%;color:var(--pb-text-dim);justify-content:center;align-items:center;display:flex}.reading-card-cover-placeholder svg{width:2.25rem;height:2.25rem}.reading-card-body{flex-direction:column;gap:.7rem;min-width:0;padding:.85rem;display:flex}.reading-card-series{color:var(--pb-text-primary);text-overflow:ellipsis;white-space:nowrap;font-family:Syne,sans-serif;font-size:.78rem;font-weight:700;display:block;overflow:hidden}.reading-card-series:hover,.reading-card-series:focus-visible{color:var(--pb-interactive)}.reading-card-issue{min-width:0;color:var(--pb-text-tertiary);gap:.4rem;margin-top:.2rem;font-size:.72rem;display:flex}.reading-card-issue>span:first-child,.reading-card-state{font-family:JetBrains Mono,monospace}.reading-card-issue-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.reading-card-state{color:var(--pb-text-secondary);font-size:.67rem;font-weight:600}.reading-card-progress{background:var(--pb-border-subtle);border-radius:999px;height:3px;margin-top:.4rem;overflow:hidden}.reading-card-progress span{border-radius:inherit;background:var(--pb-interactive);height:100%;display:block}.reading-card-state-region{min-height:1.75rem}.reading-card-actions{grid-template-rows:repeat(2,minmax(2.25rem,auto));grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch;gap:.4rem;margin-top:auto;display:grid}.reading-card-view-read .reading-card-actions{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.reading-card-view-read .reading-card-primary-action,.reading-card-view-read .reading-card-completion-action{white-space:nowrap;padding-inline:.5rem}.reading-card-primary-action,.reading-card-completion-action,.reading-card-queue-action{width:100%;min-width:0}.reading-card-queue-action{grid-column:1/-1}.reading-card-queue-action-placeholder{visibility:hidden;pointer-events:none;grid-column:1/-1;min-height:2.25rem}.reading-card-mutation-status{min-height:1rem;color:var(--pb-error);font-size:.68rem}.reading-empty-state{border:1px dashed var(--pb-border);text-align:center;background:var(--pb-bg-card);border-radius:12px;flex-direction:column;justify-content:center;align-items:center;min-height:260px;padding:2.5rem 1.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.reading-empty-state{background:color-mix(in srgb, var(--pb-bg-card) 78%, transparent)}}.reading-empty-icon{width:2.75rem;height:2.75rem;color:var(--pb-text-dim)}.reading-empty-state h2{color:var(--pb-text-primary);margin-top:.85rem;font-family:Syne,sans-serif;font-size:.95rem;font-weight:700}.reading-empty-state p{max-width:34rem;color:var(--pb-text-tertiary);margin-top:.35rem;font-size:.78rem}.dashboard-reading-shelf{border:1px solid var(--pb-border);background:var(--pb-bg-card);border-radius:12px;padding:.85rem}@supports (color:color-mix(in lab, red, red)){.dashboard-reading-shelf{background:color-mix(in srgb, var(--pb-bg-card) 88%, transparent)}}.dashboard-reading-shelf{box-shadow:var(--pb-shadow-1)}.dashboard-reading-shelf-header h2{color:var(--pb-text-primary);margin-top:.1rem;font-family:Syne,sans-serif;font-size:1rem;font-weight:700}.dashboard-reading-shelf-link{color:var(--pb-interactive);font-size:.72rem;font-weight:700}.dashboard-reading-shelf-link:hover,.dashboard-reading-shelf-link:focus-visible{text-decoration:underline}.reading-card-dashboard{grid-template-columns:76px minmax(0,1fr)}.reading-card-dashboard .reading-card-cover-link,.reading-card-dashboard .reading-card-cover-frame{min-height:124px}.reading-card-dashboard .reading-card-body{gap:.5rem;padding:.7rem}@media (max-width:1180px){.dashboard-reading-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width:640px){.reading-workspace-header{flex-direction:column;align-items:flex-start}.reading-card-grid,.dashboard-reading-card-grid{grid-template-columns:1fr}.reading-card{grid-template-columns:82px minmax(0,1fr)}.reading-card-cover-link,.reading-card-cover-frame{min-height:128px}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/src/pullbox/ui/static/js/pullbox.js b/src/pullbox/ui/static/js/pullbox.js index 84c5e437..786f503f 100644 --- a/src/pullbox/ui/static/js/pullbox.js +++ b/src/pullbox/ui/static/js/pullbox.js @@ -4991,6 +4991,11 @@ function importProgressData(jobId, nextStep, sourceType) { return ""; }, + currentItemIsIndeterminate: function () { + return this.currentItemKind === "scan" && + this.currentItemStage === "inventory" && this.currentItemProgressValue == null; + }, + currentItemProgress: function () { if (this.currentFileName) { return this.currentFileProgress; @@ -5419,8 +5424,8 @@ function importProgressData(jobId, nextStep, sourceType) { return; } - var computed = this._computeEtaSeconds(this.startedAt, this.progress); - this.etaSeconds = computed; + // Weighted workflow milestones are not a measure of remaining runtime. + this.etaSeconds = null; this.etaCapturedAt = Date.now(); }, @@ -5479,7 +5484,7 @@ function importProgressData(jobId, nextStep, sourceType) { this.etaSeconds - Math.max(0, Math.floor((this.nowMs - this.etaCapturedAt) / 1000)), ) - : this._computeEtaSeconds(this.startedAt, this.progress); + : null; if (etaSeconds == null) { return "Estimating..."; @@ -7667,6 +7672,17 @@ function importResultsData(config) { throw new Error(error.detail || "Failed to retry"); } + var payload = await response.json(); + if (Math.max(0, Number(payload.retrying_count) || 0) === 0) { + var blockedMessage = + "No failed files passed source revalidation. Review the updated failure details before retrying."; + this.retryError = blockedMessage; + if (typeof showToast === "function") { + showToast({ message: blockedMessage, level: "warning" }); + } + return; + } + dispatchImportWizardAdvance({ step: 4, jobId: this.jobId, @@ -12021,6 +12037,8 @@ function appShell() { activityAttentionCount: 0, activitySource: null, activityPollTimer: null, + activityRefreshTimer: null, + activityStreamOpen: false, activityRefreshing: false, get collapsed() { @@ -12068,6 +12086,7 @@ function appShell() { } this.disconnectActivityStream(); this.clearActivityTimer(); + this.clearActivityRefreshTimer(); }, clearActivityTimer: function () { @@ -12086,6 +12105,22 @@ function appShell() { }, delayMs || 3000); }, + clearActivityRefreshTimer: function () { + if (this.activityRefreshTimer) { + window.clearTimeout(this.activityRefreshTimer); + this.activityRefreshTimer = null; + } + }, + + scheduleActivityRefresh: function (delayMs) { + var self = this; + self.clearActivityRefreshTimer(); + self.activityRefreshTimer = window.setTimeout(function () { + self.activityRefreshTimer = null; + self.refreshActivity(); + }, delayMs || 500); + }, + bootstrapActivity: function () { this.connectActivityStream(); this.refreshActivity(); @@ -12127,7 +12162,9 @@ function appShell() { }) .finally(function () { self.activityRefreshing = false; - self.scheduleActivityPoll(3000); + if (!self.activityStreamOpen) { + self.scheduleActivityPoll(3000); + } if (!self.activitySource) { self.connectActivityStream(); } @@ -12143,9 +12180,16 @@ function appShell() { self.activitySource = source; var refreshFromEvent = function () { if (self.activitySource === source) { - self.refreshActivity(); + self.scheduleActivityRefresh(500); } }; + source.onopen = function () { + if (self.activitySource !== source) { + return; + } + self.activityStreamOpen = true; + self.clearActivityTimer(); + }; source.addEventListener("ready", refreshFromEvent); source.addEventListener("progress", refreshFromEvent); source.onmessage = refreshFromEvent; @@ -12154,6 +12198,7 @@ function appShell() { return; } self.disconnectActivityStream(); + self.scheduleActivityPoll(3000); }; }, @@ -12167,6 +12212,7 @@ function appShell() { // Closing a stale activity stream is best-effort. } this.activitySource = null; + this.activityStreamOpen = false; }, acknowledgeActivity: function (operationId) { @@ -18076,10 +18122,14 @@ function seriesDetailPage(config) { statusSaving: false, refreshing: false, searching: false, + metadataSyncing: false, + metadataSyncTimer: null, issueSearchState: {}, init: function () { this.monitored = !!cfg.monitored; + this.metadataSyncing = !!cfg.metadataSyncing; + this.startMetadataSyncPolling(); var self = this; var runNormalize = function () { @@ -18095,6 +18145,51 @@ function seriesDetailPage(config) { } }, + destroy: function () { + if (this.metadataSyncTimer !== null) { + clearTimeout(this.metadataSyncTimer); + this.metadataSyncTimer = null; + } + }, + + startMetadataSyncPolling: function () { + var self = this; + if (!self.metadataSyncing || self.metadataSyncTimer !== null) { + return; + } + self.metadataSyncTimer = window.setTimeout(function () { + self.metadataSyncTimer = null; + self.pollMetadataSyncState(); + }, 3000); + }, + + pollMetadataSyncState: function () { + var self = this; + if (!self.metadataSyncing || !cfg.updateUrl) { + return; + } + fetch(cfg.updateUrl, { + method: "GET", + headers: { Accept: "application/json" }, + cache: "no-store", + }) + .then(function (response) { + if (!response.ok) throw new Error("Failed to load metadata sync state"); + return response.json(); + }) + .then(function (data) { + if (data.issue_catalog_state !== "hydrating") { + self.metadataSyncing = false; + self.refreshIssuesPanel(); + return; + } + self.startMetadataSyncPolling(); + }) + .catch(function () { + self.startMetadataSyncPolling(); + }); + }, + csrfToken: function () { return cfg.csrfToken || readCsrfTokenFromBody(); }, diff --git a/src/pullbox/ui/templates/pages/series_detail.html b/src/pullbox/ui/templates/pages/series_detail.html index e59c98f5..a6f9e65d 100644 --- a/src/pullbox/ui/templates/pages/series_detail.html +++ b/src/pullbox/ui/templates/pages/series_detail.html @@ -5,6 +5,7 @@ {% block content_container_classes %}px-4 sm:px-6 pt-5 sm:pt-7{% endblock %} {% block content %} +{% set metadata_syncing = series.issue_catalog_state and series.issue_catalog_state.value == 'hydrating' %}
{{ files_failed }} file{{ "s failed" if files_fa
+ {% set source_changed_files = source_changed_files|default(0) %} + {% if source_changed_files > 0 %} +
+ {% call inline_alert(tone="warning", icon="warning") %} + + {{ source_changed_files }} source file{{ '' if source_changed_files == 1 else 's' }} changed after discovery. + Recheck only those files before retrying; successfully imported files remain untouched. + + {% endcall %} +
+ {% endif %} {% for f in failed_files %}
{{ f.file_name }}
diff --git a/src/pullbox/ui/templates/partials/import_step_progress.html b/src/pullbox/ui/templates/partials/import_step_progress.html index f1e1842f..11c8c648 100644 --- a/src/pullbox/ui/templates/partials/import_step_progress.html +++ b/src/pullbox/ui/templates/partials/import_step_progress.html @@ -321,13 +321,13 @@

diff --git a/src/pullbox/ui/templates/partials/series_detail_actions.html b/src/pullbox/ui/templates/partials/series_detail_actions.html index 8ebb993b..26009e1d 100644 --- a/src/pullbox/ui/templates/partials/series_detail_actions.html +++ b/src/pullbox/ui/templates/partials/series_detail_actions.html @@ -1,6 +1,7 @@ {% set primary_action_classes = "btn-primary" %} {% set secondary_action_classes = "btn-ghost" %} {% set danger_action_classes = "btn-danger" %} +{% set metadata_syncing = series.issue_catalog_state and series.issue_catalog_state.value == 'hydrating' %}
- Refresh metadata + {% if metadata_syncing %}Metadata syncing...{% else %}Refresh metadata{% endif %} diff --git a/tests/e2e/test_import_performance_progress.py b/tests/e2e/test_import_performance_progress.py new file mode 100644 index 00000000..5feab36b --- /dev/null +++ b/tests/e2e/test_import_performance_progress.py @@ -0,0 +1,34 @@ +"""Browser regressions for measured import progress and unknown ETA.""" + + +def test_scan_unknown_eta_does_not_extrapolate_weighted_progress(authed_page, seeded_server): + authed_page.goto(f"{seeded_server}/import") + result = authed_page.evaluate( + """() => { + const controller = window.importProgressData(42, 3, 'mylar3', {}, 'scan'); + controller.progress = 19; + controller.startedAt = new Date(Date.now() - 3936000).toISOString(); + controller.captureEtaState({estimated_seconds_remaining: null, elapsed_seconds: 3936}); + return {eta: controller.etaSeconds, label: controller.formatEtaLabel()}; + }""" + ) + assert result == {"eta": None, "label": "Estimating..."} + + +def test_inventory_without_total_is_indeterminate(authed_page, seeded_server): + authed_page.goto(f"{seeded_server}/import") + result = authed_page.evaluate( + """() => { + const controller = window.importProgressData(42, 3, 'filesystem', {}, 'scan'); + controller.applyExplicitCurrentItemState({ + current_item_kind: 'scan', current_item_stage: 'inventory', + current_item_progress_pct: null + }); + return { + progress: controller.currentItemProgress(), + indeterminate: typeof controller.currentItemIsIndeterminate === 'function' + && controller.currentItemIsIndeterminate() + }; + }""" + ) + assert result == {"progress": 0, "indeterminate": True} diff --git a/tests/integration/test_import_api.py b/tests/integration/test_import_api.py index 9dfdbf47..b8a62cab 100644 --- a/tests/integration/test_import_api.py +++ b/tests/integration/test_import_api.py @@ -1341,7 +1341,7 @@ async def fail(*_args, **_kwargs): is None ) - async def test_mylar_sidecar_and_comicinfo_conflict_requires_review_without_provider_calls( + async def test_mylar_sidecar_and_comicinfo_conflict_preserves_mylar_identity( self, db_session, tmp_path, @@ -1401,11 +1401,19 @@ async def fail(*_args, **_kwargs): sa_select(ImportedSeries).where(ImportedSeries.import_job_id == job.id) ) assert series_item is not None - assert series_item.status == ImportSeriesStatus.NO_MATCH - assert series_item.cv_id is None - assert series_item.cv_match_method is None - assert series_item.diagnostics["reason"] == "trusted_source_identity_conflict" - assert series_item.diagnostics["identity_conflicts"] == [ + assert series_item.status == ImportSeriesStatus.MATCHED + assert series_item.cv_id == 42721 + assert series_item.cv_match_method == "mylar3_cv_id" + assert series_item.diagnostics["file_identity_review_required"] is True + assert series_item.diagnostics["file_identity_conflict_count"] == 1 + imported_file = await db_session.scalar( + sa_select(ImportedFile).where(ImportedFile.import_series_id == series_item.id) + ) + assert imported_file is not None + assert imported_file.status == ImportedFileStatus.NO_MATCH + assert imported_file.include_in_import is False + assert imported_file.diagnostics["conflict_type"] == "trusted_source_identity_conflict" + assert imported_file.diagnostics["identity_conflicts"] == [ { "field": "comicvine_series_id", "comicinfo": 99999, @@ -1413,7 +1421,7 @@ async def fail(*_args, **_kwargs): } ] - async def test_mylar_and_comicinfo_conflict_requires_review_without_provider_calls( + async def test_mylar_and_comicinfo_conflict_preserves_mylar_identity( self, db_session, tmp_path, @@ -1471,11 +1479,19 @@ async def fail(*_args, **_kwargs): sa_select(ImportedSeries).where(ImportedSeries.import_job_id == job.id) ) assert series_item is not None - assert series_item.status == ImportSeriesStatus.NO_MATCH - assert series_item.cv_id is None - assert series_item.cv_match_method is None - assert series_item.diagnostics["reason"] == "trusted_source_identity_conflict" - assert series_item.diagnostics["identity_conflicts"] == [ + assert series_item.status == ImportSeriesStatus.MATCHED + assert series_item.cv_id == 42721 + assert series_item.cv_match_method == "mylar3_cv_id" + assert series_item.diagnostics["file_identity_review_required"] is True + assert series_item.diagnostics["file_identity_conflict_count"] == 1 + imported_file = await db_session.scalar( + sa_select(ImportedFile).where(ImportedFile.import_series_id == series_item.id) + ) + assert imported_file is not None + assert imported_file.status == ImportedFileStatus.NO_MATCH + assert imported_file.include_in_import is False + assert imported_file.diagnostics["conflict_type"] == "trusted_source_identity_conflict" + assert imported_file.diagnostics["identity_conflicts"] == [ { "field": "comicvine_series_id", "first": 42721, diff --git a/tests/tasks/test_import_orphan_recovery_task.py b/tests/tasks/test_import_orphan_recovery_task.py index b35f653b..9b0340ca 100644 --- a/tests/tasks/test_import_orphan_recovery_task.py +++ b/tests/tasks/test_import_orphan_recovery_task.py @@ -2,12 +2,22 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker from pullbox.models.import_job import ImportSeriesStatus +from pullbox.models.operation_progress import OperationProgress, OperationProgressState from pullbox.schemas.import_job import OrphanRecoveryDecision, RecoverOrphanRequest +from pullbox.services import operation_progress_dispatch +from pullbox.services.operation_activity import list_operation_activity +from pullbox.services.operation_progress_dispatch import ( + queue_operation_progress as persist_queued_progress, +) from pullbox.tasks import import_orphan_recovery_task as task @@ -17,6 +27,13 @@ def clear_orphan_recovery_state() -> None: task._background_tasks.clear() +@pytest.fixture(autouse=True) +def queued_updates(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: + queue = AsyncMock() + monkeypatch.setattr(operation_progress_dispatch, "queue_operation_progress", queue) + return queue + + def _request() -> RecoverOrphanRequest: return RecoverOrphanRequest( decisions=[ @@ -65,6 +82,7 @@ def fake_create_task(coro: object) -> FakeTask: @pytest.mark.asyncio async def test_run_orphan_recovery_updates_progress_and_final_state( monkeypatch: pytest.MonkeyPatch, + queued_updates: AsyncMock, ) -> None: commits = 0 rollbacks = 0 @@ -146,11 +164,18 @@ async def build_import_service(_session: object) -> FakeService: assert progress.files_remaining == 0 assert commits == 1 assert rollbacks == 0 + updates = [call.args[0] for call in queued_updates.await_args_list] + assert updates[-1].state is OperationProgressState.COMPLETED + assert updates[-1].message == "Recovery complete." + assert any( + update.item is not None and update.item.phase == "transferring" for update in updates + ) @pytest.mark.asyncio async def test_run_orphan_recovery_rolls_back_and_marks_failed( monkeypatch: pytest.MonkeyPatch, + queued_updates: AsyncMock, ) -> None: rollbacks = 0 @@ -179,3 +204,97 @@ async def build_import_service(_session: object) -> object: assert progress.message == "Recovery failed." assert progress.error_message == "service unavailable" assert rollbacks == 1 + assert queued_updates.await_args.args[0].state is OperationProgressState.FAILED + + +@pytest.mark.parametrize("outcome", ["complete", "partial", "failed"]) +async def test_recovery_publishes_live_and_terminal_activity_without_modal_polling( + async_engine, monkeypatch: pytest.MonkeyPatch, outcome: str +) -> None: + factory = async_sessionmaker(async_engine, expire_on_commit=False) + monkeypatch.setattr(task, "get_session_factory", lambda: factory) + monkeypatch.setattr(operation_progress_dispatch, "get_session_factory", lambda: factory) + monkeypatch.setattr( + operation_progress_dispatch, "queue_operation_progress", persist_queued_progress + ) + monkeypatch.setattr(operation_progress_dispatch, "_COALESCE_SECONDS", 0) + notifications = AsyncMock() + monkeypatch.setattr(operation_progress_dispatch, "notify_activity_changed", notifications) + live_snapshots = [] + + class FakeService: + async def _load_orphan_recovery_item(self, _session, _series_id): + return ( + SimpleNamespace( + move_to_library=True, + convert_to_preferred_format=True, + update_embedded_comicinfo_from_match=False, + ), + SimpleNamespace(cv_title="Recovered Series", raw_series_name="Raw Series"), + ) + + async def recover_orphan(self, _session, _series_id, _request, *, progress_callback): + await progress_callback( + imp_file=SimpleNamespace( + file_name="Recovered.cbr", file_path="/imports/Recovered.cbr" + ), + file_index=1, + total_files=1, + stage="transferring", + current=25, + total=100, + unit="bytes", + live_only=True, + ) + await operation_progress_dispatch.drain_operation_progress_updates() + async with factory() as read_session: + live = (await read_session.execute(select(OperationProgress))).scalar_one_or_none() + live_snapshots.append( + (live.state, live.item_phase, live.item_current, live.item_total) + if live is not None + else None + ) + if outcome == "failed": + raise RuntimeError("Source unavailable") + return { + "status": ImportSeriesStatus.RECOVERY_PENDING + if outcome == "partial" + else ImportSeriesStatus.IMPORTED, + "imported_count": 1, + "skipped_count": 1, + "failed_count": 0, + "files_remaining": 1 if outcome == "partial" else 0, + } + + monkeypatch.setattr( + "pullbox.composition.services.build_import_service", AsyncMock(return_value=FakeService()) + ) + try: + await task._run_orphan_recovery(7, _request().model_dump(mode="python")) + await operation_progress_dispatch.drain_operation_progress_updates() + async with factory() as session: + operation = (await session.execute(select(OperationProgress))).scalar_one() + expected = ( + OperationProgressState.FAILED + if outcome == "failed" + else OperationProgressState.COMPLETED + ) + assert operation.state is expected + assert operation.completed_at is not None + if outcome == "partial": + assert operation.message == "Recovery saved. Some files still need attention." + elif outcome == "complete": + assert operation.message == "Recovery complete." + if outcome != "failed": + assert operation.overall_percent == 100 + activity = await list_operation_activity(session) + assert activity.active_count == 0 + assert activity.spinner_count == 0 + expired = await list_operation_activity( + session, now=datetime.now(UTC) + timedelta(seconds=16) + ) + assert len(expired.operations) == (1 if outcome == "failed" else 0) + assert live_snapshots == [(OperationProgressState.RUNNING, "transferring", 25, 100)] + assert notifications.await_args.args[0].state is expected + finally: + await operation_progress_dispatch.drain_operation_progress_updates() diff --git a/tests/ui/test_import_collection_shell_ui_routes.py b/tests/ui/test_import_collection_shell_ui_routes.py index 630e3f2b..6ef731ec 100644 --- a/tests/ui/test_import_collection_shell_ui_routes.py +++ b/tests/ui/test_import_collection_shell_ui_routes.py @@ -667,7 +667,11 @@ async def test_app_shell_tracks_global_activity_across_navigation(self) -> None: assert "activityRateEtaLabel: function" in shell_controller assert "activityOverallIndeterminate: function" in shell_controller assert "activityItemIndeterminate: function" in shell_controller - assert "scheduleActivityPoll(3000)" in shell_controller + assert "activityRefreshTimer: null" in shell_controller + assert "activityStreamOpen: false" in shell_controller + assert "scheduleActivityRefresh(500)" in shell_controller + assert "source.onopen = function" in shell_controller + assert "if (!self.activityStreamOpen)" in shell_controller assert 'fetch("/api/v1/import/active"' not in shell_controller async def test_step_two_ephemeral_progress_uses_its_real_workflow_status(self) -> None: @@ -951,6 +955,18 @@ async def test_split_series_review_requires_future_destination_without_relocatio assert "hasRequiredPreferredRoot" in review_controller assert "target_library_root_id: this.preferredRootId" in review_controller + async def test_completed_retry_stays_on_results_when_no_source_passes_revalidation( + self, + ) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text() + start = script.index("function importResultsData") + end = script.index("function conflictResolutionData", start) + results_controller = script[start:end] + + assert "var payload = await response.json();" in results_controller + assert "payload.retrying_count" in results_controller + assert "No failed files passed source revalidation." in results_controller + async def test_import_collection_exposes_stable_step_mounts( self, authenticated_client, diff --git a/tests/ui/test_import_results_context.py b/tests/ui/test_import_results_context.py index b18e21be..5a221e67 100644 --- a/tests/ui/test_import_results_context.py +++ b/tests/ui/test_import_results_context.py @@ -101,6 +101,70 @@ async def test_load_import_results_context_splits_unmatched_queue_counts(db_sess assert context["unmatched_queue_count"] == 2 +@pytest.mark.asyncio +async def test_load_import_results_context_reports_changed_sources_separately( + db_session, +) -> None: # type: ignore[no-untyped-def] + from pullbox.ui.import_results_context import load_import_results_context + + job = ImportJob( + source_path="/tmp/comics", + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + total_files_found=2, + total_files_failed=2, + ) + db_session.add(job) + await db_session.flush() + + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Changed Sources", + file_count=2, + has_files=True, + sample_paths=[], + status=ImportSeriesStatus.FAILED, + ) + db_session.add(imported_series) + await db_session.flush() + db_session.add_all( + [ + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/tmp/comics/changed.cbz", + file_name="changed.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.FAILED, + diagnostics={ + "source_revalidation": { + "code": "source_changed", + "retryable": True, + } + }, + ), + ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path="/tmp/comics/failed.cbz", + file_name="failed.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.FAILED, + error_message="Destination is unavailable.", + ), + ] + ) + await db_session.flush() + + context = await load_import_results_context(db_session, job) + + assert context["files_total"] == 2 + assert context["files_failed"] == 2 + assert context["source_changed_files"] == 1 + + @pytest.mark.asyncio async def test_load_import_results_context_counts_pending_catalog_sync(db_session) -> None: # type: ignore[no-untyped-def] from pullbox.ui.import_results_context import load_import_results_context diff --git a/tests/ui/test_import_ui_routes.py b/tests/ui/test_import_ui_routes.py index b7c9b0ae..32708183 100644 --- a/tests/ui/test_import_ui_routes.py +++ b/tests/ui/test_import_ui_routes.py @@ -740,6 +740,50 @@ def test_results_template_shows_retry_for_file_only_failures(self) -> None: assert "Retry failed" in html assert "2000AD prog 2481.cbz" in html + def test_results_template_explains_changed_source_failures(self) -> None: + from types import SimpleNamespace + + from pullbox.ui.routes import templates + + html = templates.env.get_template("partials/import_results.html").render( + job=SimpleNamespace(id=27, status=SimpleNamespace(value="completed")), + can_rollback=False, + imported_count=18, + failed_count=1, + duplicate_count=0, + no_match_count=0, + unmatched_queue_count=0, + failed_series=[], + files_total=184190, + files_imported=79487, + files_matched=0, + files_duplicate=0, + files_already_owned=0, + files_conflict=0, + files_no_match=0, + orphaned_file_no_match_count=0, + identified_series_file_no_match_count=0, + catalog_sync_pending_count=0, + catalog_sync_failed_count=0, + catalog_sync_attention_count=0, + catalog_sync_series=[], + files_failed=4, + source_changed_files=4, + failed_files=[ + SimpleNamespace( + file_name="changed.cbz", + error_message="The source changed after discovery.", + ) + ], + resume_step=5, + resume_job_id=27, + resume_progress_snapshot={}, + ) + + assert 'data-testid="import-results-source-changed-note"' in html + assert "4 source files changed after discovery" in html + assert "Recheck only those files" in html + def test_results_template_surfaces_background_catalog_sync(self) -> None: from types import SimpleNamespace diff --git a/tests/ui/test_series_detail_ui_routes.py b/tests/ui/test_series_detail_ui_routes.py index 585d0bed..12143361 100644 --- a/tests/ui/test_series_detail_ui_routes.py +++ b/tests/ui/test_series_detail_ui_routes.py @@ -561,6 +561,36 @@ async def test_series_detail_surfaces_non_complete_issue_catalog_state( else: assert 'data-testid="series-detail-catalog-refresh"' not in response.text + refresh_start = response.text.index('data-testid="series-action-refresh"') + refresh_end = response.text.index( + 'data-testid="series-action-status-toggle"', + refresh_start, + ) + refresh_html = response.text[refresh_start:refresh_end] + if catalog_state == "hydrating": + assert "disabled" in refresh_html + assert 'aria-disabled="true"' in refresh_html + assert "Metadata syncing" in refresh_html + assert "metadataSyncing: true" in response.text + assert ':disabled="refreshing || metadataSyncing"' in refresh_html + assert "metadataSyncing ? 'Metadata syncing...' : 'Refresh metadata'" in refresh_html + else: + assert 'aria-disabled="true"' not in refresh_html + assert "Refresh metadata" in refresh_html + + async def test_series_detail_stops_sync_polling_and_reenables_refresh(self) -> None: + script = Path("src/pullbox/ui/static/js/pullbox.js").read_text(encoding="utf-8") + detail_start = script.index("function seriesDetailPage(config)") + detail_end = script.index("function issueSearchModal", detail_start) + detail_script = script[detail_start:detail_end] + + assert "metadataSyncing: false" in detail_script + assert "this.metadataSyncing = !!cfg.metadataSyncing" in detail_script + assert "startMetadataSyncPolling" in detail_script + assert 'data.issue_catalog_state !== "hydrating"' in detail_script + assert "self.metadataSyncing = false" in detail_script + assert "clearTimeout(this.metadataSyncTimer)" in detail_script + async def test_series_detail_omits_catalog_state_banner_when_complete( self, authenticated_client, diff --git a/tests/unit/test_app_lifespan.py b/tests/unit/test_app_lifespan.py index b064fb7a..c2b41fb8 100644 --- a/tests/unit/test_app_lifespan.py +++ b/tests/unit/test_app_lifespan.py @@ -349,6 +349,44 @@ async def test_lifespan_starts_background_services_and_shuts_down_cleanly( assert _FakeAirDcppRegistry.instances[-1].stopped is True +@pytest.mark.asyncio +@pytest.mark.parametrize("files_fail", [False, True]) +async def test_catalog_recovery_does_not_wait_for_all_comicinfo_files( + patched_lifespan, + monkeypatch: pytest.MonkeyPatch, + files_fail: bool, +) -> None: + file_work_started = asyncio.Event() + release_file_work = asyncio.Event() + catalog_work_started = asyncio.Event() + + async def recover_files(_factory): + file_work_started.set() + if files_fail: + raise RuntimeError("File enrichment unavailable") + await release_file_work.wait() + return 1 + + async def recover_catalogs(_factory): + catalog_work_started.set() + return 2 + + async def build_service(_session): + return SimpleNamespace( + recover_pending_comicinfo_enrichment=recover_files, + recover_pending_catalog_hydration=recover_catalogs, + ) + + monkeypatch.setattr("pullbox.composition.services.build_import_service", build_service) + try: + async with patched_lifespan.app.lifespan(FastAPI()): + await asyncio.wait_for(file_work_started.wait(), timeout=1) + await asyncio.wait_for(catalog_work_started.wait(), timeout=1) + assert not release_file_work.is_set() + finally: + release_file_work.set() + + @pytest.mark.asyncio async def test_import_recovery_completes_before_scheduler_can_start( patched_lifespan, diff --git a/tests/unit/test_collection_scanner.py b/tests/unit/test_collection_scanner.py index 32223ad4..808673a6 100644 --- a/tests/unit/test_collection_scanner.py +++ b/tests/unit/test_collection_scanner.py @@ -67,7 +67,7 @@ class TestInventoryProgress: """Inventory should emit incremental directory/file counts before completion.""" @pytest.mark.asyncio - async def test_inventory_emits_incremental_progress(self, tmp_path: Path) -> None: + async def test_inventory_emits_final_coalesced_progress(self, tmp_path: Path) -> None: for idx in range(220): folder = tmp_path / f"Series {idx:03d}" folder.mkdir(parents=True, exist_ok=True) @@ -84,7 +84,7 @@ async def capture_progress(directory_count: int, file_count: int) -> None: assert inventory.directory_count == 221 assert inventory.file_count == 220 - assert len(updates) >= 2 + assert updates assert updates[-1] == (221, 220) assert all( later[0] >= earlier[0] and later[1] >= earlier[1] diff --git a/tests/unit/test_comicvine_persistent_cache.py b/tests/unit/test_comicvine_persistent_cache.py index 1722773d..89c3b4cc 100644 --- a/tests/unit/test_comicvine_persistent_cache.py +++ b/tests/unit/test_comicvine_persistent_cache.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker from pullbox.models.provider_cache import MetadataProviderCacheEntry -from pullbox.providers.base import IssueSummary, SeriesMetadata, SeriesSearchResult +from pullbox.providers.base import IssueMetadata, IssueSummary, SeriesMetadata, SeriesSearchResult from pullbox.services.comicvine_persistent_cache import PersistentComicVineCacheProvider @@ -21,6 +21,9 @@ def __init__(self) -> None: self.search_calls = 0 self.global_search_calls = 0 self.series_calls = 0 + self.series_batch_calls: list[list[str]] = [] + self.issue_batch_calls: list[list[str]] = [] + self.catalog_batch_calls: list[list[str]] = [] self.issue_number_calls = 0 async def search_series( @@ -85,6 +88,69 @@ async def get_series(self, series_provider_id: str) -> SeriesMetadata: comicvine_url=None, ) + async def get_series_batch( + self, + series_provider_ids: list[str], + ) -> dict[str, SeriesMetadata]: + self.series_batch_calls.append(list(series_provider_ids)) + return { + provider_id: SeriesMetadata( + provider_id=provider_id, + title=f"Series {provider_id}", + sort_title=f"Series {provider_id}", + year_start=2026, + year_end=None, + status=None, + publisher="Pullbox", + description=None, + cover_url=None, + issue_count=1, + comicvine_url=None, + ) + for provider_id in series_provider_ids + } + + async def get_issue_batch( + self, + issue_provider_ids: list[str], + ) -> dict[str, IssueMetadata]: + self.issue_batch_calls.append(list(issue_provider_ids)) + return { + provider_id: IssueMetadata( + provider_id=provider_id, + series_provider_id="123", + issue_number=float(index), + title=f"Issue {index}", + description="Complete issue metadata", + release_date="2026-01-01", + store_date="2025-12-31", + cover_url=None, + page_count=None, + comicvine_url=f"https://example.test/issue/{provider_id}", + creators=[{"name": "Writer", "role": "writer"}], + ) + for index, provider_id in enumerate(issue_provider_ids, start=1) + } + + async def get_issue_catalog_batch( + self, + series_provider_ids: list[str], + ) -> dict[str, list[IssueSummary]]: + self.catalog_batch_calls.append(list(series_provider_ids)) + return { + provider_id: [ + IssueSummary( + provider_id=f"{provider_id}01", + issue_number=1.0, + title="One", + release_date="2026-01-01", + cover_url=None, + issue_type="issue", + ) + ] + for provider_id in series_provider_ids + } + async def get_issues_for_series_by_numbers( self, series_provider_id: str, @@ -304,6 +370,66 @@ async def test_persistent_cache_cached_only_series_lookup_does_not_fetch( assert cache.cache_metrics()["hits"] == {"get_series": 1} +async def test_persistent_cache_bulk_fetches_only_missing_series_and_reuses_rows( + async_engine: AsyncEngine, +) -> None: + session_factory = async_sessionmaker(async_engine, expire_on_commit=False) + provider = _ComicVineProviderDouble() + cache = PersistentComicVineCacheProvider(provider, session_factory) + await cache.get_series("1") + + first = await cache.get_series_batch(["1", "2", "3"]) + second = await PersistentComicVineCacheProvider(provider, session_factory).get_series_batch( + ["3", "2", "1"] + ) + + assert list(first) == ["1", "2", "3"] + assert list(second) == ["3", "2", "1"] + assert provider.series_batch_calls == [["2", "3"]] + + +async def test_persistent_cache_bulk_catalog_and_issue_metadata_populate_singular_keys( + async_engine: AsyncEngine, +) -> None: + session_factory = async_sessionmaker(async_engine, expire_on_commit=False) + provider = _ComicVineProviderDouble() + cache = PersistentComicVineCacheProvider(provider, session_factory) + + catalogs = await cache.get_issue_catalog_batch(["10", "20"]) + issues = await cache.get_issue_batch(["101", "201"]) + cached_catalog = await PersistentComicVineCacheProvider( + provider, session_factory + ).get_issues_for_series("10") + cached_issue = await PersistentComicVineCacheProvider(provider, session_factory).get_issue( + "101" + ) + + assert catalogs["10"][0].provider_id == "1001" + assert issues["101"].description == "Complete issue metadata" + assert cached_catalog == catalogs["10"] + assert cached_issue == issues["101"] + assert provider.catalog_batch_calls == [["10", "20"]] + assert provider.issue_batch_calls == [["101", "201"]] + + +async def test_persistent_cache_forced_refresh_replaces_singular_series_and_catalog_rows( + async_engine: AsyncEngine, +) -> None: + session_factory = async_sessionmaker(async_engine, expire_on_commit=False) + provider = _ComicVineProviderDouble() + cache = PersistentComicVineCacheProvider(provider, session_factory) + await cache.get_series("77") + await cache.get_issue_catalog_batch(["77"]) + + refreshed_series = await cache.refresh_series("77") + refreshed_catalog = await cache.refresh_issue_catalog("77") + + assert refreshed_series.provider_id == "77" + assert refreshed_catalog[0].provider_id == "7701" + assert provider.series_calls == 2 + assert provider.catalog_batch_calls == [["77"], ["77"]] + + async def test_persistent_cache_stores_targeted_issue_numbers_individually( async_engine: AsyncEngine, ) -> None: diff --git a/tests/unit/test_comicvine_provider.py b/tests/unit/test_comicvine_provider.py index f611d7db..8111ec16 100644 --- a/tests/unit/test_comicvine_provider.py +++ b/tests/unit/test_comicvine_provider.py @@ -9,6 +9,7 @@ import httpx import pytest +from pullbox.providers.metadata import comicvine as comicvine_module from pullbox.providers.metadata.comicvine import ComicVineError, ComicVineProvider @@ -49,6 +50,70 @@ def _make_response( return response +def test_rate_limit_resource_key_keeps_api_resources_independent() -> None: + assert comicvine_module._resource_rate_key("/issues/") == "issues" + assert comicvine_module._resource_rate_key("/issue/4000-123/") == "issue" + assert comicvine_module._resource_rate_key("/volumes/") == "volumes" + assert comicvine_module._resource_rate_key("/volume/4050-123/") == "volume" + assert comicvine_module._resource_rate_key("/search/") == "search" + + +@pytest.mark.asyncio +async def test_rate_coordinator_is_shared_by_api_key_and_policy() -> None: + first = comicvine_module._rate_coordinator_for( + api_key="shared-rate-key", + rate_limit=200, + requests_per_second=1, + ) + second = comicvine_module._rate_coordinator_for( + api_key="shared-rate-key", + rate_limit=200, + requests_per_second=3, + ) + other_key = comicvine_module._rate_coordinator_for( + api_key="other-rate-key", + rate_limit=200, + requests_per_second=1, + ) + lower_hourly_policy = comicvine_module._rate_coordinator_for( + api_key="shared-rate-key", + rate_limit=10, + requests_per_second=1, + ) + + assert first is second + assert first is not other_key + assert first is not lower_hourly_policy + + +@pytest.mark.asyncio +async def test_request_uses_shared_resource_aware_rate_coordinator() -> None: + provider = ComicVineProvider( + api_key="coordinator-test-key", + rate_limit=200, + burst_limit=3, + ) + coordinator = MagicMock() + coordinator.acquire = AsyncMock() + provider._rate_coordinator = coordinator + provider._client.get = AsyncMock(return_value=_make_response()) # type: ignore[method-assign] + + try: + await provider._request("/issues/", {"filter": "volume:123"}) + await provider._request("/volumes/", {"filter": "id:123"}) + finally: + await provider._client.aclose() + + assert [call.args[0] for call in coordinator.acquire.await_args_list] == [ + "issues", + "volumes", + ] + assert [call.kwargs for call in coordinator.acquire.await_args_list] == [ + {"requests_per_second": 3}, + {"requests_per_second": 3}, + ] + + @pytest.mark.asyncio async def test_search_series_globally_fetches_volume_offsets() -> None: provider = ComicVineProvider(api_key="offset-test-key", rate_limit=999_999) @@ -411,6 +476,36 @@ async def test_get_series_maps_volume_metadata() -> None: provider._request.assert_awaited_once() # type: ignore[attr-defined] +@pytest.mark.asyncio +async def test_get_series_batch_chunks_ids_and_preserves_requested_identity() -> None: + provider = ComicVineProvider(api_key="series-batch-test-key", rate_limit=999_999) + requested_ids = [str(value) for value in range(1, 102)] + provider._request = AsyncMock( # type: ignore[method-assign] + side_effect=[ + { + "number_of_total_results": 100, + "results": [ + _volume_item(value, f"Series {value}", 2026) for value in range(1, 101) + ], + }, + { + "number_of_total_results": 1, + "results": [_volume_item(101, "Series 101", 2026)], + }, + ] + ) + + try: + result = await provider.get_series_batch(requested_ids) + finally: + await provider._client.aclose() + + assert list(result) == requested_ids + filters = [call.args[1]["filter"] for call in provider._request.await_args_list] + assert filters[0] == "id:" + "|".join(requested_ids[:100]) + assert filters[1] == "id:101" + + @pytest.mark.asyncio async def test_get_issue_maps_issue_metadata_creators_and_story_arcs() -> None: provider = ComicVineProvider(api_key="issue-test-key", rate_limit=999_999) @@ -466,6 +561,41 @@ async def test_get_issue_maps_issue_metadata_creators_and_story_arcs() -> None: assert issue.story_arcs == [{"name": "One World Under Doom", "provider_id": "20"}] +@pytest.mark.asyncio +async def test_get_issue_batch_keeps_full_comicinfo_metadata() -> None: + provider = ComicVineProvider(api_key="issue-batch-test-key", rate_limit=999_999) + first = _issue_item(111, "1") + first.update( + { + "volume": {"id": 9001}, + "description": "

First issue

", + "person_credits": [{"id": 10, "name": "Writer One", "role": "writer"}], + "story_arc_credits": [{"id": 20, "name": "First Arc"}], + } + ) + second = _issue_item(222, "2") + second["volume"] = {"id": 9001} + provider._request = AsyncMock( # type: ignore[method-assign] + return_value={ + "number_of_total_results": 2, + "results": [second, first], + } + ) + + try: + result = await provider.get_issue_batch(["111", "222"]) + finally: + await provider._client.aclose() + + assert list(result) == ["111", "222"] + assert result["111"].description == "First issue" + assert result["111"].creators[0]["name"] == "Writer One" + assert result["111"].story_arcs == [{"name": "First Arc", "provider_id": "20"}] + params = provider._request.await_args.args[1] + assert params["filter"] == "id:111|222" + assert "person_credits" in params["field_list"] + + @pytest.mark.asyncio async def test_comicvine_preserves_suffix_issue_number_text() -> None: provider = ComicVineProvider(api_key="suffix-test-key", rate_limit=999_999) @@ -512,6 +642,35 @@ async def test_get_issues_for_series_paginates_until_total_reached() -> None: assert [call.args[1]["offset"] for call in request_mock.await_args_list] == [0, 100] +@pytest.mark.asyncio +async def test_get_issue_catalog_batch_groups_multiple_volumes_across_pages() -> None: + provider = ComicVineProvider(api_key="catalog-batch-test-key", rate_limit=999_999) + first_page = [] + for index in range(100): + item = _issue_item(1000 + index, str(index + 1)) + item["volume"] = {"id": 10 if index < 60 else 20} + first_page.append(item) + final_item = _issue_item(2000, "41") + final_item["volume"] = {"id": 20} + request_mock = AsyncMock( + side_effect=[ + {"number_of_total_results": 101, "results": first_page}, + {"number_of_total_results": 101, "results": [final_item]}, + ] + ) + provider._request = request_mock # type: ignore[method-assign] + + try: + result = await provider.get_issue_catalog_batch(["10", "20"]) + finally: + await provider._client.aclose() + + assert len(result["10"]) == 60 + assert len(result["20"]) == 41 + assert request_mock.await_args_list[0].args[1]["filter"] == "volume:10|20" + assert [call.args[1]["offset"] for call in request_mock.await_args_list] == [0, 100] + + @pytest.mark.asyncio async def test_get_issues_for_series_by_numbers_deduplicates_and_formats_filters() -> None: provider = ComicVineProvider(api_key="targeted-issues-test-key", rate_limit=999_999) diff --git a/tests/unit/test_comicvine_story_arcs.py b/tests/unit/test_comicvine_story_arcs.py index b1637646..c1ce81a9 100644 --- a/tests/unit/test_comicvine_story_arcs.py +++ b/tests/unit/test_comicvine_story_arcs.py @@ -420,8 +420,8 @@ async def test_hydration_failure_never_publishes_earlier_successful_batches( async def test_real_request_plumbing_keeps_limiter_and_hides_key( provider: ComicVineProvider, ) -> None: - limiter = AsyncMock() - provider._rate_limiter.acquire = limiter # type: ignore[method-assign] + coordinator = AsyncMock() + provider._rate_coordinator = coordinator response = httpx.Response( 200, json={"status_code": 1, "results": _arc()}, @@ -430,7 +430,10 @@ async def test_real_request_plumbing_keeps_limiter_and_hides_key( provider._client.get = AsyncMock(return_value=response) # type: ignore[method-assign] result = await provider.get_story_arc("55766") assert result.membership_complete - limiter.assert_awaited_once() + coordinator.acquire.assert_awaited_once_with( + "story_arc", + requests_per_second=1, + ) assert provider._client.get.call_args.kwargs["params"]["api_key"] == "never-echo-this-key" diff --git a/tests/unit/test_file_matching.py b/tests/unit/test_file_matching.py index c6822dd6..86346902 100644 --- a/tests/unit/test_file_matching.py +++ b/tests/unit/test_file_matching.py @@ -1448,7 +1448,7 @@ async def progress_callback(event: ImportProgressEvent) -> None: if event.message == "Matching files to issues for Batman (2 files)..." ) matching_completed = next( - event for event in progress_events if event.message == "Matched files in Batman" + event for event in progress_events if event.message == "Prepared file review for Batman" ) matched_first = next( event for event in progress_events if event.message == "Matched file 1/2 for Batman" @@ -1462,8 +1462,10 @@ async def progress_callback(event: ImportProgressEvent) -> None: assert target_loading.current_item_progress_pct == 5 assert matching_started.current_item_progress_pct == 50 assert matched_first.current_item_progress_pct == 75 - assert matching_completed.progress >= 99 + assert matching_completed.progress < 99 assert matching_completed.current_item_progress_pct == 100 + assert progress_events[-1].message == "File review summaries ready" + assert progress_events[-1].progress == 99 class TestMultipleSeriesFileMatching: diff --git a/tests/unit/test_github_actions_security_contracts.py b/tests/unit/test_github_actions_security_contracts.py index 5c24c9c8..61b72b3a 100644 --- a/tests/unit/test_github_actions_security_contracts.py +++ b/tests/unit/test_github_actions_security_contracts.py @@ -1010,6 +1010,41 @@ def test_grype_config_tracks_current_dhi_runtime() -> None: } +def test_grype_current_dhi_zlib_and_libuuid_exceptions_are_exact_and_expiring() -> None: + config_text = GRYPE_CONFIG.read_text(encoding="utf-8") + config = _load_yaml(GRYPE_CONFIG) + reviewed_cves = { + "CVE-2026-85091", + "CVE-2026-76642", + "CVE-2026-78408", + "CVE-2026-78409", + "CVE-2026-78410", + } + entries = [entry for entry in config["ignore"] if entry.get("vulnerability") in reviewed_cves] + + assert "Re-review by 2026-10-04" in config_text + assert { + ( + entry["vulnerability"], + entry["package"]["name"], + entry["package"]["version"], + entry["package"]["type"], + ) + for entry in entries + } == { + ( + "CVE-2026-85091", + package, + "1:1.3.dfsg+really1.3.1-1+dhi3", + "deb", + ) + for package in ("zlib1g", "zlib1g-dev") + } | { + (cve, "libuuid1", "2.41.5-0+deb13u1+dhi2", "deb") + for cve in reviewed_cves - {"CVE-2026-85091"} + } + + def test_docker_workflow_signs_and_verifies_published_images() -> None: docker_workflow_path = WORKFLOW_DIR / "docker-release.yml" docker_workflow = docker_workflow_path.read_text(encoding="utf-8") diff --git a/tests/unit/test_health_checks.py b/tests/unit/test_health_checks.py index adf142cb..72fb8ccd 100644 --- a/tests/unit/test_health_checks.py +++ b/tests/unit/test_health_checks.py @@ -70,6 +70,23 @@ def settings(tmp_path) -> MagicMock: return s +async def test_run_check_rolls_back_failed_session_before_persisting( + settings: MagicMock, +) -> None: + session = MagicMock(spec=AsyncSession) + session.is_active = False + session.rollback = AsyncMock() + service = _make_service(settings) + service._check_database = AsyncMock(side_effect=RuntimeError("database is locked")) + service._persist_outcomes = AsyncMock() + + outcomes = await service.run_check(session, "database") + + assert outcomes[0].status == HealthStatus.UNHEALTHY + session.rollback.assert_awaited_once_with() + service._persist_outcomes.assert_awaited_once_with(session, outcomes) + + @pytest.fixture def mock_scheduler() -> MagicMock: sched = MagicMock() @@ -244,9 +261,9 @@ async def test_healthy_small_db(self, settings: MagicMock, size_mb: int) -> None [ (1024**3 - 1, HealthStatus.HEALTHY), (1024**3, HealthStatus.HEALTHY), - (1024**3 + 1, HealthStatus.DEGRADED), - (2 * 1024**3, HealthStatus.DEGRADED), - (2 * 1024**3 + 1, HealthStatus.UNHEALTHY), + (1024**3 + 1, HealthStatus.HEALTHY), + (2 * 1024**3, HealthStatus.HEALTHY), + (2 * 1024**3 + 1, HealthStatus.HEALTHY), ], ) async def test_size_threshold_boundaries( @@ -263,8 +280,8 @@ async def test_size_threshold_boundaries( assert result.status == expected_status @pytest.mark.asyncio - async def test_degraded_large_db(self, settings: MagicMock) -> None: - """Database size between 1 GB and 2 GB -> degraded sub-check.""" + async def test_large_db_size_is_informational(self, settings: MagicMock) -> None: + """A large valid database does not degrade system health by size alone.""" session = self._make_session_with_url("sqlite+aiosqlite:////data/pullbox.db") service = _make_service(settings) @@ -275,13 +292,13 @@ async def test_degraded_large_db(self, settings: MagicMock) -> None: result = await service._check_db_size(session) assert result is not None - assert result.status == HealthStatus.DEGRADED - assert "1200 MB" in result.message - assert "threshold: 1024 MB" in result.message + assert result.status == HealthStatus.HEALTHY + assert "1200.0 MB" in result.message + assert "informational" in result.message @pytest.mark.asyncio - async def test_unhealthy_very_large_db(self, settings: MagicMock) -> None: - """Database size above 2 GB -> unhealthy sub-check.""" + async def test_very_large_db_size_is_informational(self, settings: MagicMock) -> None: + """Integrity, latency, bloat, and free space determine database health.""" session = self._make_session_with_url("sqlite+aiosqlite:////data/pullbox.db") service = _make_service(settings) @@ -292,9 +309,10 @@ async def test_unhealthy_very_large_db(self, settings: MagicMock) -> None: result = await service._check_db_size(session) assert result is not None - assert result.status == HealthStatus.UNHEALTHY - assert "2500 MB" in result.message - assert "threshold: 2048 MB" in result.message + assert result.status == HealthStatus.HEALTHY + assert "2500.0 MB" in result.message + assert result.details["size_bytes"] == 2500 * 1024 * 1024 + assert result.details["classification"] == "informational" @pytest.mark.asyncio async def test_skipped_for_non_sqlite(self, settings: MagicMock) -> None: diff --git a/tests/unit/test_import_catalog_hydration.py b/tests/unit/test_import_catalog_hydration.py index 0effe092..12c7d4e5 100644 --- a/tests/unit/test_import_catalog_hydration.py +++ b/tests/unit/test_import_catalog_hydration.py @@ -8,9 +8,18 @@ from sqlalchemy import select as sa_select from sqlalchemy.ext.asyncio import async_sessionmaker +from pullbox.models.import_job import ( + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) from pullbox.models.library import LibraryRoot +from pullbox.models.operation_progress import OperationProgress, OperationProgressState from pullbox.models.series import IssueCatalogState, Series from pullbox.providers.base import IssueSummary, SeriesMetadata +from pullbox.services import import_catalog_hydration as hydration_module from pullbox.services.comicvine_persistent_cache import PersistentComicVineCacheProvider from pullbox.services.import_catalog_hydration import ( load_catalog_hydration_plan, @@ -101,6 +110,39 @@ async def get_issues_for_series(self, series_provider_id: str) -> list[IssueSumm ] +class BatchCatalogHydrationSeriesServiceStub(CatalogHydrationSeriesServiceStub): + def __init__(self) -> None: + super().__init__() + self.events: list[str] = [] + self.profile_batch_calls: list[list[int]] = [] + self.catalog_batch_calls: list[list[int]] = [] + + async def prefetch_comicvine_profiles(self, comicvine_ids: list[int]): + self.profile_batch_calls.append(list(comicvine_ids)) + self.events.append("profiles_fetched") + return { + comicvine_id: {"title": f"Hydrated {comicvine_id}"} for comicvine_id in comicvine_ids + } + + async def prefetch_comicvine_issue_catalogs(self, comicvine_ids: list[int]): + assert self.events.count("profile_persisted") == sum( + len(batch) for batch in self.profile_batch_calls + ) + self.catalog_batch_calls.append(list(comicvine_ids)) + self.events.append("catalogs_fetched") + return {comicvine_id: [] for comicvine_id in comicvine_ids} + + async def upsert_comicvine_profile(self, hydrate_session, comicvine_id: int, series_meta): + result = await hydrate_session.execute( + sa_select(Series).where(Series.comicvine_id == comicvine_id) + ) + series = result.scalar_one() + series.title = series_meta["title"] + self.events.append("profile_persisted") + await hydrate_session.flush() + return series + + async def test_run_pending_catalog_hydration_recovers_hydrating_series_after_restart( db_session, ) -> None: @@ -161,6 +203,77 @@ async def test_run_pending_catalog_hydration_recovers_hydrating_series_after_res assert no_provider_id.issue_catalog_state == IssueCatalogState.HYDRATING +async def test_run_pending_catalog_hydration_batches_profiles_before_issue_catalogs( + db_session, +) -> None: + series_rows = [ + Series( + title=f"Pending {comicvine_id}", + sort_title=f"pending {comicvine_id}", + comicvine_id=comicvine_id, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + for comicvine_id in (6101, 6102, 6103) + ] + db_session.add_all(series_rows) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + service = BatchCatalogHydrationSeriesServiceStub() + + recovered = await run_pending_catalog_hydration( + session_factory, + series_service=service, + ) + + assert recovered == 3 + assert service.profile_batch_calls == [[6101, 6102, 6103]] + assert service.catalog_batch_calls == [[6101, 6102, 6103]] + assert service.prefetch_calls == [] + assert service.events.index("profiles_fetched") < service.events.index("profile_persisted") + assert service.events.index("profile_persisted") < service.events.index("catalogs_fetched") + for series in series_rows: + await db_session.refresh(series) + assert series.title == f"Hydrated {series.comicvine_id}" + assert series.issue_catalog_state == IssueCatalogState.COMPLETE + + +async def test_run_pending_catalog_hydration_chunks_provider_batches( + db_session, + monkeypatch, +) -> None: + series_rows = [ + Series( + title=f"Chunked {comicvine_id}", + sort_title=f"chunked {comicvine_id}", + comicvine_id=comicvine_id, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + for comicvine_id in (6201, 6202, 6203) + ] + db_session.add_all(series_rows) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + service = BatchCatalogHydrationSeriesServiceStub() + monkeypatch.setattr(hydration_module, "COMICVINE_BULK_BATCH_SIZE", 2, raising=False) + + recovered = await run_pending_catalog_hydration( + session_factory, + series_service=service, + ) + + assert recovered == 3 + assert service.profile_batch_calls == [[6201, 6202], [6203]] + assert service.catalog_batch_calls == [[6201, 6202], [6203]] + + async def test_catalog_hydration_does_not_recreate_series_deleted_during_fetch( db_session, ) -> None: @@ -205,6 +318,37 @@ async def add_from_prefetched(*_args, **_kwargs): assert hydrated is False +async def test_catalog_hydration_skips_series_completed_after_queue_snapshot(db_session) -> None: + series = Series( + title="Completed While Queued", + sort_title="completed while queued", + comicvine_id=4041, + monitored=True, + issue_catalog_state=IssueCatalogState.COMPLETE, + ) + db_session.add(series) + await db_session.commit() + session_factory = async_sessionmaker( + db_session.bind, + class_=type(db_session), + expire_on_commit=False, + ) + prefetch = AsyncMock() + persist = AsyncMock() + + hydrated = await run_catalog_hydration( + session_factory, + series_id=series.id, + search_on_add=True, + prefetch_comicvine_bundle=prefetch, + add_from_comicvine_prefetched=persist, + ) + + assert hydrated is False + prefetch.assert_not_awaited() + persist.assert_not_awaited() + + async def test_load_catalog_hydration_plan_uses_explicit_preferred_root(db_session) -> None: current_root = LibraryRoot( name="Existing", @@ -391,3 +535,38 @@ async def test_mark_catalog_hydration_failed_sets_retryable_state(db_session) -> assert series.issue_catalog_error == "ComicVine timed out" assert series.issue_catalog_last_synced_at is None assert series.issue_catalog_last_checked_at is None + + +async def test_restart_catalog_hydration_publishes_one_import_summary(db_session) -> None: + job = ImportJob( + source_path="/imports", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.COMPLETED, + ) + series = Series( + title="Imported", + sort_title="imported", + comicvine_id=4001, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + db_session.add_all([job, series]) + await db_session.flush() + db_session.add( + ImportedSeries( + import_job_id=job.id, + series_id=series.id, + raw_series_name="Imported", + status=ImportSeriesStatus.IMPORTED, + ) + ) + await db_session.commit() + factory = async_sessionmaker(db_session.bind, expire_on_commit=False) + + await run_pending_catalog_hydration(factory, series_service=CatalogHydrationSeriesServiceStub()) + + progress = (await db_session.scalars(sa_select(OperationProgress))).one_or_none() + assert progress is not None + assert progress.operation_key == f"metadata:{job.id}" + assert progress.state == OperationProgressState.COMPLETED + assert progress.overall_percent == 100 + assert progress.item_key is None diff --git a/tests/unit/test_import_comicinfo_enrichment.py b/tests/unit/test_import_comicinfo_enrichment.py index 66a4b86d..bd559fd1 100644 --- a/tests/unit/test_import_comicinfo_enrichment.py +++ b/tests/unit/test_import_comicinfo_enrichment.py @@ -4,8 +4,10 @@ import threading from datetime import UTC, date, datetime from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, call import pytest +from sqlalchemy import select from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -21,10 +23,12 @@ ) from pullbox.models.issue import Issue, IssueStatus, IssueType from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot, MatchConfidence +from pullbox.models.operation_progress import OperationProgress, OperationProgressState from pullbox.models.series import Series from pullbox.services import import_comicinfo_enrichment as enrichment_module from pullbox.services.import_comicinfo_enrichment import ( PreparedComicInfoEnrichment, + _run_import_comicinfo_enrichment_while_fenced, run_import_comicinfo_enrichment, run_pending_import_comicinfo_enrichment, ) @@ -33,6 +37,44 @@ from pathlib import Path +@pytest.mark.asyncio +async def test_comicinfo_prefetch_chunks_provider_batches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + prefetch_issue_metadata = AsyncMock() + monkeypatch.setattr(enrichment_module, "COMICVINE_BULK_BATCH_SIZE", 2, raising=False) + monkeypatch.setattr( + enrichment_module, + "_import_job_is_completed", + AsyncMock(return_value=True), + ) + monkeypatch.setattr( + enrichment_module, + "_load_pending_imported_file_ids", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr( + enrichment_module, + "_load_pending_issue_cv_ids", + AsyncMock(return_value=[101, 102, 103]), + ) + monkeypatch.setattr(enrichment_module, "wait_for_comicinfo_turn", AsyncMock()) + + await _run_import_comicinfo_enrichment_while_fenced( + AsyncMock(), + job_id=7, + build_comicinfo_payload=AsyncMock(), + apply_comicinfo=AsyncMock(), + log_event=AsyncMock(), + prefetch_issue_metadata=prefetch_issue_metadata, + ) + + assert prefetch_issue_metadata.await_args_list == [ + call([101, 102]), + call([103]), + ] + + @pytest.mark.asyncio async def test_run_import_comicinfo_enrichment_rewrites_pending_library_file( async_engine, @@ -185,6 +227,7 @@ async def commit_with_one_transient_lock(session: AsyncSession) -> None: monkeypatch.setattr(AsyncSession, "commit", commit_with_one_transient_lock) monkeypatch.setattr(enrichment_module, "sqlite_lock_retry_delay", lambda _attempt: 0.0) + prefetch_issue_metadata = AsyncMock() await run_import_comicinfo_enrichment( session_factory, @@ -192,6 +235,7 @@ async def commit_with_one_transient_lock(session: AsyncSession) -> None: build_comicinfo_payload=build_payload, apply_comicinfo=apply_comicinfo, log_event=log_event, + prefetch_issue_metadata=prefetch_issue_metadata, ) assert applied == [ @@ -209,6 +253,7 @@ async def commit_with_one_transient_lock(session: AsyncSession) -> None: assert apply_thread_ids != [event_loop_thread_id] assert lock_injected is True assert build_calls == 2 + prefetch_issue_metadata.assert_awaited_once_with([1234567]) async with session_factory() as session: imported_file = await session.get(ImportedFile, imported_file_id) issue = await session.get(Issue, issue_id) @@ -221,6 +266,123 @@ async def commit_with_one_transient_lock(session: AsyncSession) -> None: assert imported_file.diagnostics["comicinfo_enrichment"]["status"] == "complete" assert imported_file.diagnostics["comicinfo_enrichment"]["library_file_id"] is not None assert issue.description == "Refreshed ComicVine summary." + progress = (await session.scalars(select(OperationProgress))).one_or_none() + assert progress is not None + assert progress.operation_key == f"metadata:{job_id}" + assert progress.state == OperationProgressState.COMPLETED + assert progress.overall_percent == 100 + assert progress.item_key is None + + +@pytest.mark.asyncio +async def test_deferred_enrichment_rejects_reused_library_file_identity( + async_engine, + tmp_path: Path, +) -> None: + session_factory = async_sessionmaker(async_engine, expire_on_commit=False) + replacement_path = tmp_path / "Replacement Issue.cbz" + replacement_path.write_text("must not change") + + async with session_factory() as session: + root = LibraryRoot(name="Comics", path=str(tmp_path), enabled=True) + original_series = Series( + title="Original Series", + sort_title="original series", + comicvine_id=1001, + ) + replacement_series = Series( + title="Replacement Series", + sort_title="replacement series", + comicvine_id=2001, + ) + session.add_all([root, original_series, replacement_series]) + await session.flush() + original_issue = Issue( + series_id=original_series.id, + issue_number=1.0, + comicvine_id=1002, + issue_type=IssueType.ISSUE, + status=IssueStatus.WANTED, + ) + replacement_issue = Issue( + series_id=replacement_series.id, + issue_number=1.0, + comicvine_id=2002, + issue_type=IssueType.ISSUE, + status=IssueStatus.WANTED, + ) + session.add_all([original_issue, replacement_issue]) + await session.flush() + library_file = LibraryFile( + file_path=str(replacement_path), + file_name=replacement_path.name, + file_size=replacement_path.stat().st_size, + file_format=FileFormat.CBZ, + file_modified_at=datetime.now(tz=UTC), + match_confidence=MatchConfidence.HIGH, + issue_id=replacement_issue.id, + library_root_id=root.id, + ) + job = ImportJob( + source_path=str(tmp_path), + source_type=ImportSourceType.FILESYSTEM, + status=ImportJobStatus.COMPLETED, + ) + session.add_all([library_file, job]) + await session.flush() + imported_series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Original Series", + status=ImportSeriesStatus.IMPORTED, + series_id=original_series.id, + ) + session.add(imported_series) + await session.flush() + imported_file = ImportedFile( + import_job_id=job.id, + import_series_id=imported_series.id, + file_path=str(tmp_path / "original-source.cbz"), + file_name="original-source.cbz", + file_size=100, + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + library_file_id=library_file.id, + diagnostics={ + "comicinfo_enrichment": { + "status": "pending", + "issue_id": original_issue.id, + "issue_cv_id": original_issue.comicvine_id, + "library_file_id": library_file.id, + "artifact_path": str(tmp_path / "original-destination.cbz"), + } + }, + ) + session.add(imported_file) + await session.commit() + job_id = job.id + imported_file_id = imported_file.id + + build_payload = AsyncMock() + apply_comicinfo = AsyncMock() + log_event = AsyncMock() + + await run_import_comicinfo_enrichment( + session_factory, + job_id=job_id, + build_comicinfo_payload=build_payload, + apply_comicinfo=apply_comicinfo, + log_event=log_event, + ) + + build_payload.assert_not_awaited() + apply_comicinfo.assert_not_awaited() + assert replacement_path.read_text() == "must not change" + async with session_factory() as session: + imported_file = await session.get(ImportedFile, imported_file_id) + assert imported_file is not None + details = imported_file.diagnostics["comicinfo_enrichment"] + assert details["status"] == "failed" + assert "no longer matches" in details["error"] @pytest.mark.asyncio diff --git a/tests/unit/test_import_comicinfo_rollback_race.py b/tests/unit/test_import_comicinfo_rollback_race.py index 77bdf5a6..9d87c538 100644 --- a/tests/unit/test_import_comicinfo_rollback_race.py +++ b/tests/unit/test_import_comicinfo_rollback_race.py @@ -8,8 +8,9 @@ from unittest.mock import AsyncMock import pytest -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from pullbox.models import Base from pullbox.models.import_job import ( ImportJob, ImportJobAction, @@ -26,6 +27,16 @@ from sqlalchemy.ext.asyncio import AsyncEngine +@pytest.fixture +async def async_engine(tmp_path): + """Give concurrent progress readers and rollback writers separate connections.""" + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'rollback.db'}") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + async def _seed_rollback_job( session: AsyncSession, tmp_path: Path, diff --git a/tests/unit/test_import_counters.py b/tests/unit/test_import_counters.py index dcc541f4..373d6255 100644 --- a/tests/unit/test_import_counters.py +++ b/tests/unit/test_import_counters.py @@ -207,3 +207,40 @@ async def test_recompute_file_counters_updates_series_job_and_duplicate_diagnost assert job.total_files_matched == 2 assert job.total_files_already_owned == 1 assert job.total_files_failed == 1 + + +async def test_recompute_file_counters_does_not_shrink_discovery_total(db_session) -> None: + service = _make_service() + job = await _create_job_row(db_session) + job.total_files_found = 5 + job.scan_total_files = 6 + series = ImportedSeries( + import_job_id=job.id, + raw_series_name="Batman", + status=ImportSeriesStatus.MATCHED, + ) + db_session.add(series) + await db_session.flush() + db_session.add_all( + [ + _make_imported_file( + job, + series, + name="batman-001.cbz", + status=ImportedFileStatus.IMPORTED, + ), + _make_imported_file( + job, + series, + name="batman-changed.cbz", + status=ImportedFileStatus.FAILED, + ), + ] + ) + await db_session.flush() + + await service._recompute_file_counters(db_session, job) + + assert job.total_files_found == 6 + assert job.total_files_imported == 1 + assert job.total_files_failed == 1 diff --git a/tests/unit/test_import_file_execution.py b/tests/unit/test_import_file_execution.py index 3d1ecce8..97774286 100644 --- a/tests/unit/test_import_file_execution.py +++ b/tests/unit/test_import_file_execution.py @@ -1628,6 +1628,7 @@ async def _register_file( assert pending["issue_id"] == issues[0].id assert pending["issue_cv_id"] == issues[0].comicvine_id assert pending["library_file_id"] is not None + assert pending["artifact_path"] == str(final_path) payload = record_action.await_args.kwargs["payload"] assert payload["embedded_comicinfo_enrichment_deferred"] is True events = [call.args[3] for call in log_event.await_args_list] @@ -3478,6 +3479,40 @@ async def test_unresolved_conflicts_cannot_be_confirmed(self, db_session: AsyncS assert imp_files[0].status == ImportedFileStatus.CONFLICT assert imp_files[1].status == ImportedFileStatus.CONFLICT + @pytest.mark.asyncio + async def test_clean_files_in_mixed_group_can_be_confirmed_without_importing_conflict( + self, db_session: AsyncSession + ) -> None: + job, imp_series, imp_files, _series, _issues = await _setup_full_scenario( + db_session, + num_issues=2, + job_status=ImportJobStatus.REVIEW, + series_status=ImportSeriesStatus.MATCHED, + file_statuses=[ + ImportedFileStatus.MATCHED, + ImportedFileStatus.CONFLICT, + ], + ) + imp_series.files_total = 2 + imp_series.files_matched = 1 + imp_series.files_conflict = 1 + imp_files[1].conflict_group_id = 1 + imp_files[1].is_preferred = True + await db_session.flush() + + svc = _make_service() + await svc.confirm_import( + db_session, + job.id, + ConfirmImportRequest(series_ids=[imp_series.id]), + ) + + await db_session.refresh(imp_files[0]) + await db_session.refresh(imp_files[1]) + assert imp_files[0].status == ImportedFileStatus.CONFIRMED + assert imp_files[1].status == ImportedFileStatus.CONFLICT + assert imp_files[1].include_in_import is False + @pytest.mark.asyncio async def test_conflict_resolution_marks_preferred_confirmed( self, db_session: AsyncSession diff --git a/tests/unit/test_import_file_match_outcomes.py b/tests/unit/test_import_file_match_outcomes.py index f1b77971..7a4885a1 100644 --- a/tests/unit/test_import_file_match_outcomes.py +++ b/tests/unit/test_import_file_match_outcomes.py @@ -69,6 +69,57 @@ def test_apply_matched_file_outcome_marks_new_series_file_matched() -> None: assert event.data["confidence"] == "medium" +def test_apply_matched_file_outcome_preserves_exact_suffix_issue_number() -> None: + imp_file = ImportedFile( + file_name="The Amazing Spider-Man 54.LR.cbz", + parsed_issue_number=54.0, + issue_number_raw="54.LR", + ) + imp_series = ImportedSeries(raw_series_name="The Amazing Spider-Man") + candidate = FileMatchCandidate( + matched_issue_id=None, + matched_issue_cv_id=123456, + target_issue_number=54.0, + has_library_file=False, + matched_issue=None, + target_issue_title="Last Remains", + confidence="high", + method="comicvine_id", + ) + + apply_matched_file_outcome( + imp_file, + imp_series, + candidate, + duplicate_series=False, + duplicate_target_state=duplicate_target_state, + ) + + assert imp_file.diagnostics["target_issue_summary"]["issue_number_text"] == "54LR" + + +def test_apply_unmatched_file_outcome_always_records_specific_reason() -> None: + imp_file = ImportedFile( + file_name="Unknown Special.cbz", + parsed_issue_number=None, + diagnostics={"source_metadata": {"filename_parse": {"series": "Unknown Special"}}}, + ) + imp_series = ImportedSeries(raw_series_name="Unknown Special") + + apply_unmatched_file_outcome( + imp_file, + imp_series, + duplicate_series=False, + duplicate_merge_profile=None, + metadata_conflict=None, + ) + + assert imp_file.diagnostics["reason"] == "issue_target_not_found" + assert imp_file.diagnostics["rejection_reason"] == ( + "No issue target matched the available file name and metadata evidence." + ) + + def test_apply_matched_file_outcome_marks_multi_entry_graphic_novel_as_volume() -> None: imp_file = ImportedFile( file_name="AL15 002 (2022) (Graphic Novel) (AAM-Markosia) (Digital-HD).cbz", @@ -172,7 +223,12 @@ def test_apply_unmatched_file_outcome_marks_new_series_no_match() -> None: assert imp_file.status == ImportedFileStatus.NO_MATCH assert imp_file.include_in_import is False - assert imp_file.diagnostics == {} + assert imp_file.diagnostics == { + "reason": "issue_target_not_found", + "rejection_reason": ( + "No issue target matched the available file name and metadata evidence." + ), + } assert event.name == "import_file_no_match_detail" assert event.data["diagnostics"] is None @@ -199,6 +255,10 @@ def test_apply_unmatched_file_outcome_preserves_source_metadata_diagnostics() -> assert imp_file.diagnostics == { "source_issue_type": "volume", "source_metadata": {"filename_parse": {"volume": "Vol 02"}}, + "reason": "issue_target_not_found", + "rejection_reason": ( + "No issue target matched the available file name and metadata evidence." + ), } @@ -216,7 +276,7 @@ def test_apply_unmatched_file_outcome_uses_metadata_conflict() -> None: ) assert imp_file.status == ImportedFileStatus.NO_MATCH - assert imp_file.diagnostics == conflict + assert imp_file.diagnostics == {**conflict, "reason": "metadata_conflict"} assert event.name == "import_file_metadata_conflict" assert event.data["diagnostics"] == conflict @@ -242,6 +302,7 @@ def test_apply_unmatched_file_outcome_marks_duplicate_informational_only() -> No assert imp_file.status == ImportedFileStatus.NO_MATCH assert imp_file.diagnostics == { "kind": "duplicate_series_file", + "reason": "duplicate_series_no_importable_target", "target_state": "no_importable_targets", "actionable_duplicate_merge": False, "existing_issue_count": 3, diff --git a/tests/unit/test_import_file_match_results.py b/tests/unit/test_import_file_match_results.py index 1ccce387..36266299 100644 --- a/tests/unit/test_import_file_match_results.py +++ b/tests/unit/test_import_file_match_results.py @@ -239,6 +239,58 @@ def test_file_match_summary_preserves_trusted_folder_series_with_unmatched_file( assert series.cv_match_method == "comicinfo_cv_id" +def test_file_match_summary_quarantines_conflict_without_invalidating_mylar_series() -> None: + series = ImportedSeries( + raw_series_name="Firefly", + raw_year=2018, + status=ImportSeriesStatus.MATCHED, + cv_id=112340, + cv_title="Firefly", + cv_match_score=1.0, + cv_match_method="mylar3_cv_id", + diagnostics={"kind": "series_match"}, + ) + files = [ + ImportedFile(file_name="Firefly 006.cbz", status=ImportedFileStatus.MATCHED), + ImportedFile( + file_name="Firefly 007 Variant.cbz", + status=ImportedFileStatus.NO_MATCH, + diagnostics={ + "kind": "metadata_conflict", + "conflict_type": "trusted_source_identity_conflict", + "rejection_reason": "Sidecar identity conflicts with Mylar.", + "identity_conflicts": [ + { + "field": "comicvine_series_id", + "mylar3": 112340, + "sidecar": 999999, + } + ], + }, + ), + ] + + summary = apply_file_match_series_summary( + series, + files, + duplicate_series=False, + duplicate_merge_profile=None, + cv_match_threshold=0.88, + ) + + assert summary.series_invalidated is False + assert series.status == ImportSeriesStatus.MATCHED + assert series.cv_id == 112340 + assert series.cv_match_method == "mylar3_cv_id" + assert series.diagnostics["file_identity_conflict_count"] == 1 + assert series.diagnostics["conflicting_files"] == [ + { + "file_name": "Firefly 007 Variant.cbz", + "rejection_reason": "Sidecar identity conflicts with Mylar.", + } + ] + + def test_file_match_summary_holds_mixed_selected_layout_group_for_review() -> None: series = ImportedSeries( raw_series_name="Batman", diff --git a/tests/unit/test_import_file_matching_paging.py b/tests/unit/test_import_file_matching_paging.py index 83c85f7c..c8b735b1 100644 --- a/tests/unit/test_import_file_matching_paging.py +++ b/tests/unit/test_import_file_matching_paging.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import AsyncMock @@ -25,6 +26,7 @@ from pullbox.models.issue import Issue, IssueStatus from pullbox.models.series import Series from pullbox.services import import_file_matching as file_matching +from pullbox.services.import_progress_runtime import scan_review_progress_pct from pullbox.services.import_service import ImportService if TYPE_CHECKING: @@ -104,6 +106,251 @@ async def _job(session: AsyncSession) -> ImportJob: return job +@pytest.mark.parametrize("source_type", [ImportSourceType.MYLAR3, ImportSourceType.FILESYSTEM]) +async def test_blocked_only_review_is_batched_and_progress_counts_remaining_summary_work( + db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, source_type: ImportSourceType +) -> None: + job = await _job(db_session) + job.source_type = source_type + job.progress_snapshot = {"progress": 49, "phase": "matching"} + job.total_files_found = 48 + job.scan_started_at = datetime.now(UTC) - timedelta(minutes=10) + job.scan_completed_at = datetime.now(UTC) - timedelta(minutes=5) + groups = [ + ImportedSeries( + import_job_id=job.id, + raw_series_name=f"Blocked {i}", + status=ImportSeriesStatus.MATCHED, + cv_id=1000 + i, + file_count=4, + ) + for i in range(12) + ] + db_session.add_all(groups) + await db_session.flush() + files = [] + for group in groups: + for i in range(4): + item = _imported_file( + job_id=job.id, + import_series_id=group.id, + ordinal=i, + issue_number=float(i + 1), + series_name=group.raw_series_name, + ) + item.status = ImportedFileStatus.SAFETY_BLOCKED + item.diagnostics = {"safety_block": {"kind": "single_page_comic"}} + files.append(item) + db_session.add_all(files) + await db_session.commit() + service, provider = _service_with_provider_spy() + plan = await file_matching._build_file_matching_plan( + db_session, job, series_ids=None, raise_if_cancelled=AsyncMock(), page_size=5 + ) + assert ( + scan_review_progress_pct( + plan.progress_plan, completed_weight=plan.completed_file_match_weight + ) + == 49 + ) + assert plan.progress_plan.total_weight > plan.completed_file_match_weight + original_summary = file_matching._summarize_import_series_files + summary_spy = AsyncMock(wraps=original_summary) + count_spy = AsyncMock(wraps=file_matching._count_import_series_files) + monkeypatch.setattr(file_matching, "_summarize_import_series_files", summary_spy) + monkeypatch.setattr(file_matching, "_count_import_series_files", count_spy) + monkeypatch.setattr(file_matching, "_SERIES_PAGE_SIZE", 5) + events = [] + eta_starts = [] + started = datetime.now(UTC) + + def estimate(started_at, *, completed_units, total_units, **_kwargs): + eta_starts.append(started_at) + return 10 if 0 < completed_units < total_units else None + + monkeypatch.setattr(file_matching, "estimate_remaining_work_seconds", estimate) + finalize = file_matching._rebuild_cross_series_conflicts + + async def check_finalization(*args, **kwargs): + assert events[-1].progress < 99 + assert "Finalizing" in events[-1].message + return await finalize(*args, **kwargs) + + monkeypatch.setattr(file_matching, "_rebuild_cross_series_conflicts", check_finalization) + + async def capture(event): + events.append(event) + assert job.total_files_found == 48 + + await service._run_file_matching(db_session, job, progress_callback=capture) + assert summary_spy.await_count == 0 + assert count_spy.await_count == 0 + assert not provider.metadata_calls + assert [e.progress for e in events] == sorted(e.progress for e in events) + assert events[0].progress == 49 + assert events[-1].progress == 99 + assert any(49 < e.progress < 99 for e in events) + assert any(e.estimated_seconds_remaining == 10 for e in events) + assert all(value >= started for value in eta_starts) + assert any("review" in e.message.lower() for e in events) + assert all(group.status == ImportSeriesStatus.MATCHED for group in groups) + assert all(group.files_total == 4 and group.files_matched == 0 for group in groups) + assert all(item.status == ImportedFileStatus.SAFETY_BLOCKED for item in files) + assert all( + item.diagnostics == {"safety_block": {"kind": "single_page_comic"}} for item in files + ) + + +async def test_blocked_summary_batch_excludes_mixed_linked_and_identity_conflict_groups(db_session): + job = await _job(db_session) + canonical, _issues = await _canonical_series(db_session, title="Existing", issue_count=1) + groups = [ + ImportedSeries( + import_job_id=job.id, + raw_series_name=f"Case {i}", + status=ImportSeriesStatus.MATCHED, + cv_id=500 + i, + ) + for i in range(6) + ] + groups[4].series_id = canonical.id + groups[5].status = ImportSeriesStatus.DUPLICATE + db_session.add_all(groups) + await db_session.flush() + for i, group in enumerate(groups): + item = _imported_file( + job_id=job.id, + import_series_id=group.id, + ordinal=i, + issue_number=1, + series_name=group.raw_series_name, + ) + item.status = ImportedFileStatus.SAFETY_BLOCKED + item.diagnostics = {"safety_block": {"kind": "single_page_comic"}} + if i in (2, 3): + item.diagnostics = {"kind": "metadata_conflict" if i == 2 else "source_layout_review"} + db_session.add(item) + db_session.add( + _imported_file( + job_id=job.id, + import_series_id=groups[1].id, + ordinal=10, + issue_number=2, + series_name="Mixed", + ) + ) + await db_session.flush() + assert await file_matching._blocked_only_summary_counts(db_session, groups) == {groups[0].id: 1} + summary = await file_matching._summarize_import_series_files( + db_session, + groups[0], + duplicate_series=False, + duplicate_merge_profile=None, + cv_match_threshold=job.cv_match_threshold, + raise_if_cancelled=AsyncMock(), + job_id=job.id, + ) + assert summary == file_matching.FileMatchSeriesSummary(1, 0, 0, 0, 0, 0) + assert groups[0].status == ImportSeriesStatus.MATCHED + + +async def test_blocked_summary_pages_check_cancellation_before_loading_next_page(db_session): + job = await _job(db_session) + db_session.add_all( + [ + ImportedSeries( + import_job_id=job.id, + raw_series_name=f"Case {i}", + status=ImportSeriesStatus.MATCHED, + cv_id=1000 + i, + ) + for i in range(11) + ] + ) + await db_session.flush() + checks = AsyncMock(side_effect=[None, JobCancelledError("cancelled")]) + seen = [] + with pytest.raises(JobCancelledError): + async for _, item, _ in file_matching._iter_eligible_import_series( + db_session, + job_id=job.id, + series_ids=None, + raise_if_cancelled=checks, + page_size=5, + blocked_only_counts={}, + ): + seen.append(item.id) + assert len(seen) == 5 + + +async def test_split_matching_invalidates_cached_blocked_summary_on_same_page( + db_session, monkeypatch +): + job = await _job(db_session) + canonical, _ = await _canonical_series(db_session, title="Parent", issue_count=2) + parent = ImportedSeries( + import_job_id=job.id, + raw_series_name="Parent", + series_id=canonical.id, + status=ImportSeriesStatus.MATCHED, + file_count=2, + ) + children = [ + ImportedSeries( + import_job_id=job.id, + raw_series_name=f"Child {i}", + status=ImportSeriesStatus.MATCHED, + cv_id=4000 + i, + file_count=1, + cv_match_method="mylar3_cv_id", + ) + for i in range(2) + ] + db_session.add_all([parent, *children]) + await db_session.flush() + for i, group in enumerate([parent, *children]): + for n in range(2 if i == 0 else 1): + item = _imported_file( + job_id=job.id, + import_series_id=group.id, + ordinal=n, + issue_number=float(n + 1), + series_name=group.raw_series_name, + ) + if i: + item.status = ImportedFileStatus.SAFETY_BLOCKED + db_session.add(item) + await db_session.commit() + + async def move_to_child_reviews(_session, _job, group, files, **_kwargs): + assert group.id == parent.id + for item, child in zip(files, children, strict=True): + item.import_series_id = child.id + item.status = ImportedFileStatus.NO_MATCH + child.file_count += 1 + parent.file_count = 0 + parent.status = ImportSeriesStatus.SKIPPED + return [], [child.id for child in children] + + monkeypatch.setattr( + file_matching, "_split_explicit_issue_series_mismatches", move_to_child_reviews + ) + monkeypatch.setattr(file_matching, "_SERIES_PAGE_SIZE", 2) + service, provider = _service_with_provider_spy() + + async def check_checkpoint(event): + if event.message == "Prepared file review for Child 0": + assert children[0].files_total == 2 + assert children[0].files_no_match == 1 + + await service._run_file_matching(db_session, job, progress_callback=check_checkpoint) + assert not provider.metadata_calls + for child in children: + await db_session.refresh(child) + assert child.files_total == 2 + assert child.files_no_match == 1 + + def _imported_file( *, job_id: int, diff --git a/tests/unit/test_import_file_resolution.py b/tests/unit/test_import_file_resolution.py index 56a9fe87..362d1834 100644 --- a/tests/unit/test_import_file_resolution.py +++ b/tests/unit/test_import_file_resolution.py @@ -119,7 +119,7 @@ def _make_file( ) -async def test_load_importable_files_includes_preferred_conflicts_for_new_series( +async def test_load_importable_files_excludes_unresolved_preferred_conflicts_for_new_series( db_session: AsyncSession, ) -> None: job = await _create_job(db_session) @@ -146,7 +146,7 @@ async def test_load_importable_files_includes_preferred_conflicts_for_new_series files = await load_importable_files(db_session, item) - assert files == [matched, confirmed, preferred_conflict] + assert files == [matched, confirmed] async def test_load_importable_files_duplicate_mode_requires_selected_files( diff --git a/tests/unit/test_import_job_control_actions.py b/tests/unit/test_import_job_control_actions.py index c73e7684..c20d096d 100644 --- a/tests/unit/test_import_job_control_actions.py +++ b/tests/unit/test_import_job_control_actions.py @@ -16,6 +16,7 @@ confirm_import_response, get_import_preview_response, resume_import_job_response, + retry_failed_series_response, ) from pullbox.models.import_job import ( ImportControlRequest, @@ -194,6 +195,29 @@ async def test_resume_import_job_response_triggers_only_active_import_states() - trigger_resume.assert_called_once_with(42) +@pytest.mark.asyncio +async def test_retry_failed_response_does_not_trigger_empty_retry() -> None: + """A durable blocked-source result must not launch an empty Step 4 task.""" + service = AsyncMock() + service.retry_failed_series.return_value = ( + _import_job(ImportJobStatus.COMPLETED), + 0, + ) + session = AsyncMock() + trigger_execute = MagicMock() + + response = await retry_failed_series_response( + service, + session=session, + job_id=42, + trigger_import_execute=trigger_execute, + ) + + assert response.retrying_count == 0 + session.commit.assert_awaited_once() + trigger_execute.assert_not_called() + + @pytest.mark.asyncio async def test_clear_import_history_response_deletes_only_terminal_jobs( db_session: AsyncSession, diff --git a/tests/unit/test_import_job_execution.py b/tests/unit/test_import_job_execution.py index 8c59f2ea..16fd1118 100644 --- a/tests/unit/test_import_job_execution.py +++ b/tests/unit/test_import_job_execution.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -29,6 +30,7 @@ from pullbox.models.issue import Issue, IssueStatus, IssueType from pullbox.models.library import LibraryRoot from pullbox.models.series import IssueCatalogState, Series, SeriesStatus, SeriesType +from pullbox.providers.base import IssueSummary from pullbox.schemas.import_job import ImportProgressEvent from pullbox.services import library_root_management from pullbox.services.import_file_execution import process_import_series_files @@ -47,6 +49,79 @@ from pullbox.services.import_workflow_state import emit_progress +@pytest.mark.parametrize("source_type", [ImportSourceType.MYLAR3, ImportSourceType.FILESYSTEM]) +async def test_completed_group_eta_uses_remaining_work_before_one_percent( + db_session, monkeypatch, source_type +): + from pullbox.services import import_progress_runtime + from pullbox.services.import_operation_progress import build_import_operation_update + from pullbox.services.import_workflow_state import estimate_remaining_seconds + from pullbox.ui.import_progress_snapshot import build_import_progress_snapshot + + job = ImportJob( + source_path="/tmp/imports", + source_type=source_type, + import_started_at=datetime.now(UTC) - timedelta(days=1), + ) + db_session.add(job) + await db_session.flush() + db_session.add_all( + ImportedSeries( + import_job_id=job.id, + raw_series_name=f"Series {index:04d}", + status=ImportSeriesStatus.CONFIRMED, + cv_id=900_000 + index, + ) + for index in range(300) + ) + await db_session.commit() + events = [] + measured_starts = [] + invocation_start = datetime.now(UTC) + + def elapsed(started_at): + measured_starts.append(started_at) + return 20 + + async def complete_group(_session, _job, item, **kwargs): + item.status = ImportSeriesStatus.IMPORTED + return 0, 0, kwargs["imported_count"] + 1, 0, True + + async def capture(event): + events.append(event.model_copy(deep=True)) + + async def stop_after_group(): + raise JobCancelledError() + + monkeypatch.setattr(import_progress_runtime, "elapsed_seconds_since", elapsed) + monkeypatch.setattr(import_job_execution, "_execute_new_series", complete_group) + with pytest.raises(JobCancelledError): + await execute_import_job( + db_session, + job.id, + series_service=object(), + process_series_files=AsyncMock(), + raise_if_cancelled=AsyncMock(), + record_action=AsyncMock(), + log_event=AsyncMock(), + emit_progress=emit_progress, + estimate_remaining_seconds=estimate_remaining_seconds, + maybe_slow_item_delay=stop_after_group, + progress_callback=capture, + ) + + completed = next(event for event in events if event.message == "Processed 1/300 review groups") + assert completed.progress == 0 + assert completed.estimated_seconds_remaining == 5980 + assert measured_starts and all(start >= invocation_start for start in measured_starts) + await db_session.refresh(job) + hydrated = build_import_progress_snapshot( + job, review_summary={}, recent_logs=[], progress_revision=job.progress_revision + ) + assert hydrated["estimated_seconds_remaining"] == 5980 + assert build_import_operation_update(job, completed).eta_seconds == 5980 + + @pytest.fixture(autouse=True) async def default_managed_root( db_session, @@ -985,6 +1060,102 @@ def test_targeted_issue_summaries_preserve_review_issue_type() -> None: assert summaries[0].issue_type == IssueType.VOLUME.value +def test_targeted_issue_summaries_repair_missing_payload_and_preserve_exact_number() -> None: + imp_file = ImportedFile( + file_name="The Amazing Spider-Man 54.LR.cbz", + parsed_issue_number=54.0, + issue_number_raw="54.LR", + matched_issue_cv_id=1149277, + match_method="comicvine_id", + diagnostics={"source_issue_type": IssueType.ISSUE.value}, + ) + + summaries = _targeted_issue_summaries_for_import_files([imp_file]) + + assert summaries == [ + IssueSummary( + provider_id="1149277", + issue_number=54.0, + title=None, + release_date=None, + cover_url=None, + issue_type=IssueType.ISSUE.value, + issue_number_text="54LR", + ) + ] + assert imp_file.diagnostics["target_issue_summary"]["issue_number_text"] == "54LR" + + +def test_targeted_issue_summaries_repair_from_saved_comicinfo_number() -> None: + imp_file = ImportedFile( + file_name="2000ad Prog (2021).cbz", + matched_issue_cv_id=861153, + match_method="comicvine_id", + diagnostics={ + "source_issue_type": IssueType.ISSUE.value, + "source_metadata": {"comicinfo": {"number": "2236"}}, + }, + ) + + summaries = _targeted_issue_summaries_for_import_files([imp_file]) + + assert summaries[0].issue_number == 2236.0 + assert summaries[0].issue_number_text == "2236" + + +def test_targeted_issue_summaries_repair_saved_mylar_bracket_number() -> None: + imp_file = ImportedFile( + file_name="pedro.1.(1950).cbz", + matched_issue_cv_id=49104, + match_method="comicvine_id", + diagnostics={ + "source_issue_type": IssueType.ISSUE.value, + "source_metadata": {"comicinfo": {"number": "1 [18]"}}, + }, + ) + + summaries = _targeted_issue_summaries_for_import_files([imp_file]) + + assert summaries[0].issue_number == 1.0 + assert summaries[0].issue_number_text is None + + +def test_targeted_issue_summaries_repair_verified_unnumbered_one_shot() -> None: + imp_file = ImportedFile( + file_name="Wizard How to Draw 2007 (2007).cbz", + matched_issue_cv_id=415950, + match_method="comicvine_id", + diagnostics={ + "source_issue_type": IssueType.ONE_SHOT.value, + "issue_count_hint": 1, + "source_metadata": {"comicinfo": {"number": None}}, + }, + ) + + summaries = _targeted_issue_summaries_for_import_files([imp_file]) + + assert summaries[0].issue_number == 1.0 + assert summaries[0].issue_type == IssueType.ONE_SHOT.value + + +def test_targeted_issue_summaries_do_not_coerce_nonnumeric_one_shot_label() -> None: + imp_file = ImportedFile( + file_name="Prime Infinity #infinity (1995).cbz", + matched_issue_cv_id=652469, + match_method="comicvine_id", + diagnostics={ + "source_issue_type": IssueType.ONE_SHOT.value, + "issue_count_hint": 1, + "source_metadata": {"comicinfo": {"number": "∞"}}, + }, + ) + + summaries = _targeted_issue_summaries_for_import_files([imp_file]) + + assert summaries == [] + assert imp_file.diagnostics["reason"] == "target_issue_summary_unavailable" + + @pytest.mark.asyncio async def test_execute_new_series_safety_block_keeps_series_imported_and_clears_error( db_session, diff --git a/tests/unit/test_import_job_execution_progress.py b/tests/unit/test_import_job_execution_progress.py index 4c1d329c..3b2c3c42 100644 --- a/tests/unit/test_import_job_execution_progress.py +++ b/tests/unit/test_import_job_execution_progress.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -28,6 +28,7 @@ ) from pullbox.services.import_job_execution_types import ExecutionItemPlan from pullbox.services.import_progress_runtime import ( + ImportGroupProgressPlan, ImportProgressFileProfile, ImportProgressSettings, import_group_progress_plan, @@ -74,9 +75,9 @@ def _callback_kwargs(**overrides: Any) -> dict[str, Any]: "job_id": 42, "job": _job(), "job_started_at": datetime(2026, 6, 9, 12, 0, tzinfo=UTC), + "work_started_at": datetime.now(UTC) - timedelta(seconds=20), "progress_callback": object(), "progress_session_factory": None, - "estimate_remaining_seconds": lambda _started_at, _progress: 123, "group_progress_plans": {7: plan}, "shared_progress_settings": settings, "group_progress_weights": [plan.total_weight], @@ -111,6 +112,93 @@ def _callback_kwargs(**overrides: Any) -> dict[str, Any]: return kwargs +@pytest.mark.parametrize("large_group", [False, True]) +@pytest.mark.parametrize("live_only", [False, True]) +async def test_file_eta_is_available_before_display_reaches_one_percent( + large_group: bool, live_only: bool +) -> None: + events = [] + + async def capture_active(*_args, **kwargs): + events.append(kwargs["event"]) + + async def capture_live(_job, event, **_kwargs): + events.append(event) + + kwargs = _callback_kwargs( + work_started_at=datetime.now(UTC) - timedelta(seconds=20), + emit_active_file_progress=capture_active, + emit_live_progress=capture_live, + ) + if large_group: + plan = ImportGroupProgressPlan(2.0, tuple((99 + idx, 3.5) for idx in range(2000))) + kwargs["group_progress_plans"] = {7: plan} + kwargs["group_progress_weights"] = [plan.total_weight] + else: + kwargs["group_progress_weights"] *= 50_000 + kwargs["total_groups"] = 50_000 + callback = build_report_file_progress_callback(**kwargs) + await callback( + imp_file=_file(), + file_index=1, + total_files=2000 if large_group else 1, + stage="finalizing", + current=1, + total=1, + unit="file", + live_only=live_only, + ) + + assert events[-1].progress == 0 + assert events[-1].estimated_seconds_remaining is not None + assert events[-1].estimated_seconds_remaining > 0 + + +@pytest.mark.parametrize("live_only", [False, True]) +async def test_metadata_eta_is_available_before_display_reaches_one_percent( + live_only: bool, +) -> None: + events = [] + kwargs = _callback_kwargs() + + async def capture(_session, _job, event, _callback): + events.append(event) + + async def capture_live(_job, event, **_kwargs): + events.append(event) + + emitter = build_series_metadata_progress_emitter( + session=kwargs["session"], + job_id=42, + job=kwargs["job"], + job_started_at=datetime.now(UTC) - timedelta(seconds=20), + work_started_at=datetime.now(UTC) - timedelta(seconds=20), + progress_callback=kwargs["progress_callback"], + emit_progress=capture, + emit_live_progress=capture_live, + group_progress_plans=kwargs["group_progress_plans"], + shared_progress_settings=kwargs["shared_progress_settings"], + group_progress_weights=kwargs["group_progress_weights"] * 50_000, + stats=lambda: kwargs["stats"], + series_found=50_000, + revision_state=kwargs["revision_state"], + ) + await emitter( + group_index=1, + total_groups=50_000, + series_id=7, + series_name="Alpha", + message="Preparing series records", + current_item_stage="series_records", + current_item_progress_pct=10, + live_only=live_only, + ) + + assert events[-1].progress == 0 + assert events[-1].estimated_seconds_remaining is not None + assert events[-1].estimated_seconds_remaining > 0 + + @pytest.mark.asyncio async def test_build_import_group_progress_plans_batches_large_selections( async_engine, @@ -331,7 +419,7 @@ async def emit_live_progress(*args: Any, **kwargs: Any) -> None: assert event.series_found == 3 assert event.total_files_imported == 4 assert event.total_files_failed == 5 - assert event.estimated_seconds_remaining == 123 + assert event.estimated_seconds_remaining is None # All planned file work is complete. assert event.current_item_kind == "file" assert event.current_item_stage == "finalizing" assert event.current_item_progress_pct == event.current_file_progress_pct @@ -442,10 +530,10 @@ async def emit_live_progress(*args: Any, **kwargs: Any) -> None: job_id=42, job=kwargs["job"], job_started_at=kwargs["job_started_at"], + work_started_at=kwargs["work_started_at"], progress_callback=kwargs["progress_callback"], emit_progress=emit_progress, emit_live_progress=emit_live_progress, - estimate_remaining_seconds=kwargs["estimate_remaining_seconds"], group_progress_plans=kwargs["group_progress_plans"], shared_progress_settings=kwargs["shared_progress_settings"], group_progress_weights=kwargs["group_progress_weights"], @@ -504,10 +592,10 @@ async def emit_live_progress(*args: Any, **kwargs: Any) -> None: job_id=42, job=kwargs["job"], job_started_at=kwargs["job_started_at"], + work_started_at=kwargs["work_started_at"], progress_callback=kwargs["progress_callback"], emit_progress=emit_progress, emit_live_progress=emit_live_progress, - estimate_remaining_seconds=kwargs["estimate_remaining_seconds"], group_progress_plans=kwargs["group_progress_plans"], shared_progress_settings=kwargs["shared_progress_settings"], group_progress_weights=kwargs["group_progress_weights"], @@ -549,10 +637,10 @@ async def test_series_metadata_progress_emitter_noops_without_progress_callback( job_id=42, job=kwargs["job"], job_started_at=kwargs["job_started_at"], + work_started_at=kwargs["work_started_at"], progress_callback=None, emit_progress=lambda *args, **kwargs: persisted_events.append((args, kwargs)), emit_live_progress=lambda *args, **kwargs: live_events.append((args, kwargs)), - estimate_remaining_seconds=kwargs["estimate_remaining_seconds"], group_progress_plans=kwargs["group_progress_plans"], shared_progress_settings=kwargs["shared_progress_settings"], group_progress_weights=kwargs["group_progress_weights"], diff --git a/tests/unit/test_import_logical_groups.py b/tests/unit/test_import_logical_groups.py index 7373e1a8..477eceae 100644 --- a/tests/unit/test_import_logical_groups.py +++ b/tests/unit/test_import_logical_groups.py @@ -224,6 +224,7 @@ async def test_reclassify_matched_series_flags_owned_reversed_crossover_candidat assert imported.diagnostics["competing_candidate"]["existing_series_id"] == owned_series.id assert imported_file.status == ImportedFileStatus.NO_MATCH assert imported_file.diagnostics["kind"] == "series_conflict_file" + assert imported_file.diagnostics["reason"] == "library_owned_ambiguous_candidate" async def test_reclassify_matched_series_respects_manual_reversed_crossover_choice( diff --git a/tests/unit/test_import_matching_performance.py b/tests/unit/test_import_matching_performance.py new file mode 100644 index 00000000..c155c9ba --- /dev/null +++ b/tests/unit/test_import_matching_performance.py @@ -0,0 +1,70 @@ +"""Avoid work that cannot possibly discover a duplicate or conflict.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from pullbox.services import import_file_matching as matching + + +def test_repeated_archive_page_titles_are_parsed_once_without_losing_consensus(monkeypatch): + from pullbox.core import source_metadata + + original = source_metadata.parse_release_title + calls = [] + + def parse(title): + calls.append(title) + return original(title) + + monkeypatch.setattr(source_metadata, "parse_release_title", parse) + names = [f"Batman 007-{page:03d}.jpg" for page in range(1, 65)] + hint = source_metadata.archive_entry_issue_hint_from_names(names, expected_series_name="Batman") + assert hint is not None + assert hint["issue_number"] == 7 + assert hint["matching_entry_count"] == 64 + assert hint["parseable_image_entries"] == 64 + assert calls == ["Batman 007"] + + +def test_cached_page_titles_preserve_ambiguity_and_do_not_leak_between_archives(): + from pullbox.core import source_metadata + + names = [f"Batman 007-{page:03d}.jpg" for page in range(1, 17)] + names += [f"Batman 008-{page:03d}.jpg" for page in range(1, 17)] + assert ( + source_metadata.archive_entry_issue_hint_from_names(names, expected_series_name="Batman") + is None + ) + assert ( + source_metadata.archive_entry_issue_hint_from_names( + names[:16], expected_series_name="Superman" + ) + is None + ) + + +async def test_singleton_issue_cohorts_do_not_load_files_or_commit(monkeypatch): + async def cohorts(*args, **kwargs): + yield [SimpleNamespace(file_count=1)] + + monkeypatch.setattr(matching, "_iter_file_target_cohort_batches", cohorts) + load = AsyncMock(return_value=[SimpleNamespace()]) + monkeypatch.setattr(matching, "_load_file_target_cohort", load) + session = AsyncMock() + cancel = AsyncMock() + detect = AsyncMock(return_value=(0, 3, [])) + result = await matching._finalize_import_series_file_groups( + session, + SimpleNamespace(id=1), + SimpleNamespace(id=2, raw_series_name="Test"), + duplicate_group_counter=3, + conflict_group_counter=4, + detect_duplicate_copies=detect, + detect_conflicts=lambda *args: (0, 4, []), + log_event=AsyncMock(), + raise_if_cancelled=cancel, + ) + assert result == (3, 4) + load.assert_not_called() + session.commit.assert_not_called() + detect.assert_not_called() diff --git a/tests/unit/test_import_metadata_priority.py b/tests/unit/test_import_metadata_priority.py new file mode 100644 index 00000000..297e79cd --- /dev/null +++ b/tests/unit/test_import_metadata_priority.py @@ -0,0 +1,32 @@ +"""Catalog hydration gets a bounded head start while ComicInfo still advances.""" + +import asyncio + +import pytest + +from pullbox.services.import_metadata_priority import ( + catalog_metadata_work, + reset_import_metadata_priority, + wait_for_comicinfo_turn, +) + + +@pytest.fixture(autouse=True) +def reset_priority_gate(): + reset_import_metadata_priority() + + +async def test_three_catalogs_advance_before_next_comicinfo_file() -> None: + async with catalog_metadata_work(4) as catalog: + waiting = asyncio.create_task(wait_for_comicinfo_turn()) + await asyncio.sleep(0) + assert not waiting.done() + await catalog.complete_one() + await catalog.complete_one() + assert not waiting.done() + await catalog.complete_one() + await asyncio.wait_for(waiting, timeout=1) + + +async def test_comicinfo_does_not_wait_when_catalog_queue_is_empty() -> None: + await asyncio.wait_for(wait_for_comicinfo_turn(), timeout=1) diff --git a/tests/unit/test_import_metadata_progress.py b/tests/unit/test_import_metadata_progress.py new file mode 100644 index 00000000..cae0240c --- /dev/null +++ b/tests/unit/test_import_metadata_progress.py @@ -0,0 +1,169 @@ +"""Import-wide metadata counts and lifecycle use the shared activity contract.""" + +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import event, select + +from pullbox.api.v1.activity import operation_progress_read +from pullbox.models.import_job import ( + ImportedFile, + ImportedFileStatus, + ImportedSeries, + ImportJob, + ImportJobStatus, + ImportSeriesStatus, + ImportSourceType, +) +from pullbox.models.operation_progress import OperationProgressState +from pullbox.models.series import IssueCatalogState, Series +from pullbox.services.import_metadata_progress import ( + build_import_metadata_progress, + catalog_hydration_import_job_ids, +) +from pullbox.services.operation_activity import list_operation_activity +from pullbox.services.operation_progress import publish_operation_progress + + +@pytest.fixture(params=[ImportSourceType.FILESYSTEM, ImportSourceType.MYLAR3]) +async def metadata_job(db_session, request): + job = ImportJob( + source_path="/imports", source_type=request.param, status=ImportJobStatus.COMPLETED + ) + first = Series( + title="Synced", + sort_title="synced", + comicvine_id=101, + issue_catalog_state=IssueCatalogState.COMPLETE, + ) + second = Series( + title="Pending", + sort_title="pending", + comicvine_id=102, + issue_catalog_state=IssueCatalogState.HYDRATING, + ) + db_session.add_all([job, first, second]) + await db_session.flush() + items = [ + ImportedSeries( + import_job_id=job.id, + series_id=series.id, + raw_series_name=series.title, + status=ImportSeriesStatus.IMPORTED, + ) + for series in (first, second, second) + ] + db_session.add_all(items) + await db_session.flush() + for number, status in enumerate(["complete", "pending", None]): + db_session.add( + ImportedFile( + import_job_id=job.id, + import_series_id=items[number].id, + file_path=f"/imports/{number}.cbz", + file_name=f"{number}.cbz", + file_format="cbz", + status=ImportedFileStatus.IMPORTED, + diagnostics={"comicinfo_enrichment": {"status": status}} if status else {}, + ) + ) + await db_session.commit() + return job + + +async def test_metadata_progress_is_one_overall_summary_with_bounded_queries( + db_session, metadata_job +): + statements = [] + engine = db_session.bind.sync_engine + + def record(_conn, _cursor, statement, _params, _context, _many): + statements.append(statement) + + event.listen(engine, "before_cursor_execute", record) + try: + update = await build_import_metadata_progress( + db_session, job_id=metadata_job.id, running=True + ) + finally: + event.remove(engine, "before_cursor_execute", record) + assert len(statements) <= 3 + assert update is not None + assert update.overall.current == 2 + assert update.overall.total == 4 # Repeated series references are counted once. + assert update.item is None + assert update.state == OperationProgressState.RUNNING + assert "Series details: 1 of 2" in update.message + assert "Issue catalogs: 1 of 2" in update.message + assert "ComicInfo files: 1 of 2" in update.message + result = await publish_operation_progress(db_session, update) + await db_session.commit() + await db_session.refresh(result.operation) + response = operation_progress_read(result.operation) + assert response.overall.percent == 50 + assert response.item is None + activity = await list_operation_activity(db_session) + assert activity.spinner_count == 1 + assert len(activity.operations) == 1 + + +async def test_catalog_owner_lookup_failure_does_not_interrupt_metadata_work(): + def broken_factory(): + raise RuntimeError("database unavailable") + + assert await catalog_hydration_import_job_ids(broken_factory) == [] + + +async def test_metadata_resume_keeps_durable_counts_and_clears_pause(db_session, metadata_job): + paused = await build_import_metadata_progress(db_session, job_id=metadata_job.id, running=False) + assert paused.state == OperationProgressState.PAUSED + assert paused.attention_required + await publish_operation_progress(db_session, paused) + await db_session.commit() + assert (await list_operation_activity(db_session)).spinner_count == 0 + resumed = await build_import_metadata_progress(db_session, job_id=metadata_job.id, running=True) + assert resumed.overall == paused.overall + result = await publish_operation_progress(db_session, resumed) + await db_session.commit() + assert not result.operation.attention_required + assert (await list_operation_activity(db_session)).spinner_count == 1 + + +@pytest.mark.parametrize("failure", [False, True]) +async def test_metadata_terminal_summary_stops_spinning_and_expires_only_on_success( + db_session, + metadata_job, + failure, +): + series = await db_session.scalar(select(Series).where(Series.comicvine_id == 102)) + series.issue_catalog_state = IssueCatalogState.FAILED if failure else IssueCatalogState.COMPLETE + files = list((await db_session.scalars(select(ImportedFile))).all()) + for file in files: + if file.diagnostics: + file.diagnostics = {"comicinfo_enrichment": {"status": "complete"}} + await db_session.commit() + update = await build_import_metadata_progress(db_session, job_id=metadata_job.id, running=False) + assert update.state == ( + OperationProgressState.FAILED if failure else OperationProgressState.COMPLETED + ) + result = await publish_operation_progress(db_session, update) + await db_session.commit() + assert result.operation.overall_percent == 100 + assert result.operation.attention_required is failure + activity = await list_operation_activity( + db_session, now=datetime.now(UTC) + timedelta(seconds=16) + ) + assert activity.spinner_count == 0 + assert len(activity.operations) == int(failure) + + +async def test_rollback_stops_metadata_activity_without_reporting_completion( + db_session, metadata_job +): + update = await build_import_metadata_progress(db_session, job_id=metadata_job.id, running=True) + await publish_operation_progress(db_session, update) + metadata_job.status = ImportJobStatus.CANCELLED + await db_session.commit() + update = await build_import_metadata_progress(db_session, job_id=metadata_job.id, running=False) + assert update.state == OperationProgressState.CANCELLED + assert update.overall.percent is None diff --git a/tests/unit/test_import_mylar_scan_progress.py b/tests/unit/test_import_mylar_scan_progress.py new file mode 100644 index 00000000..90aa6b83 --- /dev/null +++ b/tests/unit/test_import_mylar_scan_progress.py @@ -0,0 +1,130 @@ +"""Mylar scan ETA uses completed source batches, not individual files.""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from pullbox.models.import_job import ImportJob, ImportJobStatus, ImportSourceType +from pullbox.services import import_progress_runtime +from pullbox.services.import_mylar_scan_progress import MylarScanProgress +from pullbox.services.import_operation_progress import build_import_operation_update +from pullbox.ui.import_progress_snapshot import build_import_progress_snapshot + + +@pytest.fixture +def scan_clock(monkeypatch): + clock = {"seconds": 0, "starts": []} + + def elapsed_seconds(started_at): + clock["starts"].append(started_at) + return clock["seconds"] if started_at is not None else None + + monkeypatch.setattr(import_progress_runtime, "elapsed_seconds_since", elapsed_seconds) + return clock + + +@pytest.fixture +async def scan_job(db_session): + job = ImportJob( + source_path="/imports/mylar.db", + source_type=ImportSourceType.MYLAR3, + status=ImportJobStatus.SCANNING, + scan_started_at=datetime.now(UTC) - timedelta(days=1), + ) + db_session.add(job) + await db_session.commit() + return job + + +async def no_cancellation(): + pass + + +@pytest.mark.parametrize("with_callback", [True, False]) +async def test_scan_eta_reaches_live_updates_and_durable_snapshot( + db_session, scan_job, scan_clock, with_callback +): + events = [] + + async def capture(event): + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == ( + event.estimated_seconds_remaining + ) + events.append(event) + + progress = MylarScanProgress( + db_session, scan_job, 1000, no_cancellation, callback=capture if with_callback else None + ) + progress.source_page_end = 100 + # Annual cohorts may outnumber the original Mylar source rows. + scan_job.series_found = 350 + scan_clock["seconds"] = 10 + await progress.checkpoint_page() + await db_session.refresh(scan_job) + + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 90 + hydrated = build_import_progress_snapshot( + scan_job, review_summary={}, recent_logs=[], progress_revision=scan_job.progress_revision + ) + assert hydrated["estimated_seconds_remaining"] == 90 + if with_callback: + assert events[-1].estimated_seconds_remaining == 90 + assert build_import_operation_update(scan_job, events[-1]).eta_seconds == 90 + + +async def test_scan_eta_waits_for_saved_batch_and_does_not_reset_with_each_file( + db_session, scan_job, scan_clock +): + progress = MylarScanProgress(db_session, scan_job, 1000, no_cancellation) + progress.source_page_end = 100 + await progress.report_safety(0, 400, "") + scan_clock["seconds"] = 10 + await progress.report_safety(400, 400, "/comics/issue.cbz") + assert scan_job.progress_snapshot["current_item_progress_pct"] == 100 + assert scan_job.progress_snapshot["estimated_seconds_remaining"] is None + + scan_clock["seconds"] = 20 + await progress.checkpoint_page() + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 180 + progress.source_page_end = 200 + scan_clock["seconds"] = 30 + await progress.report_safety(0, 200, "") + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 180 + await progress.report_safety(200, 200, "/comics/another.cbz") + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 180 + + scan_clock["seconds"] = 40 + await progress.checkpoint_page() + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 160 + + +async def test_scan_eta_uses_shared_warmup_and_current_scan_clock(db_session, scan_job, scan_clock): + invocation_start = datetime.now(UTC) + progress = MylarScanProgress(db_session, scan_job, 1000, no_cancellation) + progress.source_page_end = 100 + scan_clock["seconds"] = 1 + await progress.checkpoint_page() + assert scan_job.progress_snapshot["estimated_seconds_remaining"] is None + + progress.source_page_end = 200 + scan_clock["seconds"] = 2 + await progress.checkpoint_page() + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == 8 + # A resumed scan must not count yesterday's scan/pause as current throughput. + assert all(start >= invocation_start for start in scan_clock["starts"]) + + +@pytest.mark.parametrize("source_total", [0, 100]) +async def test_scan_eta_handles_unknown_total_and_finished_phase( + db_session, scan_job, scan_clock, source_total +): + progress = MylarScanProgress(db_session, scan_job, source_total, no_cancellation) + progress.source_page_end = 100 + scan_clock["seconds"] = 10 + await progress.checkpoint_page() + assert scan_job.progress_snapshot["estimated_seconds_remaining"] == ( + 0 if source_total else None + ) + assert scan_job.status == ImportJobStatus.SCANNING + if source_total: + assert scan_job.progress_snapshot["progress"] == 35 diff --git a/tests/unit/test_import_orphans.py b/tests/unit/test_import_orphans.py index ce8eeda6..735bc06a 100644 --- a/tests/unit/test_import_orphans.py +++ b/tests/unit/test_import_orphans.py @@ -806,6 +806,51 @@ async def test_retry_failed_series_resets_rows_and_job_counter( assert failed_file.error_message is None +async def test_retry_failed_series_repairs_confirmed_file_target_summary( + db_session: AsyncSession, +) -> None: + service = _make_service() + job = await _create_job_row(db_session, series_failed=1) + failed = await _create_imported_series( + db_session, + job, + name="The Amazing Spider-Man", + status=ImportSeriesStatus.FAILED, + ) + imp_file = ImportedFile( + import_job_id=job.id, + import_series_id=failed.id, + file_path="/tmp/The Amazing Spider-Man 54.LR.cbz", + file_name="The Amazing Spider-Man 54.LR.cbz", + file_size=1024, + file_format="cbz", + status=ImportedFileStatus.CONFIRMED, + parsed_issue_number=54.0, + issue_number_raw="54.LR", + matched_issue_cv_id=123456, + match_confidence="high", + match_method="comicvine_id", + diagnostics={"source_issue_type": "issue"}, + ) + db_session.add(imp_file) + await db_session.flush() + + updated_job, count = await service.retry_failed_series(db_session, job.id) + + assert count == 1 + assert updated_job.status == ImportJobStatus.IMPORTING + assert imp_file.status == ImportedFileStatus.CONFIRMED + assert imp_file.diagnostics["target_issue_summary"] == { + "provider_id": "123456", + "issue_number": 54.0, + "title": None, + "release_date": None, + "cover_url": None, + "issue_type": "issue", + "issue_number_text": "54LR", + } + + async def test_retry_failed_series_resets_duplicate_file_failures( db_session: AsyncSession, ) -> None: diff --git a/tests/unit/test_import_path_reconciliation.py b/tests/unit/test_import_path_reconciliation.py index 836cf318..0e4a1de5 100644 --- a/tests/unit/test_import_path_reconciliation.py +++ b/tests/unit/test_import_path_reconciliation.py @@ -222,7 +222,8 @@ async def test_saved_reconciliation_is_dry_run_first_idempotent_and_keeps_matche assert await db_session.scalar(select(func.count()).select_from(ImportedFile)) == 1 assert actual.status is ImportedFileStatus.MATCHED assert actual.matched_issue_cv_id == 703887 - assert series.files_total == series.file_count == job.total_files_found == 1 + assert series.files_total == series.file_count == 1 + assert job.total_files_found == 2 assert job.status is ImportJobStatus.REVIEW assert Path(actual.file_path).read_bytes() == original assert ( diff --git a/tests/unit/test_import_progress_runtime.py b/tests/unit/test_import_progress_runtime.py index 8a000a17..51add3c7 100644 --- a/tests/unit/test_import_progress_runtime.py +++ b/tests/unit/test_import_progress_runtime.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta from pullbox.services.import_progress_runtime import ( + ImportGroupProgressPlan, ImportProgressFileProfile, ImportProgressSettings, ScanReviewFileMatchProfile, @@ -13,9 +14,12 @@ default_phase_message, elapsed_seconds_since, estimate_remaining_work_seconds, + import_group_file_completed_weight, import_group_file_progress_pct, + import_group_metadata_completed_weight, import_group_metadata_progress_pct, import_group_progress_plan, + import_work_progress, phase_label, phase_range, scan_review_completed_weight, @@ -28,6 +32,52 @@ ) +def test_import_work_keeps_precision_separate_from_display_percentage(monkeypatch) -> None: + monkeypatch.setattr( + "pullbox.services.import_progress_runtime.elapsed_seconds_since", lambda _start: 20 + ) + work = import_work_progress( + [10.0] * 50_000, current_group_index=0, current_group_completed_weight=2.0 + ) + assert work.completed_weight == 2.0 + assert work.total_weight == 500_000.0 + assert work.progress_pct == 0 + assert work.remaining_seconds(datetime.now(UTC)) == 4_999_980 + + +def test_import_work_does_not_round_away_files_within_large_group() -> None: + plan = ImportGroupProgressPlan(2.0, tuple((idx, 3.5) for idx in range(2000))) + metadata = import_group_metadata_completed_weight(plan, metadata_progress_pct=10) + file_work = import_group_file_completed_weight(plan, file_index=1, current_file_pct=100) + assert metadata == 0.2 + assert file_work == 5.5 + assert import_group_file_progress_pct(plan, file_index=1, current_file_pct=100) == 0 + work = import_work_progress( + [plan.total_weight], current_group_index=0, current_group_completed_weight=file_work + ) + assert work.completed_weight == 5.5 + assert work.progress_pct == 0 + + +def test_import_work_keeps_unknown_and_completion_bounds() -> None: + started = datetime.now(UTC) - timedelta(seconds=20) + empty = import_work_progress([], current_group_index=0, current_group_completed_weight=0) + assert empty.progress_pct == 0 + assert empty.remaining_seconds(started) is None + pending = import_work_progress( + [10.0], current_group_index=-1, current_group_completed_weight=-5 + ) + assert pending.completed_weight == 0 + assert pending.remaining_seconds(started) is None + done = import_work_progress([10.0], current_group_index=0, current_group_completed_weight=1000) + assert done.completed_weight == 10 + assert done.progress_pct == 99 + assert done.remaining_seconds(started) is None + active = import_work_progress([10.0], current_group_index=0, current_group_completed_weight=1) + assert active.remaining_seconds(None) is None + assert active.remaining_seconds(datetime.now(UTC)) is None + + def test_shared_progress_spec_owns_phase_ranges_labels_and_defaults() -> None: assert phase_range("inventory") == (0, 10) assert phase_range("scanning") == (10, 35) diff --git a/tests/unit/test_import_resources.py b/tests/unit/test_import_resources.py new file mode 100644 index 00000000..5f7c9372 --- /dev/null +++ b/tests/unit/test_import_resources.py @@ -0,0 +1,164 @@ +"""Container-aware import budgets and bounded thread scheduling.""" + +import asyncio +import threading + +import pytest + +from pullbox.core.import_resources import ( + ImportResources, + bounded_thread_map, + detect_import_resources, +) + + +def test_budget_reserves_memory_and_cpu_for_interactive_work(): + resources = ImportResources(cpu_count=16, available_memory_bytes=16 * 1024**3) + assert resources.inspection_workers() == 4 + assert resources.inspection_workers(requested=2) == 2 + assert resources.inspection_workers(requested=32) == 12 + assert ImportResources(24, 800 * 1024**2).inspection_workers() == 1 + assert ImportResources(1, 64 * 1024**3).inspection_workers() == 1 + + +def test_cgroup_limits_and_parent_limits_override_host_hardware(tmp_path, monkeypatch): + import pullbox.core.import_resources as module + + monkeypatch.setattr(module, "_visible_cpus", lambda: 32) + monkeypatch.setattr(module, "_available_memory", lambda: 64 * 1024**3) + group = tmp_path / "workload" + group.mkdir() + (group / "cpu.max").write_text("max 100000") + (group / "memory.max").write_text("max") + (tmp_path / "cpu.max").write_text("250000 100000") + (tmp_path / "memory.max").write_text(str(4 * 1024**3)) + (tmp_path / "memory.current").write_text(str(3 * 1024**3)) + resources = detect_import_resources(cgroup_path=group, cgroup_root=tmp_path) + assert resources.cpu_count == 2 + assert resources.available_memory_bytes == 1024**3 + assert resources.inspection_workers() == 1 + + +def test_missing_resource_information_uses_serial_fallback(tmp_path, monkeypatch): + import pullbox.core.import_resources as module + + monkeypatch.setattr(module, "_visible_cpus", lambda: 1) + monkeypatch.setattr(module, "_available_memory", lambda: 0) + assert detect_import_resources(cgroup_path=tmp_path).inspection_workers() == 1 + + +async def test_bounded_thread_map_runs_concurrently_without_eager_submission(): + barrier = threading.Barrier(2) + started = [] + consumed = [] + + def work(value): + started.append(value) + if value < 2: + barrier.wait(timeout=2) + return value * 2 + + async with bounded_thread_map(work, range(20), workers=2) as results: + async for value in results: + consumed.append(value) + assert len(started) <= len(consumed) + 2 + assert sorted(consumed) == list(range(0, 40, 2)) + + +async def test_early_exit_drains_workers_without_submitting_more(): + started = [] + finished = [] + barrier = threading.Barrier(2) + + def work(value): + started.append(value) + barrier.wait(timeout=2) + finished.append(value) + return value + + with pytest.raises(RuntimeError, match="cancel"): + async with bounded_thread_map(work, range(100), workers=2) as results: + async for _ in results: + raise RuntimeError("cancel") + assert sorted(started) == sorted(finished) == [0, 1] + + +async def test_task_cancellation_waits_for_running_thread(): + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def work(value): + started.set() + release.wait(timeout=3) + finished.set() + return value + + async def consume(): + async with bounded_thread_map(work, [1, 2], workers=1) as results: + async for _ in results: + pass + + task = asyncio.create_task(consume()) + await asyncio.to_thread(started.wait, 2) + task.cancel() + await asyncio.sleep(0.02) + assert not task.done() + task.cancel() + await asyncio.sleep(0.02) + assert not task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert finished.is_set() + + +async def test_async_pool_drains_active_work_when_consumer_fails(): + from pullbox.core.import_resources import bounded_async_map + + started = [] + finished = [] + both_started = asyncio.Event() + + async def work(value): + started.append(value) + if len(started) == 2: + both_started.set() + await both_started.wait() + finished.append(value) + return value + + with pytest.raises(RuntimeError, match="stop"): + async with bounded_async_map(work, range(1000), workers=2) as results: + async for _ in results: + raise RuntimeError("stop") + assert sorted(started) == sorted(finished) == [0, 1] + + +def test_v1_limits_and_memory_pressure_are_conservative(tmp_path, monkeypatch): + import pullbox.core.import_resources as module + + monkeypatch.setattr(module, "_visible_cpus", lambda: 24) + monkeypatch.setattr(module, "_available_memory", lambda: 48 * 1024**3) + (tmp_path / "cpu").mkdir() + (tmp_path / "memory").mkdir() + (tmp_path / "cpu/cpu.cfs_quota_us").write_text("400000") + (tmp_path / "cpu/cpu.cfs_period_us").write_text("100000") + (tmp_path / "memory/memory.limit_in_bytes").write_text(str(2 * 1024**3)) + (tmp_path / "memory/memory.usage_in_bytes").write_text(str(2 * 1024**3)) + resources = detect_import_resources(cgroup_path=tmp_path, cgroup_root=tmp_path) + assert resources.cpu_count == 4 + assert resources.available_memory_bytes == 0 + assert resources.inspection_workers() == 1 + + +@pytest.mark.parametrize("bad_quota", ["garbage", "-1 100000", "1000 0", "max 100000"]) +def test_unlimited_or_malformed_cpu_quota_does_not_break_import(tmp_path, monkeypatch, bad_quota): + import pullbox.core.import_resources as module + + monkeypatch.setattr(module, "_visible_cpus", lambda: 8) + monkeypatch.setattr(module, "_available_memory", lambda: 8 * 1024**3) + (tmp_path / "cpu.max").write_text(bad_quota) + resources = detect_import_resources(cgroup_path=tmp_path, cgroup_root=tmp_path) + assert resources.cpu_count == 8 + assert resources.inspection_workers() == 4 diff --git a/tests/unit/test_import_review_actions.py b/tests/unit/test_import_review_actions.py index 2dbe017f..da87dd47 100644 --- a/tests/unit/test_import_review_actions.py +++ b/tests/unit/test_import_review_actions.py @@ -354,6 +354,7 @@ async def test_unmatch_series_match_returns_matched_row_to_series_review( assert imp_file.duplicate_group_id is None assert imp_file.is_preferred is False assert imp_file.diagnostics["kind"] == "file_no_match" + assert imp_file.diagnostics["reason"] == "series_unmatched_by_user" assert imp_file.diagnostics["target_state"] == "needs_series_match" recompute_files.assert_awaited_once_with(db_session, job, [imported.id]) recompute_series.assert_awaited_once_with(db_session, job) diff --git a/tests/unit/test_import_review_recheck.py b/tests/unit/test_import_review_recheck.py index 4bbf2406..7c6e7656 100644 --- a/tests/unit/test_import_review_recheck.py +++ b/tests/unit/test_import_review_recheck.py @@ -3,6 +3,8 @@ from __future__ import annotations import zipfile +from pathlib import Path +from unittest.mock import AsyncMock import pytest @@ -12,12 +14,19 @@ ImportedFile, ImportedFileStatus, ImportedSeries, + ImportFileHandlingMode, ImportJob, ImportJobStatus, ImportSeriesStatus, ImportSourceType, ) -from pullbox.services.import_review_recheck import prepare_review_recheck +from pullbox.models.library import LibraryRoot +from pullbox.services.import_orphans import retry_failed_series +from pullbox.services.import_review_recheck import ( + prepare_completed_import_file_recheck, + prepare_import_recheck, + prepare_review_recheck, +) async def _fixture(session, tmp_path, source_type): @@ -205,3 +214,338 @@ async def test_recheck_refuses_symlink_escape(db_session, tmp_path): db_session, job.id, source_roots=[path.parent], apply=True, accept_replaced_files=True ) assert files[0].diagnostics["safety_block"]["code"] == "source_outside_root" + + +async def test_completed_recheck_repairs_only_changed_failed_sources( + db_session, + tmp_path, +): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + job.status = ImportJobStatus.COMPLETED + item.status = ImportSeriesStatus.IMPORTED + changed = files[1] + changed.status = ImportedFileStatus.FAILED + changed.include_in_import = False + changed.matched_issue_cv_id = 100008 + changed.match_method = "comicvine_issue_id" + changed.match_confidence = "high" + changed.diagnostics = { + **changed.diagnostics, + "target_issue_summary": { + "provider_id": "100008", + "issue_number": 8.0, + }, + "source_revalidation": { + "code": "source_changed", + "retryable": True, + }, + } + untouched = files[2] + untouched.status = ImportedFileStatus.IMPORTED + untouched.include_in_import = True + before_untouched = { + "status": untouched.status, + "signature": dict(untouched.source_signature or {}), + "diagnostics": dict(untouched.diagnostics or {}), + } + path = Path(changed.file_path) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "ComicInfo.xml", + "Firefly8", + ) + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + await db_session.flush() + + report = await prepare_import_recheck( + db_session, + job.id, + source_roots=[tmp_path], + apply=True, + accept_replaced_files=True, + ) + + assert report == { + "files_checked": 1, + "files_prepared": 1, + "blocked_files": 0, + "skipped_files": 0, + } + assert job.status is ImportJobStatus.COMPLETED + assert item.status is ImportSeriesStatus.IMPORTED + assert changed.status is ImportedFileStatus.FAILED + assert changed.include_in_import is False + assert changed.matched_issue_id is None + assert changed.matched_issue_cv_id == 100008 + assert changed.match_method == "comicvine_issue_id" + assert changed.match_confidence == "high" + assert changed.diagnostics["target_issue_summary"]["provider_id"] == "100008" + assert "source_revalidation" not in changed.diagnostics + assert changed.diagnostics["source_recheck"]["ready_for_retry"] is True + assert changed.source_signature == build_file_identity_signature(path) + assert untouched.status is before_untouched["status"] + assert untouched.source_signature == before_untouched["signature"] + assert untouched.diagnostics == before_untouched["diagnostics"] + + +async def test_completed_recheck_keeps_missing_source_blocked(db_session, tmp_path): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + job.status = ImportJobStatus.COMPLETED + item.status = ImportSeriesStatus.FAILED + missing = files[0] + missing.status = ImportedFileStatus.FAILED + missing.diagnostics = { + **missing.diagnostics, + "source_revalidation": { + "code": "source_missing", + "retryable": True, + }, + } + missing_path = Path(missing.file_path) + missing_path.unlink() + await db_session.flush() + + report = await prepare_completed_import_file_recheck( + db_session, + job.id, + source_roots=[tmp_path], + apply=True, + accept_replaced_files=True, + ) + + assert report["files_checked"] == 1 + assert report["files_prepared"] == 0 + assert report["blocked_files"] == 1 + assert missing.status is ImportedFileStatus.FAILED + assert missing.diagnostics["source_revalidation"]["code"] == "source_missing" + + +@pytest.mark.parametrize( + "source_type", + [ImportSourceType.MYLAR3, ImportSourceType.FILESYSTEM], +) +async def test_retry_failed_automatically_revalidates_changed_source( + db_session, + tmp_path, + source_type, +): + job, item, files = await _fixture(db_session, tmp_path, source_type) + job.status = ImportJobStatus.COMPLETED + if source_type is ImportSourceType.MYLAR3: + job.mylar3_path_map = {"/mylar/comics": str(tmp_path)} + job.mylar3_path_map_confirmed = True + item.status = ImportSeriesStatus.IMPORTED + changed = files[1] + changed.status = ImportedFileStatus.FAILED + changed.include_in_import = False + changed.matched_issue_cv_id = 100008 + changed.match_method = "comicvine_issue_id" + changed.match_confidence = "high" + changed.diagnostics = { + **changed.diagnostics, + "target_issue_summary": {"provider_id": "100008", "issue_number": 8.0}, + "source_revalidation": {"code": "source_changed", "retryable": True}, + } + path = Path(changed.file_path) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "ComicInfo.xml", + "Firefly8", + ) + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + await db_session.flush() + + updated_job, count = await retry_failed_series( + db_session, + job.id, + log_event=AsyncMock(), + ) + + assert updated_job.status is ImportJobStatus.IMPORTING + assert count == 1 + assert changed.status is ImportedFileStatus.CONFIRMED + assert changed.include_in_import is True + assert "source_revalidation" not in changed.diagnostics + assert changed.diagnostics["source_recheck"]["ready_for_retry"] is True + assert changed.source_signature == build_file_identity_signature(path) + + +async def test_retry_failed_uses_saved_in_place_library_root(db_session, tmp_path): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + root = LibraryRoot( + name="Comics", + path=str(tmp_path), + enabled=True, + allow_referenced_registrations=True, + allow_managed_writes=False, + ) + db_session.add(root) + await db_session.flush() + job.status = ImportJobStatus.COMPLETED + job.file_handling_mode = ImportFileHandlingMode.IN_PLACE + job.mylar3_path_map = {} + job.mylar3_path_map_confirmed = True + item.status = ImportSeriesStatus.IMPORTED + changed = files[1] + changed.status = ImportedFileStatus.FAILED + changed.include_in_import = False + changed.matched_issue_cv_id = 100008 + changed.match_method = "comicvine_issue_id" + changed.match_confidence = "high" + changed.source_signature = { + **dict(changed.source_signature or {}), + "mylar_reference_root_id": root.id, + } + changed.diagnostics = { + **changed.diagnostics, + "target_issue_summary": {"provider_id": "100008", "issue_number": 8.0}, + "source_revalidation": {"code": "source_changed", "retryable": True}, + } + path = Path(changed.file_path) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + await db_session.flush() + + updated_job, count = await retry_failed_series( + db_session, + job.id, + log_event=AsyncMock(), + ) + + assert updated_job.status is ImportJobStatus.IMPORTING + assert count == 1 + assert changed.status is ImportedFileStatus.CONFIRMED + assert changed.source_signature == { + **build_file_identity_signature(path), + "mylar_reference_root_id": root.id, + } + + +async def test_retry_failed_keeps_unavailable_changed_source_failed( + db_session, + tmp_path, +): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + job.status = ImportJobStatus.COMPLETED + job.mylar3_path_map = {"/mylar/comics": str(tmp_path)} + job.mylar3_path_map_confirmed = True + item.status = ImportSeriesStatus.IMPORTED + ready, missing = files[1:] + for imp_file in (ready, missing): + imp_file.status = ImportedFileStatus.FAILED + imp_file.include_in_import = False + imp_file.matched_issue_cv_id = 100000 + int(imp_file.parsed_issue_number or 0) + imp_file.match_method = "comicvine_issue_id" + imp_file.match_confidence = "high" + imp_file.diagnostics = { + **imp_file.diagnostics, + "target_issue_summary": { + "provider_id": str(imp_file.matched_issue_cv_id), + "issue_number": imp_file.parsed_issue_number, + }, + "source_revalidation": {"code": "source_changed", "retryable": True}, + } + ready_path = Path(ready.file_path) + with zipfile.ZipFile(ready_path, "w") as archive: + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + Path(missing.file_path).unlink() + await db_session.flush() + + _updated_job, count = await retry_failed_series( + db_session, + job.id, + log_event=AsyncMock(), + ) + + assert count == 1 + assert ready.status is ImportedFileStatus.CONFIRMED + assert missing.status is ImportedFileStatus.FAILED + assert missing.include_in_import is False + assert missing.diagnostics["source_revalidation"]["code"] == "source_missing" + assert missing.diagnostics["source_recheck"]["ready_for_retry"] is False + + +async def test_retry_failed_rejects_changed_source_with_conflicting_identity( + db_session, + tmp_path, +): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + job.status = ImportJobStatus.COMPLETED + job.mylar3_path_map = {"/mylar/comics": str(tmp_path)} + job.mylar3_path_map_confirmed = True + item.status = ImportSeriesStatus.IMPORTED + changed = files[1] + changed.status = ImportedFileStatus.FAILED + changed.include_in_import = False + changed.matched_issue_cv_id = 100008 + changed.diagnostics = { + **changed.diagnostics, + "target_issue_summary": {"provider_id": "100008", "issue_number": 8.0}, + "source_revalidation": {"code": "source_changed", "retryable": True}, + } + (tmp_path / "Firefly (2018)" / "cvinfo").write_text( + "https://comicvine.gamespot.com/other/4050-123456/" + ) + path = Path(changed.file_path) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + await db_session.flush() + + updated_job, count = await retry_failed_series( + db_session, + job.id, + log_event=AsyncMock(), + ) + + assert updated_job.status is ImportJobStatus.COMPLETED + assert count == 0 + assert changed.status is ImportedFileStatus.FAILED + assert changed.include_in_import is False + assert changed.diagnostics["source_revalidation"]["code"] == "source_identity_changed" + + +async def test_retry_failed_rejects_replacement_for_different_saved_issue( + db_session, + tmp_path, +): + job, item, files = await _fixture(db_session, tmp_path, ImportSourceType.MYLAR3) + job.status = ImportJobStatus.COMPLETED + job.mylar3_path_map = {"/mylar/comics": str(tmp_path)} + job.mylar3_path_map_confirmed = True + item.status = ImportSeriesStatus.IMPORTED + changed = files[1] + changed.status = ImportedFileStatus.FAILED + changed.include_in_import = False + changed.matched_issue_cv_id = 100008 + changed.diagnostics = { + **changed.diagnostics, + "target_issue_summary": {"provider_id": "100008", "issue_number": 8.0}, + "source_revalidation": {"code": "source_changed", "retryable": True}, + } + path = Path(changed.file_path) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "ComicInfo.xml", + "Firefly9", + ) + archive.writestr("1.jpg", b"replacement image") + archive.writestr("2.jpg", b"replacement image") + await db_session.flush() + + updated_job, count = await retry_failed_series( + db_session, + job.id, + log_event=AsyncMock(), + ) + + assert updated_job.status is ImportJobStatus.COMPLETED + assert count == 0 + assert changed.status is ImportedFileStatus.FAILED + assert changed.include_in_import is False + assert changed.matched_issue_cv_id == 100008 + assert changed.diagnostics["source_revalidation"]["code"] == "source_identity_changed" diff --git a/tests/unit/test_import_rollback_state.py b/tests/unit/test_import_rollback_state.py index 20e862ad..5a8cbf25 100644 --- a/tests/unit/test_import_rollback_state.py +++ b/tests/unit/test_import_rollback_state.py @@ -206,6 +206,7 @@ async def test_restore_review_state_after_rollback_resets_series_and_files( assert matched_file.include_in_import is False assert no_match_file.status == ImportedFileStatus.NO_MATCH assert no_match_file.include_in_import is False + assert no_match_file.diagnostics["reason"] == "rollback_restored_unmatched" assert duplicate_matched_file.status == ImportedFileStatus.MATCHED assert duplicate_matched_file.include_in_import is False assert pending_file.status == ImportedFileStatus.PENDING diff --git a/tests/unit/test_import_scale_fixture_generator.py b/tests/unit/test_import_scale_fixture_generator.py index 7bd17f67..51d8b529 100644 --- a/tests/unit/test_import_scale_fixture_generator.py +++ b/tests/unit/test_import_scale_fixture_generator.py @@ -6,6 +6,7 @@ import json import sqlite3 import zipfile +from dataclasses import replace from pathlib import Path import pytest @@ -187,6 +188,7 @@ def test_generator_writes_valid_unique_archives_and_path_safe_streaming_manifest half_row = next(row for row in rows if row["issue_number"] == "0.5") with zipfile.ZipFile(output / "source" / half_row["relative_path"]) as archive: comic_info = archive.read("ComicInfo.xml").decode("utf-8") + assert len([name for name in archive.namelist() if name.endswith(".png")]) == 32 assert "0.5" in comic_info assert f"[cv_vol_id:{half_row['volume_id']}]" in comic_info assert f"[cv_issue_id:{half_row['issue_id']}]" in comic_info @@ -206,6 +208,22 @@ def test_generator_refuses_existing_output_and_preserves_operator_files(tmp_path assert marker.read_text(encoding="utf-8") == "operator data" +def test_multipage_generator_has_explicit_sparse_safety_exceptions(tmp_path: Path) -> None: + catalog = tmp_path / "localcv.db" + output = tmp_path / "fixture" + _build_catalog(catalog) + summary = generate_import_scale_fixture(replace(_request(catalog, output), single_page_every=5)) + assert summary["single_page_files"] == 3 + rows = [ + json.loads(line) for line in (output / "fixture-manifest.jsonl").read_text().splitlines() + ] + for ordinal, row in enumerate(rows, start=1): + expected = 1 if ordinal % 5 == 0 else 32 + with zipfile.ZipFile(output / "source" / row["relative_path"]) as archive: + assert len([name for name in archive.namelist() if name.endswith(".png")]) == expected + assert row["expected_safety_review"] == (expected == 1) + + def test_long_series_directory_preserves_unknown_year_and_volume_id_suffix() -> None: series = PlannedSeries( volume_id=155371, diff --git a/tests/unit/test_import_scan_benchmark.py b/tests/unit/test_import_scan_benchmark.py new file mode 100644 index 00000000..d08b4191 --- /dev/null +++ b/tests/unit/test_import_scan_benchmark.py @@ -0,0 +1,17 @@ +"""Benchmark fixtures must exercise realistic member counts without network calls.""" + +import zipfile + +from scripts.benchmark_import_scan import _build_tree + + +def test_benchmark_can_reproduce_multi_page_archives(tmp_path): + _build_tree( + tmp_path, series_count=2, files_per_series=3, trusted_comicinfo=True, archive_pages=32 + ) + files = list(tmp_path.rglob("*.cbz")) + assert len(files) == 6 + for path in files: + with zipfile.ZipFile(path) as archive: + assert len(archive.infolist()) == 33 + assert archive.read("ComicInfo.xml").startswith(b" ImportJob: return job +async def test_materialization_batches_file_inserts_without_losing_review_rows(db_session): + job = await _create_job(db_session) + files = [_discovered_file(name=f"Issue {number}.cbz") for number in range(120)] + discovered = DiscoveredSeries( + raw_series_name="Test", + raw_year=2024, + raw_publisher=None, + file_count=len(files), + sample_paths=[], + source_folder="/tmp/comics", + source_folder_relative=".", + files=files, + ) + statements = [] + + def record(conn, cursor, statement, parameters, context, executemany): + if statement.startswith("INSERT INTO import_files"): + statements.append(statement) + + engine = db_session.bind.sync_engine + event.listen(engine, "before_cursor_execute", record) + try: + await materialize_discovered_scan_results(db_session, job, [discovered]) + finally: + event.remove(engine, "before_cursor_execute", record) + assert 0 < len(statements) <= 2 + rows = (await db_session.scalars(select(ImportedFile).order_by(ImportedFile.id))).all() + assert [row.file_name for row in rows] == [file.file_name for file in files] + assert all(row.status == ImportedFileStatus.PENDING for row in rows) + assert all( + row.diagnostics["source_metadata"] == {"title": "Absolute Wonder Woman"} for row in rows + ) + + def _discovered_file( *, name: str = "Absolute Wonder Woman 019.cbz", @@ -236,6 +270,7 @@ async def test_materialize_non_fitting_layout_files_as_explicit_review_rows( assert file_row.status == ImportedFileStatus.NO_MATCH assert file_row.include_in_import is False assert file_row.diagnostics["kind"] == "source_layout_review" + assert file_row.diagnostics["reason"] == "selected_layout_no_match" assert file_row.diagnostics["rejection_reason"] == ( "This file does not fit the selected source layout. Review its series before importing." ) diff --git a/tests/unit/test_import_scan_pipeline.py b/tests/unit/test_import_scan_pipeline.py index a3b02c44..51e0c341 100644 --- a/tests/unit/test_import_scan_pipeline.py +++ b/tests/unit/test_import_scan_pipeline.py @@ -769,6 +769,10 @@ async def log_event(_session, _job_id, _level, event, **kwargs) -> None: assert [arc.source_ordinal for arc in staged_arcs] == [1, 2, 3, 4, 5] assert cancellation_checks >= len(yielded_series_page_sizes) + len(yielded_arc_page_sizes) assert all(reference() is None for reference in discovered_refs) + batch_metrics = [details for event, details in events if event == "import_mylar_batch_scanned"] + assert len(batch_metrics) == len(yielded_series_page_sizes) + assert all(details["inspection_duration_ms"] >= 0 for details in batch_metrics) + assert all(details["persistence_duration_ms"] >= 0 for details in batch_metrics) summaries = [ details for event, details in events if event == "import_story_arc_staging_completed" ] @@ -1005,6 +1009,7 @@ async def log_event(*_args, **_kwargs) -> None: async def test_filesystem_stages_one_complete_cohort_after_all_incremental_batches( db_session, tmp_path: Path, + monkeypatch, ) -> None: job = ImportJob( source_path=str(tmp_path), @@ -1084,6 +1089,14 @@ async def scan(self, _root): events: list[tuple[str, dict[str, object]]] = [] milestones: list[str] = [] cancellation_checks = 0 + progress_events = [] + + async def capture_progress(_job, event, **_kwargs): + progress_events.append(event) + + monkeypatch.setattr( + "pullbox.services.import_scan_pipeline.emit_live_progress", capture_progress + ) async def log_event(_session, _job_id, _level, event, **kwargs) -> None: events.append((event, kwargs)) @@ -1103,6 +1116,14 @@ async def resolve_extensions(_session, _formats): async def run_file_matching(*_args, **_kwargs) -> None: milestones.append("file_matching") + async def finish_series_matching(*_args, **_kwargs) -> None: + job.progress_snapshot = {"progress": 49, "phase": "matching"} + + async def capture_persisted_progress(_session, _job, event, _callback): + if event.phase == "file_matching": + assert event.progress == 49 + assert event.estimated_seconds_remaining is None + await run_import_scan_pipeline( db_session, job.id, @@ -1114,16 +1135,17 @@ async def run_file_matching(*_args, **_kwargs) -> None: validate_discovered_files_safety=no_op, materialize_discovered_scan_results=materialize_batch, deduplicate_series=no_op, - run_matching=no_op, + run_matching=finish_series_matching, consolidate_logical_series_groups=no_op, run_file_matching=run_file_matching, raise_if_cancelled=raise_if_cancelled, log_event=log_event, - emit_progress=no_op, + emit_progress=capture_persisted_progress, phase_progress=phase_progress, estimate_remaining_seconds=lambda _started_at, _progress: None, job_stats=lambda _job: {}, maybe_slow_phase_delay=no_op, + progress_callback=no_op, ) assert materialized_batch_sizes == [25, 1] @@ -1154,6 +1176,10 @@ async def run_file_matching(*_args, **_kwargs) -> None: "linked_files": 0, } assert milestones == ["file_matching", "story_arc_resolution"] + series_events = [event for event in progress_events if event.current_series] + assert len(series_events) == 26 + assert all(event.current_item_progress_pct == 100 for event in series_events) + assert all(event.estimated_seconds_remaining is None for event in series_events) assert str(tmp_path) not in json.dumps(resolution_summary) diff --git a/tests/unit/test_import_series_matching.py b/tests/unit/test_import_series_matching.py index 017ea6a0..a03f2ce4 100644 --- a/tests/unit/test_import_series_matching.py +++ b/tests/unit/test_import_series_matching.py @@ -13,7 +13,7 @@ import pullbox.services.import_series_matching as import_series_matching from pullbox.core.exceptions import ImportProviderDegradedError, JobPausedError -from pullbox.core.source_metadata import SourceMetadata +from pullbox.core.source_metadata import MetadataSignal, SourceMetadata from pullbox.models import Base from pullbox.models.import_job import ( ImportedFile, @@ -37,6 +37,50 @@ from pullbox.schemas.import_job import ImportProgressEvent +def test_mylar_identity_remains_authoritative_when_file_evidence_conflicts() -> None: + item = ImportedSeries( + raw_series_name="Firefly", + raw_year=2018, + status=ImportSeriesStatus.PENDING, + cv_id=112340, + cv_match_method="mylar3_cv_id", + ) + source_metadata = SourceMetadata( + original_title="Firefly 007 Variant.cbz", + series_name="Firefly", + year=2018, + comicvine_series_id=112340, + signals={"comicvine_series_id": MetadataSignal.MYLAR3}, + diagnostics={ + "identity_conflicts": [ + { + "field": "comicvine_series_id", + "mylar3": 112340, + "sidecar": 999999, + } + ] + }, + ) + + evaluation = import_series_matching._filesystem_source_identity_evaluation( + item, + source_metadata, + match_threshold=0.88, + ) + + assert evaluation.match is not None + assert evaluation.match["cv_id"] == 112340 + assert evaluation.match["cv_match_method"] == "mylar3_cv_id" + assert evaluation.diagnostics["reason"] == "trusted_known_cv_id_unverified" + assert evaluation.diagnostics["identity_conflicts"] == [ + { + "field": "comicvine_series_id", + "mylar3": 112340, + "sidecar": 999999, + } + ] + + async def test_series_matching_commits_logs_mid_phase_for_other_sessions(async_engine) -> None: factory = async_sessionmaker(async_engine, expire_on_commit=False) diff --git a/tests/unit/test_import_service.py b/tests/unit/test_import_service.py index d1e6c223..0032f85a 100644 --- a/tests/unit/test_import_service.py +++ b/tests/unit/test_import_service.py @@ -1882,6 +1882,7 @@ async def test_no_match_sets_no_match( ) assert len(file_rows) == 2 assert all(f.status == ImportedFileStatus.NO_MATCH for f in file_rows) + assert all(dict(f.diagnostics or {}).get("reason") == "below_threshold" for f in file_rows) assert all( dict(f.diagnostics or {}).get("rejection_reason") == "Series could not be matched to ComicVine during import review." diff --git a/tests/unit/test_issue_numbers.py b/tests/unit/test_issue_numbers.py index be3562bf..7a378e6d 100644 --- a/tests/unit/test_issue_numbers.py +++ b/tests/unit/test_issue_numbers.py @@ -36,6 +36,7 @@ def test_format_issue_number_is_exact_and_never_scientific( ("001", "1"), ("0.50", "0.5"), ("1au", "1AU"), + ("1948 Fall", "1948FALL"), ("½", "0.5"), ("1e6", "1000000"), ], @@ -53,6 +54,11 @@ def test_normalize_issue_number_text_rejects_invalid_values(value: str) -> None: normalize_issue_number_text(value) +def test_normalize_issue_number_text_rejects_oversized_raw_input() -> None: + with pytest.raises(ValueError, match="exceeds the supported exact-text length"): + normalize_issue_number_text("1" + (" " * 321) + "AU") + + def test_issue_number_text_numeric_compatibility_preserves_suffixes() -> None: assert issue_number_text_matches_numeric(1.0, "1AU") is True assert issue_number_text_matches_numeric(2.0, "1AU") is False diff --git a/tests/unit/test_mylar_multipage_fixture.py b/tests/unit/test_mylar_multipage_fixture.py new file mode 100644 index 00000000..3aca311d --- /dev/null +++ b/tests/unit/test_mylar_multipage_fixture.py @@ -0,0 +1,98 @@ +"""Only explicitly synthetic fixtures may be copied into multipage benchmarks.""" + +import json +import sqlite3 +import zipfile +from pathlib import Path + +import pytest + +from scripts.import_scale_fixtures.mylar_multipage import upgrade_fixture +from scripts.iu9_acceptance_fixtures.shared import create_deterministic_cbz, sha256_file + + +def _source(tmp_path: Path) -> Path: + source = tmp_path / "original" + source.mkdir() + (source / "generation-report.json").write_text( + json.dumps( + { + "fixture_kind": "mylar-import-from-cv-stress-manifest", + "recorded_source_root": "/imports/original/source", + "series_count": 1, + "issue_count": 4, + } + ) + ) + with sqlite3.connect(source / "mylar.db") as db: + db.executescript( + "CREATE TABLE comics(ComicID TEXT, ComicLocation TEXT);" + "CREATE TABLE issues(IssueID TEXT, ComicID TEXT, Location TEXT, ComicSize TEXT);" + ) + db.execute("INSERT INTO comics VALUES ('1','/imports/original/source/Test')") + for number in range(1, 5): + path = source / "source" / "Test" / f"{number}.cbz" + create_deterministic_cbz( + path, seed=1, case_id=str(number), series="Test", number=str(number), page_count=1 + ) + db.execute( + "INSERT INTO issues VALUES (?, '1', ?, ?)", + (str(number), path.name, str(path.stat().st_size)), + ) + return source + + +def test_upgrade_preserves_original_and_builds_explicit_multipage_and_safety_cases(tmp_path): + source = _source(tmp_path) + before = { + str(path.relative_to(source)): sha256_file(path) + for path in source.rglob("*") + if path.is_file() + } + output = tmp_path / "new" + result = upgrade_fixture( + source, output, container_root="/imports/new/source", pages=32, single_page_every=3 + ) + assert result["issue_count"] == 4 + assert result["single_page_files"] == 1 + assert result["sqlite_integrity_check"] == "ok" + with sqlite3.connect(output / "mylar.db") as db: + assert ( + db.execute("SELECT ComicLocation FROM comics").fetchone()[0] + == "/imports/new/source/Test" + ) + for number, size in db.execute("SELECT IssueID, ComicSize FROM issues"): + path = output / "source" / "Test" / f"{number}.cbz" + assert int(size) == path.stat().st_size + with zipfile.ZipFile(path) as archive: + assert archive.testzip() is None + assert len([n for n in archive.namelist() if n.endswith(".png")]) == ( + 1 if number == "3" else 32 + ) + with zipfile.ZipFile(source / "source" / "Test" / f"{number}.cbz") as old: + assert archive.read("ComicInfo.xml") == old.read("ComicInfo.xml") + assert before == { + str(path.relative_to(source)): sha256_file(path) + for path in source.rglob("*") + if path.is_file() + } + with pytest.raises(FileExistsError): + upgrade_fixture(source, output, container_root="/imports/new/source") + + +@pytest.mark.parametrize("unsafe", ["outside", "symlink", "not_synthetic"]) +def test_upgrade_rejects_non_fixture_or_escaping_sources_without_output(tmp_path, unsafe): + source = _source(tmp_path) + if unsafe == "not_synthetic": + (source / "generation-report.json").write_text('{"fixture_kind":"real-library"}') + elif unsafe == "symlink": + original = source / "source" / "Test" / "1.cbz" + link = source / "source" / "Test" / "2.cbz" + link.unlink() + link.symlink_to(original) + else: + with sqlite3.connect(source / "mylar.db") as db: + db.execute("UPDATE issues SET Location='../outside.cbz' WHERE IssueID='1'") + with pytest.raises(ValueError): + upgrade_fixture(source, tmp_path / "new", container_root="/imports/new/source") + assert not (tmp_path / "new").exists() diff --git a/tests/unit/test_series_api_branches.py b/tests/unit/test_series_api_branches.py index b649a9de..13304261 100644 --- a/tests/unit/test_series_api_branches.py +++ b/tests/unit/test_series_api_branches.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock import pytest -from fastapi import Response +from fastapi import HTTPException, Response from sqlalchemy import select from pullbox.api.v1 import series as series_api @@ -17,7 +17,13 @@ from pullbox.models.library import FileFormat, LibraryFile, LibraryRoot, MatchConfidence from pullbox.models.publisher import Publisher from pullbox.models.search_log import SearchLog -from pullbox.models.series import Series, SeriesStatus, SeriesStatusOverride, SeriesType +from pullbox.models.series import ( + IssueCatalogState, + Series, + SeriesStatus, + SeriesStatusOverride, + SeriesType, +) from pullbox.schemas.series import ( SeriesBulkDelete, SeriesCreate, @@ -330,9 +336,37 @@ async def test_add_update_refresh_and_folder_routes_delegate( "new_path": "/comics/Absolute Superman (2025)" } - refreshed = await series_api.refresh_series(10, _user(), db_session) - assert refreshed.id == 10 - metadata_service.refresh_series.assert_awaited_once_with(db_session, 10, force=True) + refresh_target = await _seed_series(db_session, title="Refresh Target") + refreshed = await series_api.refresh_series(refresh_target.id, _user(), db_session) + assert refreshed.id == refresh_target.id + metadata_service.refresh_series.assert_awaited_once_with( + db_session, + refresh_target.id, + force=True, + ) + + +@pytest.mark.asyncio +async def test_refresh_series_rejects_duplicate_initial_catalog_sync( + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + series = await _seed_series(db_session, title="Hydrating Refresh Target") + series.issue_catalog_state = IssueCatalogState.HYDRATING + await db_session.commit() + metadata_service = SimpleNamespace(refresh_series=AsyncMock()) + monkeypatch.setattr( + series_api, + "_build_metadata_service", + AsyncMock(return_value=metadata_service), + ) + + with pytest.raises(HTTPException) as exc_info: + await series_api.refresh_series(series.id, _user(), db_session) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "Initial metadata sync is already in progress." + metadata_service.refresh_series.assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/unit/test_sqlite_lock_retry.py b/tests/unit/test_sqlite_lock_retry.py new file mode 100644 index 00000000..3143d95f --- /dev/null +++ b/tests/unit/test_sqlite_lock_retry.py @@ -0,0 +1,64 @@ +"""Tests for shared SQLite transaction retry behavior.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from sqlalchemy.exc import OperationalError + +from pullbox.core.sqlite_lock import run_sqlite_transaction_with_retry + + +async def test_sqlite_transaction_retries_lock_after_rollback() -> None: + session = MagicMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + operation = AsyncMock( + side_effect=[ + OperationalError("UPDATE downloads", {}, Exception("database is locked")), + "applied", + ] + ) + sleep = AsyncMock() + logger = MagicMock() + + result = await run_sqlite_transaction_with_retry( + session, + operation, + event_name="download_monitor_write", + logger=logger, + retry_delay=lambda _attempt: 0.25, + sleep=sleep, + attempts=3, + ) + + assert result == "applied" + assert operation.await_count == 2 + session.rollback.assert_awaited_once_with() + session.commit.assert_awaited_once_with() + sleep.assert_awaited_once_with(0.25) + logger.warning.assert_called_once() + + +async def test_sqlite_transaction_does_not_retry_other_operational_errors() -> None: + session = MagicMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + error = OperationalError("UPDATE downloads", {}, Exception("disk I/O error")) + operation = AsyncMock(side_effect=error) + + try: + await run_sqlite_transaction_with_retry( + session, + operation, + event_name="download_monitor_write", + logger=MagicMock(), + sleep=AsyncMock(), + ) + except OperationalError as exc: + assert exc is error + else: + raise AssertionError("Expected the non-locking OperationalError to propagate") + + session.rollback.assert_awaited_once_with() + session.commit.assert_not_awaited()