diff --git a/.grype.yaml b/.grype.yaml index 3ab8c32f..49a44244 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -1,82 +1,26 @@ -# Grype vulnerability ignore list for the Pullbox release image. +# Exact accepted-risk exceptions for the Pullbox edge release image. +# Docker Hardened Images Python 3.14 on Debian 13. # -# Scope: -# - Docker Hardened Images Python 3.14 on Debian 13 -# - Debian package findings with no fixed package or Debian "won't fix" state -# - CPython binary findings where Grype reports no stable Python 3.14 fix +# Approved by Adam Hernandez on 2026-10-20 for ONLY the 17 matches below. +# Accepted risk, NOT fixes or false-positive claims. +# Re-review by 2026-10-31 or the next base-image refresh, whichever comes first. +# Remove entries when verified patched stable packages become available. +# All other High/Critical findings and changed package versions remain blocking. # -# Python package vulnerabilities are not suppressed here; pip-audit covers -# those separately in the security workflow. -# -# Reviewed against pullbox:validate on 2026-08-25 using the current Grype DB. -# Re-audit after each DHI base-image refresh and remove entries when fixed -# stable Python 3.14 or Debian 13 packages become available upstream. +# Reviewed with Grype 0.110.0 and a fresh database on 2026-10-21 UTC: +# 17 distinct High package/CVE/version matches, zero Critical. Duplicate package +# records do not expand this approval. Obsolete exceptions have been removed. +# Refreshed system Expat 2.9.0 removes the five old Expat High findings. +# Pillow 13.0.0 fixes the eight Python image-decoder findings; no Python package +# or binary exceptions remain. Full native dependency reachability remains unproven. +# This public-release approval is separate from earlier private canary waivers. ignore: - # Approved by Adam Hernandez on 2026-10-08 for this exact package/CVE only. - # Accepted risk, NOT a fix or a false-positive claim. CVE-2026-77214 requires - # a caller to pass XML_ParseBuffer a length exceeding available buffer space. - # Reviewed CPython 3.14.8 and Fontconfig 2.15.0 callers bound their lengths; - # full shipped native dependency reachability remains unproven. Python uses - # a separate bundled Expat; this exception does not cover Python findings. - # Debian 13 has no fixed stable package; upstream Expat 2.9.0 contains the fix. - # Re-review by 2026-10-14 or the next base-image refresh, whichever comes - # first; remove when a verified patched package is available. Other versions, - # other packages, and all other High/Critical findings remain blocking. - # https://security-tracker.debian.org/tracker/CVE-2026-77214 - # https://github.com/libexpat/libexpat/pull/1393 - # https://github.com/python/cpython/blob/v3.14.8/Modules/pyexpat.c - # https://sources.debian.org/src/fontconfig/2.15.0-2.3/src/fcxml.c/ - - vulnerability: CVE-2026-77214 - package: - name: '^libexpat1$' - version: 2.8.3-1~deb13u1+dhi5 - type: deb - - # Approved by Adam Hernandez on 2026-10-07 for ONLY these ten refreshed - # package/advisory matches. Accepted risk, NOT upstream vulnerability fixes. - # Re-review by 2026-10-14 or the next base-image refresh, whichever comes - # first; remove when fixed Debian 13 packages become available. Other package - # versions and all other High findings remain blocking. - # - # libexpat1 supports the Fontconfig/Poppler dependency closure. The separately - # bundled Python parser reported Expat 2.8.5 and passed the malformed UTF-16 - # guard. This does not prove the system Expat library or its callers safe. - # https://security-tracker.debian.org/tracker/CVE-2026-66046 - # https://security-tracker.debian.org/tracker/CVE-2026-76956 - # https://security-tracker.debian.org/tracker/CVE-2026-76957 - # https://security-tracker.debian.org/tracker/CVE-2026-93990 - - vulnerability: CVE-2026-66046 - package: - name: '^libexpat1$' - version: 2.8.3-1~deb13u1+dhi5 - type: deb - - vulnerability: CVE-2026-76956 - package: - name: '^libexpat1$' - version: 2.8.3-1~deb13u1+dhi5 - type: deb - - vulnerability: CVE-2026-76957 - package: - name: '^libexpat1$' - version: 2.8.3-1~deb13u1+dhi5 - type: deb - - vulnerability: CVE-2026-93990 - package: - name: '^libexpat1$' - version: 2.8.3-1~deb13u1+dhi5 - type: deb - - # CVE-2026-102010 affects a PBDS template header. No direct PBDS/erase_if - # callers were found in Pullbox; third-party reachability is not fully proven. - # For CVE-2026-95619, upstream says the posix_memalign implementation is not - # affected. The local ARM64 runtime rejected all nine upstream oversized - # allocation cases through the nothrow aligned-new entry point. This evidence - # does not qualify other architectures or prove every dependency safe. + # GCC PBDS erase_if and aligned-new findings. No direct Pullbox callers found; + # native dependency reachability remains unproven. No compiler is shipped. + # Debian 13 lists both as no-DSA, with no stable fixed package. # https://security-tracker.debian.org/tracker/CVE-2026-102010 - # https://github.com/gcc-mirror/gcc/commit/aaa8351f4d2e636f9680a1f0a8ebc2f0a60611e6 # https://security-tracker.debian.org/tracker/CVE-2026-95619 - # https://github.com/gcc-mirror/gcc/commit/59d235ffa5a69231eb42e5290d52dc8c90d28b7a - vulnerability: CVE-2026-102010 package: name: '^gcc-14-base$' @@ -108,27 +52,11 @@ ignore: version: 14.2.0-19+dhi5 type: deb - # Approved by Adam Hernandez on 2026-09-26 only for the exact refreshed DHI - # runtime revisions below. These are accepted-risk exceptions, NOT fixes. - # Pullbox now strictly normalizes valid UTF-16 and rejects malformed surrogate - # input before its DefusedXML/Expat call sites. The libexpat1 copy also remains - # in the PDF helper dependency closure, where it parses trusted local config, - # not imported comic XML. Residual upstream-library risk remains until DHI - # ships fixed packages; all other High findings continue to block. - # - # Re-reviewed and renewed by Adam Hernandez on 2026-10-07 ONLY for the two - # libc6 2.41-12+deb13u4+dhi1 matches below: re-review by 2026-10-14 or the - # next base refresh. Debian 13 has no fixed stable packages, and no direct - # affected calls were found in Pullbox or its pinned MEGA SDK; dependency - # reachability remains unproven. Older libc exceptions are not renewed. - # Keep the existing Expat review deadline of 2026-10-07 or the next base - # refresh, whichever comes first. Remove entries when fixed packages ship. + # Deprecated TSIG printing and strfmon/strfmon_l buffer writes. No direct + # Pullbox callers found; third-party reachability is not fully proven. + # Debian 13 has no stable fixed package. # https://security-tracker.debian.org/tracker/CVE-2026-5435 # https://security-tracker.debian.org/tracker/CVE-2026-19499 - # https://security-tracker.debian.org/tracker/CVE-2026-66046 - # https://security-tracker.debian.org/tracker/CVE-2026-76956 - # https://security-tracker.debian.org/tracker/CVE-2026-76957 - # https://security-tracker.debian.org/tracker/CVE-2026-93990 - vulnerability: CVE-2026-5435 package: name: '^libc6$' @@ -140,126 +68,9 @@ ignore: version: 2.41-12+deb13u4+dhi1 type: deb - # Approved by Adam Hernandez on 2026-10-01 for ONLY these three exact package - # matches. Accepted risk, NOT a fix or a claim that all dependencies are safe. - # GCC's fix is in the PBDS binary-heap erase_if template header. The runtime - # contains no compiler; neither Pullbox's native bridge nor its pinned MEGA - # SDK includes PBDS or calls this erase_if path. Third-party reachability and - # DHI backport status remain unproven; Debian 13 has no fixed stable package. - # Re-review by 2026-10-04 or at the next DHI refresh, whichever comes first. - # All other High findings and other package revisions remain blocking. - # https://security-tracker.debian.org/tracker/CVE-2026-102010 - # https://github.com/gcc-mirror/gcc/commit/aaa8351f4d2e636f9680a1f0a8ebc2f0a60611e6 - - vulnerability: CVE-2026-102010 - package: - name: '^gcc-14-base$' - version: 14.2.0-19+dhi3 - type: deb - - vulnerability: CVE-2026-102010 - package: - name: '^libgcc-s1$' - version: 14.2.0-19+dhi3 - type: deb - - vulnerability: CVE-2026-102010 - package: - name: '^libstdc\+\+6$' - version: 14.2.0-19+dhi3 - type: deb - - vulnerability: CVE-2026-66046 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi4 - type: deb - - vulnerability: CVE-2026-76956 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi4 - type: deb - - vulnerability: CVE-2026-76957 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi4 - type: deb - - vulnerability: CVE-2026-93990 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi4 - type: deb - - # Renewed by Adam Hernandez on 2026-09-14 only for this DHI libc6 revision. - # Accepted risk, NOT a fix: Debian 13 still lists this TSIG-printing issue - # as vulnerable/no-DSA. Direct Pullbox callers were not found, but dependency - # reachability and DHI backport status remain unproven. Re-review by - # 2026-09-30 or the next base refresh, whichever comes first; remove when - # a fixed stable package ships. All other High findings remain blocking. - # https://security-tracker.debian.org/tracker/CVE-2026-5435 - - vulnerability: CVE-2026-5435 - package: - name: libc6 - version: 2.41-12+deb13u4+dhi0 - type: deb - - # Approved by Adam Hernandez on 2026-09-15 for this exact runtime revision. - # Accepted risk, NOT a fix: strfmon/strfmon_l right-padding buffer overflow. - # Debian 13 lists this as vulnerable/no-DSA with no fixed stable package. - # No direct Pullbox callers were found; dependency reachability remains unproven. - # Re-review by 2026-09-30 or the next base refresh, whichever comes first; - # remove when a fixed stable package ships. All other High findings block. - # https://security-tracker.debian.org/tracker/CVE-2026-19499 - - vulnerability: CVE-2026-19499 - package: - name: libc6 - version: 2.41-12+deb13u4+dhi0 - type: deb - - # Renewed by Adam Hernandez on 2026-09-13 for eight exact findings in the - # refreshed DHI runtime. These are accepted-risk exceptions, NOT fixes. - # Keep all other High findings blocking, including other package versions. - # Preserve the previous entries for cached images and the review dates below. - # - # Debian 13 still lists CVE-2026-5435 as vulnerable/no-DSA (TSIG printing). - # No direct Pullbox calls were found, but third-party reachability and DHI - # backport status remain unproven. Re-review by 2026-09-30 or the next base - # refresh, whichever comes first; remove when a fixed stable package ships. - # https://security-tracker.debian.org/tracker/CVE-2026-5435 - - vulnerability: CVE-2026-5435 - package: - name: libc6 - version: 2.41-12+deb13u4 - type: deb - - # Debian 13 still has no fixed stable Expat package for these findings. - # The libexpat1 copy supports Fontconfig/Poppler. The separate Python parser - # reports Expat 2.8.2: its >=2.8.1 runtime guard does NOT establish protection - # against these newer advisories. Do not interpret this renewal as proving - # either parser safe. No Python-binary or libexpat1-dev exception is added. - # Re-review by 2026-10-07 or the next base refresh, whichever comes first; - # remove as soon as a fixed stable package is available. - # https://security-tracker.debian.org/tracker/CVE-2026-66046 - # https://security-tracker.debian.org/tracker/CVE-2026-76956 - # https://security-tracker.debian.org/tracker/CVE-2026-76957 - - vulnerability: CVE-2026-66046 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi3 - type: deb - - vulnerability: CVE-2026-76956 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi3 - type: deb - - vulnerability: CVE-2026-76957 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi3 - type: deb - - # These util-linux source-package findings attach to libuuid1, but concern - # privileged mount/nsenter helpers. The inspected ARM64 production image - # contains libuuid1 and neither executable. Other architectures still need - # their normal image validation. Renewed by Adam Hernandez on 2026-10-07 - # for only these four exact matches. Re-review by 2026-10-14 or the next base - # refresh, whichever comes first; remove when fixed stable packages ship. + # Source-package findings concern privileged mount/nsenter helpers. The ARM64 + # candidate contains libuuid1 but neither helper; it runs as UID/GID 65532. + # Both release architectures still require normal image validation. # https://security-tracker.debian.org/tracker/CVE-2026-76642 # https://security-tracker.debian.org/tracker/CVE-2026-78408 # https://security-tracker.debian.org/tracker/CVE-2026-78409 @@ -285,458 +96,37 @@ ignore: version: 2.41.5-0+deb13u1+dhi3 type: deb - # Debian Trixie has postponed the OpenSSL 3.5 QUIC listener connection-limit - # fix for CVE-2026-14456. Pullbox does not expose an OpenSSL QUIC listener; - # retain this exact DHI base-image exception only until Debian ships a fix. - - vulnerability: CVE-2026-14456 - package: - name: libssl3t64 - version: 3.5.7-1~deb13u2+dhi0 - type: deb - - vulnerability: CVE-2026-14456 - package: - name: openssl - version: 3.5.7-1~deb13u2+dhi0 - type: deb - - vulnerability: CVE-2026-14456 - package: - name: openssl-provider-legacy - version: 3.5.7-1~deb13u2+dhi0 - type: deb - - # CPython 3.14.6 binary findings. Grype reports either no fixed version or a - # fix only in Python 3.15 alpha/beta, not in the stable 3.14 runtime line. - - vulnerability: CVE-2025-12781 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2025-15366 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2025-15367 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-1502 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-3298 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-3276 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-4786 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-6019 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-6100 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-7210 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-7774 - package: - name: python - version: 3.14.6 - type: binary - - vulnerability: CVE-2026-9669 - package: - name: python - version: 3.14.6 - type: binary - - # The application XML path uses Python's bundled Expat, which the container - # runtime check requires to be 2.8.1 or newer. The Debian 13 libexpat copy is - # retained only as the Fontconfig dependency used by Poppler PDF tools. - # CVE-2026-66046 is currently marked won't-fix for Debian 13. Retain this - # exact DHI exception only until Debian ships a fixed package. - - vulnerability: CVE-2026-66046 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi2 - type: deb - - vulnerability: CVE-2026-66046 - package: - name: libexpat1-dev - version: 2.8.3-1~deb13u1+dhi2 - type: deb - - # Expat 2.8.4 fixes CVE-2026-76956 and CVE-2026-76957, but Debian 13 - # currently classifies both as no-DSA minor issues and has no fixed stable - # package. This shared library is copied only for Fontconfig/Poppler; Pullbox - # XML parsing uses Python's separately bundled parser, and pyexpat does not - # expose the custom encoding callback required by CVE-2026-76957. Keep these - # exceptions exact and temporary. Re-review by 2026-10-07 or on the next DHI - # Python refresh, whichever comes first. - - vulnerability: CVE-2026-76956 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi2 - type: deb - - vulnerability: CVE-2026-76957 - package: - name: libexpat1 - version: 2.8.3-1~deb13u1+dhi2 - type: deb - - vulnerability: CVE-2025-59375 - package: - name: libexpat1 - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2025-59375 - package: - name: libexpat1-dev - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-45186 - package: - name: libexpat1 - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-45186 - package: - name: libexpat1-dev - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-41080 - package: - name: libexpat1 - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-41080 - package: - name: libexpat1-dev - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-25210 - package: - name: libexpat1 - version: 2.7.1-2+dhi5 - type: deb - - vulnerability: CVE-2026-25210 - package: - name: libexpat1-dev - version: 2.7.1-2+dhi5 - type: deb - - # Debian 13 runtime packages. These are currently reported by Grype as - # negligible/medium, no-fix, or won't-fix findings in the DHI Debian 13 base. - - vulnerability: CVE-2010-4756 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2018-20796 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2019-1010022 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2019-1010023 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2019-1010024 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2019-1010025 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2019-9192 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-6238 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5435 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5435 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5435 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi1 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi1 - type: deb - - # Accepted risk approved by Adam Hernandez on 2026-08-30 for the DHI +dhi2 - # refresh in PR #133; this is NOT a vulnerability fix or a false-positive claim. - # Debian 13 still lists these as vulnerable with no-dsa/minor-issue status: - # https://security-tracker.debian.org/tracker/CVE-2026-5435 (DNS TSIG printing) - # https://security-tracker.debian.org/tracker/CVE-2026-5450 (scanf %mc width >1024) - # https://security-tracker.debian.org/tracker/CVE-2026-5928 (ungetwc pushback) - # No direct calls were found in Pullbox source/native integration code, but - # reachability through bundled third-party helpers and DHI backport status are - # unproven. Keep the High gate blocking and scope only these nine matches. - # Retain +dhi1 entries above for older/cached image variants. Re-review at the - # next base refresh or by 2026-09-30; remove when fixed Debian 13 packages ship. - - vulnerability: CVE-2026-5435 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5435 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5435 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5450 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc6 - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc-dev-bin - version: 2.41-12+deb13u3+dhi2 - type: deb - - vulnerability: CVE-2026-5928 - package: - name: libc6-dev - version: 2.41-12+deb13u3+dhi2 - type: deb - - - vulnerability: CVE-2021-45346 - package: - name: libsqlite3-0 - version: 3.46.1-7+deb13u1+dhi2 - type: deb - - vulnerability: CVE-2025-70873 - package: - name: libsqlite3-0 - version: 3.46.1-7+deb13u1+dhi2 - type: deb - - # Debian trixie marks these SQLite FTS5 findings as no-dsa/minor while the - # fixed 3.53.2 package is only available in unstable. - - vulnerability: CVE-2026-11822 - package: - name: libsqlite3-0 - version: 3.46.1-7+deb13u1+dhi2 - type: deb - - vulnerability: CVE-2026-11824 - package: - name: libsqlite3-0 - version: 3.46.1-7+deb13u1+dhi2 - type: deb - - - vulnerability: CVE-2022-0563 - package: - name: libuuid1 - version: 2.41-5+dhi3 - type: deb - - vulnerability: CVE-2025-14104 - package: - name: libuuid1 - version: 2.41-5+dhi3 - type: deb - - vulnerability: CVE-2026-27456 - package: - name: libuuid1 - version: 2.41-5+dhi3 - type: deb - - vulnerability: CVE-2026-3184 - package: - name: libuuid1 - 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 + # Non-blocking gzwrite followed by gzprintf/gzvprintf. No direct Pullbox calls + # found; Debian still lists this package as unfixed. Do not infer immunity + # from the runtime version string or suppress other zlib revisions. + # https://security-tracker.debian.org/tracker/CVE-2026-85091 + - vulnerability: CVE-2026-85091 package: - name: libuuid1 - version: 2.41.5-0+deb13u1+dhi2 + name: '^zlib1g$' + version: 1:1.3.dfsg+really1.3.1-1+dhi4 type: deb - - vulnerability: CVE-2025-6141 - package: - name: libncursesw6 - version: 6.5+20250216-2+dhi4 - type: deb - - vulnerability: CVE-2025-6141 - package: - name: libtinfo6 - version: 6.5+20250216-2+dhi4 - type: deb - - vulnerability: CVE-2025-6141 - package: - name: ncurses-base - version: 6.5+20250216-2+dhi4 - type: deb - - vulnerability: CVE-2025-6141 - package: - name: ncurses-bin - version: 6.5+20250216-2+dhi4 - type: deb + # infocmp analyze_string stack overflow. infocmp IS present in the runtime. + # No direct application invocation was found; this is accepted residual risk, + # not a claim that the vulnerable executable is absent or patched. + # https://security-tracker.debian.org/tracker/CVE-2025-69720 - vulnerability: CVE-2025-69720 package: - name: libncursesw6 + name: '^libncursesw6$' version: 6.5+20250216-2+dhi4 type: deb - vulnerability: CVE-2025-69720 package: - name: libtinfo6 + name: '^libtinfo6$' version: 6.5+20250216-2+dhi4 type: deb - vulnerability: CVE-2025-69720 package: - name: ncurses-base + name: '^ncurses-base$' version: 6.5+20250216-2+dhi4 type: deb - vulnerability: CVE-2025-69720 package: - name: ncurses-bin + name: '^ncurses-bin$' version: 6.5+20250216-2+dhi4 type: deb - - - vulnerability: CVE-2026-27171 - package: - 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 - # Renewed by Adam Hernandez on 2026-10-07 only for zlib1g at this exact - # revision. No direct affected calls were found; third-party reachability - # remains unproven. Re-review by 2026-10-14 or the next base refresh, - # whichever comes first. Older revisions and zlib1g-dev are not renewed. - - vulnerability: CVE-2026-85091 - package: - name: '^zlib1g$' - version: 1:1.3.dfsg+really1.3.1-1+dhi4 - 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 - version: 5.8.1-1 - type: deb - - vulnerability: CVE-2026-42250 - package: - name: libbz2-1.0 - version: 1.0.8-6+dhi2 - type: deb diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bf5b5e..f608f78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reconciled metadata, verified provider links, and field provenance. Safe custom JSON fields are preserved; shared, reference-only, and read-only folders stay unchanged. Split managed folders are reviewed individually. +- Added reviewed single-issue metadata editing and a separate series editor for + saved library metadata and `series.json`. Writes retain verified identities, + field provenance, and coordinated ComicInfo.xml/MetronInfo.xml output. +- Added explicit series-to-comic metadata propagation with selected fields and + files, per-file previews, background progress, cancellation, and resumable writes. + Saving series metadata alone does not modify comic files. +- Added recovery and dismissal actions for interrupted AirDC++ work, a reviewed + Grab anyway action for eligible rejected results, and a replacement workflow + for incomplete story-only downloads. + +### Fixed + +- Reconciled stale provider catalog counts and safely extended linked issue + catalogs without replacing existing matches or owned files. Repeated Metron + searches now respect provider cooldowns and distinguish timeouts from empty results. +- Recovered retained downloads and overlapping series packs without downloading + or rewriting already imported comics. Oversized files can receive a bounded + approval using the retained download instead of starting again. +- Corrected shared XML metadata reconciliation and restart-sensitive recovery + checks while retaining archive, identity, and changed-file protections. +- Allowed metadata edits and conversion after completed imports. Later import + rollback preserves edited files and dependent library records and reports that + preservation instead of deleting the user's subsequent work. +- Corrected background activity counts and refresh, restored active download + sizes, and prevented multiple issue tabs from exhausting activity connections + or leaving the interface stuck after a stylesheet load failure. +- Added visible Story Arc preview loading and actionable folder validation and + retry for separate reading-order copies, preserving canonical comics. + +### Security + +- Updated Pillow to the patched 13.0 line for image decoding and conversion. +- Updated the development stylesheet compiler's source-map parser and file + watcher to remove vulnerable parser dependencies. Automatic import metadata writing, GCD API v2, and LOCG enrichment are not enabled -by this development preview. This is not the completed v2.0 general release. +by default in this development preview. The experimental Anna verification flow +is not included. This is not the completed v2.0 general release. ## [1.3.0] - 2026-09-15 diff --git a/docs/development/DATABASE_STANDARDS.md b/docs/development/DATABASE_STANDARDS.md index d99c2a06..80f1e710 100644 --- a/docs/development/DATABASE_STANDARDS.md +++ b/docs/development/DATABASE_STANDARDS.md @@ -504,6 +504,15 @@ persist across refreshes until a new archive comparison updates them. The caller still owns independent file/target matching and atomic application to entity rows; baseline persistence alone neither establishes that match nor permits file writes. +An explicit metadata-editor override reset may retain `provider_managed=true` on a +field origin. It cannot simultaneously claim an override, source, derivation, +embedded document or passive release. It authorizes normal configured-provider +refresh to replace that unchanged value; subsequent manual edits remain protected. +The current value stays in place until refresh supplies a usable replacement. +Older snapshots without the flag retain their existing behavior. See +[the issue editor contract](ISSUE_METADATA_EDITOR.md) and +[the series editor contract](SERIES_METADATA_EDITOR.md) for field and rollback scope. + ### Archive Publication Intent `archive_metadata_publications` stores versioned, bounded evidence before a paired @@ -555,6 +564,21 @@ reassigned registrations. A successor must be finalized for that exact unchanged action, and its verified fingerprint/digest must still match the actual archive. The original source remains subject to the existing rollback policy. +Independent metadata edits and conversions may proceed after the import reaches +a terminal state with no outstanding control request. They lock related import +jobs/files before the library binding, recheck admission before publication and +record `post_import_edit` in ImportedFile diagnostics with successful file-state +accounting. Original signatures, placement payloads and import-time successor +receipts remain unchanged. Successful explicit edits supersede pending/failed +import enrichment for that file rather than letting older work overwrite them. +Rollback preserves these files, registrations and dependent series actions, +including folder renames. Its partial result counts intentional preservation +separately from manual recovery. Active publications/conversions also preserve +their parent folder while recovery establishes the outcome. A proven publication +may finish accounting after rollback is requested; it must not rewrite the file. +No-op previews/writes do not create preservation markers or disable ordinary +rollback of untouched imports. No schema migration or history detachment is used. + A stopped owner may receive a `settled` receipt for a proven published successor. It updates only registered file accounting and the import's successor reference, marking enrichment cancelled without applying canonical snapshots or baselines. diff --git a/docs/development/DIRECT_PACK_METADATA_WRITING.md b/docs/development/DIRECT_PACK_METADATA_WRITING.md index 435660f4..7b8397cb 100644 --- a/docs/development/DIRECT_PACK_METADATA_WRITING.md +++ b/docs/development/DIRECT_PACK_METADATA_WRITING.md @@ -44,6 +44,28 @@ hard-crash or lost-final-commit recovery guarantees beyond the existing executor Combined books are not split into guessed page boundaries, and mixed-series packs remain rejected by the existing extractor. +## Overlapping Pack Completion + +If another request imports the same issue during pack preparation, the guarded +ownership failure is now `direct_pack_ownership_changed`, not a single-file XML +conflict. Pack completion checks only affected requests; Retry import also uses +this completion path. The exact legacy ownership-guard error is recognized. + +Reconciliation verifies a sole owned CBZ, matching embedded issue ID and number, +non-conflicting metadata, archive safety/integrity, and unchanged file/metadata +bindings under the existing mutation lock. Optional descriptive enrichment does +not require rewriting the comic. Missing, ambiguous, unsafe, changed, replacement, +and genuine metadata-conflict cases stay unresolved. + +The redundant request is superseded, its Recovery entry is resolved, and its +activity notification no longer requires attention. Original failure evidence +and the retained pack remain available. The failed attempt is not recorded as a +second successful import, and no download or file write is performed. + +`tests/integration/metadata_identity/test_direct_pack_completion.py` covers both +completion orderings, legacy recovery, optional enrichment, positive identity +proof, unchanged bytes, cancellation, and concurrent file/metadata changes. + ## Qualification `tests/integration/metadata_identity/test_direct_pack_writer_boundary.py` diff --git a/docs/development/ISSUE_METADATA_EDITOR.md b/docs/development/ISSUE_METADATA_EDITOR.md new file mode 100644 index 00000000..f48691dc --- /dev/null +++ b/docs/development/ISSUE_METADATA_EDITOR.md @@ -0,0 +1,69 @@ +# Single-Issue Metadata Editor + +This is the first manual-editing milestone, not the completed full-field, +series/sidecar or bulk-editing scope. Anna verification is unrelated and parked. + +## User Workflow + +On an owned issue, choose **Edit metadata**. The default **Differences** view +highlights existing disagreements; **All fields** exposes the catalog inventory +even when no conflict exists. Select the fields, choose saved library/XML values +or enter corrections, then **Update preview** and **Apply selected changes**. +Preview does not query providers or write files. The existing background progress +and cancellation controls apply after approval. + +Only selected issue fields acquire correction intent. Clear is explicit and +remains empty during refresh. Remove manual override keeps the current value +now but permits later provider refresh to replace it. Selecting an absent source +cannot erase a nonempty value; the user must choose Clear. Closing before Apply +does not change library metadata or files. + +## Current Field Inventory + +| Field | Type / validation | ComicInfo | MetronInfo | Behavior | +| --- | --- | --- | --- | --- | +| Issue title | Bounded text | Title | Stories/Story | Editable | +| Summary | Bounded text | Summary | Summary | Editable | +| Cover date | ISO date | Year/Month/Day | CoverDate | Editable, distinct from store date | +| Store date | ISO date | No native equivalent | StoreDate | Editable; does not overwrite the cover date | +| Catalog page count | Integer 0-1000000 | PageCount | PageCount | Editable, never changes physical pages | +| Creator credits | Up to 128 normalized Name / Role entries | Role-specific elements | Credits | Editable; resource IDs/unsupported content block destructive changes | +| Issue number, native provider IDs | Identity | Number/Web | Number/IDs | Protected; use validated provider linking | +| Cover artwork URL | Artwork | Existing renderer projection | Existing renderer projection | Protected; artwork workflow | +| Series title, sort title, publisher, summary, dates, type, archive format, status, count, volume, language, artwork | Series-wide catalog values | Existing series projection | Existing series projection | Read-only; no changes to siblings | +| Rating, genres, tags, characters, teams, locations, imprint, arcs/positions | Preserved XML-only values | Corresponding elements | Corresponding elements | Display both documents; existing Write file metadata resolves disagreements | + +Arbitrary extension nodes, resource identifiers and page annotations remain under +the existing preservation contract, not editable raw XML. Full XML-only field +editing, series/series.json editing, member-file propagation and bulk editing +remain outstanding. This milestone does not claim all-schema editing completion. + +## Safety Boundary + +The editor currently requires one independently bound, managed CBZ/ZIP. Missing, +referenced/read-only, import-owned and ambiguous files retain the existing +writer's rejection. Native formats require the separate explicit conversion +workflow. There is no implicit conversion, rename, move, download or sidecar write. + +Preview binds the exact field actions into the existing evidence key. Apply and +the worker revalidate file identity, ownership and current library state. The +existing archive publication journal publishes the reconciled pair and finalizes +canonical values, credits and provenance. Unselected disagreements still block; +select-all does not approve identity changes or shared XML conflicts. + +The optional `FieldOrigin.provider_managed` flag records an explicit override +reset without inventing provider attribution. Existing snapshots remain readable. +New snapshots may contain this flag, so rollback to an older application requires +the corresponding consistent database backup, not just an old container image. +Normal refresh behavior is unchanged unless this explicit flag is present. + +## Focused Qualification + +Tests cover clean/empty/protected fields, selected XML/custom values, clear/reset +across refresh, stale approval, source replacement, cancellation, preserved pages, +unchanged series values, CSRF and request validation. Browser tests cover all +three themes, mobile bounds, accessible controls, unsaved-value retention, +preview/apply and real progress components. Existing paired-writing/recovery +tests remain part of the focused regression set. PostgreSQL uses the same +parametrized integration tests when its test database is configured; a skipped +PostgreSQL case is not qualification evidence. diff --git a/docs/development/SERIES_METADATA_EDITOR.md b/docs/development/SERIES_METADATA_EDITOR.md new file mode 100644 index 00000000..d6f7ce08 --- /dev/null +++ b/docs/development/SERIES_METADATA_EDITOR.md @@ -0,0 +1,74 @@ +# Series Metadata Editor + +This milestone edits one series and its eligible `series.json` outputs. Comic-file +propagation and bulk editing remain separate. Anna verification is parked. + +## User Workflow + +Open **Edit metadata** from series details. **Differences** shows saved-value +disagreements; **All fields** also exposes unchanged, empty and protected fields. +Select fields and choose a saved value, enter a correction, intentionally clear +an optional value, or remove a manual override. Preview before applying. + +The default destination is **Library and eligible series.json files**. **Library only** is an +explicit choice, including for reference-only libraries. Preview lists actual +sidecar destinations and blocked locations. No remote provider is queried, and +the editor does not claim to have fetched new source values. + +Manual corrections and intentional clears survive provider refresh. Removing an +override keeps the current value until configured refresh supplies a replacement. +Missing source values cannot silently erase data. Required values cannot be +cleared. User choices remain available after validation errors. + +## Field Inventory + +| Field | Scope and output | +| --- | --- | +| Series title and sorting title | Library and canonical sidecar; never renames folders or comics | +| Publisher and description | Library and canonical sidecar; does not edit issue summaries | +| Start/end year, status and series type | Validated series values and canonical sidecar | +| Archive format | Publication format, distinct from Pullbox's internal series type | +| Volume and language | Canonical baseline and sidecar; no member comic propagation | +| Issue count | Protected catalog value; editing metadata cannot create catalog rows | +| Provider IDs and cover artwork | Protected; use validated provider linking or artwork workflow | +| Aliases and safe custom JSON | Preserved; not a raw JSON editor | + +## Safety Boundary + +The editor changes no issue records, archive members, reading progress, import +history, ownership, filenames or folder names. It does not convert, download, +delete or move comics. Mixed folders, referenced files, read-only roots and +active import/archive mutations retain their existing write protections. + +One reviewed series snapshot supplies the canonical metadata and sidecar output. +Revisions, identity and destination evidence are rechecked before publication. +Each sidecar uses the existing atomic publisher; multiple files and the database +are not one atomic transaction. Results must distinguish a saved library record +from completed, blocked or still-pending sidecar writes. A retry must preserve +completed output and never silently broaden the approved destinations. + +The library update and retained sidecar job are committed together. Sidecar work +uses the existing background utility queue. Closing the dialog does not cancel +it; cancelling stops unfinished sidecars, not the already-saved library update. +**Retry unfinished sidecars** rechecks the original destinations without applying +the library correction again. An output published before an interrupted receipt +is recognized by its exact contents and is not rewritten. Changed library values, +folder ownership or unexpected sidecar contents require a new preview. + +Safe unknown JSON content is retained. Invalid or identity-conflicting sidecars +are not overwritten. Library-only editing is not permission to repair or replace +those files. ComicInfo.xml and MetronInfo.xml remain unchanged by this editor. +Explicit shared-field propagation is a separate action documented in +[Series Metadata Propagation](SERIES_METADATA_PROPAGATION.md). + +Explicitly edited descriptive fields also update their recognized legacy JSON +aliases, including `summary`/`description`. Unselected aliases and unrelated custom +JSON are preserved. The ordinary sidecar exporter keeps its existing behavior. + +## Focused Qualification + +Qualify selected edits, explicit clears and resets, stale previews, partial +sidecar publication, retry/cancellation, split managed folders, reference/mixed +root exclusions and unchanged comic bytes on SQLite and PostgreSQL. Browser +checks cover modal bounds, standard controls, focus, all themes, progress and +unsaved-value retention. No PR or full CI is part of this private deployment. diff --git a/docs/development/SERIES_METADATA_PROPAGATION.md b/docs/development/SERIES_METADATA_PROPAGATION.md new file mode 100644 index 00000000..7b6ab120 --- /dev/null +++ b/docs/development/SERIES_METADATA_PROPAGATION.md @@ -0,0 +1,74 @@ +# Series-to-Comic Metadata Propagation + +The series detail Metadata card has a separate **Update comic metadata** action. +Saving a series or series.json does not trigger comic-file changes. + +## Operator Workflow + +1. Select series fields explicitly. No fields are selected for a new preview. +2. Preview comic files in the background. No files or metadata records are changed. +3. Review the per-file differences. Select all ready files or individual issues. +4. Apply the selected files. Progress uses the shared utility operation component. +5. Review individual failures through **Review issue**, or retry unfinished writes. + +Preview and write results are paginated inside the scrollable standard modal. +The modal may be closed while work continues in Background activity and Utilities. +Cancellation stops unfinished work; a published file finishes journal accounting. +Retry retains the original operation IDs and approvals. Already-published files +recover from their journals rather than being rewritten. Changed files or metadata +require a new preview; retry does not silently approve new content. + +## Boundaries + +- Fields: series name, publisher, language, publication format, sort name, start year, + and volume. Start year and volume use the existing Metron series mappings, not + issue dates or issue numbering. +- Only registered managed CBZ/ZIP files with verified issue/series bindings and a + reconciled XML pair qualify. Missing pairs link to the issue's existing metadata + review workflow; the batch does not invent unreviewed issue metadata. +- Reference-only files, read-only roots, native archives requiring conversion, + unfinished import/rollback work, identity disagreements, unsupported XML and protected + issue-level corrections remain unchanged and receive per-file explanations. +- Issue titles, summaries, dates, IDs, credits, reading state and other unselected + metadata are not refreshed. Series description never becomes issue summary. +- No folder scan, provider fetch, download, conversion, rename, move, library-wide + utility, story arc copy synchronization or series.json write is performed. +- The bounded preview supports up to 10,000 registered issues in one series. + +## Safety Contract + +The existing FILE_METADATA executor, staged paired writer, mutation admission lock, +source fingerprints, verified archive manifest and publication journal are reused. +The optional journal propagation mode contains selected fields and exact canonical +snapshots. It checks the staged pair against both the original source projection +and its own idempotent projection before publication. It cannot carry import-owner +authority or change canonical values. Finalization updates only library-file +accounting, not shared series/issue metadata or baselines, so sibling approvals +remain valid. Historical journals without this mode retain their existing behavior. + +Completed import history does not prohibit later metadata edits or conversion. +Successful writes retain an edit marker alongside the original import evidence. +Import rollback preserves edited files, their library records and dependent series +state instead of deleting them or moving their parent folder. The rollback result +reports intentional preservation separately from work needing repair. Previews, +no-ops and failed writes do not claim a completed edit. An unfinished publication +preserves its target and parent until recovery establishes its outcome. + +Tests cover selected scope, stale approvals, explicit selection, CSRF, protected +files, page preservation, no-op previews, journal recovery without rewriting, +SQLite/PostgreSQL parity, theme/mobile modal bounds and visible progress. + +## Planet Atmos Testing + +On the October 20 home-server snapshot, issues #5 and #6 qualify directly. Removing +the historical import lock reveals existing prerequisites on #1-#4: #1/#4 need +the issue metadata workflow to review their old XML values and establish a paired +document, while #2/#3 contain GCD issue IDs 2837770/2846553 that are not verified +library links. Their ComicVine/Metron links are already verified; repeating those +links does not resolve the extra GCD identity. Verify the GCD series/issue mapping +before attempting their writes, then preview propagation again. Do not detach import history +or bypass identity validation to make the test pass. Real-file checks use private +copies, not production writes or rollback. The private-copy #1/#4 review/write and +propagation check preserved all pages, sidecar state and library records through +the affected rollback actions. Synthetic SQLite/PostgreSQL coverage exercises all +shared workflows independently of these real-file prerequisites. diff --git a/package-lock.json b/package-lock.json index 3a11c378..5bb6ce78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,17 +61,17 @@ } }, "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.3", "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">= 10.0.0" @@ -81,25 +81,24 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", "cpu": [ "arm64" ], @@ -118,9 +117,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", "cpu": [ "arm64" ], @@ -139,9 +138,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", "cpu": [ "x64" ], @@ -160,9 +159,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", "cpu": [ "x64" ], @@ -181,9 +180,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", "cpu": [ "arm" ], @@ -205,9 +204,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", "cpu": [ "arm" ], @@ -229,9 +228,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", "cpu": [ "arm64" ], @@ -253,9 +252,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", "cpu": [ "arm64" ], @@ -277,9 +276,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", "cpu": [ "x64" ], @@ -301,9 +300,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", "cpu": [ "x64" ], @@ -325,9 +324,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", "cpu": [ "arm64" ], @@ -345,31 +344,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", "cpu": [ "x64" ], @@ -751,30 +729,14 @@ "node": ">=4" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/enhanced-resolve": { @@ -791,19 +753,6 @@ "node": ">=10.13.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -834,16 +783,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1127,16 +1066,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1147,20 +1076,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1186,22 +1101,22 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1228,19 +1143,6 @@ "type": "opencollective", "url": "https://opencollective.com/webpack" } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } } } } diff --git a/package.json b/package.json index bd6ccde8..5f4a6f2e 100644 --- a/package.json +++ b/package.json @@ -8,5 +8,10 @@ "@tailwindcss/cli": "^4.3.3", "axe-core": "^4.14.0", "tailwindcss": "^4.3.3" + }, + "overrides": { + "@tailwindcss/cli": { + "@parcel/watcher": "2.6.0" + } } } diff --git a/pyproject.toml b/pyproject.toml index 4805d1c2..4d3e45f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "structlog>=25.5,<27.0", "rarfile~=4.2", "py7zr>=1.1.3,<2.0", - "pillow~=12.3", + "pillow~=13.0", "pdf2image~=1.17", "tzlocal~=5.3", "zstandard>=0.25,<1.0", diff --git a/src/pullbox/api/v1/activity.py b/src/pullbox/api/v1/activity.py index b6d52205..d38e1498 100644 --- a/src/pullbox/api/v1/activity.py +++ b/src/pullbox/api/v1/activity.py @@ -5,7 +5,7 @@ import asyncio from typing import TYPE_CHECKING -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import StreamingResponse from pullbox.api.deps import ( # noqa: TC001 - FastAPI resolves route annotations @@ -86,14 +86,18 @@ def operation_progress_read(operation: OperationProgress) -> OperationProgressRe async def get_activity( _user: AuthenticatedUser, session: DbSession, + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), ) -> OperationActivityRead: """Return visible active, recent, and attention-requiring operations.""" - activity = await list_operation_activity(session) + activity = await list_operation_activity(session, limit=limit, offset=offset) return OperationActivityRead( operations=[operation_progress_read(item) for item in activity.operations], active_count=activity.active_count, spinner_count=activity.spinner_count, attention_count=activity.attention_count, + total_count=activity.total_count, + refresh_after_ms=activity.refresh_after_ms, ) diff --git a/src/pullbox/api/v1/downloads.py b/src/pullbox/api/v1/downloads.py index 0c63bcf2..ab5c5888 100644 --- a/src/pullbox/api/v1/downloads.py +++ b/src/pullbox/api/v1/downloads.py @@ -920,7 +920,16 @@ async def cancel_download( download.state = DownloadState.FAILED download.error_message = "Cancelled by user" + download.next_retry_at = None + from pullbox.services.download_operation_progress import project_download_operation_progress + from pullbox.services.download_operation_reconciliation import retire_download_operations + + await retire_download_operations(session, download_id) + await project_download_operation_progress(session, download) logger.info("download_cancelled", download_id=download_id) else: + from pullbox.services.download_operation_reconciliation import retire_download_operations + + await retire_download_operations(session, download_id) await session.delete(download) logger.info("download_history_removed", download_id=download_id) diff --git a/src/pullbox/api/v1/intervention.py b/src/pullbox/api/v1/intervention.py index 0c5ec499..a35f4a24 100644 --- a/src/pullbox/api/v1/intervention.py +++ b/src/pullbox/api/v1/intervention.py @@ -26,6 +26,7 @@ PendingMatchResponse, RejectRequest, ) +from pullbox.services.direct_artifact_quarantine import DirectArtifactValidationError from pullbox.services.intervention_service import ( InterventionService, is_direct_pending_match, @@ -263,6 +264,8 @@ async def approve_pending_match( try: approved = await svc.approve_match(session, pending_id) + except DirectArtifactValidationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc except ValueError as exc: raise NotFoundError("PendingMatch", pending_id) from exc diff --git a/src/pullbox/api/v1/intervention_metadata.py b/src/pullbox/api/v1/intervention_metadata.py new file mode 100644 index 00000000..25d5c391 --- /dev/null +++ b/src/pullbox/api/v1/intervention_metadata.py @@ -0,0 +1,159 @@ +"""Explicit retained-file review; no provider requests or acquisition replanning.""" + +import json + +from fastapi import APIRouter, HTTPException +from sqlalchemy import JSON, Text, cast, select, type_coerce + +from pullbox.api.deps import DbSession, InteractiveOperatorUser +from pullbox.api.v1.issue_metadata_links import LocalId, file_job_status +from pullbox.models.pending_match import PendingMatch, PendingMatchStatus +from pullbox.schemas.issue_file_metadata import FileMetadataApproval, FileMetadataReview +from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError +from pullbox.services.direct_metadata_recovery import ( + DirectMetadataRecoveryError, + DirectMetadataRecoveryPreview, + dismiss_incomplete_recovery, + prepare_direct_metadata_recovery, + record_incomplete_recovery, +) +from pullbox.services.issue_file_metadata import file_metadata_error +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission +from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation +from pullbox.utilities.models import JobState, JobType, UtilityJob +from pullbox.utilities.router import _get_manager, _schedule_dispatch + +router = APIRouter(prefix="/intervention", tags=["intervention"]) + + +def _error(exc: ValueError | OSError) -> HTTPException: + return HTTPException( + 409, str(exc) if isinstance(exc, DirectMetadataRecoveryError) else file_metadata_error(exc) + ) + + +@router.post("/{pending_id}/file-metadata/preview", response_model=DirectMetadataRecoveryPreview) +async def preview( + pending_id: LocalId, + session: DbSession, + _user: InteractiveOperatorUser, + body: FileMetadataReview | None = None, +) -> DirectMetadataRecoveryPreview: + try: + return ( + await prepare_direct_metadata_recovery( + session, pending_id, choices=body.choices if body else None + ) + ).preview + except (ValueError, OSError) as exc: + if isinstance(exc, ArchiveMetadataBindingError) and exc.code == "partial_issue": + try: + replacement = await record_incomplete_recovery(session, pending_id) + await session.commit() + return replacement + except (ValueError, OSError) as changed: + raise _error(changed) from changed + raise _error(exc) from exc + + +@router.post("/{pending_id}/file-metadata/dismiss") +async def dismiss( + pending_id: LocalId, session: DbSession, _user: InteractiveOperatorUser +) -> dict[str, bool]: + try: + await dismiss_incomplete_recovery(session, pending_id) + await session.commit() + return {"dismissed": True} + except (ValueError, OSError) as exc: + raise _error(exc) from exc + + +@router.post("/{pending_id}/file-metadata/write", status_code=202) +async def write( + pending_id: LocalId, + body: FileMetadataApproval, + session: DbSession, + user: InteractiveOperatorUser, +) -> dict[str, str]: + try: + prepared = await prepare_direct_metadata_recovery(session, pending_id, choices=body.choices) + if not prepared.preview.ready or prepared.preview.review_key != body.review_key: + raise DirectMetadataRecoveryError( + "Review each conflicting field and update the preview before recovering this file." + ) + await lock_file_mutation_admission(session) + await ensure_no_active_import_file_mutation(session) + document = JSON().with_variant(Text(), "sqlite") + existing = await session.scalar( + select(UtilityJob) + .where( + UtilityJob.job_type == JobType.FILE_METADATA, + UtilityJob.state.in_( + ( + JobState.QUEUED, + JobState.RUNNING, + JobState.PAUSING, + JobState.PAUSED, + JobState.CANCELLING, + ) + ), + type_coerce(cast(UtilityJob.config, document), JSON)["issue_id"].as_integer() + == prepared.owner.issue_id, + ) + .limit(1) + ) + if existing is not None: + config = json.loads(existing.config) + if ( + config.get("recovery_pending_id") != pending_id + or config.get("review_key") != body.review_key + ): + raise DirectMetadataRecoveryError( + "A metadata job already owns this issue. " + "Wait for it to finish or cancel it before retrying." + ) + return {"job_id": existing.id, "state": existing.state} + manager = _get_manager() + job = await manager.create_job( + session, + JobType.FILE_METADATA, + f"Recover file metadata: {prepared.preview.file_name}", + { + "issue_id": prepared.owner.issue_id, + "recovery_pending_id": pending_id, + "review_key": body.review_key, + "choices": body.choices, + }, + created_by=user.username, + ) + await session.commit() + _schedule_dispatch(manager) + return {"job_id": job.id, "state": job.state} + except (ValueError, OSError) as exc: + raise _error(exc) from exc + + +@router.get("/{pending_id}/file-metadata/job") +async def job( + pending_id: LocalId, session: DbSession, _user: InteractiveOperatorUser +) -> dict[str, object]: + document = JSON().with_variant(Text(), "sqlite") + row = await session.scalar( + select(UtilityJob) + .where( + UtilityJob.job_type == JobType.FILE_METADATA, + type_coerce(cast(UtilityJob.config, document), JSON)["recovery_pending_id"].as_integer() + == pending_id, + ) + .order_by(UtilityJob.created_at.desc(), UtilityJob.id.desc()) + .limit(1) + ) + pending = await session.get(PendingMatch, pending_id) + result: dict[str, object] = await file_job_status(session, row) if row else {"job": None} + result["resolved"] = bool( + pending + and pending.status == PendingMatchStatus.APPROVED + and (pending.match_details or {}).get("resolution_reason") + in {"metadata_completed", "metadata_superseded"} + ) + return result diff --git a/src/pullbox/api/v1/issue_metadata_links.py b/src/pullbox/api/v1/issue_metadata_links.py index 1cd8f639..3acaca7c 100644 --- a/src/pullbox/api/v1/issue_metadata_links.py +++ b/src/pullbox/api/v1/issue_metadata_links.py @@ -13,6 +13,10 @@ FileMetadataPreview, FileMetadataReview, ) +from pullbox.schemas.issue_metadata_editor import ( + IssueMetadataEditorApproval, + IssueMetadataEditorReview, +) from pullbox.schemas.issue_metadata_links import ( IssueCandidatesQuery, IssueLinkPreview, @@ -180,6 +184,58 @@ async def file_job( ) if job is None: return {"job": None} + return await file_job_status(session, job) + + +@router.post("/{issue_id}/metadata-editor/preview", response_model=FileMetadataPreview) +async def editor_preview( + issue_id: LocalId, + body: IssueMetadataEditorReview, + session: DbSession, + _user: InteractiveOperatorUser, +) -> FileMetadataPreview: + try: + return (await prepare_file_metadata(session, issue_id, editor=body)).preview + except (ValueError, OSError) as exc: + raise HTTPException(409, file_metadata_error(exc)) from exc + + +@router.post("/{issue_id}/metadata-editor/write", status_code=202) +async def editor_write( + issue_id: LocalId, + body: IssueMetadataEditorApproval, + session: DbSession, + user: InteractiveOperatorUser, +) -> dict[str, str]: + review = IssueMetadataEditorReview(edits=body.edits) + try: + prepared = await prepare_file_metadata(session, issue_id, editor=review) + if not prepared.preview.ready: + raise ArchivePublicationError("unresolved_conflicts") + if not body.edits or prepared.preview.review_key != body.review_key: + raise ArchivePublicationError("approval_changed") + await ensure_no_active_import_file_mutation(session) + manager = _get_manager() + job = await manager.create_job( + session, + JobType.FILE_METADATA, + f"Edit issue metadata: {prepared.preview.file_name}", + { + "issue_id": issue_id, + "review_key": body.review_key, + "editor": review.model_dump(mode="json"), + }, + created_by=user.username, + ) + await session.commit() + _schedule_dispatch(manager) + return {"job_id": job.id, "state": job.state} + except (ValueError, OSError) as exc: + raise HTTPException(409, file_metadata_error(exc)) from exc + + +async def file_job_status(session: DbSession, job: UtilityJob) -> dict[str, object]: + """One progress projection for both issue writes and retained-download recovery.""" item = await session.scalar( select(UtilityJobItem).where(UtilityJobItem.job_id == job.id).limit(1) ) diff --git a/src/pullbox/api/v1/issues.py b/src/pullbox/api/v1/issues.py index d9871504..f4d42d00 100644 --- a/src/pullbox/api/v1/issues.py +++ b/src/pullbox/api/v1/issues.py @@ -8,7 +8,7 @@ import structlog from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse -from sqlalchemy import inspect, select +from sqlalchemy import inspect, select, update from sqlalchemy.orm import joinedload, selectinload from pullbox.api.deps import AuthenticatedUser, DbSession @@ -19,9 +19,10 @@ from pullbox.config import get_settings from pullbox.core.exceptions import ConfigurationError, NotFoundError, ValidationError from pullbox.core.file_safety import classify_resource_safety_exception +from pullbox.models.airdcpp import AirDcppAcquisition from pullbox.models.client import DownloadClientConfig from pullbox.models.direct_acquisition import DirectAcquisitionAttempt -from pullbox.models.download import DownloadClientType, DownloadState +from pullbox.models.download import DownloadClientType, DownloadHistory, DownloadState from pullbox.models.indexer import IndexerConfig from pullbox.models.issue import Issue, IssueStatus, IssueType from pullbox.models.library import LibraryFile, MatchConfidence @@ -31,6 +32,7 @@ IssueFileDeleteResponse, IssueResponse, IssueUpdate, + ManualFileImportPreviewRequest, ManualFileImportProgressResponse, ManualFileImportRequest, ManualFileImportResponse, @@ -50,7 +52,10 @@ ) from pullbox.services.airdcpp_acquisition import AirDcppQueueAcquisitionService from pullbox.services.airdcpp_automatic_search import attach_automatic_airdcpp_search -from pullbox.services.airdcpp_route_tokens import get_airdcpp_route_token_store +from pullbox.services.airdcpp_route_tokens import ( + can_override_dc_rejection, + get_airdcpp_route_token_store, +) from pullbox.services.direct_acquisition_planner_service import ( DirectAcquisitionPlanningError, plan_direct_acquisition, @@ -69,6 +74,11 @@ prepare_manual_issue_import, ) from pullbox.services.issue_service import IssueService +from pullbox.services.manual_import_metadata_review import ( + ManualImportMetadataPreview, + preview_manual_import_metadata, +) +from pullbox.services.manual_paired_metadata import read_manual_metadata_plan from pullbox.services.release_validator import ( ValidationResult, ) @@ -917,6 +927,16 @@ async def grab_direct_connect_release( except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + if not grant.candidate.validation.is_match: + if not can_override_dc_rejection(grant.candidate): + raise HTTPException( + status_code=409, detail="This Direct Connect rejection cannot be overridden." + ) + if not body.override_rejection: + raise HTTPException( + status_code=409, detail="Review the rejection and confirm Grab anyway first." + ) + route = grant.candidate.route client = ( await session.execute( @@ -945,12 +965,54 @@ async def grab_direct_connect_release( status_code=409, detail="The selected AirDC++ client is not ready.", ) + # Serialize competing grabs before persisting intent, including new search tokens. + await session.commit() + await session.execute(update(Issue).where(Issue.id == issue_id).values(status=Issue.status)) issue_result = await session.execute( select(Issue).options(joinedload(Issue.library_file)).where(Issue.id == issue_id) ) issue = issue_result.unique().scalar_one_or_none() if issue is None: raise NotFoundError("Issue", issue_id) + if issue.library_file is not None and not body.replace_existing_file: + raise HTTPException( + status_code=409, + detail="This issue is already owned. Confirm Download replacement first.", + ) + existing_intent = await session.scalar( + select(AirDcppAcquisition.id).where( + AirDcppAcquisition.request_key == grant.request_key, + ) + ) + if existing_intent is None: + active_download = await session.scalar( + select(DownloadHistory.id) + .where( + DownloadHistory.issue_id == issue_id, + DownloadHistory.state.in_( + ( + DownloadState.QUEUED, + DownloadState.SENT, + DownloadState.DOWNLOADING, + DownloadState.FINALIZING, + DownloadState.PAUSED, + DownloadState.RETRY_PENDING, + DownloadState.POST_PROCESSING, + DownloadState.COMPLETED, + ) + ), + DownloadHistory.imported_at.is_(None), + ) + .limit(1) + ) + if active_download is not None: + raise HTTPException( + status_code=409, + detail=( + "This issue already has a download in progress. " + "Review it in Downloads before grabbing another result." + ), + ) result = await AirDcppQueueAcquisitionService().acquire( session, candidate=grant.candidate, @@ -974,6 +1036,8 @@ async def grab_direct_connect_release( download_id=result.download_history_id, bundle_id=result.bundle_id, client_config_id=route.client_config_id, + rejection_overridden=not grant.candidate.validation.is_match, + rejection_code=grant.candidate.validation.rejection_code, ) return DcGrabResponse( issue_id=issue_id, @@ -1144,6 +1208,42 @@ async def download_issue( } +@router.post( + "/{issue_id}/import-file/metadata-preview", + response_model=ManualImportMetadataPreview | None, +) +async def preview_import_file_metadata( + issue_id: int, + body: ManualFileImportPreviewRequest, + _user: AuthenticatedUser, + session: DbSession, +) -> ManualImportMetadataPreview | None: + """Review the selected source without registering it or modifying either file.""" + from pullbox.config import get_settings + from pullbox.services.issue_file_metadata import file_metadata_error + + try: + prepared = await prepare_manual_issue_import( + session, + issue_id=issue_id, + file_path=body.file_path, + move_to_library=body.move_to_library, + ) + if not ( + get_settings().metadata_paired_import_writer_enabled + and prepared.ingest_policy.update_embedded_comicinfo_from_match + ): + return None + plan = await read_manual_metadata_plan(session, issue_id) + source = prepared.source_path + await session.rollback() + return (await preview_manual_import_metadata(plan, source, body.choices)).preview + except ManualIssueImportError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + except (ValueError, OSError) as exc: + raise HTTPException(status_code=409, detail=file_metadata_error(exc)) from exc + + @router.post( "/{issue_id}/import-file", status_code=201, @@ -1171,6 +1271,7 @@ async def import_file_for_issue( session, prepared, allow_resource_safety_exception=body.allow_resource_safety_exception, + metadata_approval=body.metadata_approval, ) library_file = result.library_file except ManualIssueImportError as exc: @@ -1193,6 +1294,11 @@ async def import_file_for_issue( transfer_method=result.ingest_policy.post_processing_method, ) + from pullbox.services.direct_metadata_recovery import reconcile_metadata_replacements + + await session.commit() + await reconcile_metadata_replacements(session, issue_id) + return _build_manual_file_import_response( issue_id=issue_id, library_file=library_file, diff --git a/src/pullbox/api/v1/router.py b/src/pullbox/api/v1/router.py index fbd13aec..bf22ec9b 100644 --- a/src/pullbox/api/v1/router.py +++ b/src/pullbox/api/v1/router.py @@ -22,6 +22,7 @@ from pullbox.api.v1.import_safety_bulk import router as import_safety_bulk_router from pullbox.api.v1.indexers import router as indexers_router from pullbox.api.v1.intervention import router as intervention_router +from pullbox.api.v1.intervention_metadata import router as intervention_metadata_router from pullbox.api.v1.issue_metadata_links import router as issue_metadata_links_router from pullbox.api.v1.issues import router as issues_router from pullbox.api.v1.library import router as library_router @@ -53,6 +54,7 @@ v1_router.include_router(story_arc_placements_router) v1_router.include_router(issues_router) v1_router.include_router(issue_metadata_links_router) +v1_router.include_router(intervention_metadata_router) v1_router.include_router(library_router) v1_router.include_router(metadata_identities_router) v1_router.include_router(metadata_sources_router) diff --git a/src/pullbox/api/v1/series_metadata_links.py b/src/pullbox/api/v1/series_metadata_links.py index dab9f830..fe564923 100644 --- a/src/pullbox/api/v1/series_metadata_links.py +++ b/src/pullbox/api/v1/series_metadata_links.py @@ -2,33 +2,230 @@ from typing import Annotated -from fastapi import APIRouter, HTTPException, Path +from fastapi import APIRouter, HTTPException, Path, Query +from sqlalchemy.ext.asyncio import AsyncSession from pullbox.api.deps import DbSession, InteractiveOperatorUser, Settings from pullbox.core.exceptions import ValidationError +from pullbox.models.user import User from pullbox.schemas.metadata_identity_review import IdentityReviewReceiptRead from pullbox.schemas.metadata_sources import SeriesPreviewQuery +from pullbox.schemas.series_metadata_editor import ( + SeriesMetadataEditorApproval, + SeriesMetadataEditorPreview, + SeriesMetadataEditorRetry, + SeriesMetadataEditorReview, +) from pullbox.schemas.series_metadata_links import ( SeriesLinkConfirm, SeriesLinkPreview, SeriesLinksRead, ) +from pullbox.schemas.series_metadata_propagation import ( + PropagationApproval, + PropagationPlan, + PropagationRetry, + PropagationReview, +) from pullbox.schemas.series_sidecar import ( SeriesSidecarApproval, SeriesSidecarPreview, SeriesSidecarWriteRead, ) +from pullbox.services import series_metadata_propagation as propagation +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission +from pullbox.services.series_metadata_editor import ( + PLAN, + editor_job_details, + latest_editor_job, + prepare_series_editor, + retry_editor_plan, + save_series_editor, +) from pullbox.services.series_metadata_links import ( confirm_series_link, preview_series_link, read_series_links, ) from pullbox.services.series_sidecar import prepare_series_sidecar, write_series_sidecar +from pullbox.utilities.models import JobType +from pullbox.utilities.router import _get_manager, _schedule_dispatch router = APIRouter(prefix="/series", tags=["series"]) LocalId = Annotated[int, Path(gt=0, lt=2**63)] +async def _queue_propagation( + session: AsyncSession, + user: User, + plan: PropagationPlan, + parent: str | None = None, +) -> dict[str, object]: + await lock_file_mutation_admission(session) + previous = await propagation.latest_job(session, plan.series_id) + if previous is not None and not previous.is_terminal: + raise ValueError("This series already has comic metadata work. Wait or cancel it first.") + manager = _get_manager() + job = await manager.create_job( + session, + JobType.FILE_METADATA, + ("Preview" if plan.mode == "preview" else "Update") + " series comic metadata", + {propagation.KEY: plan.model_dump(mode="json")}, + created_by=user.username, + ) + job.parent_job_id = parent + await session.commit() + _schedule_dispatch(manager) + return {"job_id": job.id, "state": job.state} + + +@router.post("/{series_id}/metadata-propagation/preview", status_code=202) +async def propagation_preview( + series_id: LocalId, body: PropagationReview, session: DbSession, user: InteractiveOperatorUser +) -> dict[str, object]: + try: + plan = await propagation.preview_plan(session, series_id, body) + return await _queue_propagation(session, user, plan) + except (ValueError, ValidationError) as exc: + raise HTTPException(409, str(exc)) from None + + +@router.post("/{series_id}/metadata-propagation/write", status_code=202) +async def propagation_write( + series_id: LocalId, body: PropagationApproval, session: DbSession, user: InteractiveOperatorUser +) -> dict[str, object]: + try: + plan = await propagation.approved_plan(session, series_id, body.job_id, body.issue_ids) + return await _queue_propagation(session, user, plan, body.job_id) + except (ValueError, ValidationError) as exc: + raise HTTPException(409, str(exc)) from None + + +@router.post("/{series_id}/metadata-propagation/retry", status_code=202) +async def propagation_retry( + series_id: LocalId, body: PropagationRetry, session: DbSession, user: InteractiveOperatorUser +) -> dict[str, object]: + try: + plan = await propagation.approved_plan(session, series_id, body.job_id, None, retry=True) + return await _queue_propagation(session, user, plan, body.job_id) + except (ValueError, ValidationError) as exc: + raise HTTPException(409, str(exc)) from None + + +@router.get("/{series_id}/metadata-propagation/job") +async def propagation_job( + series_id: LocalId, + session: DbSession, + _user: InteractiveOperatorUser, + page: Annotated[int, Query(ge=1, le=500)] = 1, +) -> dict[str, object]: + from pullbox.api.v1.issue_metadata_links import file_job_status + + job = await propagation.latest_job(session, series_id) + if job is None: + return {"job": None} + return { + **await file_job_status(session, job), + **await propagation.job_details(session, job, page), + } + + +@router.post("/{series_id}/metadata-editor/preview", response_model=SeriesMetadataEditorPreview) +async def editor_preview( + series_id: LocalId, + body: SeriesMetadataEditorReview, + session: DbSession, + _user: InteractiveOperatorUser, +) -> SeriesMetadataEditorPreview: + try: + return (await prepare_series_editor(session, series_id, body)).preview + except (ValueError, ValidationError) as exc: + raise HTTPException(409, str(exc)) from None + + +@router.post("/{series_id}/metadata-editor/write", status_code=202) +async def editor_write( + series_id: LocalId, + body: SeriesMetadataEditorApproval, + session: DbSession, + user: InteractiveOperatorUser, +) -> dict[str, object]: + username = user.username + try: + plan = await save_series_editor( + session, + series_id, + SeriesMetadataEditorReview(mode=body.mode, edits=body.edits), + body.review_key, + ) + manager = _get_manager() + job = await manager.create_job( + session, + JobType.FILE_METADATA, + f"Save series metadata: {plan.snapshot.values.title}", + {"series_id": series_id, "series_editor": PLAN.dump_python(plan, mode="json")}, + created_by=username, + ) + await session.commit() + _schedule_dispatch(manager) + return {"job_id": job.id, "state": job.state, "library_saved": True} + except (ValueError, ValidationError) as exc: + await session.rollback() + raise HTTPException(409, str(exc)) from None + + +@router.get("/{series_id}/metadata-editor/job") +async def editor_job( + series_id: LocalId, + session: DbSession, + _user: InteractiveOperatorUser, +) -> dict[str, object]: + from pullbox.api.v1.issue_metadata_links import file_job_status + + job = await latest_editor_job(session, series_id) + if job is None: + return {"job": None} + result = await file_job_status(session, job) + details = await editor_job_details(session, job) + projected = result["job"] + if isinstance(projected, dict): + projected["message"] = ( + "Library metadata saved. Series.json and comic files were left unchanged." + if details["mode"] == "library_only" + else "Library saved; unfinished sidecars need attention." + if details["partial"] and job.is_terminal + else "Library saved; writing approved sidecars." + if not job.is_terminal + else "Library saved; approved sidecar work finished." + ) + return {**result, **details} + + +@router.post("/{series_id}/metadata-editor/retry", status_code=202) +async def editor_retry( + series_id: LocalId, + body: SeriesMetadataEditorRetry, + session: DbSession, + user: InteractiveOperatorUser, +) -> dict[str, object]: + try: + plan = await retry_editor_plan(session, series_id, body.job_id) + manager = _get_manager() + job = await manager.create_job( + session, + JobType.FILE_METADATA, + f"Retry series sidecars: {plan.snapshot.values.title}", + {"series_id": series_id, "series_editor": PLAN.dump_python(plan, mode="json")}, + created_by=user.username, + ) + job.parent_job_id = body.job_id + await session.commit() + _schedule_dispatch(manager) + return {"job_id": job.id, "state": job.state, "library_saved": True} + except (ValueError, ValidationError) as exc: + raise HTTPException(409, str(exc)) from None + + @router.post("/{series_id}/sidecar/preview", response_model=SeriesSidecarPreview) async def sidecar_preview( series_id: LocalId, session: DbSession, _user: InteractiveOperatorUser diff --git a/src/pullbox/composition/airdcpp.py b/src/pullbox/composition/airdcpp.py index b603fc51..65594eb0 100644 --- a/src/pullbox/composition/airdcpp.py +++ b/src/pullbox/composition/airdcpp.py @@ -235,11 +235,10 @@ async def _reconcile_ready_client(config_id: int) -> None: registry = _registry reconciler = _reconciler supervisor = registry.get(config_id) if registry is not None else None - if ( - reconciler is None - or supervisor is None - or supervisor.state is not AirDcppSupervisorState.READY - ): + if reconciler is None: + return + if supervisor is None or supervisor.state is not AirDcppSupervisorState.READY: + await reconciler.defer_unconfirmed_requests(config_id) return result = await reconciler.reconcile_client( config_id, diff --git a/src/pullbox/core/archive_metadata_fields.py b/src/pullbox/core/archive_metadata_fields.py index 617925d1..d83fdb66 100644 --- a/src/pullbox/core/archive_metadata_fields.py +++ b/src/pullbox/core/archive_metadata_fields.py @@ -1,5 +1,7 @@ """The credit-role overlap between ComicInfo and richer canonical metadata.""" +from xml.etree import ElementTree as ET + from pullbox.schemas.metadata_credits import MetadataCredit, parse_credits COMICINFO_ROLES = { @@ -25,6 +27,14 @@ COMICINFO_ONLY_FORMATS = frozenset({"special", "compendium", "deluxe", "volume"}) +def normalize_comicinfo_aliases(root: ET.Element) -> None: + """Normalize only known aliases in a parsed copy; retain duplicates for rejection.""" + aliases = {"Issue": "Number", "writer": "Writer"} + for node in root: + if isinstance(node.tag, str) and node.tag in aliases: + node.tag = aliases[node.tag] + + def archive_format_label(value: str | None) -> str | None: if value in COMICINFO_ONLY_FORMATS: return value.title() diff --git a/src/pullbox/core/metroninfo.py b/src/pullbox/core/metroninfo.py index 9a947380..f954593c 100644 --- a/src/pullbox/core/metroninfo.py +++ b/src/pullbox/core/metroninfo.py @@ -197,7 +197,8 @@ def read_text(self, node: ElementTree.Element | None, locator: str) -> str | Non if node is None: return None value = (node.text or "").strip() - if len(node) or len(value) > _MAX_FIELD_LENGTH: + limit = 200000 if locator == "Summary" else _MAX_FIELD_LENGTH + if len(node) or len(value) > limit: self.diagnostic(MetronInfoDiagnosticCode.INVALID_FIELD, locator) return None self.text_nodes.add(node) diff --git a/src/pullbox/core/partial_issue.py b/src/pullbox/core/partial_issue.py new file mode 100644 index 00000000..59b9a80d --- /dev/null +++ b/src/pullbox/core/partial_issue.py @@ -0,0 +1,35 @@ +"""Explicit story extracts are not evidence of complete issue coverage.""" + +import re +from collections.abc import Mapping + +INCOMPLETE_RECOVERY_DISMISSED = "Recovery dismissed; the incomplete file was left unchanged." +METADATA_RECOVERY_SUPERSEDED = ( + "Superseded by a verified library copy; the previous transfer was not marked successful." +) +QUIET_METADATA_RECOVERY_MESSAGES = frozenset( + {INCOMPLETE_RECOVERY_DISMISSED, METADATA_RECOVERY_SUPERSEDED} +) + +_STORY_EXTRACT = re.compile( + r"(? bool: + """Require an issue designation before the marker; never guess from page count.""" + return bool(value and _STORY_EXTRACT.search(value)) + + +def needs_complete_copy(details: Mapping[str, object], release_title: str | None = None) -> bool: + """Classify only an explicit partial-file recovery, not arbitrary metadata errors.""" + return ( + details.get("source_kind") == "direct" + and details.get("failure_code") == "direct_metadata_review_required" + and ( + details.get("metadata_failure_code") == "partial_issue" + or is_story_extract(release_title) + ) + ) diff --git a/src/pullbox/core/series_sidecar.py b/src/pullbox/core/series_sidecar.py index db7bbe96..5289c23c 100644 --- a/src/pullbox/core/series_sidecar.py +++ b/src/pullbox/core/series_sidecar.py @@ -80,6 +80,7 @@ def render_series_sidecar( existing: bytes | None, *, updated_at: datetime, + edited_fields: frozenset[str] = frozenset(), ) -> bytes: """Keep safe user keys; never turn sidecar IDs into verified library identities.""" if snapshot.entity_kind is not MetadataEntityKind.SERIES: @@ -131,6 +132,18 @@ def render_series_sidecar( "booktype": {"booktype", "series_type", "type"}, "comicid": {"comicid", "comicvine_id", "comicvineid", "cv_vol_id", "cvid"}, } + # Only explicit edits claim these previously preserved legacy metadata keys. + editable_aliases = { + "description": {"description", "summary"}, + "sort_title": {"sort_title"}, + "year_end": {"year_end"}, + "volume": {"volume"}, + "language": {"language"}, + "archive_format": {"archive_format"}, + } + for field in edited_fields & editable_aliases.keys(): + fields[field] = getattr(values, field) + aliases_by_field[field] = editable_aliases[field] # Update every recognized layer, including cleared values and case variants. for layer in layers: for name, value in fields.items(): diff --git a/src/pullbox/providers/airdcpp/contracts.py b/src/pullbox/providers/airdcpp/contracts.py index 1ccd2763..affc2196 100644 --- a/src/pullbox/providers/airdcpp/contracts.py +++ b/src/pullbox/providers/airdcpp/contracts.py @@ -204,7 +204,7 @@ class AirDcppQueueFile(AirDcppWireModel): name: BoundedString target: SecretStr = Field(repr=False) type: AirDcppFileItemType - bundle_id: PositiveInt = Field(alias="bundle") + bundle_id: WholePositiveInt = Field(alias="bundle") size: WholePositiveInt downloaded_bytes: WholeNonNegativeInt priority: AirDcppQueuePriority diff --git a/src/pullbox/providers/airdcpp/supervisor.py b/src/pullbox/providers/airdcpp/supervisor.py index eab9585d..9992978a 100644 --- a/src/pullbox/providers/airdcpp/supervisor.py +++ b/src/pullbox/providers/airdcpp/supervisor.py @@ -23,6 +23,7 @@ from pullbox.providers.airdcpp.contracts import ( AirDcppAuthenticationInfo, + AirDcppQueueBundle, AirDcppQueueBundleAddInfo, AirDcppQueueFile, AirDcppSession, @@ -118,6 +119,8 @@ async def download_search_result( async def get_queue_files_by_tth(self, tth: str) -> list[AirDcppQueueFile]: ... + async def get_queue_bundle(self, bundle_id: int) -> AirDcppQueueBundle: ... + async def create_file_bundle( self, *, diff --git a/src/pullbox/providers/direct/client.py b/src/pullbox/providers/direct/client.py index c58c2fac..e5dd71ad 100644 --- a/src/pullbox/providers/direct/client.py +++ b/src/pullbox/providers/direct/client.py @@ -345,6 +345,20 @@ def _safe_provider_error(content: bytes) -> tuple[str, str, bool, int | None] | "Provider source access requires browser challenge handling.", True, ), + "resolver_source_blocked": ( + "The resolver could not clear the source's browser challenge. " + "Other resolvers may still work.", + True, + ), + "resolver_source_backoff": ( + "This resolver is temporarily paused for this source after repeated failures. " + "Pullbox can try the next configured resolver; this one will be retried later.", + True, + ), + "resolver_timed_out": ( + "The resolver attempt exceeded its deadline. Other resolvers may still work.", + True, + ), } raw_retry_after = decoded["error"].get("retry_after_seconds") retry_after_seconds = ( diff --git a/src/pullbox/providers/metadata/metron.py b/src/pullbox/providers/metadata/metron.py index ffcc202c..7fb86ad9 100644 --- a/src/pullbox/providers/metadata/metron.py +++ b/src/pullbox/providers/metadata/metron.py @@ -40,6 +40,7 @@ _MAX_BYTES = 2 * 1024 * 1024 _PAGE_SIZE = 100 _REQUEST_TIMEOUT = 8.0 +_QUEUE_TIMEOUT = 3.0 @dataclass(frozen=True) @@ -217,34 +218,47 @@ async def _get( ) -> _Response: _validator(validator) try: - async with asyncio.timeout(_REQUEST_TIMEOUT): - for attempt in range(2): + for attempt in range(2): + try: + if self.cooldown.remaining_seconds: + raise MetadataSourceError( + SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds + ) try: - async with self.cooldown.request_lock: - if self.cooldown.remaining_seconds: - raise MetadataSourceError( - SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds - ) - delay = self.minimum_interval - ( - time.monotonic() - self.cooldown.last_request_time + async with asyncio.timeout(_QUEUE_TIMEOUT): + await self.cooldown.request_lock.acquire() + except TimeoutError: + raise MetadataSourceError( + SourceStatus.RATE_LIMITED, 3, account_failure=False + ) from None + try: + if self.cooldown.remaining_seconds: + raise MetadataSourceError( + SourceStatus.RATE_LIMITED, self.cooldown.remaining_seconds ) - if delay > 0: - await asyncio.sleep(delay) - self.cooldown.last_request_time = time.monotonic() + delay = self.minimum_interval - ( + time.monotonic() - self.cooldown.last_request_time + ) + if delay > 0: + await asyncio.sleep(delay) + self.cooldown.last_request_time = time.monotonic() + async with asyncio.timeout(_REQUEST_TIMEOUT): return await self._exchange(path, params or {}, validator) - except MetadataSourceError as exc: - if ( - exc.status is not SourceStatus.UNAVAILABLE - or exc.retry_after_seconds - or attempt - ): - raise - except httpx.TimeoutException: - raise MetadataSourceError(SourceStatus.TIMEOUT) from None - except (httpx.NetworkError, httpx.RemoteProtocolError): - if attempt: - raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None - await asyncio.sleep(0.1) + finally: + self.cooldown.request_lock.release() + except MetadataSourceError as exc: + if ( + exc.status is not SourceStatus.UNAVAILABLE + or exc.retry_after_seconds + or attempt + ): + raise + except httpx.TimeoutException: + raise MetadataSourceError(SourceStatus.TIMEOUT) from None + except (httpx.NetworkError, httpx.RemoteProtocolError): + if attempt: + raise MetadataSourceError(SourceStatus.UNAVAILABLE) from None + await asyncio.sleep(0.1) except TimeoutError: raise MetadataSourceError(SourceStatus.TIMEOUT) from None except httpx.HTTPError: diff --git a/src/pullbox/schemas/issue.py b/src/pullbox/schemas/issue.py index 1cf8a864..1d78e201 100644 --- a/src/pullbox/schemas/issue.py +++ b/src/pullbox/schemas/issue.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field from pullbox.models.issue import IssueStatus +from pullbox.schemas.issue_file_metadata import FileMetadataApproval, FileMetadataReview class IssueUpdate(BaseModel): @@ -54,6 +55,11 @@ class ManualFileImportRequest(BaseModel): False, description="Allow one explicit resource safety exception for this manual import attempt.", ) + metadata_approval: FileMetadataApproval | None = None + + +class ManualFileImportPreviewRequest(ManualFileImportRequest, FileMetadataReview): + """Inspect the selected file and choose only values read from that file or library.""" class ManualFileImportResponse(BaseModel): diff --git a/src/pullbox/schemas/issue_file_metadata.py b/src/pullbox/schemas/issue_file_metadata.py index 820ef588..e8711d88 100644 --- a/src/pullbox/schemas/issue_file_metadata.py +++ b/src/pullbox/schemas/issue_file_metadata.py @@ -4,29 +4,45 @@ from pydantic import BaseModel, ConfigDict, Field +from pullbox.schemas.issue_metadata_editor import IssueMetadataEditorField + type FileMetadataSource = Literal["library", "ComicInfo.xml", "MetronInfo.xml"] -type FileMetadataField = Literal[ - "series.title", - "series.sort_title", - "series.publisher", - "series.language", - "series.year_start", - "series.volume", - "series.series_type", - "series.issue_count", - "issue.title", - "issue.description", - "issue.cover_date", - "issue.store_date", - "issue.page_count", - "issue.credits", +type SharedXmlField = Literal[ + "xml.AgeRating", + "xml.Genre", + "xml.Tags", + "xml.Characters", + "xml.Teams", + "xml.Locations", + "xml.Imprint", + "xml.StoryArc", ] +type SharedXmlChoices = dict[SharedXmlField, Literal["ComicInfo.xml", "MetronInfo.xml"]] +type FileMetadataField = ( + Literal[ + "series.title", + "series.sort_title", + "series.publisher", + "series.language", + "series.year_start", + "series.volume", + "series.series_type", + "series.issue_count", + "issue.title", + "issue.description", + "issue.cover_date", + "issue.store_date", + "issue.page_count", + "issue.credits", + ] + | SharedXmlField +) type FileMetadataChoices = dict[FileMetadataField, FileMetadataSource] class FileMetadataReview(BaseModel): model_config = ConfigDict(extra="forbid") - choices: FileMetadataChoices = Field(default_factory=dict, max_length=14) + choices: FileMetadataChoices = Field(default_factory=dict, max_length=22) class FileMetadataOption(BaseModel): @@ -60,6 +76,8 @@ class FileMetadataPreview(BaseModel): ready: bool = True conflicts: list[FileMetadataConflict] = Field(default_factory=list) converts_to_cbz: bool = False + editor_fields: list[IssueMetadataEditorField] = Field(default_factory=list) + editor_changed: bool = False class FileMetadataApproval(FileMetadataReview): diff --git a/src/pullbox/schemas/issue_metadata_editor.py b/src/pullbox/schemas/issue_metadata_editor.py new file mode 100644 index 00000000..016457ff --- /dev/null +++ b/src/pullbox/schemas/issue_metadata_editor.py @@ -0,0 +1,52 @@ +"""Bounded descriptive edits; identifiers and series-wide values are not editable.""" + +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +type EditableIssueField = Literal[ + "title", "description", "cover_date", "store_date", "page_count", "credits" +] + + +class IssueMetadataEdit(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: Literal["library", "ComicInfo.xml", "MetronInfo.xml", "custom", "clear", "reset"] + value: str | None = Field(default=None, max_length=200000) + + @model_validator(mode="after") + def explicit_custom_value(self) -> Self: + if (self.source == "custom") != (self.value is not None): + raise ValueError("Only a custom correction accepts a value. Use Clear to remove one.") + return self + + +class IssueMetadataEditorReview(BaseModel): + model_config = ConfigDict(extra="forbid") + + edits: dict[EditableIssueField, IssueMetadataEdit] = Field(default_factory=dict, max_length=6) + + +class IssueMetadataEditorApproval(IssueMetadataEditorReview): + review_key: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class IssueMetadataEditorOption(BaseModel): + source: str + value: str + + +class IssueMetadataEditorField(BaseModel): + key: str + label: str + current: str + proposed: str + editable: bool = False + reason: str = "" + input_type: Literal["text", "textarea", "date", "number", "credits"] = "text" + options: list[IssueMetadataEditorOption] = Field(default_factory=list) + overridden: bool = False + differs: bool = False + selected: bool = False + origin: str = "" diff --git a/src/pullbox/schemas/metadata_snapshot.py b/src/pullbox/schemas/metadata_snapshot.py index 1642183d..8b9a993a 100644 --- a/src/pullbox/schemas/metadata_snapshot.py +++ b/src/pullbox/schemas/metadata_snapshot.py @@ -39,6 +39,8 @@ class MetadataValues(BaseModel): year_end: int | None = Field(default=None, ge=1, le=9999, strict=True) volume: str | None = Field(default=None, max_length=100) series_type: str | None = Field(default=None, max_length=50) + # These embedded formats describe publication, not a library classification. + archive_format: Literal["Limited Series", "Digital Chapter"] | None = None status: str | None = Field(default=None, max_length=50) language: str | None = Field(default=None, max_length=100) issue_count: int | None = Field(default=None, ge=0, le=1000000, strict=True) @@ -106,6 +108,7 @@ class FieldOrigin(BaseModel): source_updated_at: AwareDatetime | None = None observed_at: AwareDatetime user_override: bool = False + provider_managed: bool = Field(default=False, exclude_if=lambda value: not value) derivation: Literal["classification", "lifecycle", "catalog", "normalization"] | None = None embedded_documents: tuple[EmbeddedDocument, ...] = () passive_release: PassiveReleaseOrigin | None = None @@ -138,6 +141,16 @@ def consistent_provenance(self) -> Self: or origin.field in fields or origin.domain is not field_domain(self.entity_kind, origin.field) or (origin.user_override and origin.source is not None) + or ( + origin.provider_managed + and ( + origin.user_override + or origin.source is not None + or origin.embedded_documents + or origin.passive_release is not None + or origin.derivation is not None + ) + ) or ( origin.embedded_documents and ( diff --git a/src/pullbox/schemas/operation_progress.py b/src/pullbox/schemas/operation_progress.py index 8549e117..50a49483 100644 --- a/src/pullbox/schemas/operation_progress.py +++ b/src/pullbox/schemas/operation_progress.py @@ -64,6 +64,8 @@ class OperationActivityRead(BaseModel): active_count: int spinner_count: int attention_count: int + total_count: int + refresh_after_ms: int | None class OperationAcknowledgementRead(BaseModel): diff --git a/src/pullbox/schemas/search.py b/src/pullbox/schemas/search.py index e0e78545..eb57492a 100644 --- a/src/pullbox/schemas/search.py +++ b/src/pullbox/schemas/search.py @@ -3,7 +3,7 @@ from datetime import date from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StrictBool class SeriesSearchResult(BaseModel): @@ -219,6 +219,8 @@ class DcGrabRequest(BaseModel): max_length=200, description="Opaque server-side Direct Connect route grant", ) + override_rejection: StrictBool = False + replace_existing_file: StrictBool = False class DcGrabResponse(BaseModel): diff --git a/src/pullbox/schemas/series_metadata_editor.py b/src/pullbox/schemas/series_metadata_editor.py new file mode 100644 index 00000000..e316a4bf --- /dev/null +++ b/src/pullbox/schemas/series_metadata_editor.py @@ -0,0 +1,96 @@ +"""Selected series corrections and explicit library/sidecar save scope.""" + +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, model_validator + +from pullbox.schemas.series_sidecar import SeriesSidecarTargetRead + +type EditableSeriesField = Literal[ + "title", + "sort_title", + "publisher", + "description", + "year_start", + "year_end", + "status", + "series_type", + "archive_format", + "volume", + "language", +] +type SeriesEditorMode = Literal["library_and_sidecars", "library_only"] + + +class SeriesMetadataEdit(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + action: Literal["set", "clear", "reset", "library", "sidecar"] + value: StrictStr | StrictInt | None = None + source: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") + + @model_validator(mode="after") + def action_payload(self) -> Self: + if self.action == "set": + if self.value is None or self.source is not None: + raise ValueError("Set requires a custom value, not a saved source.") + elif self.value is not None or ((self.action == "sidecar") != (self.source is not None)): + raise ValueError("Saved source, clear and reset actions do not accept custom values.") + return self + + +class SeriesMetadataEditorReview(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: SeriesEditorMode = "library_and_sidecars" + edits: dict[EditableSeriesField, SeriesMetadataEdit] = Field( + default_factory=dict, max_length=11 + ) + + +class SeriesMetadataEditorApproval(SeriesMetadataEditorReview): + review_key: str = Field(pattern=r"^[a-f0-9]{64}$") + + +class SeriesMetadataEditorRetry(BaseModel): + model_config = ConfigDict(extra="forbid") + + job_id: str = Field(pattern=r"^[a-f0-9]{32}$") + + +class SeriesMetadataEditorSource(BaseModel): + token: str + label: str + value: str + + +class SeriesMetadataEditorOption(BaseModel): + value: str + label: str + + +class SeriesMetadataEditorField(BaseModel): + key: str + label: str + current: str + proposed: str + editable: bool = True + input_type: str = "text" + overridden: bool = False + selected: bool = False + changed: bool = False + can_clear: bool = True + reason: str = "" + options: list[SeriesMetadataEditorOption] = Field(default_factory=list) + sources: list[SeriesMetadataEditorSource] = Field(default_factory=list) + + +class SeriesMetadataEditorPreview(BaseModel): + series_id: int + mode: SeriesEditorMode + editor_fields: list[SeriesMetadataEditorField] + editor_changed: bool + targets: list[SeriesSidecarTargetRead] + ready: bool + review_key: str + warnings: list[str] = Field(default_factory=list) diff --git a/src/pullbox/schemas/series_metadata_propagation.py b/src/pullbox/schemas/series_metadata_propagation.py new file mode 100644 index 00000000..e564c7dd --- /dev/null +++ b/src/pullbox/schemas/series_metadata_propagation.py @@ -0,0 +1,42 @@ +"""Bounded server-owned approvals for a single series' managed comic files.""" + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +type PropagationField = Literal[ + "title", "publisher", "language", "format", "sort_title", "year_start", "volume" +] + + +class PropagationReview(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + fields: frozenset[PropagationField] = Field(min_length=1, max_length=7) + + +class PropagationApproval(BaseModel): + model_config = ConfigDict(extra="forbid") + job_id: str = Field(pattern=r"^[a-f0-9]{32}$") + issue_ids: list[Annotated[int, Field(gt=0, lt=2**63, strict=True)]] | None = Field( + default=None, min_length=1, max_length=10000 + ) + + +class PropagationRetry(BaseModel): + model_config = ConfigDict(extra="forbid") + job_id: str = Field(pattern=r"^[a-f0-9]{32}$") + + +class PropagationTarget(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + issue_id: int = Field(gt=0, strict=True) + label: str = Field(max_length=500) + review_key: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") + operation_id: str = Field(pattern=r"^[a-f0-9]{32}$") + + +class PropagationPlan(PropagationReview): + series_id: int = Field(gt=0, strict=True) + series_key: str = Field(pattern=r"^[a-f0-9]{64}$") + mode: Literal["preview", "write"] + targets: tuple[PropagationTarget, ...] = Field(min_length=1, max_length=10000) diff --git a/src/pullbox/services/airdcpp_acquisition.py b/src/pullbox/services/airdcpp_acquisition.py index 07fe0316..f1ba4bb7 100644 --- a/src/pullbox/services/airdcpp_acquisition.py +++ b/src/pullbox/services/airdcpp_acquisition.py @@ -12,6 +12,7 @@ from pullbox.core.acquisition import AcquisitionProtocol from pullbox.models.airdcpp import AirDcppAcquisition +from pullbox.models.client import DownloadClientConfig from pullbox.models.download import DownloadClientType, DownloadHistory, DownloadState from pullbox.models.issue import Issue, IssueStatus from pullbox.providers.airdcpp.errors import ( @@ -72,6 +73,18 @@ class AirDcppQueueAcquisitionResult: state: DownloadState +async def lock_queue_intents(session: AsyncSession, client_config_id: int | None) -> None: + """Serialize short intent/claim transactions, never remote queue operations.""" + client_id = await session.scalar( + update(DownloadClientConfig) + .where(DownloadClientConfig.id == client_config_id) + .values(updated_at=DownloadClientConfig.updated_at) + .returning(DownloadClientConfig.id) + ) + if client_id is None: + raise ValueError("The AirDC++ client is no longer configured. Refresh before retrying.") + + class AirDcppQueueAcquisitionService: """Create one idempotent intent and reconcile ambiguous mutation outcomes.""" @@ -89,6 +102,23 @@ async def acquire( ) -> AirDcppQueueAcquisitionResult: if not request_key or len(request_key) > 255: raise ValueError("Invalid Direct Connect request identity") + await lock_queue_intents(session, candidate.route.client_config_id) + recovery_claim = await session.scalar( + select(AirDcppAcquisition.id) + .where( + AirDcppAcquisition.client_identity == candidate.route.client_identity, + AirDcppAcquisition.tth == candidate.route.tth, + AirDcppAcquisition.size_bytes == candidate.route.size_bytes, + AirDcppAcquisition.client_state == "recovery_check_pending", + ) + .limit(1) + ) + if recovery_claim is not None: + await session.commit() + raise ValueError( + "This AirDC++ file is being checked or cancelled. Wait for recovery to finish, " + "then retry. No new request was sent." + ) existing = ( await session.execute( select(AirDcppAcquisition).where(AirDcppAcquisition.request_key == request_key) diff --git a/src/pullbox/services/airdcpp_reconciliation.py b/src/pullbox/services/airdcpp_reconciliation.py index bb1cde76..0c451057 100644 --- a/src/pullbox/services/airdcpp_reconciliation.py +++ b/src/pullbox/services/airdcpp_reconciliation.py @@ -9,12 +9,17 @@ from typing import TYPE_CHECKING, Protocol import structlog -from sqlalchemy import or_, select -from sqlalchemy.orm import joinedload +from sqlalchemy import or_, select, update +from sqlalchemy.orm import aliased, joinedload from pullbox.models.airdcpp import AirDcppAcquisition from pullbox.models.download import DownloadHistory, DownloadState from pullbox.providers.airdcpp.errors import AirDcppEntityNotFoundError, AirDcppError +from pullbox.services.airdcpp_recovery import ( + finish_queue_recovery, + queue_failure_code, + record_queue_recovery, +) from pullbox.services.download_operation_progress import project_download_operation_progress if TYPE_CHECKING: @@ -128,6 +133,7 @@ def __init__( self._cooldown = cooldown self._client_locks: dict[int, asyncio.Lock] = {} self._reconciliation_cursors: dict[int, int] = {} + self._lookup_cursors: dict[int, int] = {} async def reconcile_client( self, @@ -136,7 +142,11 @@ async def reconcile_client( ) -> AirDcppReconciliationResult: lock = self._client_locks.setdefault(client_config_id, asyncio.Lock()) async with lock: - return await self._reconcile_client(client_config_id, api_client) + try: + return await self._reconcile_client(client_config_id, api_client) + except AirDcppError: + await self.defer_unconfirmed_requests(client_config_id) + raise async def _reconcile_client( self, @@ -145,13 +155,32 @@ async def _reconcile_client( ) -> AirDcppReconciliationResult: await self._request_due_alternate(client_config_id, api_client) async with self._session_factory() as session: + duplicate = aliased(AirDcppAcquisition) + legacy_failed_owner = ( + (DownloadHistory.state == DownloadState.FAILED) + & AirDcppAcquisition.bundle_id.is_not(None) + & AirDcppAcquisition.client_state.in_( + ["download_error", "completion_validation_error"] + ) + & select(duplicate.id) + .where( + duplicate.client_state == "duplicate", + duplicate.client_identity == AirDcppAcquisition.client_identity, + duplicate.tth == AirDcppAcquisition.tth, + duplicate.size_bytes == AirDcppAcquisition.size_bytes, + duplicate.original_name == AirDcppAcquisition.original_name, + duplicate.route_snapshot["existing_bundle_owner_acquisition_id"].as_integer() + == AirDcppAcquisition.id, + ) + .exists() + ) active_query = ( select(AirDcppAcquisition) .join(DownloadHistory) .where( AirDcppAcquisition.client_config_id == client_config_id, DownloadHistory.imported_at.is_(None), - DownloadHistory.state.not_in(_TERMINAL_STATES), + or_(DownloadHistory.state.not_in(_TERMINAL_STATES), legacy_failed_owner), ) .order_by(AirDcppAcquisition.id) ) @@ -203,18 +232,31 @@ async def _reconcile_client( adopted: dict[int, AirDcppQueueFile] = {} recovered: dict[int, _RecoveredMutation] = {} retry_failures: dict[int, str] = {} + recovery_required: dict[int, str] = {} stale_recovered_bundles: set[tuple[str, int]] = set() pre_id = [snapshot for snapshot in snapshots if snapshot.bundle_id is None] + cursor = self._lookup_cursors.get(client_config_id, 0) + pre_id.sort(key=lambda item: (item.acquisition_id <= cursor, item.acquisition_id)) + lookup_batch = pre_id[:_MAX_PRE_ID_LOOKUPS] + if lookup_batch: + self._lookup_cursors[client_config_id] = lookup_batch[-1].acquisition_id now = datetime.now(UTC) - for snapshot in pre_id[:_MAX_PRE_ID_LOOKUPS]: + for snapshot in lookup_batch: + if snapshot.client_state == "recovery_check_pending" and not _pre_id_retry_due( + snapshot, now=now + ): + continue try: files = await api_client.get_queue_files_by_tth(snapshot.tth) except AirDcppError: + if _pre_id_retry_due(snapshot, now=now): + recovery_required[snapshot.acquisition_id] = "queue_lookup_failed" continue exact = [ item for item in files - if item.size == snapshot.size_bytes + if item.tth == snapshot.tth + and item.size == snapshot.size_bytes and PurePath(item.target.get_secret_value().replace("\\", "/")).name == snapshot.target_name ] @@ -224,6 +266,16 @@ async def _reconcile_client( continue if not _pre_id_retry_due(snapshot, now=now): continue + if files: + recovery_required[snapshot.acquisition_id] = "queue_identity_ambiguous" + continue + if snapshot.client_state in { + "mutation_pending", + "retry_mutation_pending", + "recovery_check_pending", + }: + recovery_required[snapshot.acquisition_id] = "queue_request_unconfirmed" + continue try: recovered[snapshot.acquisition_id] = await _retry_pre_id_mutation( api_client, @@ -298,6 +350,20 @@ async def _reconcile_client( continue bundle = bundles.get(snapshot.bundle_id) if snapshot.bundle_id else None if bundle is not None: + if snapshot.client_state == "recovery_check_pending" and not _pre_id_retry_due( + snapshot, now=now + ): + continue + legacy_failed = ( + acquisition.client_state == snapshot.client_state + and snapshot.client_state + in {"download_error", "completion_validation_error"} + and acquisition.bundle_id == snapshot.bundle_id + and acquisition.download_history.state is DownloadState.FAILED + and acquisition.download_history.imported_at is None + ) + if acquisition.download_history.state in _TERMINAL_STATES and not legacy_failed: + continue was_completed = ( DownloadState(acquisition.download_history.state) is DownloadState.COMPLETED ) @@ -306,6 +372,35 @@ async def _reconcile_client( not was_completed and acquisition.download_history.state is DownloadState.COMPLETED ) + if ( + acquisition.download_history.state is DownloadState.FAILED + and acquisition.download_history.imported_at is None + and ( + legacy_failed + or acquisition.client_state + in {"download_error", "completion_validation_error"} + ) + ): + await record_queue_recovery( + session, acquisition, code=queue_failure_code(bundle), at=now + ) + duplicates = ( + await session.scalars( + select(AirDcppAcquisition) + .options(joinedload(AirDcppAcquisition.download_history)) + .where( + AirDcppAcquisition.client_state == "duplicate", + AirDcppAcquisition.route_snapshot[ + "existing_bundle_owner_acquisition_id" + ].as_integer() + == acquisition.id, + ) + ) + ).all() + for duplicate_request in duplicates: + await project_download_operation_progress( + session, duplicate_request.download_history + ) elif snapshot.acquisition_id in adopted: adopted_file = adopted[snapshot.acquisition_id] if _active_snapshot_is_current(acquisition, snapshot): @@ -354,6 +449,15 @@ async def _reconcile_client( stale_recovered_bundles.add( (snapshot.client_identity, recovered_mutation.bundle_id) ) + elif snapshot.acquisition_id in recovery_required: + if _active_snapshot_is_current(acquisition, snapshot): + await record_queue_recovery( + session, + acquisition, + code=recovery_required[snapshot.acquisition_id], + at=now, + ) + changed += 1 elif snapshot.acquisition_id in retry_failures: if _active_snapshot_is_current(acquisition, snapshot): changed += int( @@ -376,6 +480,13 @@ async def _reconcile_client( acquisition.download_history, _shared_progress_snapshot(acquisition), ) + if ( + snapshot.client_state == "recovery_check_pending" + and acquisition.bundle_id is not None + and acquisition.client_state + not in {"recovery_check_pending", "recovery_required"} + ): + await finish_queue_recovery(session, acquisition.download_history_id) await session.commit() for client_identity, bundle_id in stale_recovered_bundles: @@ -415,6 +526,60 @@ async def _reconcile_client( pages=pages, ) + async def defer_unconfirmed_requests(self, client_config_id: int) -> int: + """Retain overdue unconfirmed work for review when the client is unavailable.""" + now = datetime.now(UTC) + changed = 0 + async with self._session_factory() as session: + acquisitions = ( + await session.scalars( + select(AirDcppAcquisition) + .options(joinedload(AirDcppAcquisition.download_history)) + .join(DownloadHistory) + .where( + AirDcppAcquisition.client_config_id == client_config_id, + AirDcppAcquisition.bundle_id.is_(None), + AirDcppAcquisition.client_state.in_( + [ + "mutation_pending", + "reconcile_pending", + "retry_mutation_pending", + "recovery_check_pending", + ] + ), + AirDcppAcquisition.next_retry_at <= now, + DownloadHistory.state.not_in(_TERMINAL_STATES), + DownloadHistory.imported_at.is_(None), + ) + .order_by(AirDcppAcquisition.id) + .limit(_PAGE_SIZE) + ) + ).all() + for acquisition in acquisitions: + claimed = await session.scalar( + update(AirDcppAcquisition) + .where( + AirDcppAcquisition.id == acquisition.id, + AirDcppAcquisition.bundle_id.is_(None), + AirDcppAcquisition.client_state == acquisition.client_state, + AirDcppAcquisition.next_retry_at == acquisition.next_retry_at, + AirDcppAcquisition.download_history.has( + DownloadHistory.state.not_in(_TERMINAL_STATES) + ), + ) + .values(client_state="recovery_required") + .returning(AirDcppAcquisition.id) + ) + if claimed is None: + continue + await record_queue_recovery( + session, acquisition, code="queue_lookup_failed", at=now + ) + await project_download_operation_progress(session, acquisition.download_history) + changed += 1 + await session.commit() + return changed + async def _request_due_alternate( self, client_config_id: int, @@ -482,7 +647,11 @@ def _queue_priority(route_snapshot: dict[str, object] | None) -> int | None: def _pre_id_retry_due(snapshot: _ActiveSnapshot, *, now: datetime) -> bool: if snapshot.client_state == "reconcile_pending": return snapshot.next_retry_at is None or snapshot.next_retry_at <= now - if snapshot.client_state == "mutation_pending": + if snapshot.client_state in { + "mutation_pending", + "retry_mutation_pending", + "recovery_check_pending", + }: return snapshot.next_retry_at is not None and snapshot.next_retry_at <= now return False @@ -497,6 +666,7 @@ def _active_snapshot_is_current( and acquisition.bundle_id == snapshot.bundle_id and acquisition.client_state == snapshot.client_state and acquisition.retry_count == snapshot.retry_count + and acquisition.next_retry_at == snapshot.next_retry_at ) @@ -789,12 +959,14 @@ def _apply_adopted_file( history = acquisition.download_history acquisition.bundle_id = queue_file.bundle_id acquisition.client_state = queue_file.status.id + acquisition.next_retry_at = None acquisition.remote_target = queue_file.target.get_secret_value() acquisition.last_reconciled_at = at acquisition.reconciliation_error = None history.external_id = f"airdcpp:{acquisition.client_config_id}:bundle:{queue_file.bundle_id}" if history.imported_at is None and history.state not in _TERMINAL_STATES: history.state = DownloadState.SENT + history.completed_at = None history.next_retry_at = None history.error_message = None return True diff --git a/src/pullbox/services/airdcpp_recovery.py b/src/pullbox/services/airdcpp_recovery.py new file mode 100644 index 00000000..5c79abfe --- /dev/null +++ b/src/pullbox/services/airdcpp_recovery.py @@ -0,0 +1,436 @@ +"""Actionable recovery for unconfirmed AirDC++ queue requests.""" + +from __future__ import annotations + +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import PurePath +from typing import TYPE_CHECKING, Protocol + +from sqlalchemy import and_, or_, select, update +from sqlalchemy.orm import joinedload + +from pullbox.models.airdcpp import AirDcppAcquisition +from pullbox.models.download import DownloadHistory, DownloadState +from pullbox.models.issue import Issue, IssueStatus +from pullbox.models.pending_match import PendingMatch, PendingMatchStatus +from pullbox.providers.airdcpp.errors import AirDcppEntityNotFoundError, AirDcppError +from pullbox.services.airdcpp_acquisition import AirDcppQueueApi, lock_queue_intents + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from pullbox.providers.airdcpp.contracts import AirDcppQueueBundle + + +class AirDcppRecoveryApi(AirDcppQueueApi, Protocol): + async def get_queue_bundle(self, bundle_id: int) -> AirDcppQueueBundle: ... + + +RECOVERY_LINK = "/intervention?tab=recovery&protocol=dc" +RECOVERY_MESSAGES = { + "queue_hash_mismatch": ( + "AirDC++ stopped this download because its file hash does not match (TTH inconsistency). " + "Check or repair the download in AirDC++, then Recheck AirDC++; " + "or cancel this request. Pullbox will not bypass the integrity check." + ), + "queue_client_failed": ( + "An earlier AirDC++ download needs review. Check its queue error, correct it if needed, " + "then Recheck AirDC++; or cancel this request. No new download was started." + ), + "queue_lookup_failed": ( + "Pullbox could not verify this request in AirDC++. " + "Recheck the client before retrying or cancelling." + ), + "queue_identity_ambiguous": ( + "AirDC++ returned conflicting queue identities. " + "Check the client; Pullbox has not queued another copy." + ), + "queue_request_unconfirmed": ( + "AirDC++ has not confirmed this request. " + "Recheck the client, retry the request, or cancel it." + ), + "queue_bundle_shared": ( + "This AirDC++ bundle is shared by another Pullbox request. " + "Recheck AirDC++ to reconnect it, or cancel the shared bundle in AirDC++ " + "before cancelling this request. No download or library file was removed." + ), +} + + +def queue_failure_code(bundle: AirDcppQueueBundle) -> str: + return ( + "queue_hash_mismatch" + if (bundle.status.failed or bundle.status.id == "download_error") + and "tth inconsistency" in bundle.status.str.casefold() + else "queue_client_failed" + ) + + +async def record_queue_recovery( + session: AsyncSession, acquisition: AirDcppAcquisition, *, code: str, at: datetime +) -> None: + """Stop automatic mutation and preserve one actionable record per request.""" + history = acquisition.download_history + acquisition.client_state = "recovery_required" + acquisition.next_retry_at = None + acquisition.last_reconciled_at = at + acquisition.reconciliation_error = code + history.state = DownloadState.FAILED + history.completed_at = at + history.next_retry_at = None + history.error_message = RECOVERY_MESSAGES[code] + locator = f"airdcpp-recovery://download/{history.id}" + pending = await session.scalar( + select(PendingMatch).where( + PendingMatch.issue_id == history.issue_id, PendingMatch.download_url == locator + ) + ) + details = { + "source_kind": "dc", + "provider_name": "AirDC++", + "failure_class": "queue_confirmation", + "failure_code": code, + "dc_download_id": history.id, + } + if pending is None: + pending = PendingMatch( + issue_id=history.issue_id, + release_title=history.title, + download_url=locator, + file_size=history.file_size, + confidence="high", + match_details=details, + ) + session.add(pending) + else: + pending.match_details = details + pending.status = PendingMatchStatus.PENDING + pending.resolved_at = None + pending.resolved_by = None + await session.flush() + + +async def finish_queue_recovery( + session: AsyncSession, download_id: int, *, cancelled: bool = False +) -> None: + pending = await session.scalar( + select(PendingMatch).where( + PendingMatch.download_url == f"airdcpp-recovery://download/{download_id}", + PendingMatch.status == PendingMatchStatus.PENDING, + ) + ) + if pending is not None: + pending.status = PendingMatchStatus.REJECTED if cancelled else PendingMatchStatus.APPROVED + pending.resolved_at = datetime.now(UTC) + pending.resolved_by = "cancelled" if cancelled else "queue_confirmed" + + +async def act_on_queue_recovery( + session: AsyncSession, pending_id: int, action: str, api: AirDcppRecoveryApi +) -> str: + """Recheck exact provenance before any explicit retry or cancellation.""" + from pullbox.services.airdcpp_reconciliation import ( + _apply_adopted_file, + _apply_existing_bundle_owner, + _apply_recovered_mutation, + _RecoveredMutation, + _shared_progress_snapshot, + apply_airdcpp_bundle, + ) + from pullbox.services.download_operation_progress import project_download_operation_progress + + if action not in {"recheck", "retry", "cancel"}: + raise ValueError("Unknown AirDC++ recovery action.") + pending = await session.get(PendingMatch, pending_id) + if pending is None or pending.status != PendingMatchStatus.PENDING: + raise ValueError("This recovery item is no longer pending.") + details = pending.match_details or {} + download_id = details.get("dc_download_id") + if details.get("source_kind") != "dc" or type(download_id) is not int: + raise ValueError("This is not an AirDC++ recovery request.") + acquisition = await session.scalar( + select(AirDcppAcquisition) + .options(joinedload(AirDcppAcquisition.download_history)) + .where(AirDcppAcquisition.download_history_id == download_id) + ) + if ( + acquisition is None + or acquisition.client_state != "recovery_required" + or acquisition.download_history.issue_id != pending.issue_id + or acquisition.download_history.state is not DownloadState.FAILED + or acquisition.download_history.imported_at is not None + ): + raise ValueError("This AirDC++ request has changed. Refresh before trying again.") + history = acquisition.download_history + # A durable claim prevents concurrent buttons from repeating an external mutation; + # the periodic reconciler can recover this claim after a process restart. + deadline = datetime.now(UTC) + timedelta(minutes=5) + acquisition_id = acquisition.id + client_identity = acquisition.client_identity + expected_bundle = acquisition.bundle_id + await lock_queue_intents(session, acquisition.client_config_id) + claimed = await session.scalar( + update(AirDcppAcquisition) + .where( + AirDcppAcquisition.id == acquisition.id, + AirDcppAcquisition.client_state == "recovery_required", + AirDcppAcquisition.bundle_id == expected_bundle, + ) + .values(client_state="recovery_check_pending", next_retry_at=deadline) + .returning(AirDcppAcquisition.id) + ) + if claimed is None: + await session.rollback() + raise ValueError("This AirDC++ request is already being checked.") + history.state = DownloadState.RETRY_PENDING + history.next_retry_at = deadline + await session.commit() + + code = "queue_lookup_failed" + created_bundle: int | None = None + try: + files = await api.get_queue_files_by_tth(acquisition.tth) + exact = [ + item + for item in files + if item.tth == acquisition.tth + and item.size == acquisition.size_bytes + and PurePath(item.target.get_secret_value().replace("\\", "/")).name + == acquisition.original_name + ] + bundle_ids = {item.bundle_id for item in exact} + if files and (len(bundle_ids) != 1 or len(exact) != len(files)): + code = "queue_identity_ambiguous" + raise ValueError(RECOVERY_MESSAGES[code]) + bundle_id = exact[0].bundle_id if exact else None + bundle = None + probe_id = bundle_id or expected_bundle + if expected_bundle is not None and bundle_id not in {None, expected_bundle}: + code = "queue_identity_ambiguous" + raise ValueError(RECOVERY_MESSAGES[code]) + if probe_id is not None: + try: + bundle = await api.get_queue_bundle(probe_id) + except AirDcppEntityNotFoundError: + if exact: + raise ValueError(RECOVERY_MESSAGES[code]) from None + if bundle is not None: + if ( + not exact + or bundle.id != probe_id + or bundle.type.id != "file" + or bundle.size != acquisition.size_bytes + or PurePath(bundle.target.get_secret_value().replace("\\", "/")).name + != acquisition.original_name + ): + code = "queue_identity_ambiguous" + raise ValueError(RECOVERY_MESSAGES[code]) + if ( + bundle.status.failed + or bundle.status.id in {"download_error", "completion_validation_error"} + ) and action != "cancel": + code = queue_failure_code(bundle) + raise ValueError(RECOVERY_MESSAGES[code]) + owner_id = ( + await session.scalar( + select(AirDcppAcquisition.id).where( + AirDcppAcquisition.client_identity == acquisition.client_identity, + AirDcppAcquisition.bundle_id == bundle_id, + AirDcppAcquisition.id != acquisition.id, + ) + ) + if bundle_id is not None + else None + ) + shared_bundle = owner_id is not None + if action == "cancel" and bundle_id is not None: + # Intents admitted before our claim may still be merging remotely. + # Later admissions are blocked by the durable claim above. + shared_bundle = ( + await session.scalar( + select(AirDcppAcquisition.id) + .join(DownloadHistory) + .where( + AirDcppAcquisition.client_identity == client_identity, + AirDcppAcquisition.id != acquisition_id, + or_( + AirDcppAcquisition.bundle_id == bundle_id, + and_( + AirDcppAcquisition.tth == acquisition.tth, + AirDcppAcquisition.size_bytes == acquisition.size_bytes, + AirDcppAcquisition.client_state.is_distinct_from("cancelled"), + DownloadHistory.imported_at.is_(None), + ), + ), + ) + .limit(1) + ) + ) is not None + issue = await session.get(Issue, history.issue_id) + another_active = await session.scalar( + select(DownloadHistory.id) + .where( + DownloadHistory.issue_id == history.issue_id, + DownloadHistory.id != history.id, + DownloadHistory.state.in_( + [ + DownloadState.QUEUED, + DownloadState.SENT, + DownloadState.DOWNLOADING, + DownloadState.RETRY_PENDING, + DownloadState.PAUSED, + DownloadState.FINALIZING, + DownloadState.POST_PROCESSING, + ] + ), + ) + .limit(1) + ) + still_claimed = await session.scalar( + select(AirDcppAcquisition.id).where( + AirDcppAcquisition.id == acquisition_id, + AirDcppAcquisition.client_state == "recovery_check_pending", + AirDcppAcquisition.next_retry_at == deadline, + AirDcppAcquisition.bundle_id == expected_bundle, + ) + ) + await session.commit() + if still_claimed is None: + raise ValueError( + "This request changed while it was being checked. Refresh before retrying." + ) + if action == "cancel" and shared_bundle and expected_bundle == bundle_id: + code = "queue_bundle_shared" + raise ValueError(RECOVERY_MESSAGES[code]) + added = None + if not files and action == "retry": + if issue is None or issue.status is IssueStatus.OWNED or another_active is not None: + raise ValueError( + "This issue is already owned or has another active download. " + "No new request was sent." + ) + priority = (acquisition.route_snapshot or {}).get("queue_priority") + added = await api.create_file_bundle( + tth=acquisition.tth, + size=acquisition.size_bytes, + target_name=acquisition.original_name, + priority=priority if type(priority) is int else None, + ) + bundle_id = added.id + created_bundle = None if added.merged else added.id + elif action == "cancel" and bundle_id is not None and not shared_bundle: + with suppress(AirDcppEntityNotFoundError): + await api.remove_queue_bundle(bundle_id) + + current = await session.scalar( + update(AirDcppAcquisition) + .where( + AirDcppAcquisition.id == acquisition.id, + AirDcppAcquisition.client_state == "recovery_check_pending", + AirDcppAcquisition.next_retry_at == deadline, + AirDcppAcquisition.bundle_id == expected_bundle, + ) + .values(client_state="recovery_check_pending") + .returning(AirDcppAcquisition.id) + ) + if current is None: + await session.rollback() + if created_bundle is not None: + owner = await session.scalar( + select(AirDcppAcquisition.id).where( + AirDcppAcquisition.client_identity == client_identity, + AirDcppAcquisition.bundle_id == created_bundle, + ) + ) + await session.commit() + if owner is None: + await api.remove_queue_bundle(created_bundle) + raise ValueError( + "The request changed while AirDC++ was responding. " + "Refresh to see its current state." + ) + await session.refresh(history) + if issue is not None: + await session.refresh(issue) + now = datetime.now(UTC) + if action == "cancel": + acquisition.client_state = "cancelled" + acquisition.next_retry_at = None + acquisition.reconciliation_error = None + history.state = DownloadState.FAILED + history.error_message = "Cancelled by user" + history.completed_at = now + history.next_retry_at = None + if ( + issue is not None + and issue.status is IssueStatus.DOWNLOADING + and another_active is None + ): + issue.status = IssueStatus.WANTED + message = "AirDC++ request cancelled. No library files were deleted." + if shared_bundle: + message += " The shared AirDC++ bundle was kept for another request." + elif bundle_id is not None: + owner_id = await session.scalar( + select(AirDcppAcquisition.id).where( + AirDcppAcquisition.client_identity == acquisition.client_identity, + AirDcppAcquisition.bundle_id == bundle_id, + AirDcppAcquisition.id != acquisition.id, + ) + ) + if owner_id is not None: + _apply_existing_bundle_owner(acquisition, owner_acquisition_id=owner_id, at=now) + message = "This request is already covered by another Pullbox download." + elif exact: + history.state = DownloadState.QUEUED + _apply_adopted_file(acquisition, exact[0], at=now) + if bundle is not None: + apply_airdcpp_bundle(acquisition, bundle, at=now) + message = "Existing AirDC++ download reconnected." + else: + _apply_recovered_mutation( + acquisition, + _RecoveredMutation( + bundle_id, "source_search_pending", created_bundle is not None + ), + at=now, + ) + message = "AirDC++ request queued." + if owner_id is None and issue is not None and issue.status is IssueStatus.WANTED: + issue.status = IssueStatus.DOWNLOADING + else: + await record_queue_recovery( + session, acquisition, code="queue_request_unconfirmed", at=now + ) + message = ( + "No matching AirDC++ queue item was found. You can retry or cancel this request." + ) + if action == "cancel" or bundle_id is not None: + await finish_queue_recovery(session, history.id, cancelled=action == "cancel") + await project_download_operation_progress( + session, history, _shared_progress_snapshot(acquisition) + ) + await session.commit() + return message + except (AirDcppError, ValueError) as exc: + await session.rollback() + current = await session.scalar( + update(AirDcppAcquisition) + .where( + AirDcppAcquisition.id == acquisition_id, + AirDcppAcquisition.client_state == "recovery_check_pending", + AirDcppAcquisition.next_retry_at == deadline, + ) + .values(client_state="recovery_required") + .returning(AirDcppAcquisition.id) + ) + if current is not None: + await session.refresh(acquisition) + await session.refresh(history) + await record_queue_recovery(session, acquisition, code=code, at=datetime.now(UTC)) + await project_download_operation_progress(session, history) + await session.commit() + if isinstance(exc, AirDcppError): + raise ValueError(RECOVERY_MESSAGES[code]) from exc + raise diff --git a/src/pullbox/services/airdcpp_route_tokens.py b/src/pullbox/services/airdcpp_route_tokens.py index 68fdeb28..44440a19 100644 --- a/src/pullbox/services/airdcpp_route_tokens.py +++ b/src/pullbox/services/airdcpp_route_tokens.py @@ -5,6 +5,7 @@ import secrets from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from pathlib import PurePath from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -13,6 +14,25 @@ from pullbox.services.airdcpp_search_types import DcValidatedCandidate +def can_override_dc_rejection(candidate: DcValidatedCandidate) -> bool: + """Only explicit semantic disagreements are reviewable; unknown failures stay closed.""" + validation = candidate.validation + return ( + not validation.is_match + and not validation.parsed.is_pack + and PurePath(candidate.release.title).suffix.casefold() in {".cbz", ".cbr", ".pdf"} + and validation.rejection_code + in { + "series_mismatch", + "issue_mismatch", + "type_mismatch", + "issue_title_mismatch", + "numbered_issue_collection_mismatch", + "ambiguous_issue_collection_type", + } + ) + + @dataclass(frozen=True, slots=True) class AirDcppRouteGrant: candidate: DcValidatedCandidate diff --git a/src/pullbox/services/archive_metadata_binding.py b/src/pullbox/services/archive_metadata_binding.py index 2f22b3ae..7d083c25 100644 --- a/src/pullbox/services/archive_metadata_binding.py +++ b/src/pullbox/services/archive_metadata_binding.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime from pathlib import Path -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from pullbox.core.exceptions import NotFoundError @@ -23,7 +23,7 @@ ) from pullbox.core.metadata_identity_state import IdentityVerificationState from pullbox.models import Issue, LibraryFile, LibraryRoot, Series -from pullbox.models.import_job import ImportedFile, ImportJob, ImportJobStatus +from pullbox.models.import_job import ImportControlRequest, ImportedFile, ImportJob, ImportJobStatus from pullbox.models.library import FileFormat, LibraryFileStorageMode from pullbox.models.metadata_source import MetadataSourceConfig from pullbox.schemas.metadata_snapshot import MetadataSnapshot @@ -217,18 +217,28 @@ async def lock_archive_metadata_binding( async def require_unowned_metadata_file(session: AsyncSession, file_id: int) -> None: - """An independent write cannot replace retained import rollback evidence.""" + """Historical ownership is not a write lock; unfinished import work still is.""" protected = await session.scalar( select(ImportedFile.id) .join(ImportJob) .where( ImportedFile.library_file_id == file_id, - ImportJob.status != ImportJobStatus.ROLLED_BACK, + or_( + ImportJob.status.not_in( + ( + ImportJobStatus.COMPLETED, + ImportJobStatus.FAILED, + ImportJobStatus.CANCELLED, + ImportJobStatus.ROLLED_BACK, + ) + ), + ImportJob.control_request != ImportControlRequest.NONE, + ), ) .limit(1) ) if protected: - raise ArchiveMetadataBindingError("import_rollback_protected") + raise ArchiveMetadataBindingError("import_operation_active") @dataclass(frozen=True) @@ -342,6 +352,8 @@ def assemble_bound_archive_metadata( return assemble_archive_metadata_state( binding.metadata, archive, now=now, issue_candidates=issue_candidates ) + except ArchiveMetadataBindingError: + raise except ValueError: raise ArchiveMetadataBindingError("metadata_requires_review") from None @@ -354,6 +366,8 @@ def assemble_archive_metadata_state( issue_candidates: Sequence[ProviderIssueRead] = (), ) -> tuple[MetadataSnapshot, MetadataSnapshot]: """Pure assembly shared by the bound writer and its one-pass conversion worker.""" + if archive is not None and any(item.code == "partial_issue" for item in archive.diagnostics): + raise ArchiveMetadataBindingError("partial_issue") series, issue = metadata.series, metadata.issues[0] return ( assemble_metadata( diff --git a/src/pullbox/services/archive_metadata_finalization.py b/src/pullbox/services/archive_metadata_finalization.py index e20d983b..d3ff7b1e 100644 --- a/src/pullbox/services/archive_metadata_finalization.py +++ b/src/pullbox/services/archive_metadata_finalization.py @@ -25,10 +25,12 @@ _stat_fingerprint, load_archive_publication, ) +from pullbox.services.archive_series_metadata import project_archive_series from pullbox.services.import_archive_publication import ( acknowledge_import_archive_publication, require_import_archive_owner, ) +from pullbox.services.import_file_edit_history import record_post_import_edit from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_credits import write_issue_credits from pullbox.services.metadata_entity_values import ( @@ -46,6 +48,7 @@ async def apply_bound_archive_metadata( ) -> None: """Apply a proven publication's descriptive values without changing identities/counts.""" bound = binding.metadata + series_snapshot = project_archive_series(series_snapshot, bound.series.values) if ( not series_snapshot.values.title or not series_snapshot.values.sort_title @@ -131,7 +134,10 @@ async def finalize_archive_publication( await require_import_archive_owner(session, plan) await revalidate_archive_metadata_target(session, plan.target) modified_at = await _file_work(lambda stop: _check_output(receipt, inspection)) - await apply_bound_archive_metadata(session, plan.target.binding, plan.series, plan.issue) + if plan.propagation_fields is None: + await apply_bound_archive_metadata( + session, plan.target.binding, plan.series, plan.issue + ) file = await session.get(LibraryFile, plan.target.binding.library_file_id) assert file is not None assert inspection.fingerprint is not None @@ -139,6 +145,10 @@ async def finalize_archive_publication( file.file_modified_at = modified_at file.file_hash = plan.output_digest file.has_comicinfo = True + if plan.import_owner is None: + await record_post_import_edit( + session, file.id, kind="metadata", operation_id=receipt.operation_id + ) await acknowledge_import_archive_publication(session, receipt) await session.flush() await _file_work(lambda stop: _check_output(receipt, inspection)) diff --git a/src/pullbox/services/archive_metadata_preservation.py b/src/pullbox/services/archive_metadata_preservation.py index 53c2ee28..c4efea93 100644 --- a/src/pullbox/services/archive_metadata_preservation.py +++ b/src/pullbox/services/archive_metadata_preservation.py @@ -1,10 +1,20 @@ """Preserve schema-specific values while coordinating shared archive metadata.""" +from copy import deepcopy +from typing import cast from xml.etree import ElementTree as ET from pullbox.core.archive_metadata import ArchiveMetadataFiles +from pullbox.core.archive_metadata_fields import normalize_comicinfo_aliases from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.issue_file_metadata import ( + FileMetadataChoices, + FileMetadataConflict, + FileMetadataOption, + SharedXmlChoices, + SharedXmlField, +) _SCHEMA_HINT = "{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation" _CI_FIELDS = frozenset( @@ -84,6 +94,7 @@ def preserved_roots(files: ArchiveMetadataFiles) -> tuple[ET.Element, ET.Element else ET.Element(name) ) if name == "ComicInfo": + normalize_comicinfo_aliases(root) _validate_comicinfo(root) elif member.payload is not None: if root.get("version") not in {None, "1.0", "1.1"}: @@ -160,15 +171,217 @@ def _parts(value: str | None) -> tuple[str, ...]: return tuple(part.strip() for part in value.split(",") if part.strip()) if value else () -def coordinate_preserved_fields(ci: ET.Element, mi: ET.Element) -> None: - """Build shared values from agreeing existing evidence, never attach identities.""" - for ci_tag, mi_path in ( - ("Genre", "Genres/Genre"), - ("Tags", "Tags/Tag"), - ("Characters", "Characters/Character"), - ("Teams", "Teams/Team"), - ("Locations", "Locations/Location"), +SHARED_FIELD_LABELS = { + "AgeRating": "age rating", + "Genre": "genres", + "Tags": "tags", + "Characters": "characters", + "Teams": "teams", + "Locations": "locations", + "Imprint": "imprint", + "StoryArc": "story arcs and positions", + "StoryArcNumber": "story-arc positions", +} +_LIST_FIELDS = ( + ("Genre", "Genres/Genre"), + ("Tags", "Tags/Tag"), + ("Characters", "Characters/Character"), + ("Teams", "Teams/Team"), + ("Locations", "Locations/Location"), +) +_SCALAR_FIELDS = (("Imprint", "Publisher/Imprint"), ("AgeRating", "AgeRating")) +_AGE_RATINGS = frozenset( + {"Unknown", "Everyone", "Teen", "Teen Plus", "Mature", "Explicit", "Adult"} +) + + +def shared_xml_choices(choices: FileMetadataChoices) -> SharedXmlChoices: + result: SharedXmlChoices = {} + for key, source in choices.items(): + if key.startswith("xml."): + if source not in {"ComicInfo.xml", "MetronInfo.xml"}: + raise ArchiveMetadataRenderError("invalid_choice", key.split(".")[1]) + result[cast("SharedXmlField", key)] = source + return result + + +def review_preserved_fields( + files: ArchiveMetadataFiles, choices: SharedXmlChoices +) -> list[FileMetadataConflict]: + ci, mi = preserved_roots(files) + return _resolve_shared_fields(ci, mi, choices) + + +def _normalize_rating(ci: ET.Element, mi: ET.Element) -> None: + left, right = ci.findtext("AgeRating"), mi.findtext("AgeRating") + for root, value, other in ((ci, left, right), (mi, right, left)): + if value and value.strip().casefold() == "unknown" and other in _AGE_RATINGS: + set_text( + root, "AgeRating", other if other.strip().casefold() != "unknown" else "Unknown" + ) + + +def _arc_values( + ci: ET.Element, mi: ET.Element +) -> tuple[tuple[tuple[str, str | None], ...], tuple[tuple[str, str | None], ...]]: + names, numbers = _parts(ci.findtext("StoryArc")), _parts(ci.findtext("StoryArcNumber")) + if numbers and ( + len(numbers) != len(names) + or any(len(n) > 10 or not n.isascii() or not n.isdecimal() or int(n) < 1 for n in numbers) + ): + raise ArchiveMetadataRenderError("unsupported_content", "StoryArcNumber") + left = tuple((name, str(int(numbers[i])) if numbers else None) for i, name in enumerate(names)) + right = tuple( + ((a.findtext("Name") or "").strip(), a.findtext("Number")) for a in mi.findall("Arcs/Arc") + ) + if any(not name or "," in name for name, _ in right): + raise ArchiveMetadataRenderError("unsupported_content", "StoryArc") + if any( + n is not None and (len(n) > 10 or not n.isascii() or not n.isdecimal() or int(n) < 1) + for _, n in right + ): + raise ArchiveMetadataRenderError("unsupported_content", "StoryArcNumber") + right = tuple( + (name, str(int(number)) if number is not None else None) for name, number in right + ) + return left, right + + +def _replace_list(mi: ET.Element, path: str, values: tuple[str, ...]) -> None: + parent_tag, child_tag = path.split("/") + parent = mi.find(parent_tag) + assert parent is not None + old = parent.findall(child_tag) + for node in old: + parent.remove(node) + for value in values: + matches = [n for n in old if (n.text or "").strip() == value] + node = deepcopy(matches[0]) if len(matches) == 1 else ET.Element(child_tag) + node.text = value + parent.append(node) + + +def _choose_arcs(ci: ET.Element, mi: ET.Element, source: str) -> None: + left, right = _arc_values(ci, mi) + selected = left if source == "ComicInfo.xml" else right + set_text(ci, "StoryArc", ", ".join(name for name, _ in selected)) + set_text( + ci, + "StoryArcNumber", + ", ".join(number or "" for _, number in selected) + if all(number for _, number in selected) + else None, + ) + if source == "ComicInfo.xml": + parent = mi.find("Arcs") + assert parent is not None + old = parent.findall("Arc") + for node in old: + parent.remove(node) + for name, number in selected: + matches = [n for n in old if (n.findtext("Name") or "").strip() == name] + arc = deepcopy(matches[0]) if len(matches) == 1 else ET.Element("Arc") + set_text(arc, "Name", name) + set_text(arc, "Number", number) + parent.append(arc) + + +def _resolve_shared_fields( + ci: ET.Element, mi: ET.Element, choices: SharedXmlChoices +) -> list[FileMetadataConflict]: + _normalize_rating(ci, mi) + conflicts: list[FileMetadataConflict] = [] + + def review(field: str, left: str, right: str) -> str | None: + key = cast("SharedXmlField", f"xml.{field}") + selected = choices.get(key) + if selected is not None and selected not in {"ComicInfo.xml", "MetronInfo.xml"}: + raise ArchiveMetadataRenderError("invalid_choice", field) + left_selectable = field != "AgeRating" or left in _AGE_RATINGS + if selected == "ComicInfo.xml" and not left_selectable: + raise ArchiveMetadataRenderError("invalid_choice", field) + conflicts.append( + FileMetadataConflict( + key=key, + label=SHARED_FIELD_LABELS[field].capitalize(), + selected=selected, + options=[ + FileMetadataOption( + source="ComicInfo.xml", value=left, selectable=left_selectable + ), + FileMetadataOption(source="MetronInfo.xml", value=right), + ], + note=( + "This ComicInfo age rating is not supported by MetronInfo. " + "Choose the MetronInfo value or correct the source rating before retrying." + ) + if not left_selectable + else ( + "Choose the embedded values to keep in both XML documents. " + "This does not change the library catalog." + ), + ) + ) + return selected + + for field, path in _LIST_FIELDS: + left = _parts(ci.findtext(field)) + right = tuple((n.text or "").strip() for n in mi.findall(path)) + if any(not value or "," in value for value in right): + raise ArchiveMetadataRenderError("unsupported_content", field) + if left and right and left != right: + selected = review(field, ", ".join(left), ", ".join(right)) + if selected == "ComicInfo.xml": + _replace_list(mi, path, left) + elif selected == "MetronInfo.xml": + set_text(ci, field, ", ".join(right)) + for field, path in _SCALAR_FIELDS: + left_text, right_text = ci.findtext(field), mi.findtext(path) + if left_text and right_text and left_text != right_text: + selected = review(field, left_text, right_text) + if selected == "ComicInfo.xml": + parent = mi.find(path.rsplit("/", 1)[0]) if "/" in path else mi + node = mi.find(path) + assert parent is not None and node is not None + # A new value cannot inherit an unrelated embedded resource ID. + parent.remove(node) + ET.SubElement(parent, field).text = left_text + elif selected == "MetronInfo.xml": + set_text(ci, field, right_text) + left_arcs, right_arcs = _arc_values(ci, mi) + if ( + left_arcs + and right_arcs + and ( + tuple(name for name, _ in left_arcs) != tuple(name for name, _ in right_arcs) + or any( + a is not None and b is not None and a != b + for (_, a), (_, b) in zip(left_arcs, right_arcs, strict=True) + ) + ) ): + + def display(arcs: tuple[tuple[str, str | None], ...]) -> str: + return "\n".join( + f"{name} (position {number})" if number else name for name, number in arcs + ) + + selected = review("StoryArc", display(left_arcs), display(right_arcs)) + if selected is not None: + _choose_arcs(ci, mi, selected) + if set(choices) - {c.key for c in conflicts}: + raise ArchiveMetadataRenderError("choices_changed") + return conflicts + + +def coordinate_preserved_fields( + ci: ET.Element, mi: ET.Element, choices: SharedXmlChoices | None = None +) -> None: + """Build shared values from agreeing existing evidence, never attach identities.""" + for conflict in _resolve_shared_fields(ci, mi, choices or {}): + if conflict.selected is None: + raise ArchiveMetadataRenderError("unreconciled_field", conflict.key.split(".")[1]) + for ci_tag, mi_path in _LIST_FIELDS: left = _parts(ci.findtext(ci_tag)) right = tuple((node.text or "").strip() for node in mi.findall(mi_path)) if any(not value or "," in value for value in right): @@ -185,7 +398,7 @@ def coordinate_preserved_fields(ci: ET.Element, mi: ET.Element) -> None: parent = ET.SubElement(mi, parent_tag) for value in values: ET.SubElement(parent, child_tag).text = value - for ci_tag, mi_path in (("Imprint", "Publisher/Imprint"), ("AgeRating", "AgeRating")): + for ci_tag, mi_path in _SCALAR_FIELDS: left_text, right_text = ci.findtext(ci_tag), mi.findtext(mi_path) if left_text and right_text and left_text != right_text: raise ArchiveMetadataRenderError("unreconciled_field", ci_tag) @@ -197,7 +410,7 @@ def coordinate_preserved_fields(ci: ET.Element, mi: ET.Element) -> None: parent_tag, child_tag = mi_path.split("/") parent = mi.find(parent_tag) if parent is None: - raise ArchiveMetadataRenderError("unreconciled_field", ci_tag) + raise ArchiveMetadataRenderError("publisher_required", ci_tag) set_text(parent, child_tag, scalar) else: set_text(mi, mi_path, scalar) diff --git a/src/pullbox/services/archive_metadata_publication.py b/src/pullbox/services/archive_metadata_publication.py index b187084e..c3824159 100644 --- a/src/pullbox/services/archive_metadata_publication.py +++ b/src/pullbox/services/archive_metadata_publication.py @@ -12,7 +12,7 @@ from typing import Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -32,6 +32,7 @@ ArchiveMetadataRenderError, render_archive_metadata, ) +from pullbox.services.archive_series_metadata import project_archive_series from pullbox.services.library_mutation_coordination import lock_file_mutation_admission from pullbox.services.metadata_writer_identity import metadata_write_scope from pullbox.utilities.executors.archive_metadata_staging import StagedArchiveMetadata @@ -60,6 +61,24 @@ class ArchivePublicationPlan(BaseModel): issue: MetadataSnapshot import_owner: ImportArchiveOwner | None = None metadata_job_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$") + propagation_fields: frozenset[str] | None = None + + @model_validator(mode="after") + def validate_projection(self) -> "ArchivePublicationPlan": + if self.propagation_fields is not None: + from pullbox.services.series_metadata_propagation_preview import canonical_snapshot + from pullbox.services.series_metadata_propagation_rendering import FIELDS + + bound = self.target.binding.metadata + if ( + not self.propagation_fields + or not self.propagation_fields <= FIELDS.keys() + or self.import_owner is not None + or self.series != canonical_snapshot(bound.series, MetadataEntityKind.SERIES) + or self.issue != canonical_snapshot(bound.issues[0], MetadataEntityKind.ISSUE) + ): + raise ValueError("Invalid selected-field propagation plan") + return self @dataclass(frozen=True) @@ -147,9 +166,13 @@ async def prepare_archive_publication( staged: StagedArchiveMetadata, series: MetadataSnapshot, issue: MetadataSnapshot, + *, + propagation_fields: frozenset[str] | None = None, ) -> ArchivePublicationPlan: """Hash verified evidence outside DB sessions; change only the private stage mode.""" - return await _file_work(lambda stop: _prepare(target, staged, series, issue, stop)) + return await _file_work( + lambda stop: _prepare(target, staged, series, issue, stop, propagation_fields) + ) def _prepare( @@ -158,9 +181,11 @@ def _prepare( series: MetadataSnapshot, issue: MetadataSnapshot, stop: Event, + propagation_fields: frozenset[str] | None = None, ) -> ArchivePublicationPlan: _check_cancelled(stop) bound = target.binding.metadata + series = project_archive_series(series, bound.series.values) if ( series.entity_kind is not MetadataEntityKind.SERIES or issue.entity_kind is not MetadataEntityKind.ISSUE @@ -179,9 +204,18 @@ def _prepare( staged.check_unchanged() files = read_archive_metadata(staged.path, "cbz", max_solid_scan_bytes=_MAX_PLAN_BYTES) try: - pair = render_archive_metadata(series, issue, files) + pair = render_archive_metadata(series, issue, files, propagation_fields=propagation_fields) if pair.comicinfo != files.comicinfo.payload or pair.metroninfo != files.metroninfo.payload: raise ArchivePublicationError("snapshot_disagrees") + if propagation_fields is not None: + original = read_archive_metadata( + target.path, "cbz", max_solid_scan_bytes=_MAX_PLAN_BYTES + ) + expected = render_archive_metadata( + series, issue, original, propagation_fields=propagation_fields + ) + if expected != pair: + raise ArchivePublicationError("snapshot_disagrees") except ArchiveMetadataRenderError: raise ArchivePublicationError("snapshot_disagrees") from None _check_cancelled(stop) @@ -200,6 +234,7 @@ def _prepare( output_digest=_digest(staged.path, output, stop), series=series, issue=issue, + propagation_fields=propagation_fields, ) _encode(plan) return plan @@ -243,8 +278,11 @@ async def load_archive_publication( async def _lock_binding(session: AsyncSession, plan: ArchivePublicationPlan) -> None: from pullbox.services.import_archive_publication import lock_import_archive_owner + from pullbox.services.import_file_edit_history import lock_import_file_history await lock_import_archive_owner(session, plan.import_owner) + if plan.import_owner is None: + await lock_import_file_history(session, plan.target.binding.library_file_id) await lock_archive_metadata_binding(session, plan.target.binding) @@ -356,6 +394,10 @@ async def publish_archive_publication( raise ArchivePublicationError("publication_not_intended") plan = _receipt(row).plan await require_import_archive_owner(session, plan) + if plan.import_owner is None: + from pullbox.services.archive_metadata_binding import require_unowned_metadata_file + + await require_unowned_metadata_file(session, plan.target.binding.library_file_id) await revalidate_archive_metadata_target(session, plan.target) await _file_work(lambda stop: _publish_files(plan, stop)) row.state = PublicationState.PUBLISHED diff --git a/src/pullbox/services/archive_metadata_reconciliation.py b/src/pullbox/services/archive_metadata_reconciliation.py index c5b0fa37..8f7b8140 100644 --- a/src/pullbox/services/archive_metadata_reconciliation.py +++ b/src/pullbox/services/archive_metadata_reconciliation.py @@ -13,6 +13,7 @@ COMICINFO_ROLES, canonical_archive_format, comicinfo_credits, + normalize_comicinfo_aliases, ) from pullbox.core.comicinfo_sanitizer import references_stale_retailer from pullbox.core.issue_numbers import normalize_issue_number_text @@ -27,6 +28,8 @@ ) from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml from pullbox.core.metroninfo import MetronInfoData, parse_metroninfo +from pullbox.core.partial_issue import is_story_extract +from pullbox.schemas.metadata_credits import MetadataCredit from pullbox.schemas.metadata_snapshot import MetadataValues _CI_ROLES = COMICINFO_ROLES @@ -83,8 +86,8 @@ def requires_review(self) -> bool: def reconcile_archive_metadata(files: ArchiveMetadataFiles) -> ArchiveMetadataReconciliation: """Return shared values and explicit disagreements, never authority or write permission.""" - comic = _read_document(files.comicinfo, "ComicInfo.xml") metron = _read_document(files.metroninfo, "MetronInfo.xml") + comic = _read_document(files.comicinfo, "ComicInfo.xml", metron.issue.credits) differences: list[ArchiveMetadataDifference] = [] series = _shared_values(comic.series, metron.series, "series", differences) issue = _shared_values(comic.issue, metron.issue, "issue", differences) @@ -149,6 +152,9 @@ def _values( for name, (value, locator) in fields.items(): if value is None: continue + if name == "issue_number_text" and isinstance(value, str) and is_story_extract(value): + diagnostics.append(ArchiveMetadataDiagnostic(document, "partial_issue", locator)) + continue try: if name == "issue_number_text" and isinstance(value, str): value = normalize_issue_number_text(value) @@ -162,7 +168,11 @@ def _values( return MetadataValues.model_validate(values) -def _read_document(member: MetadataFile, document: str) -> EmbeddedMetadataValues: +def _read_document( + member: MetadataFile, + document: str, + structured_credits: tuple[MetadataCredit, ...] | None = None, +) -> EmbeddedMetadataValues: diagnostics = [ ArchiveMetadataDiagnostic(document, item.value, document) for item in member.diagnostics ] @@ -179,7 +189,8 @@ def _read_document(member: MetadataFile, document: str) -> EmbeddedMetadataValue except MetadataXmlError as exc: diagnostics.append(ArchiveMetadataDiagnostic(document, exc.code, "ComicInfo")) return EmbeddedMetadataValues(diagnostics=tuple(diagnostics)) - return _ComicInfoReader(root, diagnostics).read() + normalize_comicinfo_aliases(root) + return _ComicInfoReader(root, diagnostics, structured_credits).read() def _metron_values( @@ -230,12 +241,41 @@ def _metron_values( class _ComicInfoReader: def __init__( - self, root: ElementTree.Element, diagnostics: list[ArchiveMetadataDiagnostic] + self, + root: ElementTree.Element, + diagnostics: list[ArchiveMetadataDiagnostic], + structured_credits: tuple[MetadataCredit, ...] | None = None, ) -> None: self.root = root self.diagnostics = diagnostics self.seen = {root} self.evidence: list[ExactIdentityEvidence] = [] + self.structured_credits = structured_credits + + def credit_names(self, value: str, role: str) -> list[str]: + parts = [part.strip() for part in value.split(",")] + names = [ + credit.name + for credit in self.structured_credits or () + if role in credit.role.split(", ") + ] + # ComicInfo has no name boundaries. Match complete paired names per role, + # longest first (e.g. John and John, Jr.), never unordered name fragments. + remaining = sorted( + ([part.strip() for part in name.split(",")] for name in names), + key=len, + reverse=True, + ) + offset = 0 + while remaining: + match = next( + (name for name in remaining if parts[offset : offset + len(name)] == name), None + ) + if match is None: + return parts + offset += len(match) + remaining.remove(match) + return names if names and offset == len(parts) else parts def diagnostic(self, code: str, locator: str) -> None: item = ArchiveMetadataDiagnostic("ComicInfo.xml", code, locator) @@ -343,7 +383,9 @@ def read(self) -> EmbeddedMetadataValues: credits_present = credits_present or bool(self.root.findall(tag)) value = self.read_field(tag) if value: - credits.extend({"name": name.strip(), "role": role} for name in value.split(",")) + credits.extend( + {"name": name, "role": role} for name in self.credit_names(value, role) + ) invalid_credits = any(item.locator in _CI_ROLES for item in self.diagnostics) issue = _values( { diff --git a/src/pullbox/services/archive_metadata_rendering.py b/src/pullbox/services/archive_metadata_rendering.py index 9630a9ee..7a793554 100644 --- a/src/pullbox/services/archive_metadata_rendering.py +++ b/src/pullbox/services/archive_metadata_rendering.py @@ -18,6 +18,7 @@ ) from pullbox.core.metadata_xml import MetadataXmlError, parse_metadata_xml from pullbox.core.metroninfo_schema import validate_metroninfo_xml +from pullbox.schemas.issue_file_metadata import SharedXmlChoices from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues from pullbox.services.archive_metadata_preservation import ( ArchiveMetadataRenderError as ArchiveMetadataRenderError, @@ -68,6 +69,8 @@ def render_archive_metadata( previous_series: MetadataSnapshot | None = None, previous_issue: MetadataSnapshot | None = None, replace_managed: bool = False, + shared_choices: SharedXmlChoices | None = None, + propagation_fields: frozenset[str] | None = None, ) -> RenderedArchiveMetadata: """Render both documents from bound snapshots; this is not write permission. @@ -75,6 +78,15 @@ def render_archive_metadata( metadata into these snapshots, and authorize the eventual file operation. Previous managed baselines authorize refresh only while XML is unchanged. """ + if propagation_fields is not None: + from pullbox.services.series_metadata_propagation_rendering import render_series_propagation + + return render_series_propagation(series, issue, files, propagation_fields) + from pullbox.services.archive_series_metadata import archive_render_series + + series = archive_render_series(series) + if previous_series is not None: + previous_series = archive_render_series(previous_series) if ( series.entity_kind is not MetadataEntityKind.SERIES or issue.entity_kind is not MetadataEntityKind.ISSUE @@ -113,7 +125,7 @@ def render_archive_metadata( _render_core(series, issue, ci, mi) _render_ids(series, issue, ci, mi, primary) _render_credits(issue, archive, ci, mi) - coordinate_preserved_fields(ci, mi) + coordinate_preserved_fields(ci, mi, shared_choices) return _validated_pair(ci, mi) @@ -233,14 +245,20 @@ def _guard_values( _format_text(snapshot.values.series_type), ) if ( - field == "title" + field in {"title", "sort_title"} and snapshot.entity_kind is MetadataEntityKind.SERIES and isinstance(old, str) and isinstance(new, str) - and _series_separator_key(old) == _series_separator_key(new) - and any(item.identity in issue.identities for item in archive.evidence) + and ( + old.strip() == new.strip() + or ( + field == "title" + and _series_separator_key(old) == _series_separator_key(new) + and any(item.identity in issue.identities for item in archive.evidence) + ) + ) ): - # Only a proven exact issue can reconcile filesystem-safe subtitle separators. + # XML readers trim both names; only verified titles may change separators. continue if old != new and not _authorized( snapshot, @@ -429,12 +447,6 @@ def _render_credits( for tag in COMICINFO_ROLES: set_text(ci, tag, None) return - if any( - "," in credit.name - for credit in credits - if any(role in COMICINFO_ROLES.values() for role in credit.role.split(", ")) - ): - raise ArchiveMetadataRenderError("unsupported_content", "credits") for tag, role in COMICINFO_ROLES.items(): names = [credit.name for credit in credits if role in credit.role.split(", ")] set_text(ci, tag, ", ".join(names)) @@ -484,5 +496,9 @@ def _validated_pair(ci: ET.Element, mi: ET.Element) -> RenderedArchiveMetadata: or check.identity_conflicts or any(item.code != "unmapped_content" for item in check.diagnostics) ): - raise ArchiveMetadataRenderError("output_disagreement") + locator = next( + (item.locator for item in check.diagnostics if item.code != "unmapped_content"), + check.differences[0].field if check.differences else "", + ) + raise ArchiveMetadataRenderError("output_disagreement", locator) return result diff --git a/src/pullbox/services/archive_metadata_writing.py b/src/pullbox/services/archive_metadata_writing.py index 00489902..ee142cbb 100644 --- a/src/pullbox/services/archive_metadata_writing.py +++ b/src/pullbox/services/archive_metadata_writing.py @@ -22,6 +22,7 @@ from pullbox.core.metadata_archive_source import MetadataArchiveSource, open_metadata_archive from pullbox.core.metadata_identity import ExternalIdentityRef from pullbox.core.metadata_pdf_source import PdfQuality +from pullbox.schemas.issue_file_metadata import SharedXmlChoices from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_binding import ( assemble_archive_metadata_state, @@ -55,6 +56,8 @@ def write_cbz_metadata( replace_managed: bool = False, pdf_quality: PdfQuality = "medium", metadata_state: SeriesRefreshState | None = None, + shared_choices: SharedXmlChoices | None = None, + propagation_fields: frozenset[str] | None = None, ) -> bool: """Write a verified pair in one CBZ construction, off the event loop. @@ -136,6 +139,8 @@ def write_cbz_metadata( previous_series=previous_series, previous_issue=previous_issue, replace_managed=replace_managed, + shared_choices=shared_choices, + propagation_fields=propagation_fields, ) outputs = {"ComicInfo.xml": pair.comicinfo, "MetronInfo.xml": pair.metroninfo} members = [entry for entry in entries if not _metadata_member(entry)] diff --git a/src/pullbox/services/archive_series_metadata.py b/src/pullbox/services/archive_series_metadata.py new file mode 100644 index 00000000..fa45a9b7 --- /dev/null +++ b/src/pullbox/services/archive_series_metadata.py @@ -0,0 +1,55 @@ +"""Project embedded publication formats without changing library classification.""" + +from pullbox.core.archive_metadata_fields import canonical_archive_format +from pullbox.models.series import SeriesStatus, SeriesType +from pullbox.schemas.metadata_snapshot import MetadataSnapshot, MetadataValues +from pullbox.services.archive_metadata_preservation import ArchiveMetadataRenderError + + +def project_archive_series(snapshot: MetadataSnapshot, current: MetadataValues) -> MetadataSnapshot: + """Validate database enums before publication; also accept pre-fix journals.""" + values = snapshot.values.model_dump() + origins = list(snapshot.origins) + format_value = values["series_type"] + if format_value in {"Limited Series", "Digital Chapter"}: + values["archive_format"] = format_value + values["series_type"] = current.series_type + origins = [ + origin.model_copy(update={"field": "archive_format"}) + if origin.field == "series_type" + else origin + for origin in origins + if origin.field != "archive_format" + ] + elif format_value is not None: + values["series_type"] = canonical_archive_format(format_value) + try: + if values["series_type"] is not None: + SeriesType(values["series_type"]) + if values["status"] is not None: + SeriesStatus(values["status"]) + except ValueError as exc: + raise ArchiveMetadataRenderError("invalid_database_metadata") from exc + return MetadataSnapshot.model_validate( + {**snapshot.model_dump(), "values": values, "origins": origins} + ) + + +def archive_render_series(snapshot: MetadataSnapshot) -> MetadataSnapshot: + """Use the retained embedded format only when rendering XML, never in the ORM.""" + if snapshot.values.archive_format is None: + return snapshot + return snapshot.model_copy( + update={ + "values": snapshot.values.model_copy( + update={"series_type": snapshot.values.archive_format} + ), + "origins": tuple( + origin.model_copy(update={"field": "series_type"}) + if origin.field == "archive_format" + else origin + for origin in snapshot.origins + if origin.field != "series_type" + ), + } + ) diff --git a/src/pullbox/services/direct_acquisition_executor.py b/src/pullbox/services/direct_acquisition_executor.py index 7b76497d..f0729c12 100644 --- a/src/pullbox/services/direct_acquisition_executor.py +++ b/src/pullbox/services/direct_acquisition_executor.py @@ -64,7 +64,7 @@ transition_acquisition, transition_artifact, ) -from pullbox.services.direct_artifact_pack import DirectArtifactPackError +from pullbox.services.direct_artifact_pack import RECOVERABLE_PACK_FAILURES, DirectArtifactPackError from pullbox.services.direct_artifact_post_processing import ( DirectPostProcessingResult, run_direct_artifact_pack_post_processing, @@ -76,6 +76,7 @@ DirectArtifactValidationResult, DirectQuarantineWorkspace, validate_direct_artifact, + validate_direct_resource_approval, ) from pullbox.services.direct_configuration_service import load_host_credential_material from pullbox.services.direct_download_history_adapter import sync_direct_download_history @@ -253,6 +254,12 @@ async def execute( try: _raise_if_cancelled(cancel_event) final_path = _recover_final_path(workspace, artifact) + if attempt.failure_code in RECOVERABLE_PACK_FAILURES: + if final_path is None: + raise _missing_quarantine_error() + # A retained matching failure is never permission to trust changed content. + if not _resource_safety_override_allowed(attempt): + await self._validator(session, final_path) if ( attempt.state is DirectAcquisitionState.INTERVENTION and attempt.failure_class is DirectArtifactFailureClass.POST_PROCESS @@ -623,6 +630,19 @@ async def _post_process( download_history_id = progress.download_history_id if download_history_id is None: raise RuntimeError("Direct post-processing requires a download history record.") + if _resource_safety_override_allowed(attempt): + review = (attempt.plan_snapshot or {}).get("safety_review") + await asyncio.to_thread(validate_direct_resource_approval, final_path, review) + try: + await validate_direct_artifact( + session, final_path, allow_resource_safety_exception=True + ) + except DirectArtifactValidationError as exc: + # Keep a newly discovered safety finding actionable without another transfer. + raise DirectArtifactValidationError( + code=exc.code, message=str(exc), safety_block=exc.safety_block + ) from exc + await asyncio.to_thread(validate_direct_resource_approval, final_path, review) try: pack_coverage = _selected_pack_coverage(attempt) if len(pack_coverage) > 1: @@ -668,10 +688,27 @@ async def _post_process( artifact.failure_class = DirectArtifactFailureClass.POST_PROCESS artifact.failure_code = exc.code artifact.error_message = str(exc) - transition_artifact(artifact, DirectArtifactState.FAILED, at=self._now()) - transition_acquisition(attempt, DirectAcquisitionState.FAILED, at=self._now()) - self._quarantine.cleanup(workspace) - await progress.write(stage="failed", force=True) + if exc.code in RECOVERABLE_PACK_FAILURES: + transition_artifact(artifact, DirectArtifactState.INTERVENTION, at=self._now()) + transition_acquisition(attempt, DirectAcquisitionState.INTERVENTION, at=self._now()) + await progress.write(stage="intervention", force=True) + if exc.code == "direct_pack_ownership_changed": + from pullbox.services.direct_pack_completion import reconcile_fulfilled_pack + + if await reconcile_fulfilled_pack(session, acquisition_id): + await session.commit() + attempt, artifact = await _load_attempt( + session, acquisition_id, artifact_id + ) + return _result(attempt, artifact) + attempt, artifact = await _load_attempt(session, acquisition_id, artifact_id) + await InterventionService().create_direct_attempt_intervention(session, attempt) + await session.commit() + else: + transition_artifact(artifact, DirectArtifactState.FAILED, at=self._now()) + transition_acquisition(attempt, DirectAcquisitionState.FAILED, at=self._now()) + self._quarantine.cleanup(workspace) + await progress.write(stage="failed", force=True) return _result(attempt, artifact) except Exception as exc: await session.rollback() @@ -691,6 +728,8 @@ async def _post_process( artifact.failure_class = DirectArtifactFailureClass.POST_PROCESS artifact.failure_code = attempt.failure_code artifact.error_message = attempt.error_message + if isinstance(exc, DirectMetadataReviewError) and exc.code == "partial_issue": + attempt.plan_snapshot = {**attempt.plan_snapshot, "metadata_failure_code": exc.code} transition_artifact(artifact, DirectArtifactState.INTERVENTION, at=self._now()) transition_acquisition(attempt, DirectAcquisitionState.INTERVENTION, at=self._now()) await InterventionService().create_direct_attempt_intervention(session, attempt) @@ -713,6 +752,23 @@ async def _post_process( force=True, final_path=str(processed.final_path), ) + from pullbox.services.direct_metadata_recovery import reconcile_metadata_replacements + + for imported_issue_id in processed.imported_issue_ids: + await reconcile_metadata_replacements(session, imported_issue_id) + attempt, artifact = await _load_attempt(session, acquisition_id, artifact_id) + if len(pack_coverage) > 1: + from pullbox.services.direct_pack_completion import reconcile_completed_pack_issues + + try: + await reconcile_completed_pack_issues(session, processed.imported_issue_ids) + except Exception: + # The import is already committed. A follow-up failure must not undo it. + await session.rollback() + logger.exception( + "direct_pack_completion_reconciliation_failed", acquisition_id=acquisition_id + ) + attempt, artifact = await _load_attempt(session, acquisition_id, artifact_id) try: request_story_arc_sync_now() except Exception: diff --git a/src/pullbox/services/direct_acquisition_planner_service.py b/src/pullbox/services/direct_acquisition_planner_service.py index 6e27e473..c076a09d 100644 --- a/src/pullbox/services/direct_acquisition_planner_service.py +++ b/src/pullbox/services/direct_acquisition_planner_service.py @@ -16,6 +16,7 @@ from pullbox.core.issue_numbers import normalize_issue_number_text from pullbox.core.log_sanitizer import sanitize_log_string from pullbox.core.name_matcher import NameMatcher +from pullbox.core.partial_issue import is_story_extract from pullbox.core.type_semantics import TypeFamily, issue_type_family from pullbox.models.direct_acquisition import ( DirectAcquisitionAttempt, @@ -70,6 +71,7 @@ record_provider_quota, record_provider_resolution_error, ) +from pullbox.services.direct_resolver_budget import ProviderResolverBudget, resolver_retry_allowed if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -187,6 +189,13 @@ async def plan_direct_acquisition( clock = now or (lambda: datetime.now(UTC)) try: provider = _validate_planning_source(attempt, at=clock()) + title = (attempt.candidate_snapshot or {}).get("display_title") + if isinstance(title, str) and is_story_extract(title): + raise DirectAcquisitionPlanningError( + "partial_issue", + "This result is a story-only extract, not a complete issue. " + "Choose a complete copy. No download was started.", + ) response = await _resolve_provider_candidate( session, attempt, @@ -194,6 +203,7 @@ async def plan_direct_acquisition( provider_client_factory=provider_client_factory, provider_secret_loader=provider_secret_loader, deadline=clock() + timedelta(seconds=_RESOLVE_SECONDS), + now=clock, ) if response.quota is not None: record_provider_quota(provider, response.quota, observed_at=clock()) @@ -217,6 +227,28 @@ async def plan_direct_acquisition( pinned_route_identity=pinned_route_identity, ) if not plan.selected or not plan.complete: + known_coverage = frozenset().union( + *(route.option.coverage for route in resolved_routes) + ) + if not plan.requested <= known_coverage and any( + is_story_extract(artifact.coverage.description) for artifact in response.artifacts + ): + raise DirectAcquisitionPlanningError( + "partial_issue", + "The available file is a story-only extract, not a complete issue. " + "Choose a complete copy. No download was started.", + ) + if ( + any(route.route.eligible for route in resolved_routes) + and not plan.requested <= known_coverage + ): + raise DirectAcquisitionPlanningError( + "artifact_coverage_unverified", + "Download links are available, but the provider's file details do not " + "establish coverage of the requested issue. No download was started. " + "Update the provider and retry, or choose a result with explicit " + "issue coverage.", + ) raise DirectAcquisitionPlanningError( "no_eligible_complete_plan", "No enabled artifact route completely covers this issue.", @@ -415,6 +447,7 @@ async def resolve_planned_artifact_source( provider_client_factory=provider_client_factory, provider_secret_loader=provider_secret_loader, deadline=clock() + timedelta(seconds=_RESOLVE_SECONDS), + now=clock, ) provider_artifact = next( ( @@ -488,6 +521,7 @@ async def _resolve_provider_candidate( provider_client_factory: DirectResolveClientFactory | None, provider_secret_loader: ProviderSecretLoader | None, deadline: datetime, + now: Callable[[], datetime], ) -> DirectResolveResponse: from pullbox.services.direct_configuration_service import load_provider_secret_material from pullbox.services.direct_resolver_service import ( @@ -513,6 +547,7 @@ async def _resolve_provider_candidate( provider_id=provider.provider_id, ) try: + budget = ProviderResolverBudget(deadline, now=now) resolver_options = await build_provider_resolver_profiles(session, provider) options = (None, *resolver_options) last_error: DirectProviderClientError | None = None @@ -542,7 +577,7 @@ async def _resolve_provider_candidate( ) await session.commit() try: - return await client.resolve( + return await budget.run( DirectResolveRequest( protocol_version=( provider.negotiated_protocol or "direct-download-provider/v1" @@ -553,13 +588,13 @@ async def _resolve_provider_candidate( source_credentials=material.configuration, resolver_profile=option.profile if option is not None else None, provider_candidate_id=attempt.provider_candidate_id, - ) + ), + client.resolve, + remaining_resolvers=len(options) - option_index, ) except DirectProviderClientError as exc: last_error = exc - retry_allowed = exc.code == "browser_challenge_required" or exc.code.startswith( - "resolver_" - ) + retry_allowed = resolver_retry_allowed(exc, using_resolver=option is not None) if not retry_allowed or option_index == len(options) - 1: record_provider_resolution_error( provider, @@ -717,6 +752,8 @@ def _build_route_options( _validate_stable_identity("provider artifact", artifact.artifact_id) if artifact.route is not DirectArtifactRoute.DIRECT_ARTIFACT: continue + if is_story_extract(artifact.coverage.description): + continue coverage = _artifact_coverage( artifact, requested, diff --git a/src/pullbox/services/direct_artifact_pack.py b/src/pullbox/services/direct_artifact_pack.py index ff03b0ab..baa85e5f 100644 --- a/src/pullbox/services/direct_artifact_pack.py +++ b/src/pullbox/services/direct_artifact_pack.py @@ -11,6 +11,18 @@ from pullbox.core.release_parser import parse_release_title _COMIC_FILE_SUFFIXES = frozenset({".cbz", ".cbr", ".cb7", ".cbt", ".pdf"}) +RECOVERABLE_PACK_FAILURES = frozenset( + { + "direct_pack_mixed_series", + "direct_pack_ambiguous_issue", + "direct_pack_incomplete", + "direct_pack_series_invalid", + "direct_pack_ownership_changed", + } +) +PACK_FULFILLED_MESSAGE = ( + "Request fulfilled by a verified library copy; no additional import was needed." +) class DirectArtifactPackError(RuntimeError): @@ -24,8 +36,8 @@ def __init__(self, *, code: str, message: str) -> None: def is_separable_issue_pack(source_path: Path) -> bool: """Return whether an archive safely presents multiple named comic members. - This is deliberately structural only. Series identity and complete claimed - coverage are enforced by ``extract_same_series_issue_files`` before import. + This is deliberately structural only. Series identity and coverage of selected + issues are enforced by ``extract_same_series_issue_files`` before import. """ try: members = ArchiveReader(source_path).list_members() @@ -49,16 +61,29 @@ def extract_same_series_issue_files( destination: Path, expected_issue_numbers: frozenset[str], expected_series_titles: frozenset[str], + required_issue_numbers: frozenset[str] | None = None, ) -> dict[str, Path]: """Extract separately packaged issues from one contiguous direct-download pack. A pack with page images but no nested comic files is one combined comic file. Pullbox intentionally will not guess page boundaries or split that file. + Required issues may be a subset of advertised coverage, excluding owned/skipped + siblings. Unsafe paths still block even when their issue will not be imported. """ if len(expected_issue_numbers) < 2: return {} expected = _normalized_issue_numbers(expected_issue_numbers) + required = ( + expected + if required_issue_numbers is None + else _normalized_issue_numbers(required_issue_numbers) + ) + if not required or not required <= expected: + raise DirectArtifactPackError( + code="direct_pack_coverage_invalid", + message="The selected issues are not covered by this direct-download pack.", + ) normalized_series_titles = { normalized for title in expected_series_titles @@ -108,18 +133,26 @@ def extract_same_series_issue_files( if issue_number is None: continue exact_issue_number = issue_number_text or format_issue_number(issue_number) - if exact_issue_number not in expected: + if exact_issue_number not in required: continue parsed_series_title = NameMatcher.normalize(parsed.series_name or "") if parsed else "" if parsed_series_title not in normalized_series_titles: raise DirectArtifactPackError( code="direct_pack_mixed_series", - message="The direct-download pack contains files for a different series.", + message=( + f"The direct-download pack contains files for a different series: " + f"{Path(member.name).name}. Review the file for requested issue " + f"{exact_issue_number}; Pullbox has not imported it." + ), ) if exact_issue_number in extracted: raise DirectArtifactPackError( code="direct_pack_ambiguous_issue", - message="The direct-download pack contains more than one file for the same issue.", + message=( + "The direct-download pack contains more than one file for the same issue: " + f"{exact_issue_number}. Choose the correct file using manual import " + "on the issue page, or correct the pack and retry import." + ), ) suffix = Path(member.name).suffix.lower() target = destination / f"issue-{_issue_path_token(exact_issue_number)}{suffix}" @@ -133,7 +166,7 @@ def extract_same_series_issue_files( ) from exc extracted[exact_issue_number] = target - missing = expected - set(extracted) + missing = required - set(extracted) if missing: raise DirectArtifactPackError( code="direct_pack_incomplete", diff --git a/src/pullbox/services/direct_artifact_post_processing.py b/src/pullbox/services/direct_artifact_post_processing.py index 7aa4df33..17bccb47 100644 --- a/src/pullbox/services/direct_artifact_post_processing.py +++ b/src/pullbox/services/direct_artifact_post_processing.py @@ -212,15 +212,6 @@ async def run_direct_artifact_pack_post_processing( if get_settings().metadata_paired_direct_writer_enabled or allow_resource_safety_exception else None ) - extracted_paths = await asyncio.to_thread( - extract_same_series_issue_files, - source_path, - destination=source_path.parent / "pack-members", - expected_issue_numbers=expected_issue_numbers, - expected_series_titles=frozenset( - (initiating_issue.series.title, *(initiating_issue.series.alternate_names or [])) - ), - ) issues_result = await session.execute( select(Issue) .options( @@ -235,6 +226,31 @@ async def run_direct_artifact_pack_post_processing( ): issue for issue in issues_result.unique().scalars() } + required_numbers = frozenset( + number + for number, candidate in issue_by_number.items() + if number in expected_issue_numbers + and ( + getattr(candidate, "library_file", None) is None + or (candidate.id == issue_id and replace_existing_file) + ) + and ( + candidate.id == issue_id + or candidate.status in {IssueStatus.WANTED, IssueStatus.DOWNLOADING} + ) + ) + if not required_numbers: + raise RuntimeError("No wanted issues in this direct-download pack can be imported.") + extracted_paths = await asyncio.to_thread( + extract_same_series_issue_files, + source_path, + destination=source_path.parent / "pack-members", + expected_issue_numbers=expected_issue_numbers, + required_issue_numbers=required_numbers, + expected_series_titles=frozenset( + (initiating_issue.series.title, *(initiating_issue.series.alternate_names or [])) + ), + ) prepared_imports = [] for issue_number_text, file_path in sorted(extracted_paths.items()): candidate = issue_by_number.get(issue_number_text) diff --git a/src/pullbox/services/direct_artifact_quarantine.py b/src/pullbox/services/direct_artifact_quarantine.py index 320ed779..58c3fc7b 100644 --- a/src/pullbox/services/direct_artifact_quarantine.py +++ b/src/pullbox/services/direct_artifact_quarantine.py @@ -143,6 +143,47 @@ def cleanup(self, workspace: DirectQuarantineWorkspace) -> None: return +def validate_direct_resource_approval(path: Path, review: object) -> None: + """Bind a resource exception to the unchanged file that originally failed inspection.""" + if ( + not isinstance(review, dict) + or review.get("kind") != "archive_decompressed_size" + or review.get("overrideable") is not True + or not isinstance(review.get("source_signature"), dict) + ): + raise DirectArtifactValidationError( + code="artifact_size_approval_unverified", + message="The retained file has no verified size review. Reinspect it before approving.", + ) + try: + if path.is_symlink(): + raise DirectArtifactValidationError( + code="artifact_size_approval_changed", + message=( + "The retained file changed since inspection. Reinspect it before approving." + ), + ) + if not path.is_file(): + raise FileNotFoundError + signature = build_file_identity_signature(path) + except OSError as exc: + raise DirectArtifactValidationError( + code="artifact_size_approval_missing", + message=( + "The retained file is missing or unreadable. Restore it before approving. " + "No new download was started." + ), + ) from exc + if signature != review["source_signature"]: + raise DirectArtifactValidationError( + code="artifact_size_approval_changed", + message=( + "The retained file changed since inspection. Reinspect it before approving. " + "No new download was started." + ), + ) + + async def validate_direct_artifact( session: AsyncSession, path: Path, diff --git a/src/pullbox/services/direct_download_history_adapter.py b/src/pullbox/services/direct_download_history_adapter.py index 9834cd3a..4c474b88 100644 --- a/src/pullbox/services/direct_download_history_adapter.py +++ b/src/pullbox/services/direct_download_history_adapter.py @@ -110,6 +110,7 @@ async def sync_direct_download_history( *, at: datetime, final_path: str | None = None, + update_issue_status: bool = True, ) -> DownloadHistory: """Project durable direct state onto the existing download UI record.""" history = await ensure_direct_download_history(session, attempt, artifact, at=at) @@ -129,7 +130,8 @@ async def sync_direct_download_history( history.error_message = "Cancelled by user" elif attempt.state is DirectAcquisitionState.FAILED: history.completed_at = at - await _sync_issue_status(session, attempt) + if update_issue_status: + await _sync_issue_status(session, attempt) from pullbox.services.download_operation_progress import ( project_download_operation_progress, ) diff --git a/src/pullbox/services/direct_metadata_file_proof.py b/src/pullbox/services/direct_metadata_file_proof.py new file mode 100644 index 00000000..239ca1b1 --- /dev/null +++ b/src/pullbox/services/direct_metadata_file_proof.py @@ -0,0 +1,82 @@ +"""Content-backed remount checks scoped to retained direct metadata recovery.""" + +from __future__ import annotations + +import hmac +import re +from typing import TYPE_CHECKING + +from pullbox.core.exceptions import ConfigurationError +from pullbox.core.library_file_ownership import ( + build_file_identity_signature, + build_managed_placement_signature, +) + +if TYPE_CHECKING: + from pathlib import Path + +_IDENTITY_KEYS = ("schema_version", "resolved_path", "size", "mtime_ns", "device", "inode") + + +def proof_identity(proof: dict[str, int | str]) -> dict[str, int | str]: + return {key: proof[key] for key in _IDENTITY_KEYS} + + +def _identity(path: Path) -> dict[str, int | str]: + if path.is_symlink(): + raise ValueError("A retained metadata file must not be a symlink.") + try: + return {**build_file_identity_signature(path), "ctime_ns": path.stat().st_ctime_ns} + except ConfigurationError as exc: + raise ValueError("The retained metadata file is no longer a regular file.") from exc + + +def capture_file_proof(path: Path) -> dict[str, int | str]: + before = _identity(path) + try: + hashed = build_managed_placement_signature(path) + except ConfigurationError as exc: + raise ValueError("The retained metadata file changed while hashing.") from exc + if _identity(path) != before or proof_identity(hashed) != proof_identity(before): + raise ValueError("The retained metadata file changed while hashing.") + return {**before, "sha256": hashed["content_digest"]} + + +def verify_file_proof( + path: Path, + saved: object, + digest: object, + verified: dict[str, int | str] | None = None, +) -> dict[str, int | str] | None: + current = _identity(path) + signature = proof_identity(current) + if digest is None: + # Legacy records cannot infer original bytes from today's file after a remount. + if signature != saved: + raise ValueError("No saved content proof for the changed storage identity.") + return None + if ( + not isinstance(digest, str) + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or not isinstance(saved, dict) + or type(saved.get("device")) is not int + or {**signature, "device": saved["device"]} != saved + ): + raise ValueError("The retained metadata file identity changed.") + if verified is not None: + # Repeated writer control checks use this preview's verified stat/ctime snapshot. + if verified == {**current, "sha256": digest}: + return verified + if { + **current, + "device": verified.get("device"), + "ctime_ns": verified.get("ctime_ns"), + "sha256": digest, + } != verified: + raise ValueError("The retained metadata file changed after preview.") + proof = capture_file_proof(path) + if proof != {**current, "sha256": proof["sha256"]} or not hmac.compare_digest( + str(proof["sha256"]), digest + ): + raise ValueError("The retained metadata file content changed.") + return proof diff --git a/src/pullbox/services/direct_metadata_recovery.py b/src/pullbox/services/direct_metadata_recovery.py new file mode 100644 index 00000000..1e151810 --- /dev/null +++ b/src/pullbox/services/direct_metadata_recovery.py @@ -0,0 +1,704 @@ +"""Review retained direct-download files without reacquiring them.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from dataclasses import asdict, dataclass, replace +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import NAMESPACE_URL, uuid5 + +import structlog +from pydantic import BaseModel, Field, TypeAdapter +from sqlalchemy import select + +from pullbox.config import get_settings +from pullbox.core.partial_issue import ( + INCOMPLETE_RECOVERY_DISMISSED, + METADATA_RECOVERY_SUPERSEDED, + needs_complete_copy, +) +from pullbox.models import Issue, LibraryFile +from pullbox.models.direct_acquisition import ( + DirectAcquisitionAttempt, + DirectAcquisitionState, + DirectArtifactAttempt, + DirectArtifactFailureClass, + DirectArtifactState, +) +from pullbox.models.download import DownloadHistory, DownloadState +from pullbox.models.issue import IssueStatus +from pullbox.models.pending_match import PendingMatch, PendingMatchStatus +from pullbox.schemas.issue_file_metadata import ( + FileMetadataApproval, + FileMetadataChange, + FileMetadataConflict, +) +from pullbox.services.direct_acquisition_state import ( + advance_acquisition_progress, + transition_acquisition, + transition_artifact, +) +from pullbox.services.direct_download_history_adapter import sync_direct_download_history +from pullbox.services.direct_metadata_file_proof import verify_file_proof +from pullbox.services.issue_file_metadata import prepare_file_metadata, write_file_metadata +from pullbox.services.issue_import_service import ( + execute_manual_issue_import, + prepare_manual_issue_import, +) +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission +from pullbox.services.manual_import_metadata_review import preview_manual_import_metadata +from pullbox.services.manual_paired_metadata import read_manual_metadata_plan +from pullbox.services.post_processing_operation_progress import ( + project_post_processing_operation_progress, +) +from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation +from pullbox.utilities.models import UtilityJob, UtilityJobItem + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from pullbox.schemas.issue_file_metadata import FileMetadataChoices + from pullbox.services.archive_metadata_binding import ArchiveMetadataTarget + from pullbox.utilities.executors.archive_subprocess import ControlCheck, ProgressCallback + + +class DirectMetadataRecoveryError(ValueError): + """An operator-actionable recovery error without a download URL or secret.""" + + +async def reconcile_metadata_replacements(session: AsyncSession, issue_id: int) -> None: + """Post-commit follow-up only; never restore, rewrite, or undo a completed import.""" + from sqlalchemy.ext.asyncio import async_sessionmaker + + if session.new or session.dirty or session.deleted or session.in_transaction(): + raise ValueError("Replacement reconciliation requires a committed session") + # A failed follow-up must not expire the completed import's response objects. + async with async_sessionmaker(session.bind, expire_on_commit=False)() as recovery_session: + try: + await _reconcile_metadata_replacements(recovery_session, issue_id) + except Exception: + await recovery_session.rollback() + structlog.get_logger(__name__).exception( + "partial_issue_replacement_followup_failed", issue_id=issue_id + ) + + +async def _reconcile_metadata_replacements(session: AsyncSession, issue_id: int) -> None: + from pullbox.services.archive_metadata_binding import revalidate_archive_metadata_target + + pending = list( + await session.scalars( + select(PendingMatch) + .where( + PendingMatch.issue_id == issue_id, PendingMatch.status == PendingMatchStatus.PENDING + ) + .order_by(PendingMatch.id) + .limit(100) + ) + ) + ids = [p.id for p in pending if needs_complete_copy(p.match_details or {}, p.release_title)] + await session.commit() + for pending_id in ids: + try: + prepared = await prepare_direct_metadata_recovery(session, pending_id) + if not ( + prepared.preview.superseded + and prepared.preview.ready + and prepared.preview.unchanged + ): + await session.rollback() + continue + assert prepared.file_target is not None + await session.commit() + await lock_file_mutation_admission(session) + await revalidate_archive_metadata_target(session, prepared.file_target) + await asyncio.to_thread(prepared.file_target.check_unchanged) + await _check_source(prepared.owner) + file = await session.get(LibraryFile, prepared.owner.file_id) + if file is None or file.issue_id != issue_id: + raise DirectMetadataRecoveryError("The replacement library binding changed.") + await _complete( + session, + prepared.owner, + file, + uuid5( + NAMESPACE_URL, f"partial-replacement:{pending_id}:{prepared.preview.review_key}" + ), + prepared.preview.review_key, + ) + await session.commit() + except Exception: + await session.rollback() + structlog.get_logger(__name__).warning( + "partial_issue_replacement_unverified", + issue_id=issue_id, + pending_id=pending_id, + exc_info=True, + ) + + +async def dismiss_incomplete_recovery(session: AsyncSession, pending_id: int) -> None: + """Dismiss the notification, not the issue or files; do not blocklist a release.""" + await lock_file_mutation_admission(session) + owner = await _read_owner(session, pending_id, validate_library=False) + pending = await session.get(PendingMatch, pending_id) + assert pending is not None + if not needs_complete_copy(pending.match_details or {}, pending.release_title): + raise DirectMetadataRecoveryError("This recovery does not identify an incomplete copy.") + attempt = await session.get(DirectAcquisitionAttempt, owner.attempt_id) + artifact = await session.get(DirectArtifactAttempt, owner.artifact_id) + assert attempt is not None and artifact is not None + now = datetime.now(UTC) + transition_acquisition(attempt, DirectAcquisitionState.FAILED, at=now) + transition_artifact(artifact, DirectArtifactState.FAILED, at=now) + attempt.error_message = artifact.error_message = INCOMPLETE_RECOVERY_DISMISSED + attempt.next_retry_at = artifact.next_retry_at = None + advance_acquisition_progress( + attempt, + revision=attempt.progress_revision + 1, + snapshot={"schema_version": 1, "stage": "failed", "artifact_attempt_id": artifact.id}, + ) + history = await sync_direct_download_history( + session, attempt, artifact, at=now, update_issue_status=False + ) + await project_post_processing_operation_progress(session, history) + pending.status = PendingMatchStatus.REJECTED + pending.resolved_at = now + pending.resolved_by = "user" + pending.match_details = { + **pending.match_details, + "resolution_reason": "incomplete_copy_dismissed", + } + await session.flush() + + +class DirectMetadataRecoveryPreview(BaseModel): + issue_id: int + file_name: str + review_key: str + unchanged: bool = False + ready: bool + conflicts: list[FileMetadataConflict] = Field(default_factory=list) + changes: list[FileMetadataChange] = Field(default_factory=list) + converts_to_cbz: bool = False + restores_copy: bool = False + superseded: bool = False + replacement_required: bool = False + + +@dataclass(frozen=True) +class RecoveryOwner: + pending_id: int + issue_id: int + attempt_id: int + artifact_id: int + history_id: int + plan_revision: int + marker: dict[str, Any] + safety_review: object + file_id: int | None + file_path: str | None + source: Path + superseded: bool + source_proof: dict[str, int | str] | None = None + + +@dataclass(frozen=True) +class PreparedRecovery: + owner: RecoveryOwner + metadata_key: str + preview: DirectMetadataRecoveryPreview + file_target: ArchiveMetadataTarget | None + + +async def record_incomplete_recovery( + session: AsyncSession, pending_id: int +) -> DirectMetadataRecoveryPreview: + """Persist a typed partial-issue diagnostic after verified preview rejection.""" + owner = await _read_owner(session, pending_id) + pending = await session.get(PendingMatch, pending_id) + assert pending is not None + pending.match_details = {**pending.match_details, "metadata_failure_code": "partial_issue"} + await session.flush() + return DirectMetadataRecoveryPreview( + issue_id=owner.issue_id, + file_name=owner.source.name, + review_key="0" * 64, + ready=False, + replacement_required=True, + ) + + +async def _read_owner( + session: AsyncSession, pending_id: int, *, validate_library: bool = True +) -> RecoveryOwner: + pending = await session.get(PendingMatch, pending_id, populate_existing=True) + if ( + pending is None + or pending.status != PendingMatchStatus.PENDING + or (pending.match_details or {}).get("failure_code") != "direct_metadata_review_required" + ): + raise DirectMetadataRecoveryError( + "This metadata recovery is no longer pending. Refresh the queue." + ) + attempt_id = pending.match_details.get("direct_attempt_id") + if type(attempt_id) is not int: + raise DirectMetadataRecoveryError("The saved download identity is unavailable.") + attempt = await session.get(DirectAcquisitionAttempt, attempt_id, populate_existing=True) + if ( + attempt is None + or attempt.issue_id != pending.issue_id + or attempt.state is not DirectAcquisitionState.INTERVENTION + or attempt.failure_class is not DirectArtifactFailureClass.POST_PROCESS + or attempt.failure_code != "direct_metadata_review_required" + ): + raise DirectMetadataRecoveryError( + "The download changed. Refresh the queue before reviewing metadata." + ) + selected = list( + await session.scalars( + select(DirectArtifactAttempt) + .where( + DirectArtifactAttempt.acquisition_attempt_id == attempt.id, + DirectArtifactAttempt.is_selected.is_(True), + ) + .limit(2) + ) + ) + marker = (attempt.plan_snapshot or {}).get("paired_metadata_copy") + if ( + len(selected) != 1 + or not isinstance(marker, dict) + or marker.get("artifact_id") != selected[0].id + or marker.get("plan_revision") != attempt.plan_revision + or type(marker.get("file_id")) is not int + or selected[0].state is not DirectArtifactState.INTERVENTION + or selected[0].failure_code != "direct_metadata_review_required" + or not selected[0].quarantine_path + ): + raise DirectMetadataRecoveryError( + "The retained download evidence changed. Review the issue before retrying." + ) + histories = list( + await session.scalars( + select(DownloadHistory) + .where( + DownloadHistory.external_id == f"direct:{attempt.id}", + ) + .limit(2) + ) + ) + if ( + len(histories) != 1 + or histories[0].issue_id != pending.issue_id + or histories[0].imported_at is not None + or histories[0].state is not DownloadState.FAILED + ): + raise DirectMetadataRecoveryError( + "The download history changed. Refresh the queue before retrying." + ) + files = ( + list( + await session.scalars( + select(LibraryFile).where(LibraryFile.issue_id == pending.issue_id).limit(2) + ) + ) + if validate_library + else [] + ) + if len(files) > 1: + raise DirectMetadataRecoveryError( + "This issue has multiple registered files. Reconcile them before recovery." + ) + file = files[0] if files else None + if file is None and validate_library: + issue = await session.get(Issue, pending.issue_id) + if issue is None or issue.status is not IssueStatus.WANTED: + raise DirectMetadataRecoveryError( + "The library copy is missing. Mark this issue Wanted " + "before explicitly restoring it." + ) + return RecoveryOwner( + pending.id, + pending.issue_id, + attempt.id, + selected[0].id, + histories[0].id, + attempt.plan_revision, + dict(marker), + (attempt.plan_snapshot or {}).get("safety_review"), + file.id if file else None, + file.file_path if file else None, + Path(selected[0].quarantine_path), + file is not None and (file.id != marker["file_id"] or attempt.library_file_id != file.id), + ) + + +async def _check_source(owner: RecoveryOwner) -> dict[str, int | str] | None: + try: + if ( + owner.source.parent.name != f"artifact-{owner.artifact_id}" + or owner.source.parent.parent.name != f"attempt-{owner.attempt_id}" + ): + raise ValueError("The retained download path changed.") + return await asyncio.to_thread( + verify_file_proof, + owner.source, + owner.marker.get("source_signature"), + owner.marker.get("source_sha256"), + owner.source_proof, + ) + except (OSError, ValueError) as exc: + raise DirectMetadataRecoveryError( + "The retained download is missing, changed, or has no saved content proof for " + "its changed storage identity. Review the file on the issue page before retrying; " + "Pullbox will not download it again." + ) from exc + + +async def prepare_direct_metadata_recovery( + session: AsyncSession, + pending_id: int, + *, + choices: FileMetadataChoices | None = None, +) -> PreparedRecovery: + if not get_settings().metadata_paired_direct_writer_enabled: + raise DirectMetadataRecoveryError( + "Enable direct-download paired metadata writing before recovering this file." + ) + owner = await _read_owner(session, pending_id) + await ensure_no_active_import_file_mutation(session) + await session.commit() + owner = replace(owner, source_proof=await _check_source(owner)) + file_target = None + if owner.file_id is not None: + prepared = await prepare_file_metadata(session, owner.issue_id, choices=choices) + file_target = prepared.target + if prepared.target.binding.library_file_id != owner.file_id: + raise DirectMetadataRecoveryError("The library file changed. Preview again.") + try: + await asyncio.to_thread( + verify_file_proof, + prepared.target.path, + owner.marker.get("copy_signature"), + owner.marker.get("copy_sha256"), + ) + except (OSError, ValueError): + owner = replace(owner, superseded=True) + preview = DirectMetadataRecoveryPreview( + issue_id=owner.issue_id, + **prepared.preview.model_dump(exclude={"file_id"}), + superseded=owner.superseded, + ) + if owner.superseded and (not preview.ready or not preview.unchanged): + raise DirectMetadataRecoveryError( + "A different library copy now owns this issue. Review its file metadata on " + "the issue page first; this recovery will not overwrite it." + ) + else: + if not get_settings().metadata_paired_import_writer_enabled: + raise DirectMetadataRecoveryError( + "Enable paired import metadata writing before restoring the retained download." + ) + plan = await read_manual_metadata_plan(session, owner.issue_id) + if plan.existing_file is not None or not plan.policy.update_embedded_comicinfo_from_match: + raise DirectMetadataRecoveryError( + "The import settings or library binding changed. " + "Enable embedded metadata updates and preview again." + ) + await session.commit() + reviewed = await preview_manual_import_metadata(plan, owner.source, choices or {}) + preview = DirectMetadataRecoveryPreview( + issue_id=owner.issue_id, + file_name=owner.source.name, + review_key=reviewed.preview.review_key, + ready=reviewed.preview.ready, + conflicts=reviewed.preview.conflicts, + restores_copy=True, + converts_to_cbz=owner.source.suffix.lower() in {".cbr", ".cb7", ".pdf"}, + ) + metadata_key = preview.review_key + preview.review_key = hashlib.sha256( + json.dumps( + {"owner": asdict(owner), "metadata_key": metadata_key}, + default=str, + sort_keys=True, + ).encode() + ).hexdigest() + return PreparedRecovery(owner, metadata_key, preview, file_target) + + +async def _complete( + session: AsyncSession, + owner: RecoveryOwner, + file: LibraryFile, + operation: UUID, + review_key: str, +) -> None: + await lock_file_mutation_admission(session) + await ensure_no_active_import_file_mutation(session) + current = await _read_owner(session, owner.pending_id) + # The approved writer may convert the owned path or restore its missing registration. + expected = replace( + owner, + file_id=file.id, + file_path=file.file_path, + superseded=current.superseded if owner.file_id is None else owner.superseded, + ) + # Supersession can also be detected from changed bytes at the same registered path. + if ( + replace( + current, + superseded=owner.superseded if owner.file_id else current.superseded, + source_proof=owner.source_proof, + ) + != expected + ): + raise DirectMetadataRecoveryError( + "Recovery ownership changed before completion. The saved file needs review." + ) + pending = await session.get(PendingMatch, owner.pending_id) + attempt = await session.get(DirectAcquisitionAttempt, owner.attempt_id) + artifact = await session.get(DirectArtifactAttempt, owner.artifact_id) + assert pending is not None and attempt is not None and artifact is not None + now = datetime.now(UTC) + reason = "metadata_superseded" if owner.superseded else "metadata_completed" + if owner.superseded: + transition_acquisition(attempt, DirectAcquisitionState.FAILED, at=now) + transition_artifact(artifact, DirectArtifactState.FAILED, at=now) + attempt.error_message = METADATA_RECOVERY_SUPERSEDED + artifact.error_message = attempt.error_message + else: + transition_acquisition(attempt, DirectAcquisitionState.POST_PROCESSING, at=now) + transition_artifact(artifact, DirectArtifactState.VALIDATING, at=now) + transition_acquisition(attempt, DirectAcquisitionState.COMPLETED, at=now) + transition_artifact(artifact, DirectArtifactState.COMPLETED, at=now) + attempt.library_file_id = file.id + attempt.failure_class = artifact.failure_class = None + attempt.failure_code = artifact.failure_code = None + attempt.error_message = artifact.error_message = None + issue = await session.get(Issue, owner.issue_id) + assert issue is not None + issue.status = IssueStatus.OWNED + advance_acquisition_progress( + attempt, + revision=attempt.progress_revision + 1, + snapshot={ + "schema_version": 1, + "stage": "completed" if not owner.superseded else "superseded", + "percent": 100, + "artifact_attempt_id": artifact.id, + }, + ) + history = await sync_direct_download_history( + session, + attempt, + artifact, + at=now, + final_path=file.file_path, + update_issue_status=not owner.superseded, + ) + await project_post_processing_operation_progress(session, history, None) + pending.status = PendingMatchStatus.APPROVED + pending.resolved_at = now + pending.resolved_by = "metadata_recovery" + pending.match_details = { + **pending.match_details, + "resolution_reason": reason, + "metadata_operation_id": str(operation), + "metadata_review_key": review_key, + "resolved_library_file_id": file.id, + } + + +async def run_direct_metadata_recovery( + factory: async_sessionmaker[AsyncSession], + pending_id: int, + review_key: str, + operation: UUID, + *, + choices: FileMetadataChoices, + limit: int, + check_control: ControlCheck, + progress: ProgressCallback, + job_id: str | None = None, +) -> str: + async with factory() as reader: + pending = await reader.get(PendingMatch, pending_id) + if ( + pending is not None + and pending.status == PendingMatchStatus.APPROVED + and ( + pending.match_details.get("metadata_operation_id") == str(operation) + and pending.match_details.get("metadata_review_key") == review_key + ) + ): + return "already_completed" + prepared = await prepare_direct_metadata_recovery(reader, pending_id, choices=choices) + if not prepared.preview.ready or prepared.preview.review_key != review_key: + raise DirectMetadataRecoveryError( + "The file, metadata, or recovery state changed. Preview again before writing." + ) + owner = prepared.owner + + async def control() -> None: + await check_control() + async with factory() as reader: + current = await _read_owner(reader, pending_id) + if replace(current, superseded=owner.superseded, source_proof=owner.source_proof) != owner: + raise DirectMetadataRecoveryError( + "The recovery or library binding changed. No further file changes will be made." + ) + await _check_source(owner) + + await control() + if job_id is not None: + async with factory.begin() as writer: + item = await writer.get(UtilityJobItem, operation.hex) + if item is None or item.job_id != job_id: + raise DirectMetadataRecoveryError("The recovery job no longer owns this operation.") + item.before_state = json.dumps( + { + "direct_metadata_recovery": { + "owner": asdict(owner), + "review_key": review_key, + } + }, + default=str, + ) + if owner.file_id is None: + async with factory() as writer: + plan = await prepare_manual_issue_import( + writer, issue_id=owner.issue_id, file_path=str(owner.source), move_to_library=True + ) + result = await execute_manual_issue_import( + writer, + plan, + metadata_approval=FileMetadataApproval( + review_key=prepared.metadata_key, choices=choices + ), + cancellation_check=control, + preparation_progress_callback=progress, + comicinfo_progress_callback=progress, + ) + await _complete(writer, owner, result.library_file, operation, review_key) + await writer.commit() + return "restored" + outcome = ( + "superseded" + if owner.superseded + else await write_file_metadata( + factory, + owner.issue_id, + prepared.metadata_key, + operation, + limit=limit, + choices=choices, + check_control=control, + progress=progress, + job_id=job_id, + ) + ) + if job_id is not None and await recover_direct_metadata_operation(factory, operation, job_id): + return outcome + async with factory.begin() as writer: + if outcome in {"superseded", "unchanged"}: + from pullbox.services.archive_metadata_binding import revalidate_archive_metadata_target + + assert prepared.file_target is not None + await lock_file_mutation_admission(writer) + await revalidate_archive_metadata_target(writer, prepared.file_target) + await asyncio.to_thread(prepared.file_target.check_unchanged) + file = await writer.get(LibraryFile, owner.file_id) + if file is None or file.issue_id != owner.issue_id: + raise DirectMetadataRecoveryError("The library binding changed before completion.") + await _complete(writer, owner, file, operation, review_key) + return outcome + + +async def recover_direct_metadata_operation( + factory: async_sessionmaker[AsyncSession], + operation: UUID, + job_id: str, +) -> bool: + """A lost acknowledgement needs the exact writer journal, never OWNED alone.""" + from pullbox.models.archive_metadata_publication import PublicationState + from pullbox.services.archive_metadata_binding import revalidate_archive_metadata_target + from pullbox.services.archive_metadata_publication import ( + inspect_archive_publication, + load_archive_publication, + ) + from pullbox.services.issue_file_metadata import recover_file_metadata + from pullbox.services.native_file_metadata import recover_native_file_metadata + + async with factory() as session: + job = await session.get(UtilityJob, job_id) + item = await session.get(UtilityJobItem, operation.hex) + if job is None or item is None or item.job_id != job_id: + return False + config = json.loads(job.config) + saved = json.loads(item.before_state or "{}").get("direct_metadata_recovery") + if not saved: + return False + owner = TypeAdapter(RecoveryOwner).validate_python(saved["owner"]) + if ( + config.get("recovery_pending_id") != owner.pending_id + or config.get("issue_id") != owner.issue_id + or config.get("review_key") != saved["review_key"] + ): + raise DirectMetadataRecoveryError( + "The recovery job evidence changed. Review the queue entry again." + ) + pending = await session.get(PendingMatch, owner.pending_id) + if ( + pending is not None + and pending.status == PendingMatchStatus.APPROVED + and pending.match_details.get("metadata_operation_id") == str(operation) + ): + return True + # Restoration commits its new registration and recovery result together. + if owner.file_id is None: + return False + receipt = await load_archive_publication(session, operation) + await _check_source(owner) + if receipt is not None: + if ( + receipt.plan.metadata_job_id != job_id + or receipt.plan.target.binding.library_file_id != owner.file_id + ): + raise DirectMetadataRecoveryError( + "The completed metadata journal belongs to a different file." + ) + if await recover_file_metadata(factory, operation) != PublicationState.FINALIZED: + return False + async with factory() as session: + receipt = await load_archive_publication(session, operation) + assert receipt is not None + inspected = await inspect_archive_publication(receipt) + if inspected.digest != receipt.plan.output_digest: + raise DirectMetadataRecoveryError( + "The written file changed after publication. Review it before completing recovery." + ) + elif await recover_native_file_metadata(factory, owner.issue_id, operation) is None: + return False + async with factory() as session: + prepared = await prepare_file_metadata(session, owner.issue_id) + if not prepared.preview.ready or not prepared.preview.unchanged: + raise DirectMetadataRecoveryError( + "The recovered file metadata changed. Preview the queue entry again." + ) + async with factory.begin() as session: + await lock_file_mutation_admission(session) + await revalidate_archive_metadata_target(session, prepared.target) + await asyncio.to_thread(prepared.target.check_unchanged) + file = await session.get(LibraryFile, owner.file_id) + if file is None or file.issue_id != owner.issue_id: + raise DirectMetadataRecoveryError("The library binding changed after publication.") + await _complete(session, owner, file, operation, saved["review_key"]) + return True diff --git a/src/pullbox/services/direct_pack_completion.py b/src/pullbox/services/direct_pack_completion.py new file mode 100644 index 00000000..623b979c --- /dev/null +++ b/src/pullbox/services/direct_pack_completion.py @@ -0,0 +1,301 @@ +"""Reconcile redundant pack requests without changing their library files.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +import structlog +from sqlalchemy import select + +from pullbox.core.archive_metadata import MAX_METADATA_BYTES, read_archive_metadata +from pullbox.models import Issue, LibraryFile +from pullbox.models.direct_acquisition import ( + DirectAcquisitionAttempt, + DirectAcquisitionState, + DirectArtifactAttempt, + DirectArtifactFailureClass, + DirectArtifactState, +) +from pullbox.models.download import DownloadHistory, DownloadState +from pullbox.models.issue import IssueStatus +from pullbox.models.pending_match import PendingMatch, PendingMatchStatus +from pullbox.services.archive_metadata_binding import ( + lock_archive_metadata_binding, + revalidate_archive_metadata_target, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.direct_acquisition_state import ( + advance_acquisition_progress, + transition_acquisition, + transition_artifact, +) +from pullbox.services.direct_artifact_pack import PACK_FULFILLED_MESSAGE +from pullbox.services.direct_artifact_quarantine import ( + DirectArtifactValidationError, + validate_direct_artifact, +) +from pullbox.services.issue_file_metadata import prepare_file_metadata +from pullbox.services.library_mutation_coordination import lock_file_mutation_admission +from pullbox.utilities.import_guards import ensure_no_active_import_file_mutation + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = structlog.get_logger(__name__) + + +async def reconcile_completed_pack_issues( + session: AsyncSession, issue_ids: tuple[int, ...] +) -> None: + """Bound follow-up to ownership failures for this pack's imported members.""" + ids = list( + await session.scalars( + select(DirectAcquisitionAttempt.id) + .where( + DirectAcquisitionAttempt.issue_id.in_(issue_ids), + DirectAcquisitionAttempt.state == DirectAcquisitionState.INTERVENTION, + DirectAcquisitionAttempt.failure_code.in_( + ( + "direct_pack_ownership_changed", + "direct_metadata_review_required", + ) + ), + ) + .order_by(DirectAcquisitionAttempt.id) + .limit(100) + ) + ) + await session.commit() + for attempt_id in ids: + await reconcile_fulfilled_pack(session, attempt_id) + await session.commit() + + +def is_pack_ownership_failure(attempt: DirectAcquisitionAttempt) -> bool: + """Recognize the old misclassified guard, never arbitrary metadata conflicts.""" + return attempt.failure_code == "direct_pack_ownership_changed" or ( + attempt.failure_code == "direct_metadata_review_required" + and attempt.error_message == "Pack metadata or ownership changed; review before retrying." + ) + + +@dataclass(frozen=True) +class _Owner: + issue_id: int + file_id: int + artifact_id: int + history_id: int + pending_ids: tuple[int, ...] + snapshot: str + + +async def _read_owner(session: AsyncSession, attempt_id: int) -> _Owner | None: + attempt = await session.get(DirectAcquisitionAttempt, attempt_id, populate_existing=True) + if ( + attempt is None + or attempt.state is not DirectAcquisitionState.INTERVENTION + or attempt.failure_class is not DirectArtifactFailureClass.POST_PROCESS + or attempt.replace_existing_file + or not is_pack_ownership_failure(attempt) + or (attempt.plan_snapshot or {}).get("paired_metadata_copy") is not None + ): + return None + coverage = (attempt.plan_snapshot or {}).get("coverage") + numbers = coverage.get("selected_content_issue_numbers") if isinstance(coverage, dict) else None + issue = await session.get(Issue, attempt.issue_id, populate_existing=True) + if ( + issue is None + or issue.status is not IssueStatus.OWNED + or not isinstance(numbers, list) + or len(numbers) <= 1 + or issue.issue_number_text not in numbers + ): + return None + artifacts = list( + await session.scalars( + select(DirectArtifactAttempt) + .where( + DirectArtifactAttempt.acquisition_attempt_id == attempt_id, + DirectArtifactAttempt.is_selected.is_(True), + ) + .limit(2) + .execution_options(populate_existing=True) + ) + ) + files = list( + await session.scalars( + select(LibraryFile) + .where( + LibraryFile.issue_id == issue.id, + ) + .limit(2) + .execution_options(populate_existing=True) + ) + ) + histories = list( + await session.scalars( + select(DownloadHistory) + .where( + DownloadHistory.external_id == f"direct:{attempt_id}", + ) + .limit(2) + .execution_options(populate_existing=True) + ) + ) + pending = list( + await session.scalars( + select(PendingMatch) + .where( + PendingMatch.download_url == f"pullbox-direct://attempt/{attempt_id}", + ) + .limit(2) + .execution_options(populate_existing=True) + ) + ) + if len(artifacts) != 1 or len(files) != 1 or len(histories) != 1 or len(pending) > 1: + return None + artifact, file, history = artifacts[0], files[0], histories[0] + if ( + artifact.state is not DirectArtifactState.INTERVENTION + or artifact.failure_code != attempt.failure_code + or history.issue_id != issue.id + or history.state is not DownloadState.FAILED + or history.imported_at is not None + or any( + p.status != PendingMatchStatus.PENDING + or p.issue_id != issue.id + or p.match_details.get("direct_attempt_id") != attempt_id + for p in pending + ) + ): + return None + snapshot = json.dumps( + { + "plan": attempt.plan_snapshot, + "revision": attempt.plan_revision, + "progress_revision": attempt.progress_revision, + "failure": attempt.failure_code, + "message": attempt.error_message, + "file": file.id, + "path": file.file_path, + "artifact": artifact.id, + "source": artifact.quarantine_path, + "history": history.id, + "pending": [(p.id, p.match_details) for p in pending], + }, + sort_keys=True, + ) + return _Owner( + issue.id, file.id, artifact.id, history.id, tuple(p.id for p in pending), snapshot + ) + + +async def reconcile_fulfilled_pack(session: AsyncSession, attempt_id: int) -> bool: + """Read/verify/recheck a clean session; the caller commits the final state. + + Archive work happens outside a write transaction. No files, providers, queues, + or import timestamps are changed by this completion-only reconciliation. + """ + if session.new or session.dirty or session.deleted: + raise ValueError("Pack reconciliation requires a clean session") + owner = await _read_owner(session, attempt_id) + if owner is None: + return False + try: + prepared = await prepare_file_metadata(session, owner.issue_id) + if not prepared.preview.ready: + return False + if prepared.target.binding.library_file_id != owner.file_id: + return False + if prepared.target.path.suffix.lower() not in {".cbz", ".zip"}: + return False + embedded = reconcile_archive_metadata( + await asyncio.to_thread( + read_archive_metadata, + prepared.target.path, + "cbz", + max_solid_scan_bytes=MAX_METADATA_BYTES, + ) + ) + expected = prepared.target.binding.metadata.issues[0] + if ( + embedded.identity_conflicts + or embedded.diagnostics + or embedded.issue.issue_number_text != prepared.issue.values.issue_number_text + or not any(item.identity in expected.identities for item in embedded.evidence) + ): + return False + await validate_direct_artifact(session, prepared.target.path) + await session.commit() + await lock_file_mutation_admission(session) + await ensure_no_active_import_file_mutation(session) + await lock_archive_metadata_binding(session, prepared.target.binding) + for column, local_id in ( + (DirectAcquisitionAttempt.id, attempt_id), + (DirectArtifactAttempt.id, owner.artifact_id), + (DownloadHistory.id, owner.history_id), + *((PendingMatch.id, pid) for pid in owner.pending_ids), + ): + await session.execute(select(column).where(column == local_id).with_for_update()) + if await _read_owner(session, attempt_id) != owner: + await session.rollback() + return False + await revalidate_archive_metadata_target(session, prepared.target) + await asyncio.to_thread(prepared.target.check_unchanged) + except (ValueError, OSError, DirectArtifactValidationError) as exc: + await session.rollback() + logger.info( + "direct_pack_completion_unverified", attempt_id=attempt_id, reason=type(exc).__name__ + ) + return False + + attempt = await session.get(DirectAcquisitionAttempt, attempt_id) + artifact = await session.get(DirectArtifactAttempt, owner.artifact_id) + assert attempt is not None and artifact is not None + now = datetime.now(UTC) + receipt = { + "resolution_reason": "pack_fulfilled_by_library", + "resolved_library_file_id": owner.file_id, + "original_failure_code": attempt.failure_code, + "original_error_message": attempt.error_message, + } + attempt.plan_snapshot = {**attempt.plan_snapshot, "pack_completion": receipt} + transition_acquisition(attempt, DirectAcquisitionState.FAILED, at=now) + transition_artifact(artifact, DirectArtifactState.FAILED, at=now) + attempt.error_message = artifact.error_message = PACK_FULFILLED_MESSAGE + attempt.next_retry_at = artifact.next_retry_at = None + advance_acquisition_progress( + attempt, + revision=attempt.progress_revision + 1, + snapshot={ + "schema_version": 1, + "stage": "superseded", + "artifact_attempt_id": artifact.id, + }, + ) + from pullbox.services.direct_download_history_adapter import sync_direct_download_history + from pullbox.services.post_processing_operation_progress import ( + project_post_processing_operation_progress, + ) + + history = await sync_direct_download_history(session, attempt, artifact, at=now) + await project_post_processing_operation_progress(session, history) + for pending_id in owner.pending_ids: + pending = await session.get(PendingMatch, pending_id) + assert pending is not None + pending.status = PendingMatchStatus.APPROVED + pending.resolved_at = now + pending.resolved_by = "pack_completion" + pending.match_details = {**pending.match_details, **receipt} + await session.flush() + logger.info( + "direct_pack_fulfilled_by_library", + attempt_id=attempt_id, + issue_id=owner.issue_id, + file_id=owner.file_id, + ) + return True diff --git a/src/pullbox/services/direct_pack_paired_metadata.py b/src/pullbox/services/direct_pack_paired_metadata.py index 8c76142a..a8052fb3 100644 --- a/src/pullbox/services/direct_pack_paired_metadata.py +++ b/src/pullbox/services/direct_pack_paired_metadata.py @@ -18,6 +18,7 @@ from pullbox.models.direct_acquisition import DirectAcquisitionAttempt, DirectArtifactAttempt from pullbox.models.download import DownloadHistory from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError +from pullbox.services.direct_artifact_pack import DirectArtifactPackError from pullbox.services.direct_paired_metadata import DirectMetadataReviewError from pullbox.services.manual_paired_metadata import ( _directories, @@ -179,8 +180,10 @@ async def stage_direct_pack_metadata( await read_manual_metadata_plan(session, item.issue_id) != member.plan or item.issue.status != statuses[item.issue_id] ): - raise DirectMetadataReviewError( - "Pack metadata or ownership changed; review before retrying." + raise DirectArtifactPackError( + code="direct_pack_ownership_changed", + message="The library changed while this pack was importing. " + "Recheck the retained pack; no new download is needed.", ) yield staged_members except (ArchiveMetadataBindingError, ArchiveMetadataStagingError, ValueError) as exc: diff --git a/src/pullbox/services/direct_paired_metadata.py b/src/pullbox/services/direct_paired_metadata.py index 8ee2a879..c9722ec8 100644 --- a/src/pullbox/services/direct_paired_metadata.py +++ b/src/pullbox/services/direct_paired_metadata.py @@ -24,6 +24,11 @@ ) from pullbox.models.download import DownloadHistory, DownloadState from pullbox.models.issue import IssueStatus +from pullbox.services.direct_metadata_file_proof import ( + capture_file_proof, + proof_identity, + verify_file_proof, +) from pullbox.services.issue_file_metadata import ( file_metadata_error, prepare_file_metadata, @@ -42,6 +47,11 @@ class DirectMetadataReviewError(ValidationError): """A managed direct copy needs attention before acquisition completion.""" + def __init__(self, message: str, *, code: str | None = None) -> None: + super().__init__(message) + if code is not None: + self.code = code + @dataclass class DirectMetadataHandoff: @@ -106,21 +116,30 @@ async def finish( if self.marker is None: if file.file_path != download.final_path: raise DirectMetadataReviewError("The direct-download destination changed.") - if ( + source_proof = await asyncio.to_thread(capture_file_proof, self.source_path) + copy_proof = await asyncio.to_thread(capture_file_proof, Path(file.file_path)) + if proof_identity(source_proof) != self.source_signature or ( await asyncio.to_thread(build_file_identity_signature, self.source_path) != self.source_signature ): raise DirectMetadataReviewError( "The downloaded source changed during placement. Review it before retrying." ) + await asyncio.to_thread( + verify_file_proof, + self.source_path, + self.source_signature, + source_proof["sha256"], + source_proof, + ) self.marker = { "artifact_id": self.artifact_id, "plan_revision": self.plan_revision, "file_id": file.id, "source_signature": self.source_signature, - "copy_signature": await asyncio.to_thread( - build_file_identity_signature, Path(file.file_path) - ), + "copy_signature": proof_identity(copy_proof), + "source_sha256": source_proof["sha256"], + "copy_sha256": copy_proof["sha256"], } attempt.plan_snapshot = {**(attempt.plan_snapshot or {}), _COPY_KEY: self.marker} attempt.library_file_id = file.id @@ -190,6 +209,10 @@ async def progress(_stage: str, _current: int, _total: int, _unit: str) -> None: await session.refresh(attempt) return Path(self.final_path) except ValueError as exc: + if getattr(exc, "code", None) == "partial_issue": + raise DirectMetadataReviewError( + file_metadata_error(exc), code="partial_issue" + ) from exc raise DirectMetadataReviewError( f"{file_metadata_error(exc)} Review file metadata on the issue, " "then retry this acquisition." diff --git a/src/pullbox/services/direct_resolver_budget.py b/src/pullbox/services/direct_resolver_budget.py new file mode 100644 index 00000000..ef913dcc --- /dev/null +++ b/src/pullbox/services/direct_resolver_budget.py @@ -0,0 +1,80 @@ +"""Keep provider resolver fallbacks within one bounded operation deadline.""" + +from __future__ import annotations + +import asyncio +import time +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +import structlog + +from pullbox.providers.direct.client import DirectProviderClientError +from pullbox.providers.direct.contract import DirectDeadlineRequest + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +logger = structlog.get_logger(__name__) + + +class ProviderResolverBudget: + def __init__(self, deadline: datetime, *, now: Callable[[], datetime]) -> None: + self.deadline = deadline + self.now = now + self.expires_at = time.monotonic() + max(0.0, (deadline - now()).total_seconds()) + + async def run[RequestT: DirectDeadlineRequest, ResponseT]( + self, + request: RequestT, + invoke: Callable[[RequestT], Awaitable[ResponseT]], + *, + remaining_resolvers: int, + ) -> ResponseT: + remaining = min( + (self.deadline - self.now()).total_seconds(), + self.expires_at - time.monotonic(), + ) + if remaining <= 0: + raise DirectProviderClientError( + "provider_timed_out", "The provider operation deadline expired.", retryable=True + ) + profile = request.resolver_profile + seconds = remaining + if profile is not None: + # Reserve an equal share for each untried resolver; unused time carries forward. + seconds = min(profile.timeout_seconds, remaining / max(1, remaining_resolvers)) + profile = profile.model_copy(update={"timeout_seconds": seconds}) + logger.info( + "direct_provider_resolver_budget", + request_id=str(request.request_id), + timeout_seconds=round(seconds, 3), + remaining_resolvers=remaining_resolvers, + ) + bounded = request.model_copy( + update={ + "deadline": self.deadline - timedelta(seconds=remaining - seconds), + "resolver_profile": profile, + } + ) + try: + async with asyncio.timeout(seconds): + return await invoke(bounded) + except TimeoutError as exc: + logger.warning( + "direct_provider_attempt_timed_out", + request_id=str(request.request_id), + using_resolver=profile is not None, + timeout_seconds=round(seconds, 3), + ) + raise DirectProviderClientError( + "provider_timed_out", "The provider attempt timed out.", retryable=True + ) from exc + + +def resolver_retry_allowed(error: DirectProviderClientError, *, using_resolver: bool) -> bool: + return ( + error.code == "browser_challenge_required" + or error.code.startswith("resolver_") + or (using_resolver and error.code == "provider_timed_out") + ) diff --git a/src/pullbox/services/direct_search_coordinator.py b/src/pullbox/services/direct_search_coordinator.py index 6bb26b46..83172c28 100644 --- a/src/pullbox/services/direct_search_coordinator.py +++ b/src/pullbox/services/direct_search_coordinator.py @@ -37,6 +37,7 @@ from pullbox.services.direct_provider_source_origin import ( effective_direct_provider_source_domains, ) +from pullbox.services.direct_resolver_budget import ProviderResolverBudget, resolver_retry_allowed from pullbox.services.release_validator import ReleaseValidator, ValidationResult from pullbox.services.search_scoring import match_confidence_rank @@ -56,6 +57,7 @@ logger = structlog.get_logger(__name__) _DEFAULT_SEARCH_SECONDS = 30.0 +_ANNA_BROWSER_SEARCH_SECONDS = 90.0 _DEFAULT_RESULT_LIMIT = 20 _MAX_FANOUT = 4 _COLLECTION_EDITION_QUALIFIERS = ("expanded edition",) @@ -150,7 +152,11 @@ def _default_client_factory( bearer_token=bearer_token, allow_private_http=allow_private_http, provider_id=provider_id, - request_timeout_seconds=_DEFAULT_SEARCH_SECONDS, + request_timeout_seconds=( + _ANNA_BROWSER_SEARCH_SECONDS + if provider_id == "pullbox.annas_archive" + else _DEFAULT_SEARCH_SECONDS + ), ) @@ -290,7 +296,7 @@ async def search_direct_issue_target( validator_kwargs: ValidatorKwargs | None = None, client_factory: DirectSearchClientFactory = _default_client_factory, result_limit: int = _DEFAULT_RESULT_LIMIT, - search_seconds: float = _DEFAULT_SEARCH_SECONDS, + search_seconds: float | None = None, max_fanout: int = _MAX_FANOUT, now: Callable[[], datetime] | None = None, on_resolver_attempt: ResolverAttemptCallback | None = None, @@ -298,7 +304,7 @@ async def search_direct_issue_target( """Search eligible providers concurrently and preserve matcher semantics.""" if not 1 <= result_limit <= 100: raise ValueError("Direct provider result limit must be between 1 and 100.") - if search_seconds <= 0 or search_seconds > 300: + if search_seconds is not None and (search_seconds <= 0 or search_seconds > 300): raise ValueError("Direct provider search timeout must be between 0 and 300 seconds.") if not 1 <= max_fanout <= _MAX_FANOUT: raise ValueError("Direct provider fan-out must be between 1 and 4.") @@ -335,13 +341,23 @@ async def _search( DirectSearchFailure | None, ]: async with semaphore: + seconds = search_seconds + if seconds is None: + # Anna's browser interstitial needs time to finish before fallback. + seconds = ( + _ANNA_BROWSER_SEARCH_SECONDS + if provider.provider_identity == "pullbox.annas_archive" + and provider.resolver_options + else _DEFAULT_SEARCH_SECONDS + ) return await _search_provider( target, provider, validator=validator, client_factory=client_factory, result_limit=result_limit, - deadline=clock() + timedelta(seconds=search_seconds), + deadline=clock() + timedelta(seconds=seconds), + now=clock, on_resolver_attempt=record_resolver_attempt, ) @@ -378,6 +394,7 @@ async def _search_provider( client_factory: DirectSearchClientFactory, result_limit: int, deadline: datetime, + now: Callable[[], datetime], on_resolver_attempt: ResolverAttemptCallback | None, ) -> tuple[ list[DirectValidatedCandidate], @@ -397,6 +414,7 @@ async def _search_provider( target=target, result_limit=result_limit, deadline=deadline, + now=now, on_resolver_attempt=on_resolver_attempt, ) except asyncio.CancelledError: @@ -566,9 +584,11 @@ async def _search_with_resolver_fallback( target: IssueSearchTarget, result_limit: int, deadline: datetime, + now: Callable[[], datetime], on_resolver_attempt: ResolverAttemptCallback | None, ) -> DirectSearchResponse: options = (None, *provider.resolver_options) + budget = ProviderResolverBudget(deadline, now=now) last_error: DirectProviderClientError | None = None for option_index, option in enumerate(options): if option is not None and on_resolver_attempt is not None: @@ -585,7 +605,13 @@ async def _search_with_resolver_fallback( ) ) try: - return await client.search( + attempt_budget = budget + if option is None and provider.provider_identity == "pullbox.annas_archive": + # The browser allowance must not extend an ordinary stalled HTTP request. + attempt_budget = ProviderResolverBudget( + min(deadline, now() + timedelta(seconds=_DEFAULT_SEARCH_SECONDS)), now=now + ) + return await attempt_budget.run( DirectSearchRequest( protocol_version=provider.protocol_version, request_id=uuid4(), @@ -595,21 +621,22 @@ async def _search_with_resolver_fallback( resolver_profile=option.profile if option is not None else None, intent=_build_intent(target), limit=result_limit, - ) + ), + client.search, + remaining_resolvers=len(options) - option_index, ) except DirectProviderClientError as exc: last_error = exc - if not _resolver_retry_allowed(exc) or option_index == len(options) - 1: + if ( + not resolver_retry_allowed(exc, using_resolver=option is not None) + or option_index == len(options) - 1 + ): raise if last_error is not None: raise last_error raise RuntimeError("Direct provider search had no executable request path.") -def _resolver_retry_allowed(exc: DirectProviderClientError) -> bool: - return exc.code == "browser_challenge_required" or exc.code.startswith("resolver_") - - def _build_intent(target: IssueSearchTarget) -> DirectSearchIntent: issue_number = target.effective_issue_number_text return DirectSearchIntent( diff --git a/src/pullbox/services/download_operation_progress.py b/src/pullbox/services/download_operation_progress.py index 4d7a15ac..d83d8228 100644 --- a/src/pullbox/services/download_operation_progress.py +++ b/src/pullbox/services/download_operation_progress.py @@ -5,13 +5,19 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any -from pullbox.models.download import DownloadState +from pullbox.core.partial_issue import ( + INCOMPLETE_RECOVERY_DISMISSED, + QUIET_METADATA_RECOVERY_MESSAGES, +) +from pullbox.models.download import DownloadClientType, DownloadState from pullbox.models.operation_progress import ( OperationProgressState, OperationProgressTone, OperationProgressType, OperationProgressVisibility, ) +from pullbox.services.airdcpp_recovery import RECOVERY_LINK, RECOVERY_MESSAGES +from pullbox.services.direct_artifact_pack import PACK_FULFILLED_MESSAGE from pullbox.services.operation_progress import ( OperationProgressMeasure, OperationProgressUpdate, @@ -22,6 +28,8 @@ from pullbox.models.download import DownloadHistory +CANCELLED_DOWNLOAD_MESSAGES = ("Cancelled by user", "Cancelled: series deleted") + def _progress_value(progress: Any, name: str, default: object = None) -> object: if isinstance(progress, Mapping): @@ -69,12 +77,23 @@ def _default_source_label(download: DownloadHistory) -> str: return download.download_client.value.replace("_", " ").title() +def download_was_cancelled(download: DownloadHistory) -> bool: + """Cancellation uses FAILED in the legacy download model, but is not an alert.""" + return ( + download.state == DownloadState.FAILED + and download.error_message in CANCELLED_DOWNLOAD_MESSAGES + ) + + def build_download_operation_update( download: DownloadHistory, progress: Any | None = None, ) -> OperationProgressUpdate: """Build a truthful download projection from durable state and client metrics.""" shared_state = _shared_state(DownloadState(download.state)) + source_label = _progress_value(progress, "source_label") if progress is not None else None + if download_was_cancelled(download): + shared_state = OperationProgressState.CANCELLED size_bytes = _int_value( _progress_value(progress, "size_bytes") if progress is not None else None ) @@ -96,10 +115,27 @@ def build_download_operation_update( percent = 100.0 client_state = _progress_value(progress, "client_state") if progress is not None else None - source_label = _progress_value(progress, "source_label") if progress is not None else None + recovery_required = download.error_message in RECOVERY_MESSAGES.values() + superseded = ( + download.download_client is DownloadClientType.AIRDCPP + and download.error_message + == "AirDC++ merged this request into an existing Pullbox download." + ) or ( + download.download_client is DownloadClientType.DIRECT + and download.error_message in {PACK_FULFILLED_MESSAGE, *QUIET_METADATA_RECOVERY_MESSAGES} + ) + if client_state: + client_state = { + "mutation_pending": "Sending to AirDC++", + "retry_mutation_pending": "Sending to AirDC++", + "recovery_check_pending": "Checking AirDC++", + }.get(str(client_state).lower().replace(" ", "_"), client_state) source_slow = ( bool(_progress_value(progress, "source_slow", False)) if progress is not None else False ) + if shared_state.is_terminal: + client_state = None + source_slow = False rate = _float_value(_progress_value(progress, "speed_bytes")) if progress is not None else None eta_seconds = ( _int_value(_progress_value(progress, "eta_seconds")) if progress is not None else None @@ -110,7 +146,7 @@ def build_download_operation_update( elif eta_seconds is not None and eta_seconds <= 0: eta_seconds = None - attention_required = shared_state is OperationProgressState.FAILED + attention_required = shared_state is OperationProgressState.FAILED and not superseded tone = OperationProgressTone.INFO if attention_required: tone = OperationProgressTone.DANGER @@ -128,12 +164,29 @@ def build_download_operation_update( group_key="downloads", revision=None, state=shared_state, - phase=str(client_state or download.state.value).lower().replace(" ", "_"), + phase=str( + ( + "dismissed" + if superseded and download.error_message == INCOMPLETE_RECOVERY_DISMISSED + else "superseded" + if superseded + else client_state + ) + or ( + "cancelled" + if shared_state is OperationProgressState.CANCELLED + else download.state.value + ) + ) + .lower() + .replace(" ", "_"), title=download.title, message=str(client_state or download.error_message or ""), source_label=str(source_label or _default_source_label(download)), - detail_url="/downloads", - visibility=OperationProgressVisibility.PROMINENT, + detail_url=RECOVERY_LINK if recovery_required else "/downloads", + visibility=OperationProgressVisibility.QUIET + if superseded + else OperationProgressVisibility.PROMINENT, tone=tone, attention_required=attention_required, overall=OperationProgressMeasure( diff --git a/src/pullbox/services/download_operation_reconciliation.py b/src/pullbox/services/download_operation_reconciliation.py new file mode 100644 index 00000000..4cb7fc9e --- /dev/null +++ b/src/pullbox/services/download_operation_reconciliation.py @@ -0,0 +1,178 @@ +"""Reconcile download activity with its durable lifecycle owner.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from sqlalchemy import String, and_, cast, or_, select + +from pullbox.models.download import DownloadHistory, DownloadState +from pullbox.models.operation_progress import ( + OperationProgress, + OperationProgressTone, +) +from pullbox.models.operation_progress import ( + OperationProgressState as State, +) +from pullbox.models.operation_progress import ( + OperationProgressType as Kind, +) +from pullbox.services.download_operation_progress import ( + CANCELLED_DOWNLOAD_MESSAGES, + build_download_operation_update, + download_was_cancelled, +) +from pullbox.services.operation_progress import OperationProgressUpdate, publish_operation_progress + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +_KINDS = (Kind.DOWNLOAD, Kind.POST_PROCESSING) +_ACTIVE = (State.QUEUED, State.RUNNING, State.PAUSED, State.RETRYING) + + +def _terminal_update(download: DownloadHistory, kind: Kind) -> OperationProgressUpdate | None: + if download.state == DownloadState.FAILED: + return replace(build_download_operation_update(download), operation_type=kind) + if ( + download.imported_at is not None + or download.state == DownloadState.IMPORTED + or (kind is Kind.DOWNLOAD and download.state == DownloadState.COMPLETED) + ): + return replace( + build_download_operation_update(download), + operation_type=kind, + state=State.COMPLETED, + phase="completed", + attention_required=False, + tone=OperationProgressTone.SUCCESS, + ) + return None + + +async def _retire(session: AsyncSession, operation: OperationProgress) -> None: + await publish_operation_progress( + session, + OperationProgressUpdate( + operation_type=operation.operation_type, + operation_key=operation.operation_key, + revision=None, + state=State.CANCELLED, + phase="cancelled", + title=operation.title, + message="Download history or series removed.", + group_key=operation.group_key, + source_label=operation.source_label, + detail_snapshot=operation.detail_snapshot, + ), + ) + + +async def retire_download_operations(session: AsyncSession, download_id: int) -> None: + """Retire projections before their download owner is removed.""" + operations = await session.scalars( + select(OperationProgress).where( + OperationProgress.operation_type.in_(_KINDS), + OperationProgress.operation_key == str(download_id), + or_( + OperationProgress.state.in_(_ACTIVE), OperationProgress.attention_required.is_(True) + ), + ) + ) + for operation in operations: + await _retire(session, operation) + + +async def reconcile_download_operations(session: AsyncSession, *, limit: int = 100) -> int: + """Repair a bounded batch of stale projections without modifying downloads.""" + if limit <= 0: + return 0 + candidates = await session.scalars( + select(OperationProgress) + .outerjoin( + DownloadHistory, + cast(DownloadHistory.id, String) == OperationProgress.operation_key, + ) + .where( + OperationProgress.operation_type.in_(_KINDS), + or_( + and_( + OperationProgress.state == State.FAILED, + DownloadHistory.state == DownloadState.FAILED, + DownloadHistory.error_message.in_(CANCELLED_DOWNLOAD_MESSAGES), + ), + and_( + DownloadHistory.id.is_(None), + or_( + OperationProgress.state.in_(_ACTIVE), + OperationProgress.attention_required.is_(True), + ), + ), + and_( + OperationProgress.state.in_(_ACTIVE), + or_( + DownloadHistory.state.in_((DownloadState.FAILED, DownloadState.IMPORTED)), + DownloadHistory.imported_at.is_not(None), + and_( + OperationProgress.operation_type == Kind.DOWNLOAD, + DownloadHistory.state == DownloadState.COMPLETED, + ), + ), + ), + ), + ) + .order_by(OperationProgress.id) + .limit(min(limit, 100)) + ) + repaired = 0 + for operation in candidates: + # Lock/reload the owner before publishing: a legitimate retry may have + # started since the candidate query. Never expire work based on age. + download = await session.scalar( + select(DownloadHistory) + .where( + cast(DownloadHistory.id, String) == operation.operation_key, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + await session.refresh(operation) + if operation.state not in _ACTIVE and not ( + (download is None and operation.attention_required) + or ( + download is not None + and operation.state == State.FAILED + and download_was_cancelled(download) + ) + ): + continue + if download is None: + await _retire(session, operation) + else: + update = _terminal_update(download, operation.operation_type) + if update is None: + continue + await publish_operation_progress(session, update) + repaired += 1 + return repaired + + +async def guard_download_progress( + session: AsyncSession, update: OperationProgressUpdate +) -> OperationProgressUpdate | None: + """Revalidate detached download events before they are published.""" + if update.operation_type not in _KINDS: + return update + download = await session.scalar( + select(DownloadHistory) + .where( + cast(DownloadHistory.id, String) == update.operation_key, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if download is None: + return None + terminal = _terminal_update(download, update.operation_type) + return terminal or update diff --git a/src/pullbox/services/download_service.py b/src/pullbox/services/download_service.py index 7363817c..fa05eca2 100644 --- a/src/pullbox/services/download_service.py +++ b/src/pullbox/services/download_service.py @@ -360,7 +360,11 @@ async def retry_download( download.next_retry_at = None # noinspection PyTypeChecker - return await self._send_download(download, log) + result = await self._send_download(download, log) + from pullbox.services.download_operation_progress import project_download_operation_progress + + await project_download_operation_progress(session, result) + return result async def manual_retry( self, @@ -390,7 +394,11 @@ async def manual_retry( download.next_retry_at = None # noinspection PyTypeChecker - return await self._send_download(download, log) + result = await self._send_download(download, log) + from pullbox.services.download_operation_progress import project_download_operation_progress + + await project_download_operation_progress(session, result) + return result async def _send_download( self, diff --git a/src/pullbox/services/import_file_edit_history.py b/src/pullbox/services/import_file_edit_history.py new file mode 100644 index 00000000..3e9044dc --- /dev/null +++ b/src/pullbox/services/import_file_edit_history.py @@ -0,0 +1,145 @@ +"""Keep post-import user edits out of historical import rollback.""" + +from uuid import UUID + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models import Issue, LibraryFile +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication +from pullbox.models.import_job import ( + ImportedFile, + ImportedSeries, + ImportJob, + ImportJobAction, + ImportJobActionStatus, +) +from pullbox.models.library_conversion import LibraryConversion + +EDIT_KEY = "post_import_edit" +PRESERVED_EDIT_MESSAGE = ( + "Preserved because these comic files were edited after import. " + "Their files and library records were kept; no repair is needed." +) + + +async def lock_import_file_history(session: AsyncSession, file_id: int) -> None: + """Use the import-owner order before locking the library binding.""" + jobs = select(ImportedFile.import_job_id).where(ImportedFile.library_file_id == file_id) + await session.execute( + select(ImportJob.id).where(ImportJob.id.in_(jobs)).order_by(ImportJob.id).with_for_update() + ) + await session.execute( + select(ImportedFile.id) + .where(ImportedFile.library_file_id == file_id) + .order_by(ImportedFile.id) + .with_for_update() + ) + + +async def record_post_import_edit( + session: AsyncSession, file_id: int, *, kind: str, operation_id: UUID +) -> None: + """Commit the last successful edit with file accounting, never replace source proof.""" + file = await session.get(LibraryFile, file_id) + if file is None or file.issue_id is None: + return + issue = await session.get(Issue, file.issue_id) + if issue is None: + return + evidence = { + "schema_version": 1, + "library_file_id": file.id, + "issue_id": issue.id, + "series_id": issue.series_id, + "kind": kind, + "operation_id": str(operation_id), + } + cursor = 0 + while True: + rows = list( + await session.scalars( + select(ImportedFile) + .where(ImportedFile.library_file_id == file_id, ImportedFile.id > cursor) + .order_by(ImportedFile.id) + .limit(128) + ) + ) + if not rows: + return + for row in rows: + diagnostics = dict(row.diagnostics or {}) + details = diagnostics.get("comicinfo_enrichment") + if isinstance(details, dict) and details.get("status") in {"pending", "failed"}: + diagnostics["comicinfo_enrichment"] = { + **details, + "status": "superseded", + "reason": "post_import_edit", + } + row.diagnostics = {**diagnostics, EDIT_KEY: evidence} + cursor = rows[-1].id + + +async def preserve_edited_import_action(session: AsyncSession, action: ImportJobAction) -> bool: + """Preserve changed files and dependent series state, including renamed folders.""" + if action.status is ImportJobActionStatus.ROLLED_BACK: + return False + payload = action.payload + query = select(ImportedFile.id).where(ImportedFile.import_job_id == action.import_job_id) + if action.action_type in {"library_file_registered", "library_file_placement_started"}: + imported_id, file_id = payload.get("imported_file_id"), payload.get("library_file_id") + if type(imported_id) is int and imported_id > 0: + query = query.where(ImportedFile.id == imported_id) + elif type(file_id) is int and file_id > 0: + query = query.where( + or_( + ImportedFile.library_file_id == file_id, + ImportedFile.diagnostics[EDIT_KEY]["library_file_id"].as_integer() == file_id, + ) + ) + else: + return False + elif action.action_type.startswith("series_"): + series_id = payload.get("series_id") + if type(series_id) is not int or series_id <= 0: + return False + query = query.join(ImportedSeries).where( + or_( + ImportedSeries.series_id == series_id, + ImportedFile.diagnostics[EDIT_KEY]["series_id"].as_integer() == series_id, + ) + ) + else: + return False + await session.execute( + select(ImportJob.id).where(ImportJob.id == action.import_job_id).with_for_update() + ) + edited = await session.scalar( + query.where(ImportedFile.diagnostics[EDIT_KEY]["schema_version"].as_integer() == 1).limit(1) + ) + if edited is None: + files = query.with_only_columns(ImportedFile.library_file_id) + publication = await session.scalar( + select(ArchiveMetadataPublication.id) + .where(ArchiveMetadataPublication.active_file_id.in_(files)) + .limit(1) + ) + conversion = await session.scalar( + select(LibraryConversion.id) + .where(LibraryConversion.library_file_id.in_(files), LibraryConversion.active.is_(True)) + .limit(1) + ) + if publication is None and conversion is None: + return False + action.status = ImportJobActionStatus.ROLLBACK_FAILED + action.error_message = ( + PRESERVED_EDIT_MESSAGE + if edited is not None + else ( + "A file update still needs to finish or recover. Pullbox preserved its file and " + "series folder; retry rollback after the update settles." + ) + ) + action.rolled_back_at = None + await session.flush() + return True diff --git a/src/pullbox/services/import_job_actions.py b/src/pullbox/services/import_job_actions.py index 9167f2a9..2fbbf670 100644 --- a/src/pullbox/services/import_job_actions.py +++ b/src/pullbox/services/import_job_actions.py @@ -787,6 +787,11 @@ async def rollback_action( if action.action_type != action_type or dict(action.payload or {}) != payload: raise ValueError("Import action changed after it was selected for rollback") + from pullbox.services.import_file_edit_history import preserve_edited_import_action + + if await preserve_edited_import_action(session, action): + return + if action_type == _STORY_ARC_MANAGED_PLACEMENT_ACTION: await _rollback_import_managed_story_arc_placement(session, action, payload) elif action_type == "story_arc_referenced_placement_attached": diff --git a/src/pullbox/services/import_rollback_execution.py b/src/pullbox/services/import_rollback_execution.py index c117385c..addf8beb 100644 --- a/src/pullbox/services/import_rollback_execution.py +++ b/src/pullbox/services/import_rollback_execution.py @@ -20,6 +20,7 @@ ) from pullbox.schemas.import_job import ImportProgressEvent from pullbox.services.import_comicinfo_enrichment import comicinfo_enrichment_gate +from pullbox.services.import_file_edit_history import PRESERVED_EDIT_MESSAGE from pullbox.services.import_job_actions import ( StoryArcManagedPlacementRollbackDeferredError, ) @@ -340,12 +341,19 @@ async def _rollback_import_job_while_enrichment_fenced( current_action is not None and current_action.status == ImportJobActionStatus.ROLLBACK_FAILED ): + preserved = current_action.error_message == PRESERVED_EDIT_MESSAGE await log_event( session, job_id, - "ERROR", - "import_rollback_action_failed", - message=(f"Rollback action failed: {action.action_type} #{action.sequence_no}."), + "INFO" if preserved else "ERROR", + "import_rollback_action_preserved" + if preserved + else "import_rollback_action_failed", + message=( + current_action.error_message + if preserved + else f"Rollback action failed: {action.action_type} #{action.sequence_no}." + ), action_index=idx + 1, action_count=total, action_type=action.action_type, @@ -442,13 +450,27 @@ async def _rollback_import_job_while_enrichment_fenced( rolled_back_count = action_status_counts.get(ImportJobActionStatus.ROLLED_BACK, 0) manual_recovery_count = action_status_counts.get(ImportJobActionStatus.ROLLBACK_FAILED, 0) if manual_recovery_count > 0: + preserved_count = int( + await session.scalar( + sa_select(func.count(ImportJobAction.id)).where( + ImportJobAction.import_job_id == job_id, + ImportJobAction.status == ImportJobActionStatus.ROLLBACK_FAILED, + ImportJobAction.error_message == PRESERVED_EDIT_MESSAGE, + ) + ) + or 0 + ) + manual_recovery_count -= preserved_count await recompute_series_counters(session, job) await recompute_file_counters(session, job) message = _incomplete_rollback_message( rolled_back_count=rolled_back_count, manual_recovery_count=manual_recovery_count, + preserved_count=preserved_count, + ) + progress = round( + ((rolled_back_count + preserved_count) / max(rollback_action_count, 1)) * 100 ) - progress = round((rolled_back_count / max(rollback_action_count, 1)) * 100) job.status = ImportJobStatus.FAILED job.control_request = ImportControlRequest.NONE job.error_message = message @@ -458,7 +480,7 @@ async def _rollback_import_job_while_enrichment_fenced( job, status=ImportJobStatus.FAILED, mode="rollback", - phase="rollback_incomplete", + phase="rollback_preserved" if not manual_recovery_count else "rollback_incomplete", progress=progress, message=message, ) @@ -468,6 +490,7 @@ async def _rollback_import_job_while_enrichment_fenced( "rollback_action_count": rollback_action_count, "rollback_actions_rolled_back": rolled_back_count, "rollback_manual_recovery_count": manual_recovery_count, + "rollback_preserved_action_count": preserved_count, } ) job.progress_snapshot = snapshot @@ -475,12 +498,15 @@ async def _rollback_import_job_while_enrichment_fenced( await log_event( session, job_id, - "ERROR", - "import_rollback_incomplete", + "WARNING" if not manual_recovery_count else "ERROR", + "import_rollback_preserved" + if not manual_recovery_count + else "import_rollback_incomplete", message=message, action_count=rollback_action_count, rolled_back_count=rolled_back_count, manual_recovery_count=manual_recovery_count, + preserved_action_count=preserved_count, ) # True means the reverse walk reached a terminal state. The FAILED job # and durable snapshot distinguish incomplete rollback from success. @@ -520,7 +546,19 @@ def _incomplete_rollback_message( *, rolled_back_count: int, manual_recovery_count: int, + preserved_count: int = 0, ) -> str: + if preserved_count: + return ( + f"Rollback partially applied: {rolled_back_count} actions reversed; " + f"{preserved_count} preserved to keep files edited after import " + "and their library records. " + + ( + f"{manual_recovery_count} other actions need manual recovery." + if manual_recovery_count + else "The edited files need no repair." + ) + ) if rolled_back_count <= 0: action_word = "action" if manual_recovery_count == 1 else "actions" require_word = "requires" if manual_recovery_count == 1 else "require" diff --git a/src/pullbox/services/intervention_service.py b/src/pullbox/services/intervention_service.py index 054a6ccd..7198ba0b 100644 --- a/src/pullbox/services/intervention_service.py +++ b/src/pullbox/services/intervention_service.py @@ -8,8 +8,10 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import TYPE_CHECKING, Protocol from uuid import uuid4 @@ -35,6 +37,11 @@ transition_acquisition, transition_artifact, ) +from pullbox.services.direct_artifact_pack import RECOVERABLE_PACK_FAILURES +from pullbox.services.direct_artifact_quarantine import ( + DirectArtifactValidationError, + validate_direct_resource_approval, +) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -313,9 +320,13 @@ async def create_direct_attempt_intervention( ) if artifact_host_kind is not None: details["artifact_host_kind"] = artifact_host_kind + if (attempt.plan_snapshot or {}).get("metadata_failure_code") == "partial_issue": + details["metadata_failure_code"] = "partial_issue" safety_review = (attempt.plan_snapshot or {}).get("safety_review") if isinstance(safety_review, dict): details["safety_block"] = dict(safety_review) + if attempt.failure_code in RECOVERABLE_PACK_FAILURES: + details["pack_review_reason"] = attempt.error_message if pending_match is None: pending_match = PendingMatch( issue_id=attempt.issue_id, @@ -360,6 +371,10 @@ async def approve_match( raise ValueError(f"Pending match {pending_id} is not pending (status={pm.status})") if pm.match_details.get("source_kind") == "dc": + if pm.match_details.get("failure_class"): + raise ValueError( + "Use the AirDC++ recovery actions to recheck, retry, or cancel this request." + ) from pullbox.services.airdcpp_search_acquisition import ( acquire_dc_candidate, dc_review_candidate, @@ -436,8 +451,51 @@ async def _approve_direct_match( if attempt is None or attempt.issue_id != pending_match.issue_id: raise ValueError("Pending match has an invalid direct attempt reference") + from pullbox.services.direct_pack_completion import ( + is_pack_ownership_failure, + reconcile_fulfilled_pack, + ) + + if is_pack_ownership_failure(attempt): + from pullbox.models.issue import Issue, IssueStatus + + pending_issue_id = pending_match.issue_id + if await reconcile_fulfilled_pack(session, attempt_id): + await session.commit() + return attempt + # Do not requeue an already-owned issue when its current file could + # not be verified. Keep the actionable recovery and retained pack. + await session.refresh(pending_match) + await session.refresh(attempt, attribute_names=["artifact_attempts"]) + issue = await session.get(Issue, pending_issue_id, populate_existing=True) + if issue is not None and issue.status is IssueStatus.OWNED: + raise DirectArtifactValidationError( + code="direct_pack_completion_unverified", + message=( + "The library copy needs review before this pack can be reconciled. " + "Check the issue's registered file and metadata. " + "No new download was started; the retained pack is unchanged." + ), + ) + initial_source = None + if attempt.failure_code in RECOVERABLE_PACK_FAILURES: + selected = [item for item in attempt.artifact_attempts if item.is_selected] + if ( + len(selected) != 1 + or not selected[0].quarantine_path + or not await asyncio.to_thread(Path(selected[0].quarantine_path).is_file) + ): + raise DirectArtifactValidationError( + code="artifact_quarantine_missing", + message=( + "The retained pack is missing. Restore it or manually import the " + "correct issue file. No new download was started." + ), + ) if not attempt.artifact_attempts: + if attempt.failure_class is DirectArtifactFailureClass.SAFETY: + raise ValueError("Safety recovery requires a retained artifact, not a new download") try: planned = await self._direct_planner(session, acquisition_id=attempt_id) except DirectAcquisitionPlanningError as exc: @@ -473,6 +531,10 @@ async def _approve_direct_match( ) if can_override_safety_once: assert isinstance(safety_review, dict) + assert artifact.quarantine_path is not None + await asyncio.to_thread( + validate_direct_resource_approval, Path(artifact.quarantine_path), safety_review + ) transition_acquisition(attempt, DirectAcquisitionState.POST_PROCESSING) transition_artifact(artifact, DirectArtifactState.VALIDATING) plan_snapshot = dict(attempt.plan_snapshot or {}) @@ -527,6 +589,19 @@ async def retry_direct_recovery( prior = await session.get(DirectAcquisitionAttempt, attempt_id) if prior is None or prior.issue_id != pending_match.issue_id: raise ValueError("Pending match has an invalid direct attempt reference") + if prior.failure_code == "direct_metadata_review_required": + raise ValueError( + "Review the retained file metadata instead of refreshing download routes." + ) + if prior.failure_code in RECOVERABLE_PACK_FAILURES: + raise ValueError( + "Use Retry import to recheck the retained pack, not refresh download routes." + ) + if prior.failure_class is DirectArtifactFailureClass.SAFETY: + raise ValueError( + "Review the retained file for size approval or other safety findings " + "instead of refreshing download routes." + ) fresh = DirectAcquisitionAttempt( request_key=f"direct-recovery:{uuid4().hex}", @@ -599,6 +674,16 @@ async def reject_match( if pm.status != PendingMatchStatus.PENDING: raise ValueError(f"Pending match {pending_id} is not pending (status={pm.status})") + from pullbox.core.partial_issue import needs_complete_copy + + if needs_complete_copy(pm.match_details, pm.release_title): + from pullbox.services.direct_metadata_recovery import dismiss_incomplete_recovery + + await dismiss_incomplete_recovery(session, pending_id) + return False + + if pm.match_details.get("source_kind") == "dc" and pm.match_details.get("failure_class"): + raise ValueError("Use Cancel request to safely stop this AirDC++ request.") direct_attempt_id = _direct_attempt_id(pm) should_blocklist_release = True if direct_attempt_id is not None: @@ -737,6 +822,13 @@ async def expire_stale( select(PendingMatch.id).where( PendingMatch.status == PendingMatchStatus.PENDING, PendingMatch.created_at < cutoff, + ~( + ( + func.coalesce(PendingMatch.match_details["source_kind"].as_string(), "") + == "dc" + ) + & PendingMatch.match_details["failure_class"].as_string().is_not(None) + ), ) ) stale_ids = [row[0] for row in stale.all()] diff --git a/src/pullbox/services/issue_file_metadata.py b/src/pullbox/services/issue_file_metadata.py index 1cefa9cb..8a745847 100644 --- a/src/pullbox/services/issue_file_metadata.py +++ b/src/pullbox/services/issue_file_metadata.py @@ -28,6 +28,7 @@ FileMetadataChange, FileMetadataChoices, FileMetadataPreview, + SharedXmlChoices, ) from pullbox.services.archive_metadata_binding import ( ArchiveMetadataBindingError, @@ -39,6 +40,11 @@ require_unowned_metadata_file, ) from pullbox.services.archive_metadata_finalization import finalize_archive_publication +from pullbox.services.archive_metadata_preservation import ( + SHARED_FIELD_LABELS, + review_preserved_fields, + shared_xml_choices, +) from pullbox.services.archive_metadata_publication import ( ArchivePublicationError, inspect_archive_publication, @@ -65,20 +71,94 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from pullbox.core.metadata_identity import ExternalIdentityRef + from pullbox.schemas.issue_metadata_editor import ( + IssueMetadataEditorField, + IssueMetadataEditorReview, + ) from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.utilities.executors.archive_subprocess import ControlCheck, ProgressCallback def file_metadata_error(exc: BaseException) -> str: + from pullbox.services.issue_metadata_editor import IssueMetadataEditError + + if isinstance(exc, IssueMetadataEditError): + return str(exc) code = getattr(exc, "code", "") + if code == "propagation_pair_required": + return ( + "This file needs a reconciled XML pair first. Open its issue page, " + "use Write file metadata to review both documents, then preview this series again." + ) + if code == "propagation_issue_override": + return ( + "An issue-level manual correction protects a selected field. Review it in " + "Edit metadata on the issue page; this file was left unchanged." + ) + if code == "editor_conversion_required": + return ( + "This editor updates managed CBZ files without conversion. Convert this file to CBZ " + "using the existing conversion workflow, then open Edit metadata again." + ) + if code == "partial_issue": + return ( + "The embedded issue number identifies a story-only extract, not a complete issue. " + "The file was left unchanged. Open the issue page and search for a complete copy " + "or use Import File with a complete copy and review the Replace File confirmation. " + "Changing the metadata cannot restore missing stories." + ) + if code == "output_disagreement": + label = { + "Summary": "summary", + "description": "summary", + "Number": "issue number", + "issue_number_text": "issue number", + "title": "title", + "credits": "credits", + }.get(getattr(exc, "field", ""), "metadata") + return ( + f"Pullbox could not validate the {label} in its generated XML pair. " + "The comic was left unchanged; no new download is needed. " + "Please report this validation error before retrying." + ) + if code == "snapshot_disagrees": + return ( + "Pullbox's final verification did not match the approved metadata preview. " + "The comic was left unchanged; there is no need to download it again. " + "Please report this error before retrying." + ) if code == "unresolved_conflicts": return "Choose a value for each conflicting field and update the preview before writing." if code in {"invalid_choice", "choices_changed"}: return "The available metadata choices changed. Preview again and review each field." if code == "archive_unreadable": return "This comic archive cannot be read. Replace or repair it, then preview again." + if code == "publisher_required": + return ( + "The file contains an imprint but the series has no publisher. " + "Set or verify the series publisher, then preview again. The file was left unchanged." + ) + if getattr(exc, "field", "") == "credits": + if code == "resource_metadata_change": + return ( + "Updating creator credits would discard existing creator or role identifiers " + "or unsupported XML content. The file was left unchanged. Keep the existing " + "MetronInfo.xml credits or correct their resource metadata, then preview again." + ) + if code == "unsupported_content": + return ( + "The selected creator credits cannot be written safely to both ComicInfo.xml " + "and MetronInfo.xml. Review the creator names and roles, then preview again. " + "The file was left unchanged." + ) if code == "unreconciled_field": field = getattr(exc, "field", "metadata") + if field in SHARED_FIELD_LABELS: + return ( + f"ComicInfo.xml and MetronInfo.xml disagree about {SHARED_FIELD_LABELS[field]}. " + "The file was left unchanged. Choose which embedded values to keep " + "and update the preview." + ) label = ( field.replace("_", " ") if field in {"title", "issue_count", "credits"} else "metadata" ) @@ -92,10 +172,11 @@ def file_metadata_error(exc: BaseException) -> str: ) if code in {"root_not_managed", "readonly_source"}: return "This library location is read-only. Choose a writable managed library copy." - if code == "import_rollback_protected": + if code == "import_operation_active": return ( - "This file is protected by import rollback history. " - "Its original bytes were left unchanged." + "An unfinished import or rollback still controls this file. " + "Finish or cancel that operation, then retry. " + "Completed import history does not block edits." ) if code in { "identity_requires_review", @@ -128,6 +209,11 @@ def file_metadata_error(exc: BaseException) -> str: "directories_changed", }: return "The file or metadata changed after preview. Preview again before writing." + if code == "invalid_database_metadata": + return ( + "The selected metadata cannot be stored in the library. " + "Choose a supported series classification or status, then preview again." + ) return ( "The embedded metadata could not be safely reconciled. " "Check the file and provider matches, then preview again." @@ -142,6 +228,7 @@ class PreparedFileMetadata: primary: ExternalIdentityRef | None preview: FileMetadataPreview approved_resource_limit: int | None = None + shared_choices: SharedXmlChoices | None = None async def prepare_file_metadata( @@ -150,6 +237,8 @@ async def prepare_file_metadata( *, choices: FileMetadataChoices | None = None, approved_resource_limit: int | None = None, + editor: IssueMetadataEditorReview | None = None, + propagation_fields: frozenset[str] | None = None, ) -> PreparedFileMetadata: """Release the clean read transaction before archive I/O; never fetch providers.""" if session.new or session.dirty or session.deleted: @@ -164,6 +253,10 @@ async def prepare_file_metadata( binding = await read_archive_metadata_binding( session, ids[0], expected_issue_id=issue_id, allow_conversion_source=True ) + if ( + editor is not None or propagation_fields is not None + ) and not binding.file_path.casefold().endswith((".cbz", ".zip")): + raise ArchiveMetadataBindingError("editor_conversion_required") await _require_no_import_owner(session, ids[0]) from pullbox.core.file_safety import get_archive_size_limit_bytes @@ -192,7 +285,16 @@ async def prepare_file_metadata( max_solid_scan_bytes=MAX_METADATA_BYTES if kind in {"cbz", "zip"} else limit, ) ) - prepared = await asyncio.to_thread(_prepare, target, files, choices or {}) + if propagation_fields is not None: + from pullbox.services.series_metadata_propagation_preview import prepare_projection + + if choices or editor is not None: + raise ArchiveMetadataRenderError("invalid_choice") + prepared = await asyncio.to_thread( + prepare_projection, target, files, propagation_fields + ) + else: + prepared = await asyncio.to_thread(_prepare, target, files, choices or {}, editor) prepared = replace(prepared, approved_resource_limit=approved_resource_limit) except (ArchiveError, BadZipFile) as exc: raise ArchiveMetadataBindingError("archive_unreadable") from exc @@ -226,17 +328,42 @@ def visit(node: ET.Element, path: str) -> None: def _prepare( - target: ArchiveMetadataTarget, files: ArchiveMetadataFiles, choices: FileMetadataChoices + target: ArchiveMetadataTarget, + files: ArchiveMetadataFiles, + choices: FileMetadataChoices, + editor: IssueMetadataEditorReview | None = None, ) -> PreparedFileMetadata: binding = target.binding converts = target.path.suffix.casefold() in {".cbr", ".cb7", ".pdf"} archive = reconcile_archive_metadata(files) series, issue = assemble_bound_archive_metadata(binding, archive, now=datetime.now(UTC)) primary = archive_primary_identity(binding, archive) - series, issue, conflicts = review_metadata_fields(binding, archive, series, issue, choices) + editor_fields: list[IssueMetadataEditorField] = [] + if editor is not None: + from pullbox.services.issue_metadata_editor import prepare_issue_editor + + if choices: + raise ArchiveMetadataRenderError("invalid_choice") + series, issue, editor_fields = prepare_issue_editor( + binding, archive, series, issue, files, editor + ) + _, _, conflicts = review_metadata_fields(binding, archive, series, issue, {}) + conflicts = [ + conflict + for conflict in conflicts + if not ( + conflict.key.startswith("issue.") and conflict.key.split(".")[1] in editor.edits + ) + ] + else: + series, issue, conflicts = review_metadata_fields(binding, archive, series, issue, choices) + shared_choices = shared_xml_choices(choices) + conflicts.extend(review_preserved_fields(files, shared_choices)) ready = all(item.selected is not None for item in conflicts) evidence = TypeAdapter(ArchiveMetadataTarget).dump_json(target) evidence += json.dumps(choices, sort_keys=True, separators=(",", ":")).encode() + if editor is not None: + evidence += json.dumps(editor.model_dump(), sort_keys=True, separators=(",", ":")).encode() try: rendered = render_archive_metadata( series, @@ -245,6 +372,7 @@ def _prepare( primary_identity=primary, previous_series=binding.metadata.series.baseline, previous_issue=binding.metadata.issues[0].baseline, + shared_choices=shared_choices, ) except ArchiveMetadataRenderError as exc: # Only descriptive disagreements enter review; identity/schema guards still fail closed. @@ -274,7 +402,10 @@ def _prepare( ready=False, conflicts=conflicts, converts_to_cbz=converts, + editor_fields=editor_fields, + editor_changed=bool(editor and editor.edits), ), + shared_choices=shared_choices, ) assert rendered is not None changes = [] @@ -306,7 +437,11 @@ def _prepare( if not converts: with ZipFile(target.path) as archive_file: canonical_names = {"ComicInfo.xml", "MetronInfo.xml"} <= set(archive_file.namelist()) - unchanged = canonical_names and all(old == new for _, old, new in pairs) + unchanged = ( + canonical_names + and all(old == new for _, old, new in pairs) + and not (editor and editor.edits) + ) return PreparedFileMetadata( target, series, @@ -320,7 +455,10 @@ def _prepare( unchanged=unchanged, converts_to_cbz=converts, conflicts=conflicts, + editor_fields=editor_fields, + editor_changed=bool(editor and editor.edits), ), + shared_choices=shared_choices, ) @@ -359,6 +497,8 @@ async def write_file_metadata( progress: ProgressCallback, job_id: str | None = None, choices: FileMetadataChoices | None = None, + editor: IssueMetadataEditorReview | None = None, + propagation_fields: frozenset[str] | None = None, ) -> str: """Approval is checked again before staging and under the publication lock.""" from uuid import uuid5 @@ -374,6 +514,8 @@ async def write_file_metadata( file = await reader.scalar(select(LibraryFile).where(LibraryFile.issue_id == issue_id)) native = file is not None and file.file_path.casefold().endswith((".cbr", ".cb7", ".pdf")) if native or native_conversion is not None: + if editor is not None or propagation_fields is not None: + raise ArchiveMetadataBindingError("editor_conversion_required") return await write_native_file_metadata( factory, issue_id, @@ -391,7 +533,9 @@ async def write_file_metadata( raise ArchivePublicationError("approval_changed") await check_control() async with factory() as session: - prepared = await prepare_file_metadata(session, issue_id, choices=choices) + prepared = await prepare_file_metadata( + session, issue_id, choices=choices, editor=editor, propagation_fields=propagation_fields + ) if not prepared.preview.ready: raise ArchivePublicationError("unresolved_conflicts") if prepared.preview.review_key != review_key: @@ -412,8 +556,12 @@ async def write_file_metadata( previous_issue=target.binding.metadata.issues[0].baseline, cancellation_check=check_control, progress_callback=progress, + shared_choices=prepared.shared_choices, + propagation_fields=propagation_fields, ) as staged: - plan = await prepare_archive_publication(target, staged, series, issue) + plan = await prepare_archive_publication( + target, staged, series, issue, propagation_fields=propagation_fields + ) if job_id is not None: plan = plan.model_copy(update={"metadata_job_id": job_id}) await check_control() diff --git a/src/pullbox/services/issue_file_metadata_recovery.py b/src/pullbox/services/issue_file_metadata_recovery.py index 089473e3..3ba751e9 100644 --- a/src/pullbox/services/issue_file_metadata_recovery.py +++ b/src/pullbox/services/issue_file_metadata_recovery.py @@ -45,6 +45,11 @@ async def _complete_metadata_item( .select_from(UtilityJobItem) .where(UtilityJobItem.job_id == job.id, UtilityJobItem.state == ItemState.COMPLETED) ) + job.failed_items = await session.scalar( + select(func.count()) + .select_from(UtilityJobItem) + .where(UtilityJobItem.job_id == job.id, UtilityJobItem.state == ItemState.FAILED) + ) if job.completed_items == job.total_items and job.state not in { JobState.CANCELLING, JobState.CANCELLED, @@ -89,6 +94,14 @@ async def _recover_native_metadata_jobs(factory: async_sessionmaker[AsyncSession if type(issue_id) is not int or issue_id <= 0: continue operation = UUID(item_id) + if payload.get("recovery_pending_id") is not None: + from pullbox.services.direct_metadata_recovery import ( + recover_direct_metadata_operation, + ) + + if await recover_direct_metadata_operation(factory, operation, job_id): + await _complete_metadata_item(factory, operation, job_id) + continue if await recover_native_file_metadata(factory, issue_id, operation) is None: continue await _complete_metadata_item(factory, operation, job_id) @@ -137,6 +150,24 @@ async def recover_file_metadata_jobs(factory: async_sessionmaker[AsyncSession]) continue try: state = await recover_file_metadata(factory, receipt.operation_id) + if state is not PublicationState.FINALIZED: + continue + async with factory() as session: + job = await session.get(UtilityJob, receipt.plan.metadata_job_id) + is_recovery = ( + job is not None + and json.loads(job.config).get("recovery_pending_id") is not None + ) + if is_recovery: + # A settled archive alone does not resolve its download recovery entry. + from pullbox.services.direct_metadata_recovery import ( + recover_direct_metadata_operation, + ) + + if not await recover_direct_metadata_operation( + factory, receipt.operation_id, receipt.plan.metadata_job_id + ): + continue except (ValueError, OSError) as exc: logger.warning( "file_metadata_recovery_needs_review", @@ -150,8 +181,6 @@ async def recover_file_metadata_jobs(factory: async_sessionmaker[AsyncSession]) job.error_message = file_metadata_error(exc) await project_utility_operation_progress(session, job) continue - if state is not PublicationState.FINALIZED: - continue await _complete_metadata_item( factory, receipt.operation_id, receipt.plan.metadata_job_id ) diff --git a/src/pullbox/services/issue_file_metadata_review.py b/src/pullbox/services/issue_file_metadata_review.py index c9081936..9ffe4994 100644 --- a/src/pullbox/services/issue_file_metadata_review.py +++ b/src/pullbox/services/issue_file_metadata_review.py @@ -17,6 +17,8 @@ from pullbox.services.archive_metadata_preservation import ArchiveMetadataRenderError from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation from pullbox.services.archive_metadata_rendering import _series_separator_key +from pullbox.services.archive_series_metadata import project_archive_series +from pullbox.services.metadata_series_refresh_state import SeriesRefreshState _FIELDS = { "series": ( @@ -51,7 +53,7 @@ def _equal(field: str, source: FileMetadataSource, old: object, new: object) -> def review_metadata_fields( - binding: ArchiveMetadataBinding, + binding: ArchiveMetadataBinding | SeriesRefreshState, archive: ArchiveMetadataReconciliation, series: MetadataSnapshot, issue: MetadataSnapshot, @@ -61,14 +63,17 @@ def review_metadata_fields( conflicts: list[FileMetadataConflict] = [] snapshots = {"series": series, "issue": issue} seen: set[str] = set() + metadata = binding.metadata if isinstance(binding, ArchiveMetadataBinding) else binding for kind, fields in _FIELDS.items(): - current = binding.metadata.series if kind == "series" else binding.metadata.issues[0] + current = metadata.series if kind == "series" else metadata.issues[0] snapshot = snapshots[kind] values = snapshot.values.model_dump() origins = {item.field: item for item in snapshot.origins} for field in fields: key = cast("FileMetadataField", f"{kind}.{field}") options: dict[FileMetadataSource, object] = {"library": getattr(current.values, field)} + if kind == "series" and field == "series_type" and current.values.archive_format: + options["library"] = current.values.archive_format for source, document in ( ("ComicInfo.xml", archive.comicinfo), ("MetronInfo.xml", archive.metroninfo), @@ -77,6 +82,8 @@ def review_metadata_fields( if old is not None: options[cast("FileMetadataSource", source)] = old proposed = getattr(snapshot.values, field) + if kind == "series" and field == "series_type" and snapshot.values.archive_format: + proposed = snapshot.values.archive_format disagrees = any( source != "library" and not _equal(field, source, old, proposed) for source, old in options.items() @@ -98,12 +105,18 @@ def review_metadata_fields( ) if ( kind == "series" - and field == "title" + and field in {"title", "sort_title"} and isinstance(proposed, str) - and any(item.identity in issue.identities for item in archive.evidence) and all( isinstance(old, str) - and _series_separator_key(old) == _series_separator_key(proposed) + and ( + old.strip() == proposed.strip() + or ( + field == "title" + and any(item.identity in issue.identities for item in archive.evidence) + and _series_separator_key(old) == _series_separator_key(proposed) + ) + ) for source, old in options.items() if source != "library" ) @@ -142,16 +155,26 @@ def review_metadata_fields( conflicts.append( FileMetadataConflict( key=key, - label=f"{kind.title()} {field.replace('_', ' ')}", + label="Embedded format" + if field == "series_type" + else f"{kind.title()} {field.replace('_', ' ')}", options=display, selected=selected, note="Issue count comes from the library catalog; file counts cannot change it." if field == "issue_count" + else ( + "Limited Series and Digital Chapter are embedded formats; " + "choosing them keeps the library classification unchanged." + ) + if field == "series_type" else "", ) ) if selected is not None: values[field] = options[selected] + if kind == "series" and field == "series_type": + values["archive_format"] = None + origins.pop("archive_format", None) origins[field] = FieldOrigin( field=field, domain=field_domain(snapshot.entity_kind, field), @@ -165,7 +188,11 @@ def review_metadata_fields( "origins": tuple(origins.values()), } ) - if set(choices) - seen: + if {key for key in choices if not key.startswith("xml.")} - seen: raise ArchiveMetadataRenderError("choices_changed") assert snapshots["series"].entity_kind is MetadataEntityKind.SERIES - return snapshots["series"], snapshots["issue"], conflicts + return ( + project_archive_series(snapshots["series"], metadata.series.values), + snapshots["issue"], + conflicts, + ) diff --git a/src/pullbox/services/issue_import_service.py b/src/pullbox/services/issue_import_service.py index 9891c2c5..dd346c49 100644 --- a/src/pullbox/services/issue_import_service.py +++ b/src/pullbox/services/issue_import_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Any @@ -27,6 +28,7 @@ from pullbox.models.series import Series from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError from pullbox.services.issue_file_service import resolve_configured_utility_trash_dir +from pullbox.services.manual_import_metadata_review import preview_manual_import_metadata from pullbox.services.manual_paired_metadata import ( materialize_manual_metadata, read_manual_metadata_plan, @@ -41,6 +43,7 @@ from sqlalchemy.ext.asyncio import AsyncSession + from pullbox.schemas.issue_file_metadata import FileMetadataApproval from pullbox.services.direct_pack_paired_metadata import StagedPackMember @@ -158,6 +161,7 @@ async def execute_manual_issue_import( prepared: PreparedManualIssueImport, *, use_paired_metadata: bool = True, + metadata_approval: FileMetadataApproval | None = None, staged_pack_member: StagedPackMember | None = None, allow_resource_safety_exception: bool = False, preparation_progress_callback: Callable[[str, int, int, str], Any] | None = None, @@ -177,7 +181,12 @@ async def execute_manual_issue_import( else None ) plan = None + reviewed = None source_signature = None + if metadata_approval is not None and (not paired or staged_pack_member is not None): + raise ManualIssueImportError( + status_code=409, detail="Import settings changed; preview metadata again." + ) if staged_pack_member is not None: prepared = await prepare_manual_issue_import( session, @@ -223,6 +232,23 @@ async def execute_manual_issue_import( source_signature = await asyncio.to_thread( build_file_identity_signature, prepared.source_path ) + if metadata_approval is not None: + try: + reviewed = await preview_manual_import_metadata( + plan, prepared.source_path, metadata_approval.choices + ) + if ( + not reviewed.preview.ready + or reviewed.preview.review_key != metadata_approval.review_key + ): + raise ValueError("Approval changed") + except ValueError: + raise ManualIssueImportError( + status_code=409, + detail=( + "The file, metadata or choices changed. Preview and review metadata again." + ), + ) from None async def converter_with_progress( source: Path, @@ -237,7 +263,9 @@ async def converter_with_progress( async def conversion_progress(stage: str, current: int, total: int, unit: str) -> None: if preparation_progress_callback is not None: - preparation_progress_callback(stage, current, total, unit) + reported = preparation_progress_callback(stage, current, total, unit) + if inspect.isawaitable(reported): + await reported return await convert_file_interruptible( source, @@ -273,7 +301,9 @@ async def materialize_cbz_with_progress( async def paired_progress(stage: str, current: int, total: int, unit: str) -> None: if progress_callback is not None: - progress_callback(stage, current, total, unit) + reported = progress_callback(stage, current, total, unit) + if inspect.isawaitable(reported): + await reported try: return await materialize_manual_metadata( @@ -286,6 +316,7 @@ async def paired_progress(stage: str, current: int, total: int, unit: str) -> No allow_resource_safety_exception=allow_resource_safety_exception, cancellation_check=cancellation_check, progress_callback=paired_progress, + reviewed=reviewed, ) except (ArchiveMetadataStagingError, ValueError) as exc: if isinstance(exc, ArchiveMetadataStagingError) and str(exc) != "metadata_conflict": diff --git a/src/pullbox/services/issue_metadata_editor.py b/src/pullbox/services/issue_metadata_editor.py new file mode 100644 index 00000000..2861643e --- /dev/null +++ b/src/pullbox/services/issue_metadata_editor.py @@ -0,0 +1,264 @@ +"""Issue-only field inventory and corrections on an independently bound archive.""" + +from datetime import UTC, date, datetime +from typing import cast +from xml.etree import ElementTree as ET + +from pydantic import ValidationError + +from pullbox.core.archive_metadata import ArchiveMetadataFiles +from pullbox.schemas.issue_metadata_editor import ( + EditableIssueField, + IssueMetadataEditorField, + IssueMetadataEditorOption, + IssueMetadataEditorReview, +) +from pullbox.schemas.metadata_snapshot import ( + FieldOrigin, + MetadataSnapshot, + MetadataValues, + field_domain, +) +from pullbox.services.archive_metadata_binding import ArchiveMetadataBinding +from pullbox.services.archive_metadata_preservation import ( + ArchiveMetadataRenderError, + review_preserved_fields, +) +from pullbox.services.archive_metadata_reconciliation import ArchiveMetadataReconciliation +from pullbox.services.metadata_series_refresh_state import ISSUE_FIELDS, SERIES_FIELDS + +EDITABLE_FIELDS = { + "title": ("Title", "text"), + "description": ("Summary", "textarea"), + "cover_date": ("Cover date", "date"), + "store_date": ("Store date", "date"), + "page_count": ("Catalog page count", "number"), + "credits": ("Creator credits", "credits"), +} +SHARED_TAGS = { + "AgeRating": ("Age rating", "AgeRating"), + "Genre": ("Genre", "Genres/Genre"), + "Tags": ("Tags", "Tags/Tag"), + "Characters": ("Characters", "Characters/Character"), + "Teams": ("Teams", "Teams/Team"), + "Locations": ("Locations", "Locations/Location"), + "Imprint": ("Imprint", "Publisher/Imprint"), + "StoryArc": ("Story arc", "Arcs/Arc/Name"), + "StoryArcNumber": ("Story arc position", "Arcs/Arc/Number"), +} + + +class IssueMetadataEditError(ValueError): + """A safe field-specific validation message without echoing submitted values.""" + + +def display_editor_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, tuple): + return "\n".join(f"{item.name} | {item.role}" for item in value) + return value.isoformat() if isinstance(value, date) else str(value) + + +def _custom_value(field: str, text: str) -> object: + if not text.strip(): + raise IssueMetadataEditError( + f"Enter a {EDITABLE_FIELDS[field][0].lower()} or choose Clear value." + ) + if any(ord(c) < 32 and c not in "\n\r\t" for c in text): + raise IssueMetadataEditError( + f"{EDITABLE_FIELDS[field][0]} contains unsupported control characters." + ) + value: object = text + if field == "page_count": + if not text.isascii() or not text.isdecimal() or len(text) > 7: + raise IssueMetadataEditError( + "Catalog page count must be a whole number from 0 to 1000000." + ) + value = int(text) + elif field == "credits": + credits = [] + for line in text.splitlines(): + if not line.strip(): + continue + name, separator, role = line.rpartition("|") + if not separator or not name.strip(): + raise IssueMetadataEditError( + "Enter each credit as Name | Role, one creator per line." + ) + credits.append({"name": name.strip(), "role": role.strip()}) + value = credits + try: + return getattr(MetadataValues.model_validate({field: value}), field) + except ValidationError: + raise IssueMetadataEditError( + f"Enter a valid {EDITABLE_FIELDS[field][0].lower()}." + ) from None + + +def prepare_issue_editor( + binding: ArchiveMetadataBinding, + archive: ArchiveMetadataReconciliation, + assembled_series: MetadataSnapshot, + assembled_issue: MetadataSnapshot, + files: ArchiveMetadataFiles, + review: IssueMetadataEditorReview, +) -> tuple[MetadataSnapshot, MetadataSnapshot, list[IssueMetadataEditorField]]: + """Never use client-supplied source values or adopt unselected descriptive changes.""" + series_state, issue_state = binding.metadata.series, binding.metadata.issues[0] + series = assembled_series.model_copy(update={"values": series_state.values}) + issue = assembled_issue.model_copy(update={"values": issue_state.values}) + origins = {origin.field: origin for origin in issue.origins} + values = issue.values.model_dump() + fields: list[IssueMetadataEditorField] = [] + for field, (label, input_type) in EDITABLE_FIELDS.items(): + current = getattr(issue_state.values, field) + options: dict[str, object] = {"library": current} + for source, document in ( + ("ComicInfo.xml", archive.comicinfo), + ("MetronInfo.xml", archive.metroninfo), + ): + value = getattr(document.issue, field) + if value is not None: + options[source] = value + edit = review.edits.get(cast("EditableIssueField", field)) + previous = origins.get(field) + if edit is not None: + if edit.source == "custom": + values[field] = _custom_value(field, edit.value or "") + elif edit.source == "clear": + values[field] = () if field == "credits" else None + elif edit.source == "reset": + if previous is None or not previous.user_override: + raise IssueMetadataEditError(f"{label} has no manual override to remove.") + elif edit.source in options: + if options[edit.source] in (None, "", ()) and any( + value not in (None, "", ()) for value in options.values() + ): + raise IssueMetadataEditError( + f"{label} has no value in that source. Choose Clear value to remove it." + ) + values[field] = options[edit.source] + else: + raise ArchiveMetadataRenderError("choices_changed", field) + origins[field] = FieldOrigin( + field=field, + domain=field_domain(issue.entity_kind, field), + observed_at=datetime.now(UTC), + user_override=edit.source != "reset", + provider_managed=edit.source == "reset", + ) + proposed = MetadataValues.model_validate(values) + fields.append( + IssueMetadataEditorField( + key=f"issue.{field}", + label=label, + current=display_editor_value(current), + proposed=display_editor_value(getattr(proposed, field)), + editable=True, + input_type=input_type, + options=[ + IssueMetadataEditorOption(source=source, value=display_editor_value(value)) + for source, value in options.items() + ], + overridden=bool(previous and previous.user_override), + differs=any(value != current for value in options.values()), + selected=edit is not None, + origin=( + "Manual correction" + if previous and previous.user_override + else previous.source.value + if previous and previous.source + else "Library" + ), + reason="Catalog page count does not change the number of comic pages." + if field == "page_count" + else ( + "One creator per line: Name | Role. " + "Existing resource identifiers remain protected." + ) + if field == "credits" + else "", + ) + ) + issue = MetadataSnapshot.model_validate( + { + **issue.model_dump(), + "values": values, + "origins": tuple(origins.values()), + "diagnostics": tuple( + item + for item in issue.diagnostics + if not any(item.startswith(f"archive:issue:{field}:") for field in review.edits) + ), + } + ) + for kind, state, applicable in ( + ("series", series_state, SERIES_FIELDS | {"archive_format"}), + ("issue", issue_state, ISSUE_FIELDS), + ): + for field in MetadataValues.model_fields: + if field not in applicable or (kind == "issue" and field in EDITABLE_FIELDS): + continue + value = display_editor_value(getattr(state.values, field)) + fields.append( + IssueMetadataEditorField( + key=f"{kind}.{field}", + label=f"{kind.title()} {field.replace('_', ' ')}", + current=value, + proposed=value, + reason=( + "Series-wide field: use the series workflow; " + "this editor changes one issue only." + ) + if kind == "series" + else ( + "Protected identity or artwork field. " + "Use provider links or the appropriate artwork workflow." + ), + ) + ) + for identity in state.identities: + fields.append( + IssueMetadataEditorField( + key=f"{kind}.id.{identity.namespace}", + label=f"{kind.title()} {identity.namespace} ID", + current=identity.external_id, + proposed=identity.external_id, + reason="Identity is protected. Review provider links to change a match.", + ) + ) + # The reconciliation parser has already validated bounded XML before this display-only read. + ci = ( + ET.fromstring(files.comicinfo.payload) + if files.comicinfo.payload + else ET.Element("ComicInfo") + ) + mi = ( + ET.fromstring(files.metroninfo.payload) + if files.metroninfo.payload + else ET.Element("MetronInfo") + ) + shared_conflicts = {item.key for item in review_preserved_fields(files, {})} + for tag, (label, mi_path) in SHARED_TAGS.items(): + left = ci.findtext(tag) or "" + right = ", ".join(node.text or "" for node in mi.findall(mi_path)) + value = "\n".join( + f"{source}: {value}" + for source, value in (("ComicInfo.xml", left), ("MetronInfo.xml", right)) + if value + ) + fields.append( + IssueMetadataEditorField( + key=f"xml.{tag}", + label=label, + current=value, + proposed=value, + differs=f"xml.{tag}" in shared_conflicts, + reason=( + "Preserved XML-only field. Manual editing of this field is not available " + "in this milestone; existing conflicts use Write file metadata." + ), + ) + ) + return series, issue, fields diff --git a/src/pullbox/services/library_conversion_files.py b/src/pullbox/services/library_conversion_files.py index 85dd6fa6..dcc95869 100644 --- a/src/pullbox/services/library_conversion_files.py +++ b/src/pullbox/services/library_conversion_files.py @@ -18,6 +18,7 @@ from pullbox.core.exceptions import ValidationError from pullbox.core.file_publication import publish_file_without_overwrite from pullbox.core.metadata_identity import ExternalIdentityRef +from pullbox.schemas.issue_file_metadata import SharedXmlChoices from pullbox.schemas.metadata_snapshot import MetadataSnapshot from pullbox.services.archive_metadata_binding import ( FileFingerprint, @@ -194,6 +195,7 @@ async def prepare_conversion( metadata_policy_limit: int | None = None, block_dangerous: bool = True, reviewed: tuple[MetadataSnapshot, MetadataSnapshot] | None = None, + shared_choices: SharedXmlChoices | None = None, primary_identity: ExternalIdentityRef | None = None, expected_source: FileFingerprint | None = None, check_control: ControlCheck | None = None, @@ -243,6 +245,7 @@ async def prepare_conversion( previous_issue=metadata_state.issues[0].baseline, cancellation_check=check_control, progress_callback=progress, + shared_choices=shared_choices, ) as staged: staged.check_unchanged() await asyncio.to_thread(publish_file_without_overwrite, staged.path, output_stage) diff --git a/src/pullbox/services/library_conversion_recovery.py b/src/pullbox/services/library_conversion_recovery.py index 837396ba..e03f8a79 100644 --- a/src/pullbox/services/library_conversion_recovery.py +++ b/src/pullbox/services/library_conversion_recovery.py @@ -25,6 +25,10 @@ ) from pullbox.services.archive_metadata_finalization import apply_bound_archive_metadata from pullbox.services.archive_metadata_publication import _fingerprint +from pullbox.services.import_file_edit_history import ( + lock_import_file_history, + record_post_import_edit, +) from pullbox.services.library_conversion_files import ( ConversionBinding, ConversionFile, @@ -45,13 +49,16 @@ logger = structlog.get_logger(__name__) -async def _require_conversion_metadata(session: AsyncSession, plan: ConversionPlan) -> None: +async def _require_conversion_metadata( + session: AsyncSession, plan: ConversionPlan, *, published: bool = False +) -> None: if plan.metadata_state_digest is None: return if plan.binding.file_id is None or plan.binding.issue_id is None: raise ValidationError("Conversion metadata requires a verified issue match.") try: - await require_unowned_metadata_file(session, plan.binding.file_id) + if not published: + await require_unowned_metadata_file(session, plan.binding.file_id) binding = await read_archive_metadata_binding( session, plan.binding.file_id, @@ -59,7 +66,8 @@ async def _require_conversion_metadata(session: AsyncSession, plan: ConversionPl allow_conversion_source=True, ) await lock_archive_metadata_binding(session, binding) - await require_unowned_metadata_file(session, plan.binding.file_id) + if not published: + await require_unowned_metadata_file(session, plan.binding.file_id) binding = await read_archive_metadata_binding( session, plan.binding.file_id, @@ -67,10 +75,10 @@ async def _require_conversion_metadata(session: AsyncSession, plan: ConversionPl allow_conversion_source=True, ) except ArchiveMetadataBindingError as exc: - if exc.code == "import_rollback_protected": + if exc.code == "import_operation_active": raise ValidationError( - "This file still belongs to an import's rollback journal. " - "Paired conversion is not available for it yet; the file has not been changed." + "An unfinished import or rollback still controls this file. " + "Finish or cancel that operation, then retry conversion." ) from None raise ValidationError( "File metadata needs review before conversion can continue." @@ -187,6 +195,9 @@ async def record_conversion( decode_plan(plan.model_dump_json()) await lock_file_mutation_admission(session) + if plan.binding.file_id is not None: + await lock_import_file_history(session, plan.binding.file_id) + await require_unowned_metadata_file(session, plan.binding.file_id) await require_no_archive_publication(session, *plan.paths, include_descendants=False) if await read_conversion_binding(session, plan.original.path) != plan.binding: raise ValidationError("The library registration changed during conversion.") @@ -226,6 +237,9 @@ async def publish_conversion(session: AsyncSession, operation_id: UUID) -> None: if row is None or not row.active or row.state != "intended": raise ValidationError("Conversion has already been recovered; retry from the Library.") plan = decode_plan(row.plan_json) + if plan.binding.file_id is not None: + await lock_import_file_history(session, plan.binding.file_id) + await require_unowned_metadata_file(session, plan.binding.file_id) if await read_conversion_binding(session, plan.original.path) != plan.binding: raise ValidationError("The library registration changed during conversion.") await _require_conversion_metadata(session, plan) @@ -274,6 +288,8 @@ async def _apply_inspected_conversion( return row.state if row else "missing" if row.plan_json != encoded: raise ValidationError("Conversion evidence changed during recovery.") + if plan.binding.file_id is not None: + await lock_import_file_history(session, plan.binding.file_id) check_directories(plan) for name, actual in inspected.items(): if _fingerprint(getattr(plan, name).path) != (actual.fingerprint if actual else None): @@ -308,7 +324,9 @@ async def _apply_inspected_conversion( current = await read_conversion_binding(session, plan.original.path) if current != plan.binding or (not plan.same_path and not proven["original"]): raise ValidationError("Conversion registration changed.") - await _require_conversion_metadata(session, plan) + # Proven output and backup already exist. Finish their accounting even + # if rollback was requested after publication; preserve the edit there. + await _require_conversion_metadata(session, plan, published=True) if not plan.same_path and await session.scalar( select(LibraryFile.id).where(LibraryFile.file_path == str(plan.output.path)) ): @@ -319,6 +337,10 @@ async def _apply_inspected_conversion( after_path=str(plan.output.path), metadata_embedded=plan.metadata_state_digest is not None, ) + if plan.binding.file_id is not None: + await record_post_import_edit( + session, plan.binding.file_id, kind="conversion", operation_id=operation_id + ) except ValidationError: row.state = "review" await session.commit() @@ -345,7 +367,7 @@ async def _apply_inspected_conversion( await session.commit() return "review" try: - await _require_conversion_metadata(session, plan) + await _require_conversion_metadata(session, plan, published=True) except ValidationError: row.state = "review" await session.commit() diff --git a/src/pullbox/services/library_convert_service.py b/src/pullbox/services/library_convert_service.py index bca075a6..045cd0e0 100644 --- a/src/pullbox/services/library_convert_service.py +++ b/src/pullbox/services/library_convert_service.py @@ -89,10 +89,10 @@ async def _sync_converted_file_record( def _conversion_error_message(exc: Exception) -> str: if isinstance(exc, ArchiveMetadataBindingError): - if exc.code == "import_rollback_protected": + if exc.code == "import_operation_active": return ( - "This file still belongs to an import's rollback journal. " - "Paired conversion is not available for it yet; the file has not been changed." + "An unfinished import or rollback still controls this file. " + "Finish or cancel that operation, then retry conversion." ) return ( "This comic's metadata match needs review before conversion. " @@ -204,6 +204,7 @@ async def convert_library_file( if reviewed_metadata else None, primary_identity=reviewed_metadata.primary if reviewed_metadata else None, + shared_choices=reviewed_metadata.shared_choices if reviewed_metadata else None, expected_source=reviewed_metadata.target.fingerprint if reviewed_metadata else None, check_control=check_control, progress=progress, diff --git a/src/pullbox/services/manual_import_metadata_review.py b/src/pullbox/services/manual_import_metadata_review.py new file mode 100644 index 00000000..239431ce --- /dev/null +++ b/src/pullbox/services/manual_import_metadata_review.py @@ -0,0 +1,150 @@ +"""Read-only descriptive review for a manually selected source archive.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from pydantic import BaseModel, TypeAdapter + +from pullbox.core.archive import ArchiveError +from pullbox.core.archive_metadata import ( + MAX_METADATA_BYTES, + ArchiveMetadataFiles, + MetadataFile, + read_archive_metadata, +) +from pullbox.core.library_file_ownership import build_file_identity_signature +from pullbox.schemas.issue_file_metadata import ( # noqa: TC001 - Pydantic field + FileMetadataConflict, + SharedXmlChoices, +) +from pullbox.services.archive_metadata_binding import ( + assemble_archive_metadata_state, + select_archive_primary_identity, +) +from pullbox.services.archive_metadata_preservation import ( + review_preserved_fields, + shared_xml_choices, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.archive_metadata_rendering import ( + ArchiveMetadataRenderError, + render_archive_metadata, +) +from pullbox.services.issue_file_metadata_review import review_metadata_fields + +if TYPE_CHECKING: + from pathlib import Path + + from pullbox.core.metadata_identity import ExternalIdentityRef + from pullbox.schemas.issue_file_metadata import FileMetadataChoices + from pullbox.schemas.metadata_snapshot import MetadataSnapshot + from pullbox.services.manual_paired_metadata import ManualMetadataPlan + + +class ManualImportMetadataPreview(BaseModel): + ready: bool + review_key: str + context_key: str + conflicts: list[FileMetadataConflict] + + +@dataclass(frozen=True) +class ReviewedManualMetadata: + series: MetadataSnapshot + issue: MetadataSnapshot + primary: ExternalIdentityRef | None + preview: ManualImportMetadataPreview + shared_choices: SharedXmlChoices | None = None + + +async def preview_manual_import_metadata( + plan: ManualMetadataPlan, source: Path, choices: FileMetadataChoices +) -> ReviewedManualMetadata: + """Read bounded XML off-thread; no publication, database writes or provider calls.""" + try: + return await asyncio.to_thread(_preview, plan, source, choices) + except ArchiveError as exc: + raise ArchiveMetadataRenderError("archive_unreadable") from exc + + +def _preview( + plan: ManualMetadataPlan, source: Path, choices: FileMetadataChoices +) -> ReviewedManualMetadata: + fingerprint = build_file_identity_signature(source) + kind = source.suffix.lstrip(".").casefold() + files = ( + ArchiveMetadataFiles(MetadataFile("ComicInfo.xml"), MetadataFile("MetronInfo.xml")) + if kind == "pdf" + else read_archive_metadata( + source, + "cbz" if kind == "zip" else kind, + max_solid_scan_bytes=MAX_METADATA_BYTES if kind in {"cbz", "zip"} else plan.limit, + ) + ) + archive = reconcile_archive_metadata(files) + series, issue = assemble_archive_metadata_state(plan.metadata, archive, now=datetime.now(UTC)) + primary = select_archive_primary_identity(plan.metadata, archive) + series, issue, conflicts = review_metadata_fields( + plan.metadata, archive, series, issue, choices + ) + shared_choices = shared_xml_choices(choices) + conflicts.extend(review_preserved_fields(files, shared_choices)) + ready = all(item.selected is not None for item in conflicts) + try: + render_archive_metadata( + series, + issue, + files, + primary_identity=primary, + previous_series=plan.metadata.series.baseline, + previous_issue=plan.metadata.issues[0].baseline, + shared_choices=shared_choices, + ) + except ArchiveMetadataRenderError as exc: + if ( + ready + or exc.code != "unreconciled_field" + or not any( + item.key.split(".")[1] == exc.field and item.selected is None for item in conflicts + ) + ): + raise + for conflict in conflicts: + if conflict.key == "issue.page_count": + conflict.note = ( + "Archive page counts can include covers and extras; catalog counts may differ. " + "Choose the value for this library copy. The catalog record stays unchanged." + ) + elif conflict.key == "issue.cover_date": + conflict.note = ( + "The file date may describe publication rather than the catalog cover date. " + "Pullbox will not guess a missing day or change the store date." + ) + # Bind approval to the exact plan, source, XML and choices, not observation timestamps. + evidence = TypeAdapter(type(plan)).dump_json(plan) + evidence += json.dumps( + {"path": str(source), "fingerprint": fingerprint}, sort_keys=True + ).encode() + evidence += (files.comicinfo.payload or b"") + (files.metroninfo.payload or b"") + context_key = hashlib.sha256(evidence).hexdigest() + evidence += json.dumps(choices, sort_keys=True).encode() + if build_file_identity_signature(source) != fingerprint: + raise ArchiveMetadataRenderError("source_changed") + return ReviewedManualMetadata( + series, + issue, + primary, + ManualImportMetadataPreview( + ready=ready, + review_key=hashlib.sha256(evidence).hexdigest(), + context_key=context_key, + conflicts=conflicts, + ), + shared_choices=shared_choices, + ) diff --git a/src/pullbox/services/manual_paired_metadata.py b/src/pullbox/services/manual_paired_metadata.py index 852c5806..32f84a6b 100644 --- a/src/pullbox/services/manual_paired_metadata.py +++ b/src/pullbox/services/manual_paired_metadata.py @@ -27,6 +27,7 @@ require_mutable_library_target, ) from pullbox.core.library_policy import ( + LibraryIngestPolicy, load_effective_library_ingest_policy, load_library_ingest_policy, ) @@ -43,7 +44,10 @@ require_unowned_metadata_file, require_verified_archive_metadata, ) -from pullbox.services.metadata_series_refresh_state import read_series_refresh_state +from pullbox.services.metadata_series_refresh_state import ( + SeriesRefreshState, + read_series_refresh_state, +) from pullbox.utilities.executors.archive_metadata_staging import stage_cbz_metadata_interruptible if TYPE_CHECKING: @@ -51,8 +55,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - from pullbox.core.library_policy import LibraryIngestPolicy - from pullbox.services.metadata_series_refresh_state import SeriesRefreshState + from pullbox.services.manual_import_metadata_review import ReviewedManualMetadata from pullbox.utilities.executors.archive_metadata_staging import StagedArchiveMetadata from pullbox.utilities.executors.archive_subprocess import ControlCheck, ProgressCallback @@ -168,6 +171,7 @@ async def materialize_manual_metadata( allow_resource_safety_exception: bool = False, cancellation_check: ControlCheck | None = None, progress_callback: ProgressCallback | None = None, + reviewed: ReviewedManualMetadata | None = None, ) -> bool: """Stage both XML files privately, revalidate, then publish without overwrite.""" directories = await asyncio.to_thread(_directories, target, Path(plan.root_path)) @@ -179,6 +183,7 @@ async def materialize_manual_metadata( allow_resource_safety_exception=allow_resource_safety_exception, cancellation_check=cancellation_check, progress_callback=progress_callback, + reviewed=reviewed, ) as staged, factory() as reader, ): @@ -208,6 +213,7 @@ async def stage_manual_metadata( allow_resource_safety_exception: bool = False, cancellation_check: ControlCheck | None = None, progress_callback: ProgressCallback | None = None, + reviewed: ReviewedManualMetadata | None = None, ) -> AsyncIterator[StagedArchiveMetadata]: """Reuse the verified reconciler before an existing owner registers a batch.""" limit = plan.limit @@ -216,7 +222,10 @@ async def stage_manual_metadata( if allow_resource_safety_exception: members = await asyncio.to_thread(ArchiveReader(source).list_members) limit = max(limit, sum(member.size for member in members) + 2 * MAX_METADATA_BYTES) - series, issue = assemble_archive_metadata_state(plan.metadata, None, now=datetime.now(UTC)) + if reviewed is None: + series, issue = assemble_archive_metadata_state(plan.metadata, None, now=datetime.now(UTC)) + else: + series, issue = reviewed.series, reviewed.issue async with stage_cbz_metadata_interruptible( source, staging_parent, @@ -224,7 +233,11 @@ async def stage_manual_metadata( issue, max_uncompressed_bytes=limit, block_dangerous=plan.block_dangerous, - metadata_state=plan.metadata, + metadata_state=plan.metadata if reviewed is None else None, + primary_identity=reviewed.primary if reviewed is not None else None, + shared_choices=reviewed.shared_choices if reviewed is not None else None, + previous_series=plan.metadata.series.baseline, + previous_issue=plan.metadata.issues[0].baseline, cancellation_check=cancellation_check, progress_callback=progress_callback, ) as staged: diff --git a/src/pullbox/services/metadata_account_admission.py b/src/pullbox/services/metadata_account_admission.py index 292ba648..a9db7be8 100644 --- a/src/pullbox/services/metadata_account_admission.py +++ b/src/pullbox/services/metadata_account_admission.py @@ -254,10 +254,13 @@ async def finish(self, runtime: SourceRuntime, attempt: AccountAttempt) -> None: delay = outcome.retry_after_seconds or ( 3600 if outcome.status is SourceStatus.RATE_LIMITED else 300 ) + floor = 1 if runtime.policy.source is MetadataSource.METRON_API else 60 due = ( None if outcome.status is SourceStatus.AUTHENTICATION_FAILED - else (datetime.now(UTC) + timedelta(seconds=max(60, min(delay, 7 * 86400)))) + else ( + datetime.now(UTC) + timedelta(seconds=max(floor, min(delay, 7 * 86400))) + ) ) statement = statement.values(status=outcome.status.value, retry_at=due) elif outcome is not None and ( diff --git a/src/pullbox/services/metadata_assembly.py b/src/pullbox/services/metadata_assembly.py index f2d21326..fb82fceb 100644 --- a/src/pullbox/services/metadata_assembly.py +++ b/src/pullbox/services/metadata_assembly.py @@ -3,6 +3,8 @@ from collections.abc import Sequence from datetime import UTC, datetime +from pydantic import ValidationError + from pullbox.core.issue_numbers import parse_issue_number_text from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource from pullbox.schemas.metadata_snapshot import ( @@ -61,6 +63,13 @@ def _candidate_values( for key, value in candidate.model_dump().items() if key in MetadataValues.model_fields } + if isinstance(candidate, ProviderSeriesRead): + try: + MetadataValues(issue_count=candidate.issue_count) + except ValidationError: + # Summary counts are advisory; retain the raw provider row as evidence. + values["issue_count"] = None + diagnostics.add(f"{candidate.source.value}:invalid_advisory_issue_count") if candidate.image_url and ( candidate.source in {MetadataSource.GCD_LOCAL, MetadataSource.GCD_API_V2} or not allowed_artwork_url(candidate.image_url) @@ -199,7 +208,11 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: diagnostics.add(f"archive:{kind.value}:{field}:current_disagreement") unchanged_managed = ( prior is not None - and (prior.source is not None or prior.derivation is not None) + and ( + prior.source is not None + or prior.derivation is not None + or prior.provider_managed + ) and existing == local_value ) if chosen.embedded_documents or (has_local_value and not unchanged_managed): @@ -215,7 +228,8 @@ def rank(source: MetadataSource, domain: MetadataDomain) -> tuple[int, str]: replace_managed and prior is not None and ( - prior.derivation is not None + prior.provider_managed + or prior.derivation is not None or prior.passive_release is not None or ( prior.source is not None diff --git a/src/pullbox/services/metadata_catalog_crosswalks.py b/src/pullbox/services/metadata_catalog_crosswalks.py new file mode 100644 index 00000000..0aa69b8b --- /dev/null +++ b/src/pullbox/services/metadata_catalog_crosswalks.py @@ -0,0 +1,76 @@ +"""Bounded identity evidence for Metron's lightweight issue-list rows.""" + +from dataclasses import replace + +from pullbox.core.issue_numbers import parse_issue_number_text +from pullbox.core.metadata_identity import ExternalIdentityRef, MetadataEntityKind, MetadataSource +from pullbox.schemas.metadata_sources import SourceStatus +from pullbox.services.metadata_discovery import MetadataSourceRegistry +from pullbox.services.metadata_issue_catalog import IssueCatalogConflictError +from pullbox.services.metadata_series_adoption import SourceSeriesBundle, _validate_bundle +from pullbox.services.metadata_series_refresh_state import SeriesRefreshState + +_MAX_DETAIL_READS = 25 + + +async def hydrate_metron_catalog_crosswalks( + registry: MetadataSourceRegistry, + state: SeriesRefreshState, + bundles: tuple[SourceSeriesBundle, ...], +) -> tuple[SourceSeriesBundle, ...]: + """Fetch proof, never infer a match from an overlapping issue designation. + + Already verified native issues need no detail read. Only the crosswalks + augment the list row; the normal atomic writer still decides ownership. + """ + known = {identity for issue in state.issues for identity in issue.identities} + numbers = {issue.values.issue_number_text for issue in state.issues} + for bundle in bundles: + if bundle.series.source is not MetadataSource.METRON_API: + numbers.update(text for _, text in _validate_bundle(bundle)) + result = [] + for bundle in bundles: + source = bundle.series.source + if source is not MetadataSource.METRON_API: + result.append(bundle) + continue + pending = [ + row + for row, (_, text) in zip(bundle.issues, _validate_bundle(bundle), strict=True) + if text in numbers + and not row.cross_identities + and ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.ISSUE, row.external_id + ) + not in known + ] + if len(pending) > _MAX_DETAIL_READS: + raise IssueCatalogConflictError( + "The linked Metron catalog needs more issue identity checks than one refresh " + "can safely perform. Review the issue matches; no library metadata was changed." + ) + checked = {} + for row in pending: + fetched = await registry.issue(source, row.external_id) + detail = fetched.data + if fetched.status is not SourceStatus.OK or detail is None: + raise IssueCatalogConflictError( + "Metron issue identities could not be verified. Check source availability " + "and retry the refresh; no library metadata was changed." + ) + if detail.series_external_id != row.series_external_id or parse_issue_number_text( + detail.issue_number_text + ) != parse_issue_number_text(row.issue_number_text): + raise IssueCatalogConflictError( + "Metron issue details disagree with its catalog. Review the issue match; " + "no library metadata was changed." + ) + checked[row.external_id] = row.model_copy( + update={"cross_identities": detail.cross_identities} + ) + enriched = replace( + bundle, issues=tuple(checked.get(row.external_id, row) for row in bundle.issues) + ) + _validate_bundle(enriched) + result.append(enriched) + return tuple(result) diff --git a/src/pullbox/services/metadata_discovery.py b/src/pullbox/services/metadata_discovery.py index 2417bfe6..375b144c 100644 --- a/src/pullbox/services/metadata_discovery.py +++ b/src/pullbox/services/metadata_discovery.py @@ -94,9 +94,16 @@ class _SourceHandle: class MetadataSourceError(Exception): - def __init__(self, status: SourceStatus, retry_after_seconds: int | None = None) -> None: + def __init__( + self, + status: SourceStatus, + retry_after_seconds: int | None = None, + *, + account_failure: bool = True, + ) -> None: self.status = status self.retry_after_seconds = retry_after_seconds + self.account_failure = account_failure super().__init__(status.value) @@ -238,9 +245,19 @@ async def _run_admitted( truncated=page.truncated, ) except TimeoutError: - outcome = SourceOutcome(source=source, status=SourceStatus.TIMEOUT) + # Metron's operation budget includes local pacing and multiple pages. + # Only a transport/provider failure proves the account is unavailable. + if source is MetadataSource.METRON_API: + attempt.started = False + outcome = SourceOutcome( + source=source, + status=SourceStatus.TIMEOUT, + retry_after_seconds=3 if source is MetadataSource.METRON_API else None, + ) page = SourcePage([]) except MetadataSourceError as exc: + if not exc.account_failure: + attempt.started = False outcome = SourceOutcome( source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds ) diff --git a/src/pullbox/services/metadata_issue_catalog.py b/src/pullbox/services/metadata_issue_catalog.py index 2a5047e8..a5234b55 100644 --- a/src/pullbox/services/metadata_issue_catalog.py +++ b/src/pullbox/services/metadata_issue_catalog.py @@ -41,6 +41,7 @@ async def apply_issue_batch( now: datetime, *, complete: bool = False, + reconcile_crosswalks: bool = False, replace_managed: bool = False, release_facts: tuple[IssueReleaseFacts, ...] = (), ) -> tuple[int, ...]: @@ -50,6 +51,8 @@ async def apply_issue_batch( checkpoint updates. This writer never starts provider or filesystem work. """ source = batch.source + if reconcile_crosswalks and not complete: + raise IssueCatalogConflictError("Linked issue reconciliation requires a complete catalog.") parent = ExternalIdentityRef( source.identity_namespace, MetadataEntityKind.SERIES, batch.series_external_id ) @@ -67,6 +70,7 @@ async def apply_issue_batch( except ValueError as exc: raise IssueCatalogConflictError(str(exc)) from exc by_identity = {identity: item for item in state.issues for identity in item.identities} + parent_namespaces = {identity.namespace for identity in state.series.identities} offered = { ExternalIdentityRef(source.identity_namespace, MetadataEntityKind.ISSUE, item.external_id) for item in batch.issues @@ -87,6 +91,27 @@ async def apply_issue_batch( source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id ) existing = by_identity.get(identity) + if reconcile_crosswalks: + # Native membership plus a verified local crosswalk proves overlap; + # equal issue numbers alone never authorize attachment. + matches = { + by_identity[claim].local_id: by_identity[claim] + for claim in _identities(metadata, MetadataEntityKind.ISSUE) + if claim in by_identity and claim.namespace in parent_namespaces + } + if len(matches) > 1: + raise IssueCatalogConflictError( + "Provider crosswalks identify different library issues. Review their matches." + ) + if matches: + existing = next(iter(matches.values())) + if any( + claim.namespace is identity.namespace and claim != identity + for claim, _, _ in existing.claims + ): + raise IssueCatalogConflictError( + "This issue already has a different provider identity. Review its match." + ) if existing is not None and existing.values.issue_number_text != text: raise IssueCatalogConflictError( "A provider issue was renumbered. Review the issue match; existing files were kept." @@ -98,7 +123,7 @@ async def apply_issue_batch( ) snapshot = assemble_metadata( MetadataEntityKind.ISSUE, - existing.identities if existing else (identity,), + tuple(dict.fromkeys((*existing.identities, identity))) if existing else (identity,), [metadata.model_copy(update={"issue_number_text": text})], state.policies, now=now, @@ -114,6 +139,8 @@ async def apply_issue_batch( ) prepared.append((existing, metadata, number, text, snapshot)) new_metadata = tuple(metadata for old, metadata, *_rest in prepared if old is None) + if len(state.issues) + len(new_metadata) > 10000: + raise IssueCatalogConflictError("The combined catalog exceeds the 10,000 issue limit.") if new_metadata: await _require_unowned_issues(session, replace(batch, issues=new_metadata)) series = await session.get(Series, state.series.local_id) @@ -149,7 +176,12 @@ async def apply_issue_batch( ) for issue, old, metadata, _ in pending if old is None + or ExternalIdentityRef( + source.identity_namespace, MetadataEntityKind.ISSUE, metadata.external_id + ) + not in old.identities ], + require_current_ownership=True, ) for issue, old, metadata, _ in pending: if old is None: diff --git a/src/pullbox/services/metadata_refresh_snapshot.py b/src/pullbox/services/metadata_refresh_snapshot.py index 5794cd19..4b41613f 100644 --- a/src/pullbox/services/metadata_refresh_snapshot.py +++ b/src/pullbox/services/metadata_refresh_snapshot.py @@ -101,7 +101,11 @@ def assemble(*, local: bool) -> MetadataSnapshot: if missing or ( replace_managed and origin is not None - and (origin.source is not None or origin.derivation is not None) + and ( + origin.source is not None + or origin.derivation is not None + or origin.provider_managed + ) ): needed.add(field) capability = { diff --git a/src/pullbox/services/metadata_search_cache.py b/src/pullbox/services/metadata_search_cache.py index e14c3ffa..29bd9418 100644 --- a/src/pullbox/services/metadata_search_cache.py +++ b/src/pullbox/services/metadata_search_cache.py @@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass +from pullbox.core.metadata_identity import MetadataSource from pullbox.schemas.metadata_sources import ( SeriesDiscoveryQuery, SeriesDiscoveryRead, @@ -106,6 +107,13 @@ async def _load( if task is not None and task.cancelling(): raise asyncio.CancelledError payload = result.model_dump_json().encode() + # Account admission already prevents requests during a cooldown. Reusing + # its snapshot would freeze the countdown or outlive a short local wait. + if any( + source.source is MetadataSource.METRON_API and source.retry_after_seconds + for source in result.sources + ): + cache_result = False if cache_result and len(payload) <= self.max_bytes: while self._entries and ( len(self._entries) >= self.max_entries diff --git a/src/pullbox/services/metadata_series_adoption.py b/src/pullbox/services/metadata_series_adoption.py index f3a01d4c..0395c494 100644 --- a/src/pullbox/services/metadata_series_adoption.py +++ b/src/pullbox/services/metadata_series_adoption.py @@ -163,8 +163,6 @@ async def fetch_source_series_bundle( raise SeriesAdoptionError( "The series profile belongs to a different source identity." ) - if profile.issue_count is not None and profile.issue_count > max_issues: - raise SeriesAdoptionError("The issue catalog exceeds the interactive add limit.") issues: list[ProviderIssueRead] = [] seen: set[str] = set() page = 1 @@ -181,9 +179,8 @@ async def fetch_source_series_bundle( current = result.data if total is None: total = current.total - if current.total != total or ( - profile.issue_count is not None and profile.issue_count != total - ): + # Summary counts can lag; completeness comes from the paginated catalog. + if current.total != total: raise SeriesCatalogCountError( "The provider catalog count changed. Retry the add." ) @@ -208,6 +205,14 @@ async def fetch_source_series_bundle( if current.next_page is None: if len(issues) != total: raise SeriesAdoptionError("The issue catalog is incomplete. Retry the add.") + if profile.issue_count is not None and profile.issue_count != total: + logger.info( + "metadata_series_summary_count_disagreement", + source=source.value, + external_id=identifier, + summary_count=profile.issue_count, + catalog_count=total, + ) return SourceSeriesBundle( profile, tuple(issues), source_revision, total, started_at ) @@ -285,7 +290,6 @@ def _validate_bundle(bundle: SourceSeriesBundle) -> list[tuple[float, str]]: or len(catalog) > 10000 or type(bundle.catalog_total) is not int or bundle.catalog_total != len(catalog) - or (profile.issue_count is not None and profile.issue_count != len(catalog)) ): raise SeriesAdoptionError("The series profile or issue catalog is incomplete.") if bundle.excluded_issues: diff --git a/src/pullbox/services/metadata_series_refresh.py b/src/pullbox/services/metadata_series_refresh.py index 4fccb893..41c73059 100644 --- a/src/pullbox/services/metadata_series_refresh.py +++ b/src/pullbox/services/metadata_series_refresh.py @@ -30,6 +30,7 @@ from pullbox.services.metadata_assembly import assemble_metadata from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines from pullbox.services.metadata_catalog_checkpoints import save_full_catalog_checkpoint +from pullbox.services.metadata_catalog_crosswalks import hydrate_metron_catalog_crosswalks from pullbox.services.metadata_catalog_review import apply_catalog_exclusions from pullbox.services.metadata_discovery import MetadataSourceRegistry from pullbox.services.metadata_entity_values import apply_series_metadata_values @@ -228,17 +229,18 @@ async def refresh_series_catalog_from_sources( for item in fetched.outcomes if item.status not in {SourceStatus.OK, SourceStatus.NOT_QUERIED} } - bundle = await _catalog(registry, before, profiles, failed) + bundles = await _catalogs(registry, before, profiles, failed) + bundles = await hydrate_metron_catalog_crosswalks(registry, before, bundles) + profiles.update((item.series.source, item.series) for item in bundles) profile = profiles.get(MetadataSource.METRON_API) if ( profile is not None and not profile.image_url - and bundle.series.source is not profile.source + and not any(item.series.source is profile.source for item in bundles) ): page = await registry.issues(profile.source, profile.external_id) if page.status is SourceStatus.OK and page.data is not None: profiles[profile.source] = with_representative_cover(profile, page.data.results) - profiles[bundle.series.source] = bundle.series snapshot = assemble_metadata( MetadataEntityKind.SERIES, before.series.identities, @@ -287,13 +289,15 @@ async def refresh_series_catalog_from_sources( result = await _apply( session, current, - bundle, + bundles, snapshot, now, replace_managed=replace_managed, release_facts=rechecked_issues, ) - if bundle.catalog_started_at is not None: + for bundle in bundles: + if bundle.catalog_started_at is None: + continue checkpoint = next( (item for item in current.checkpoints if item.source is bundle.series.source), None, @@ -319,8 +323,8 @@ async def refresh_series_catalog_from_sources( logger.info( "metadata_series_source_refreshed", series_id=series_id, - source=bundle.series.source.value, - catalog_count=bundle.catalog_total, + sources=[item.series.source.value for item in bundles], + catalog_count=result.series.issue_count, ) return replace(result, outcomes=tuple(failed.values())) except SeriesRefreshError: @@ -341,17 +345,19 @@ async def refresh_series_catalog_from_sources( ) from exc -async def _catalog( +async def _catalogs( registry: MetadataSourceRegistry, state: SeriesRefreshState, profiles: dict[MetadataSource, ProviderSeriesRead], failed: dict[MetadataSource, SourceOutcome], -) -> SourceSeriesBundle: +) -> tuple[SourceSeriesBundle, ...]: known = {item.namespace: item.external_id for item in state.series.identities} inconsistent_counts = False catalog_namespaces = { identity.namespace for issue in state.issues for identity in issue.identities } + bundles: list[SourceSeriesBundle] = [] + loaded_namespaces = set() sources = sorted( registry.runtime, key=lambda source: ( @@ -365,25 +371,49 @@ async def _catalog( if ( source in failed or source.identity_namespace not in known - # A series link authorizes descriptive enrichment, not issue rematching. - or (catalog_namespaces and source.identity_namespace not in catalog_namespaces) + or source.identity_namespace in loaded_namespaces or registry._unavailable(source, capability=SourceCapability.ISSUE_LIST) ): + if source in failed: + logger.info( + "metadata_series_catalog_unavailable", + series_id=state.series.local_id, + source=source.value, + status=failed[source].status.value, + ) continue try: + profile = profiles.get(source) + if catalog_namespaces and source.identity_namespace not in catalog_namespaces: + # Descriptive fields may already be complete. Catalog eligibility + # still needs the linked source's independently verified parent. + if profile is None: + fetched = await registry.series(source, known[source.identity_namespace]) + if fetched.status is not SourceStatus.OK or fetched.data is None: + raise SeriesAdoptionError( + "The linked series identity could not be checked.", + status=fetched.status, + retry_after_seconds=fetched.retry_after_seconds, + ) + profile = profiles[source] = fetched.data + if not any( + identity in state.series.identities for identity in profile.cross_identities + ): + continue bundle = await fetch_source_series_bundle( registry, source, known[source.identity_namespace], source_revision=registry.runtime[source].policy.revision, - profile=profiles.get(source), + profile=profile, ) bundle = apply_catalog_exclusions(bundle, state.catalog_exclusions) _validate_bundle(bundle) - return bundle + bundles.append(bundle) + loaded_namespaces.add(source.identity_namespace) except SeriesCatalogCountError: - # Do not accept an incomplete catalog or mistake a stale summary for - # an identity dispute. Another eligible source must pass every check. + # Changing page totals cannot establish completeness. Another eligible + # source must still pass every catalog and identity check. inconsistent_counts = True failed[source] = SourceOutcome(source=source, status=SourceStatus.INCOMPATIBLE_RESPONSE) logger.info( @@ -403,6 +433,8 @@ async def _catalog( failed[source] = SourceOutcome( source=source, status=exc.status, retry_after_seconds=exc.retry_after_seconds ) + if bundles: + return tuple(bundles) if inconsistent_counts: raise SeriesRefreshError( "A metadata source reported inconsistent issue counts, and no configured source " @@ -441,7 +473,7 @@ def _with_derived( async def _apply( session: AsyncSession, state: SeriesRefreshState, - bundle: SourceSeriesBundle, + bundles: tuple[SourceSeriesBundle, ...], snapshot: MetadataSnapshot, now: datetime, *, @@ -451,17 +483,26 @@ async def _apply( series = await session.get(Series, state.series.local_id) assert series is not None await apply_series_metadata_values(session, series, snapshot.values) - created = await apply_issue_batch( - session, - state, - SourceIssueBatch( - bundle.series.source, bundle.series.external_id, bundle.issues, bundle.source_revision - ), - now, - complete=True, - replace_managed=replace_managed, - release_facts=release_facts, - ) + created: tuple[int, ...] = () + current = state + for index, bundle in enumerate(bundles): + if index: + current = await read_series_refresh_state(session, series.id) + created += await apply_issue_batch( + session, + current, + SourceIssueBatch( + bundle.series.source, + bundle.series.external_id, + bundle.issues, + bundle.source_revision, + ), + now, + complete=True, + reconcile_crosswalks=True, + replace_managed=replace_managed, + release_facts=release_facts, + ) series.issue_count = len(state.issues) + len(created) snapshot = _with_derived(snapshot, "issue_count", series.issue_count, now) origins = {item.field: item for item in snapshot.origins} @@ -473,7 +514,12 @@ async def _apply( ): await MetadataService.infer_series_status(session, series) snapshot = _with_derived(snapshot, "status", series.status.value, now) - snapshot = _with_derived(snapshot, "year_end", series.year_end, now) + year_end_origin = origins.get("year_end") + if year_end_origin is not None and year_end_origin.user_override: + # Status inference also mutates year_end; its override is independent. + series.year_end = snapshot.values.year_end + else: + snapshot = _with_derived(snapshot, "year_end", series.year_end, now) series.metadata_last_refreshed = now series.issue_catalog_state = IssueCatalogState.COMPLETE series.issue_catalog_last_synced_at = now diff --git a/src/pullbox/services/metadata_source_reads.py b/src/pullbox/services/metadata_source_reads.py index 7afbc9d0..d84c483d 100644 --- a/src/pullbox/services/metadata_source_reads.py +++ b/src/pullbox/services/metadata_source_reads.py @@ -236,8 +236,16 @@ async def _read_admitted[T]( ): raise ValueError("Invalid source read outcome") except TimeoutError: - result = MetadataFetch(status=SourceStatus.TIMEOUT) + # An operation deadline may expire in Metron's local request queue. + if source is MetadataSource.METRON_API: + attempt.started = False + result = MetadataFetch( + status=SourceStatus.TIMEOUT, + retry_after_seconds=3 if source is MetadataSource.METRON_API else None, + ) except MetadataSourceError as exc: + if not exc.account_failure: + attempt.started = False result = MetadataFetch(status=exc.status, retry_after_seconds=exc.retry_after_seconds) except (ValueError, TypeError, AttributeError): result = MetadataFetch(status=SourceStatus.INCOMPATIBLE_RESPONSE) diff --git a/src/pullbox/services/operation_activity.py b/src/pullbox/services/operation_activity.py index 91d6680d..c86b738b 100644 --- a/src/pullbox/services/operation_activity.py +++ b/src/pullbox/services/operation_activity.py @@ -4,9 +4,11 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from math import ceil from typing import TYPE_CHECKING -from sqlalchemy import and_, case, func, or_, select +from sqlalchemy import and_, case, func, or_, select, true +from sqlalchemy.orm import aliased from pullbox.models.operation_progress import ( OperationProgress, @@ -44,6 +46,8 @@ class OperationActivity: active_count: int spinner_count: int attention_count: int + total_count: int + refresh_after_ms: int | None async def list_operation_activity( @@ -51,6 +55,7 @@ async def list_operation_activity( *, now: datetime | None = None, limit: int = 20, + offset: int = 0, ) -> OperationActivity: """Return user-visible work without promoting successful routine jobs.""" current_time = now or datetime.now(UTC) @@ -69,49 +74,86 @@ async def list_operation_activity( recent_terminal_clause = and_( OperationProgress.state.not_in(_ACTIVE_STATES), OperationProgress.completed_at.is_not(None), - OperationProgress.completed_at >= recent_cutoff, + OperationProgress.completed_at > recent_cutoff, OperationProgress.visibility != OperationProgressVisibility.QUIET, + OperationProgress.acknowledged_at.is_(None), ) - result = await session.execute( - select(OperationProgress) - .where( - or_( - active_clause, - unacknowledged_attention_clause, - recent_terminal_clause, - ) + visible_clause = or_(active_clause, unacknowledged_attention_clause, recent_terminal_clause) + attention_rank = case((unacknowledged_attention_clause, 0), else_=1) + active_rank = case((active_clause, 0), else_=1) + page = ( + select( + OperationProgress, + attention_rank.label("attention_rank"), + active_rank.label("active_rank"), ) + .where(visible_clause) .order_by( - case((unacknowledged_attention_clause, 0), else_=1), - case((active_clause, 0), else_=1), + attention_rank, + active_rank, OperationProgress.updated_at.desc(), OperationProgress.id.desc(), ) .limit(max(1, min(limit, 100))) + .offset(max(0, offset)) + .subquery() + ) + counts = ( + select( + func.count(OperationProgress.id).filter(active_clause).label("active_count"), + func.count(OperationProgress.id) + .filter( + OperationProgress.state.in_(_SPINNER_STATES), + OperationProgress.visibility == OperationProgressVisibility.PROMINENT, + ) + .label("spinner_count"), + func.count(OperationProgress.id) + .filter( + unacknowledged_attention_clause, + ) + .label("attention_count"), + func.count(OperationProgress.id).label("total_count"), + func.min(OperationProgress.completed_at) + .filter( + recent_terminal_clause, + ~unacknowledged_attention_clause, + ) + .label("earliest_completion"), + ) + .where(visible_clause) + .subquery() ) - operations = list(result.scalars().all()) - count_row = ( + # One statement keeps counters, rows, and expiry on the same database snapshot, + # including an empty page after work completes (SQLite and PostgreSQL). + paged_operation = aliased(OperationProgress, page) + rows = ( await session.execute( - select( - func.count(OperationProgress.id).filter(active_clause), - func.count(OperationProgress.id).filter( - OperationProgress.state.in_(_SPINNER_STATES), - OperationProgress.visibility == OperationProgressVisibility.PROMINENT, - ), - func.count(OperationProgress.id).filter( - unacknowledged_attention_clause, - ), + select(paged_operation, counts) + .select_from(counts.outerjoin(page, true())) + .order_by( + page.c.attention_rank, + page.c.active_rank, + page.c.updated_at.desc(), + page.c.id.desc(), ) + .execution_options(populate_existing=True) + ) + ).all() + count_row = rows[0] + earliest = count_row.earliest_completion + refresh_after_ms = None + if earliest is not None: + earliest = earliest.replace(tzinfo=UTC) if earliest.tzinfo is None else earliest + refresh_after_ms = max( + 1, ceil((earliest + _COMPLETION_GRACE - current_time).total_seconds() * 1000) ) - ).one() - active_count = int(count_row[0]) - spinner_count = int(count_row[1]) - attention_count = int(count_row[2]) return OperationActivity( - operations=operations, - active_count=active_count, - spinner_count=spinner_count, - attention_count=attention_count, + operations=[row[0] for row in rows if row[0] is not None], + active_count=count_row.active_count, + spinner_count=count_row.spinner_count, + attention_count=count_row.attention_count, + total_count=count_row.total_count, + refresh_after_ms=refresh_after_ms, ) diff --git a/src/pullbox/services/operation_progress_dispatch.py b/src/pullbox/services/operation_progress_dispatch.py index ad081529..48846a68 100644 --- a/src/pullbox/services/operation_progress_dispatch.py +++ b/src/pullbox/services/operation_progress_dispatch.py @@ -73,7 +73,13 @@ async def _flush_operation_progress(identity: tuple[Hashable, str]) -> None: factory = get_session_factory() async with factory() as session: try: - await publish_operation_progress(session, update) + from pullbox.services.download_operation_reconciliation import ( + guard_download_progress, + ) + + guarded = await guard_download_progress(session, update) + if guarded is not None: + await publish_operation_progress(session, guarded) await session.commit() except Exception: await session.rollback() diff --git a/src/pullbox/services/post_processing_operation_progress.py b/src/pullbox/services/post_processing_operation_progress.py index 7cb9fd4b..0bf07728 100644 --- a/src/pullbox/services/post_processing_operation_progress.py +++ b/src/pullbox/services/post_processing_operation_progress.py @@ -7,6 +7,10 @@ from types import SimpleNamespace from typing import TYPE_CHECKING, Any +from pullbox.core.partial_issue import ( + INCOMPLETE_RECOVERY_DISMISSED, + QUIET_METADATA_RECOVERY_MESSAGES, +) from pullbox.models.download import DownloadClientType, DownloadState from pullbox.models.operation_progress import ( OperationProgressState, @@ -14,6 +18,7 @@ OperationProgressType, OperationProgressVisibility, ) +from pullbox.services.direct_artifact_pack import PACK_FULFILLED_MESSAGE from pullbox.services.operation_progress import ( OperationProgressMeasure, OperationProgressUpdate, @@ -75,6 +80,12 @@ def build_post_processing_operation_update( download_client_value = _download_client_value(download.download_client) phase = _phase_value(snapshot) is_failed = DownloadState(download.state) is DownloadState.FAILED + superseded = ( + is_failed + and download_client_value == "direct" + and getattr(download, "error_message", None) + in {PACK_FULFILLED_MESSAGE, *QUIET_METADATA_RECOVERY_MESSAGES} + ) is_complete = phase == "import_complete" or getattr(download, "imported_at", None) is not None if is_failed: state = OperationProgressState.FAILED @@ -105,14 +116,24 @@ def build_post_processing_operation_update( group_key="downloads", revision=None, state=state, - phase="failed" if is_failed else phase, + phase=( + "dismissed" + if superseded and download.error_message == INCOMPLETE_RECOVERY_DISMISSED + else "superseded" + if superseded + else "failed" + if is_failed + else phase + ), title=download.title, message=message or "Post-processing", source_label=_source_label(download.download_client), detail_url="/post-processing", - visibility=OperationProgressVisibility.PROMINENT, - tone=tone, - attention_required=is_failed, + visibility=OperationProgressVisibility.QUIET + if superseded + else OperationProgressVisibility.PROMINENT, + tone=OperationProgressTone.INFO if superseded else tone, + attention_required=is_failed and not superseded, overall=OperationProgressMeasure( current=current_bytes, total=total_bytes, diff --git a/src/pullbox/services/release_validator.py b/src/pullbox/services/release_validator.py index 672194c8..d04cde1f 100644 --- a/src/pullbox/services/release_validator.py +++ b/src/pullbox/services/release_validator.py @@ -76,6 +76,7 @@ class ValidationResult: issue_type_match: bool = False size_warning: str | None = None year_match_basis: str | None = None + rejection_code: str | None = None # Confidence sort order for descending sort @@ -496,6 +497,7 @@ def _validate_one( series_similarity=float(decision.match_diagnostics.get("series_similarity", 0.0)), match_type=str(decision.match_diagnostics.get("match_type", "none")), issue_type_match=decision.match_method != "type_mismatch", + rejection_code=decision.match_method, ) year_matched = decision.match_diagnostics.get("year_match") @@ -580,6 +582,7 @@ def _reject( series_similarity: float = 0.0, match_type: str = "none", issue_type_match: bool = False, + rejection_code: str | None = None, ) -> ValidationResult: """Create a rejection ValidationResult.""" if parsed is None: @@ -604,4 +607,5 @@ def _reject( series_similarity=series_similarity, match_type=match_type, issue_type_match=issue_type_match, + rejection_code=rejection_code, ) diff --git a/src/pullbox/services/semantic_matching.py b/src/pullbox/services/semantic_matching.py index 8d1a82da..8c17975c 100644 --- a/src/pullbox/services/semantic_matching.py +++ b/src/pullbox/services/semantic_matching.py @@ -10,6 +10,7 @@ from pullbox.core.issue_title import collection_title_number, collection_title_subtitle from pullbox.core.name_matcher import NameMatcher, NameMatchResult from pullbox.core.naming import extract_base_series_title +from pullbox.core.partial_issue import is_story_extract from pullbox.core.release_parser import issues_match, normalize_issue_number from pullbox.core.release_year_matching import ReleaseYearContext, match_release_year from pullbox.core.source_metadata import MetadataSignal @@ -162,6 +163,13 @@ def match_against_issue( year_context: ReleaseYearContext | None = None, ) -> IssueMatchDecision: """Return a workflow-aware semantic match decision for one issue target.""" + if is_story_extract(metadata.original_title): + return IssueMatchDecision( + is_match=False, + confidence=MatchConfidence.LOW, + match_method="partial_issue", + rejection_reason="This is a story-only extract, not a complete issue.", + ) series_name = metadata.series_name or "" match_result = _matcher.match( series_name, diff --git a/src/pullbox/services/series_metadata_editor.py b/src/pullbox/services/series_metadata_editor.py new file mode 100644 index 00000000..9063b632 --- /dev/null +++ b/src/pullbox/services/series_metadata_editor.py @@ -0,0 +1,663 @@ +"""Canonical series corrections with retained, independently guarded sidecar work.""" + +import asyncio +import hashlib +import json +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from pathlib import Path +from typing import cast as type_cast + +from pydantic import TypeAdapter +from sqlalchemy import JSON, Text, cast, select, type_coerce +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.core.series_sidecar import read_series_sidecar, render_series_sidecar +from pullbox.core.source_sidecars import parse_source_sidecar +from pullbox.models import Series +from pullbox.models.metadata_source import MetadataSourceConfig +from pullbox.models.series import SeriesStatus, SeriesType +from pullbox.schemas.metadata_snapshot import ( + FieldOrigin, + MetadataSnapshot, + MetadataValues, + field_domain, +) +from pullbox.schemas.series_metadata_editor import ( + EditableSeriesField, + SeriesMetadataEditorField, + SeriesMetadataEditorOption, + SeriesMetadataEditorPreview, + SeriesMetadataEditorReview, + SeriesMetadataEditorSource, +) +from pullbox.services.archive_metadata_binding import ArchiveMetadataBindingError +from pullbox.services.library_mutation_coordination import ( + finish_short_mutation, + lock_file_mutation_admission, +) +from pullbox.services.metadata_assembly import assemble_metadata +from pullbox.services.metadata_baselines import MetadataBaselineWrite, save_metadata_baselines +from pullbox.services.metadata_entity_values import apply_series_metadata_values +from pullbox.services.metadata_series_refresh_state import ( + SeriesRefreshState, + read_series_refresh_state, +) +from pullbox.services.metadata_writer_identity import metadata_write_scope +from pullbox.services.provider_artwork import allowed_artwork_url +from pullbox.services.series_sidecar import ( + SidecarBinding, + SidecarTarget, + inspect_series_sidecar_binding, + read_series_sidecar_binding, + write_editor_sidecar_target, +) +from pullbox.utilities.models import ItemState, JobType, UtilityJob, UtilityJobItem + +FIELDS = { + "title": ("Title", "text"), + "sort_title": ("Sort title", "text"), + "publisher": ("Publisher", "text"), + "description": ("Description", "textarea"), + "year_start": ("Start year", "number"), + "year_end": ("End year", "number"), + "status": ("Status", "select"), + "series_type": ("Type", "select"), + "archive_format": ("Publication format", "select"), + "volume": ("Volume", "text"), + "language": ("Language", "text"), +} +REQUIRED = frozenset({"title", "sort_title", "status", "series_type"}) +_MAX_PLAN_BYTES = 4 * 1024 * 1024 + + +@dataclass(frozen=True) +class SeriesEditorPlan: + state: SeriesRefreshState + snapshot: MetadataSnapshot + binding: SidecarBinding | None + targets: tuple[SidecarTarget, ...] + edited_fields: frozenset[str] + mode: str + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class PreparedSeriesEditor: + state: SeriesRefreshState + snapshot: MetadataSnapshot + binding: SidecarBinding | None + targets: tuple[SidecarTarget, ...] + preview: SeriesMetadataEditorPreview + + +PLAN = TypeAdapter(SeriesEditorPlan) + + +def _display(value: object) -> str: + return "" if value is None else str(value) + + +def _library_snapshot(state: SeriesRefreshState, now: datetime) -> MetadataSnapshot: + """Protected artwork is retained, not validated as permission for file output.""" + previous = state.series.baseline + snapshot = assemble_metadata( + MetadataEntityKind.SERIES, + state.series.identities, + (), + state.policies, + now=now, + current=state.series.values.model_copy(update={"image_url": None}), + previous=previous.model_copy( + update={ + "values": previous.values.model_copy(update={"image_url": None}), + } + ) + if previous + else None, + overrides=state.series.overrides, + ) + artwork = ( + next((origin for origin in previous.origins if origin.field == "image_url"), None) + if previous + else None + ) + if previous is not None and previous.values.image_url != state.series.values.image_url: + artwork = FieldOrigin( + field="image_url", + domain=field_domain(MetadataEntityKind.SERIES, "image_url"), + observed_at=now, + user_override=True, + ) + return snapshot.model_copy( + update={ + "values": snapshot.values.model_copy( + update={"image_url": state.series.values.image_url} + ), + "origins": tuple(origin for origin in snapshot.origins if origin.field != "image_url") + + ((artwork,) if artwork else ()), + } + ) + + +def _validated(field: str, value: object) -> object: + label = FIELDS[field][0] + if isinstance(value, str): + if not value.strip() or any(ord(c) < 32 and c not in "\n\r\t" for c in value): + raise ValueError(f"Enter a valid {label.lower()} or choose Clear value.") + if field != "description": + value = value.strip() + if value is None and field in REQUIRED: + raise ValueError(f"{label} cannot be cleared.") + try: + if field == "status" and value is not None: + if not isinstance(value, str): + raise ValueError("Expected status text") + value = SeriesStatus(value).value + if field == "series_type" and value is not None: + if not isinstance(value, str): + raise ValueError("Expected series type text") + value = SeriesType(value).value + return getattr(MetadataValues.model_validate({field: value}), field) + except (ValueError, TypeError): + raise ValueError(f"Enter a valid {label.lower()}.") from None + + +def _saved_sources( + binding: SidecarBinding | None, targets: tuple[SidecarTarget, ...] +) -> dict[str, tuple[str, dict[str, object]]]: + sources: dict[str, tuple[str, dict[str, object]]] = {} + if binding is None: + return sources + for target in targets: + if target.read.action == "blocked" or target.previous is None: + continue + data = read_series_sidecar(target.previous) + legacy = parse_source_sidecar(target.previous.decode()) + layers: list[tuple[str, dict[str, object]]] = [] + prior = data.get("pullbox") + if isinstance(prior, dict): + snapshot = MetadataSnapshot.model_validate(prior["snapshot"]) + if snapshot.identities and set(snapshot.identities) <= set(binding.snapshot.identities): + layers.append(("Pullbox snapshot", snapshot.values.model_dump())) + if legacy.get("comicid") is not None and any( + ref.namespace.value == "comicvine" and ref.external_id == str(legacy["comicid"]) + for ref in binding.snapshot.identities + ): + for name, layer in [ + ("saved fields", data), + *[ + (key, item) + for key, item in data.items() + if key.casefold() == "metadata" and isinstance(item, dict) + ], + ]: + mapped: dict[str, object] = {} + aliases = { + "title": {"name", "series", "title"}, + "year_start": {"year", "year_start"}, + "description": {"summary", "description"}, + "series_type": {"type", "booktype", "series_type"}, + "status": {"status", "series_status"}, + } + for field in FIELDS: + found = [ + value + for key, value in layer.items() + if key.casefold() in aliases.get(field, {field}) + ] + if found and all(value == found[0] for value in found): + mapped[field] = ( + "standard" + if field == "series_type" and found[0] == "Print" + else found[0] + ) + layers.append((name, mapped)) + for name, values in layers: + valid = {} + for field in FIELDS: + if field not in values or values[field] is None: + continue + try: + valid[field] = _validated(field, values[field]) + except ValueError: + continue + token = hashlib.sha256(f"{target.location.directory}\0{name}".encode()).hexdigest() + sources[token] = ( + f"series.json ({Path(target.location.directory).name}, {name})", + valid, + ) + return sources + + +def _render_targets( + binding: SidecarBinding, targets: tuple[SidecarTarget, ...], edited_fields: frozenset[str] +) -> tuple[SidecarTarget, ...]: + """Render from the reviewed bytes, never silently recapture a changed source.""" + result = [] + for target in targets: + if target.read.action == "blocked": + result.append(target) + continue + try: + content = render_series_sidecar( + binding.snapshot, + binding.aliases, + target.previous, + updated_at=binding.updated_at, + edited_fields=edited_fields, + ) + unchanged = target.previous is not None and json.loads(target.previous) == json.loads( + content + ) + read = target.read.model_copy( + update={ + "action": "unchanged" + if unchanged + else "create" + if target.previous is None + else "update", + "changes": [] if unchanged else ["Selected series fields and saved provenance"], + } + ) + except ValueError: + content = b"" + read = target.read.model_copy( + update={ + "action": "blocked", + "reason": ( + "Selected metadata cannot be safely written to this sidecar. " + "Review the values or use Library only." + ), + } + ) + result.append(replace(target, content=content, read=read)) + return tuple(result) + + +async def prepare_series_editor( + session: AsyncSession, series_id: int, review: SeriesMetadataEditorReview +) -> PreparedSeriesEditor: + if session.new or session.dirty or session.deleted: + raise ValueError("Finish pending changes before editing series metadata.") + state = await read_series_refresh_state(session, series_id, include_issues=False) + series = await session.get(Series, series_id) + assert series is not None + snapshot = _library_snapshot(state, series.updated_at) + binding = None + targets: tuple[SidecarTarget, ...] = () + warnings: list[str] = [] + unsupported_artwork = bool( + state.series.values.image_url and not allowed_artwork_url(state.series.values.image_url) + ) + if review.mode != "library_only" and unsupported_artwork: + warnings.append( + "Sidecars are unavailable until the protected artwork URL is reviewed. " + "Library metadata can still be saved." + ) + elif review.mode != "library_only": + try: + binding = await read_series_sidecar_binding(session, series_id) + targets = await inspect_series_sidecar_binding(binding) + except ArchiveMetadataBindingError: + warnings.append( + "Sidecars are unavailable until the series provider links are verified. " + "Library metadata can still be saved." + ) + sources = _saved_sources(binding, targets) + original = targets + values = snapshot.values.model_dump() + origins = {origin.field: origin for origin in snapshot.origins} + fields = [] + for field, (label, input_type) in FIELDS.items(): + current = getattr(state.series.values, field) + previous = origins.get(field) + edit = review.edits.get(type_cast("EditableSeriesField", field)) + if edit is not None: + if edit.action == "reset": + if previous is None or not previous.user_override: + raise ValueError(f"{label} has no manual override to remove.") + elif edit.action == "set": + values[field] = _validated(field, edit.value) + elif edit.action == "clear": + values[field] = _validated(field, None) + else: + if edit.action == "sidecar" and ( + edit.source not in sources or field not in sources[edit.source][1] + ): + raise ValueError( + f"The saved source for {label.lower()} is unavailable. Preview again." + ) + selected = ( + current + if edit.action == "library" + else sources.get(edit.source or "", ("", {}))[1].get(field) + ) + if selected is None or selected == "": + if current not in (None, "") or any( + source.get(field) not in (None, "") for _, source in sources.values() + ): + raise ValueError( + f"{label} has no value in that saved source. " + "Choose Clear value to remove it." + ) + values[field] = selected + else: + values[field] = _validated(field, selected) + origins[field] = FieldOrigin( + field=field, + domain=field_domain(snapshot.entity_kind, field), + observed_at=series.updated_at, + user_override=edit.action != "reset", + provider_managed=edit.action == "reset", + ) + choices = ( + [item.value for item in SeriesStatus] + if field == "status" + else [item.value for item in SeriesType] + if field == "series_type" + else ["Limited Series", "Digital Chapter"] + if field == "archive_format" + else [] + ) + fields.append( + SeriesMetadataEditorField( + key=f"series.{field}", + label=label, + current=_display(current), + proposed=_display(values[field]), + input_type=input_type, + overridden=bool(previous and previous.user_override), + selected=edit is not None, + changed=edit is not None, + can_clear=field not in REQUIRED, + options=[ + SeriesMetadataEditorOption(value=value, label=value.replace("_", " ").title()) + for value in choices + ], + sources=[ + SeriesMetadataEditorSource( + token="library", label="Library", value=_display(current) + ), + *[ + SeriesMetadataEditorSource( + token=token, label=name, value=_display(source[field]) + ) + for token, (name, source) in sources.items() + if field in source + ], + ], + ) + ) + proposed = MetadataValues.model_validate(values) + if ( + proposed.year_start is not None + and proposed.year_end is not None + and proposed.year_end < proposed.year_start + ): + raise ValueError("End year cannot be earlier than start year.") + snapshot = MetadataSnapshot.model_validate( + {**snapshot.model_dump(), "values": proposed, "origins": tuple(origins.values())} + ) + if binding is not None: + targets = _render_targets( + replace(binding, snapshot=snapshot), targets, frozenset(review.edits) + ) + protected = [ + ("issue_count", "Issue count", state.series.values.issue_count), + ("image_url", "Cover artwork", state.series.values.image_url), + *[ + ( + f"id.{identity.namespace.value}", + f"{identity.namespace.value} ID", + identity.external_id, + ) + for identity in state.series.identities + ], + ] + fields.extend( + SeriesMetadataEditorField( + key=f"series.{field}", + label=label, + current=_display(value), + proposed=_display(value), + editable=False, + can_clear=False, + reason="Protected catalog or identity field; use its dedicated workflow.", + ) + for field, label, value in protected + ) + evidence = TypeAdapter(SeriesRefreshState).dump_json(state) + review.model_dump_json().encode() + evidence += TypeAdapter(SidecarBinding | None).dump_json(binding) + evidence += TypeAdapter(tuple[SidecarTarget, ...]).dump_json(original) + if len(evidence) > _MAX_PLAN_BYTES: + raise ValueError( + "This sidecar review is too large. Use Library only or review fewer folders." + ) + preview = SeriesMetadataEditorPreview( + series_id=series_id, + mode=review.mode, + editor_fields=fields, + editor_changed=bool(review.edits), + targets=[target.read for target in targets], + ready=bool(review.edits), + review_key=hashlib.sha256(evidence).hexdigest(), + warnings=warnings, + ) + return PreparedSeriesEditor(state, snapshot, binding, targets, preview) + + +async def save_series_editor( + session: AsyncSession, series_id: int, review: SeriesMetadataEditorReview, review_key: str +) -> SeriesEditorPlan: + prepared = await prepare_series_editor(session, series_id, review) + if not prepared.preview.ready or prepared.preview.review_key != review_key: + raise ValueError( + "The selected values, library metadata or sidecars changed. Preview again." + ) + # Release the read snapshot before entering SQLite's writer admission path. + await session.rollback() + await lock_file_mutation_admission(session) + await session.execute( + select(MetadataSourceConfig.id) + .order_by(MetadataSourceConfig.source) + .with_for_update(read=True) + ) + await session.execute(select(Series.id).where(Series.id == series_id).with_for_update()) + if await read_series_refresh_state(session, series_id, include_issues=False) != prepared.state: + raise ValueError("Library metadata changed. Preview again.") + if ( + prepared.binding is not None + and await read_series_sidecar_binding(session, series_id) != prepared.binding + ): + raise ValueError("Series folder ownership changed. Preview again.") + now = datetime.now(UTC) + snapshot = prepared.snapshot.model_copy( + update={ + "origins": tuple( + origin.model_copy(update={"observed_at": now}) + if origin.field in review.edits + else origin + for origin in prepared.snapshot.origins + ) + } + ) + async with metadata_write_scope(session): + series = await session.get(Series, series_id) + assert series is not None + if {"status", "year_end"} & review.edits.keys(): + # The legacy pin owns both fields. Assembly retained the unselected pin as provenance. + series.status_override = None + await apply_series_metadata_values(session, series, snapshot.values) + series.updated_at = now + await save_metadata_baselines( + session, + [MetadataBaselineWrite(series_id, snapshot, prepared.state.series.baseline_revision)], + ) + await session.flush() + state = await read_series_refresh_state(session, series_id, include_issues=False) + binding = None + targets = prepared.targets + if prepared.binding is not None: + binding = replace( + await read_series_sidecar_binding(session, series_id), snapshot=snapshot + ) + targets = _render_targets(binding, targets, frozenset(review.edits)) + plan = SeriesEditorPlan( + state, + snapshot, + binding, + targets, + frozenset(review.edits), + review.mode, + tuple(prepared.preview.warnings), + ) + if len(PLAN.dump_json(plan)) > _MAX_PLAN_BYTES: + raise ValueError("The retained sidecar plan is too large. Use Library only.") + return plan + + +def read_editor_plan(config: dict[str, object]) -> SeriesEditorPlan: + if set(config) != {"series_id", "series_editor"} or type(config["series_id"]) is not int: + raise ValueError("Invalid series metadata job.") + plan = PLAN.validate_python(config["series_editor"]) + if ( + plan.state.series.local_id != config["series_id"] + or plan.state.issues + or plan.snapshot != plan.state.series.baseline + or not plan.edited_fields <= FIELDS.keys() + or plan.mode not in {"library_only", "library_and_sidecars"} + or len(plan.targets) > 50 + or len({target.location.directory for target in plan.targets}) != len(plan.targets) + or (plan.mode == "library_only" and (plan.binding is not None or plan.targets)) + or (plan.targets and plan.binding is None) + or ( + plan.binding is not None + and ( + plan.binding.snapshot != plan.snapshot + or plan.binding.series_id != plan.state.series.local_id + ) + ) + or len(PLAN.dump_json(plan)) > _MAX_PLAN_BYTES + ): + raise ValueError("Invalid retained series metadata plan.") + return plan + + +async def run_series_editor_target( + session: AsyncSession, plan: SeriesEditorPlan, index: int +) -> dict[str, object]: + if plan.binding is None or index < 0 or index >= len(plan.targets): + raise ValueError("Invalid retained sidecar target.") + if ( + await read_series_refresh_state(session, plan.state.series.local_id, include_issues=False) + != plan.state + ): + raise ValueError("Library metadata is saved but has changed since this job. Preview again.") + await session.rollback() + result = await finish_short_mutation( + asyncio.create_task( + write_editor_sidecar_target( + session, + plan.binding, + plan.targets[index], + plan.edited_fields, + ) + ) + ) + return { + "directory": result.directory, + "outcome": "saved" if result.action == "unchanged" else "failed", + "reason": result.reason, + } + + +async def latest_editor_job(session: AsyncSession, series_id: int) -> UtilityJob | None: + document = JSON().with_variant(Text(), "sqlite") + return await session.scalar( + select(UtilityJob) + .where( + UtilityJob.job_type == JobType.FILE_METADATA, + type_coerce(cast(UtilityJob.config, document), JSON)["series_id"].as_integer() + == series_id, + ) + .order_by(UtilityJob.created_at.desc(), UtilityJob.id.desc()) + .limit(1) + ) + + +async def editor_job_details(session: AsyncSession, job: UtilityJob) -> dict[str, object]: + plan = read_editor_plan(json.loads(job.config)) + items = list( + await session.scalars( + select(UtilityJobItem) + .where(UtilityJobItem.job_id == job.id) + .order_by(UtilityJobItem.item_index) + .limit(51) + ) + ) + indexed = {json.loads(item.before_state or "{}").get("target_index"): item for item in items} + targets = [] + for index, target in enumerate(plan.targets): + item = indexed.get(index) + targets.append( + { + "directory": target.location.directory, + "state": item.state + if item + else "SKIPPED" + if target.read.action == "blocked" + else "COMPLETED" + if target.read.action == "unchanged" + else "PENDING", + "reason": item.error_message if item else target.read.reason, + } + ) + unfinished = [target for target in targets if target["state"] not in {"COMPLETED", "SKIPPED"}] + blocked = any(target.read.action == "blocked" for target in plan.targets) + warnings = list(plan.warnings) + if blocked: + warnings.append( + "Some folders were ineligible and were not written. " + "Review their reasons before a new preview." + ) + return { + "library_saved": True, + "mode": plan.mode, + "targets": targets, + "partial": bool(unfinished) or blocked or bool(warnings), + "retryable": bool(unfinished) and job.is_terminal, + "warnings": warnings, + } + + +async def retry_editor_plan(session: AsyncSession, series_id: int, job_id: str) -> SeriesEditorPlan: + job = await session.get(UtilityJob, job_id) + if job is None or job.job_type != JobType.FILE_METADATA or not job.is_terminal: + raise ValueError("Choose a finished or cancelled series metadata job.") + plan = read_editor_plan(json.loads(job.config)) + if plan.state.series.local_id != series_id: + raise ValueError("The metadata job belongs to another series.") + if await read_series_refresh_state(session, series_id, include_issues=False) != plan.state: + raise ValueError( + "Library metadata changed. Preview again; retry will not reapply old edits." + ) + items = list( + await session.scalars( + select(UtilityJobItem).where(UtilityJobItem.job_id == job.id).limit(51) + ) + ) + completed = { + json.loads(item.before_state or "{}").get("target_index") + for item in items + if item.state in {ItemState.COMPLETED, ItemState.SKIPPED} + } + remaining = tuple( + target + for index, target in enumerate(plan.targets) + if index not in completed and target.read.action in {"create", "update"} + ) + if not remaining: + raise ValueError("No unfinished approved sidecars remain.") + return replace(plan, targets=remaining) diff --git a/src/pullbox/services/series_metadata_propagation.py b/src/pullbox/services/series_metadata_propagation.py new file mode 100644 index 00000000..c626b773 --- /dev/null +++ b/src/pullbox/services/series_metadata_propagation.py @@ -0,0 +1,203 @@ +"""Durable, selected-field propagation using the existing file writer and job lane.""" + +import hashlib +import json +from collections import Counter +from typing import Any +from uuid import uuid4 + +from sqlalchemy import JSON, Text, case, cast, select, type_coerce +from sqlalchemy.ext.asyncio import AsyncSession + +from pullbox.models import Issue, LibraryFile +from pullbox.schemas.series_metadata_propagation import ( + PropagationPlan, + PropagationReview, + PropagationTarget, +) +from pullbox.services.metadata_series_refresh_state import ( + read_series_refresh_state, +) +from pullbox.services.series_metadata_propagation_preview import stable_propagation_evidence +from pullbox.utilities.models import JobType, UtilityJob, UtilityJobItem + +KEY = "series_propagation" + + +def read_plan(config: dict[str, Any]) -> PropagationPlan: + if set(config) != {KEY}: + raise ValueError("Invalid propagation job") + plan = PropagationPlan.model_validate(config[KEY]) + if len({t.issue_id for t in plan.targets}) != len(plan.targets) or any( + (t.review_key is not None) != (plan.mode == "write") for t in plan.targets + ): + raise ValueError("Invalid propagation targets") + return plan + + +async def series_key(session: AsyncSession, series_id: int) -> str: + state = await read_series_refresh_state(session, series_id, include_issues=False) + return hashlib.sha256(stable_propagation_evidence(state.series)).hexdigest() + + +async def preview_plan( + session: AsyncSession, series_id: int, review: PropagationReview +) -> PropagationPlan: + key = await series_key(session, series_id) + rows = ( + await session.execute( + select(Issue.id, Issue.issue_number_text) + .where( + Issue.series_id == series_id, + select(LibraryFile.id).where(LibraryFile.issue_id == Issue.id).exists(), + ) + .order_by(Issue.issue_number, Issue.id) + .limit(10001) + ) + ).all() + if not rows: + raise ValueError("This series has no registered comic files. Import an issue first.") + if len(rows) > 10000: + raise ValueError( + "This series exceeds the 10,000-issue preview limit. Update issues individually." + ) + return PropagationPlan( + series_id=series_id, + series_key=key, + fields=review.fields, + mode="preview", + targets=tuple( + PropagationTarget( + issue_id=row.id, label=f"Issue {row.issue_number_text}", operation_id=uuid4().hex + ) + for row in rows + ), + ) + + +async def latest_job(session: AsyncSession, series_id: int) -> UtilityJob | None: + return await session.scalar( + select(UtilityJob) + .where( + UtilityJob.job_type == JobType.FILE_METADATA, + type_coerce(cast(UtilityJob.config, JSON().with_variant(Text(), "sqlite")), JSON)[KEY][ + "series_id" + ].as_integer() + == series_id, + ) + .order_by(UtilityJob.created_at.desc(), UtilityJob.id.desc()) + .limit(1) + ) + + +async def job_items(session: AsyncSession, job: UtilityJob) -> list[UtilityJobItem]: + return list( + await session.scalars( + select(UtilityJobItem) + .where(UtilityJobItem.job_id == job.id) + .order_by(UtilityJobItem.item_index) + ) + ) + + +async def approved_plan( + session: AsyncSession, + series_id: int, + job_id: str, + selected: list[int] | None, + *, + retry: bool = False, +) -> PropagationPlan: + job = await session.get(UtilityJob, job_id) + if job is None or job.job_type != JobType.FILE_METADATA or KEY not in json.loads(job.config): + raise ValueError("Open a completed comic metadata preview first.") + plan = read_plan(json.loads(job.config)) + if ( + plan.series_id != series_id + or not job.is_terminal + or plan.mode != ("write" if retry else "preview") + ): + raise ValueError("Wait for this series' preview or write to finish before continuing.") + targets = [] + for item in await job_items(session, job): + data = json.loads(item.after_state or "{}") + before = json.loads(item.before_state or "{}") + original = plan.targets[before["target_index"]] + if retry: + if item.state != "COMPLETED": + targets.append(original) + elif data.get("outcome") == "ready": + targets.append(original.model_copy(update={"review_key": data["review_key"]})) + if selected is not None: + if len(set(selected)) != len(selected) or not set(selected) <= { + t.issue_id for t in targets + }: + raise ValueError("Select only ready issues from this completed preview.") + targets = [target for target in targets if target.issue_id in selected] + if not targets: + raise ValueError( + "No unfinished eligible files are selected. Preview again or review the issue details." + ) + return plan.model_copy(update={"mode": "write", "targets": tuple(targets)}) + + +async def job_details(session: AsyncSession, job: UtilityJob, page: int) -> dict[str, object]: + plan = read_plan(json.loads(job.config)) + document = JSON().with_variant(Text(), "sqlite") + before = type_coerce(cast(UtilityJobItem.before_state, document), JSON) + after = type_coerce(cast(UtilityJobItem.after_state, document), JSON) + # One snapshot for totals and the visible page; do not load every XML diff on each poll. + items = ( + await session.execute( + select( + UtilityJobItem.item_index, + UtilityJobItem.state, + UtilityJobItem.error_message, + before["target_index"].as_integer().label("target_index"), + after["outcome"].as_string().label("outcome"), + case( + ( + UtilityJobItem.item_index.between((page - 1) * 20, page * 20 - 1), + UtilityJobItem.after_state, + ), + else_=None, + ).label("details"), + ) + .where(UtilityJobItem.job_id == job.id) + .order_by(UtilityJobItem.item_index) + ) + ).all() + counts: Counter[str] = Counter() + targets = [] + ready_ids = [] + for item in items: + target = plan.targets[item.target_index] + outcome = item.outcome or ("attention" if item.error_message else item.state.lower()) + counts[outcome] += 1 + if outcome == "ready": + ready_ids.append(target.issue_id) + if not (page - 1) * 20 <= item.item_index < page * 20: + continue + details = json.loads(item.details or "{}") + targets.append( + { + "issue_id": target.issue_id, + "label": target.label, + "outcome": outcome, + "file_name": details.get("file_name", ""), + "changes": details.get("changes", []), + "reason": item.error_message or details.get("reason", ""), + } + ) + return { + "mode": plan.mode, + "fields": sorted(plan.fields), + "counts": dict(counts), + "targets": targets, + "page": page, + "pages": max(1, (len(items) + 19) // 20), + "ready_ids": ready_ids, + "retryable": plan.mode == "write" + and job.is_terminal + and any(i.state != "COMPLETED" for i in items), + } diff --git a/src/pullbox/services/series_metadata_propagation_preview.py b/src/pullbox/services/series_metadata_propagation_preview.py new file mode 100644 index 00000000..bee24506 --- /dev/null +++ b/src/pullbox/services/series_metadata_propagation_preview.py @@ -0,0 +1,82 @@ +"""Capture selected canonical series values without refreshing issue metadata.""" + +import hashlib +import json + +from pydantic import TypeAdapter + +from pullbox.core.archive_metadata import ArchiveMetadataFiles +from pullbox.core.metadata_identity import MetadataEntityKind +from pullbox.schemas.issue_file_metadata import FileMetadataChange, FileMetadataPreview +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_binding import ArchiveMetadataTarget +from pullbox.services.archive_metadata_rendering import ArchiveMetadataRenderError +from pullbox.services.issue_file_metadata import PreparedFileMetadata, _fields +from pullbox.services.metadata_series_refresh_state import RefreshEntityState +from pullbox.services.series_metadata_propagation_rendering import render_series_propagation + + +def stable_propagation_evidence(value: RefreshEntityState | ArchiveMetadataTarget) -> bytes: + data = TypeAdapter(type(value)).dump_python(value, mode="json") + if isinstance(value, ArchiveMetadataTarget): + metadata = data["binding"]["metadata"] + entities = [metadata["series"], *metadata["issues"]] + else: + entities = [data] + # Sets have no semantic order; Python changes their iteration order on restart. + for entity in entities: + entity["overrides"] = sorted(entity["overrides"]) + return json.dumps(data, sort_keys=True, separators=(",", ":")).encode() + + +def canonical_snapshot(state: RefreshEntityState, kind: MetadataEntityKind) -> MetadataSnapshot: + return MetadataSnapshot( + entity_kind=kind, + identities=state.identities, + values=state.values, + origins=state.baseline.origins if state.baseline else (), + ) + + +def prepare_projection( + target: ArchiveMetadataTarget, + files: ArchiveMetadataFiles, + fields: frozenset[str], +) -> PreparedFileMetadata: + bound = target.binding.metadata + protected = (fields - {"title", "sort_title", "year_start", "volume", "format"}) | ( + {"series_type", "archive_format"} if "format" in fields else set() + ) + origins = bound.issues[0].baseline.origins if bound.issues[0].baseline else () + if protected & (bound.issues[0].overrides | {o.field for o in origins if o.user_override}): + raise ArchiveMetadataRenderError("propagation_issue_override") + series = canonical_snapshot(bound.series, MetadataEntityKind.SERIES) + issue = canonical_snapshot(bound.issues[0], MetadataEntityKind.ISSUE) + pair = render_series_propagation(series, issue, files, fields) + changes: list[FileMetadataChange] = [] + for name, original, rendered in ( + ("ComicInfo.xml", files.comicinfo.payload, pair.comicinfo), + ("MetronInfo.xml", files.metroninfo.payload, pair.metroninfo), + ): + old, new = _fields(original), _fields(rendered) + changes.extend( + FileMetadataChange(document=name, field=key, before=old.get(key), after=new.get(key)) + for key in sorted(old.keys() | new.keys()) + if old.get(key) != new.get(key) + ) + evidence = stable_propagation_evidence(target) + evidence += json.dumps(sorted(fields)).encode() + key = hashlib.sha256(evidence + pair.comicinfo + pair.metroninfo).hexdigest() + return PreparedFileMetadata( + target, + series, + issue, + None, + FileMetadataPreview( + file_id=target.binding.library_file_id, + file_name=target.path.name, + changes=changes, + review_key=key, + unchanged=not changes, + ), + ) diff --git a/src/pullbox/services/series_metadata_propagation_rendering.py b/src/pullbox/services/series_metadata_propagation_rendering.py new file mode 100644 index 00000000..ba8c9bd8 --- /dev/null +++ b/src/pullbox/services/series_metadata_propagation_rendering.py @@ -0,0 +1,105 @@ +"""Selected series-field projection into the existing reconciled XML pair.""" + +from xml.etree import ElementTree as ET + +from pullbox.core.archive_metadata import ArchiveMetadataFiles +from pullbox.core.archive_metadata_fields import COMICINFO_ONLY_FORMATS, archive_format_label +from pullbox.core.issue_numbers import normalize_issue_number_text +from pullbox.schemas.metadata_snapshot import MetadataSnapshot +from pullbox.services.archive_metadata_preservation import ( + preserved_roots, + review_preserved_fields, + set_text, +) +from pullbox.services.archive_metadata_reconciliation import reconcile_archive_metadata +from pullbox.services.archive_metadata_rendering import ( + ArchiveMetadataRenderError, + RenderedArchiveMetadata, + _identities, + _validated_pair, +) + +FIELDS = { + "title": "Series name", + "publisher": "Publisher", + "language": "Language", + "format": "Publication format", + "sort_title": "Series sort name", + "year_start": "Series start year", + "volume": "Series volume", +} + + +def render_series_propagation( + series: MetadataSnapshot, + issue: MetadataSnapshot, + files: ArchiveMetadataFiles, + fields: frozenset[str], +) -> RenderedArchiveMetadata: + if not fields or not fields <= FIELDS.keys(): + raise ArchiveMetadataRenderError("invalid_choice") + if not files.comicinfo.payload or not files.metroninfo.payload: + raise ArchiveMetadataRenderError("propagation_pair_required") + archive = reconcile_archive_metadata(files) + _identities(series, issue, archive) + if any(item.code != "unmapped_content" for item in archive.diagnostics): + raise ArchiveMetadataRenderError("invalid_input") + for document in (archive.comicinfo, archive.metroninfo): + number = document.issue.issue_number_text + if number is not None and normalize_issue_number_text( + number + ) != normalize_issue_number_text(issue.values.issue_number_text or ""): + raise ArchiveMetadataRenderError("issue_number_conflict") + conflicts = review_preserved_fields(files, {}) + if conflicts: + raise ArchiveMetadataRenderError("unreconciled_field", conflicts[0].key) + ci, mi = preserved_roots(files) + parent = mi.find("Series") + if parent is None: + parent = ET.SubElement(mi, "Series") + for field, ci_tag, mi_tag in ( + ("title", "Series", "Name"), + ("sort_title", None, "SortName"), + ("year_start", None, "StartYear"), + ("volume", None, "Volume"), + ): + if field not in fields: + continue + value = getattr(series.values, field) + if field == "title" and not value: + raise ArchiveMetadataRenderError("missing_series_title") + if field == "year_start" and value is not None: + value = f"{value:04d}" + if ci_tag: + set_text(ci, ci_tag, value) + set_text(parent, mi_tag, value) + if "language" in fields: + language = series.values.language + set_text(ci, "LanguageISO", language) + if language: + parent.set("lang", language) + else: + parent.attrib.pop("lang", None) + if "format" in fields: + value = series.values.archive_format or series.values.series_type + set_text(ci, "Format", archive_format_label(value)) + set_text( + parent, + "Format", + None if value in COMICINFO_ONLY_FORMATS else archive_format_label(value), + ) + if "publisher" in fields: + publisher = mi.find("Publisher") + value = series.values.publisher + if publisher is not None and publisher.get("id") and publisher.findtext("Name") != value: + raise ArchiveMetadataRenderError("resource_metadata_change", "publisher") + if value: + if publisher is None: + publisher = ET.SubElement(mi, "Publisher") + set_text(publisher, "Name", value) + elif publisher is not None: + if publisher.attrib or publisher.find("Imprint") is not None: + raise ArchiveMetadataRenderError("unsupported_content", "Publisher") + mi.remove(publisher) + set_text(ci, "Publisher", value) + return _validated_pair(ci, mi) diff --git a/src/pullbox/services/series_service.py b/src/pullbox/services/series_service.py index 8682b2a8..6920a200 100644 --- a/src/pullbox/services/series_service.py +++ b/src/pullbox/services/series_service.py @@ -1127,6 +1127,13 @@ async def delete( ) # ── 3. Delete series record (cascades to issues) ──────────── + from pullbox.services.download_operation_reconciliation import retire_download_operations + + download_ids = await session.scalars( + select(DownloadHistory.id).where(DownloadHistory.issue_id.in_(issue_ids_subq)) + ) + for download_id in download_ids: + await retire_download_operations(session, download_id) await purge_series_cover_cache(session, series_id) await session.delete(series) logger.info( diff --git a/src/pullbox/services/series_sidecar.py b/src/pullbox/services/series_sidecar.py index dc2499d7..3d1e1ffd 100644 --- a/src/pullbox/services/series_sidecar.py +++ b/src/pullbox/services/series_sidecar.py @@ -7,7 +7,7 @@ import stat from collections.abc import Iterator, Sequence from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path from uuid import uuid4 @@ -327,7 +327,11 @@ def _require_registered_folder(descriptor: int, location: SidecarLocation) -> No ) -def _inspect(binding: SidecarBinding, location: SidecarLocation) -> SidecarTarget: +def _inspect( + binding: SidecarBinding, + location: SidecarLocation, + edited_fields: frozenset[str] = frozenset(), +) -> SidecarTarget: if not location.managed: return SidecarTarget( location, @@ -344,7 +348,11 @@ def _inspect(binding: SidecarBinding, location: SidecarLocation) -> SidecarTarge _require_registered_folder(descriptor, location) previous, fingerprint = _existing(descriptor) content = render_series_sidecar( - binding.snapshot, binding.aliases, previous, updated_at=binding.updated_at + binding.snapshot, + binding.aliases, + previous, + updated_at=binding.updated_at, + edited_fields=edited_fields, ) unchanged = previous is not None and json.loads(previous) == json.loads(content) read = SeriesSidecarTargetRead( @@ -403,6 +411,101 @@ def _inspect_all(binding: SidecarBinding) -> list[SidecarTarget]: return [_inspect(binding, location) for location in binding.locations] +async def read_series_sidecar_binding(session: AsyncSession, series_id: int) -> SidecarBinding: + """Read the bounded binding without committing the caller's transaction.""" + return await _binding(session, series_id) + + +async def inspect_series_sidecar_binding( + binding: SidecarBinding, edited_fields: frozenset[str] = frozenset() +) -> tuple[SidecarTarget, ...]: + return tuple( + await asyncio.to_thread( + lambda: [_inspect(binding, location, edited_fields) for location in binding.locations] + ) + ) + + +def _stage_or_replay(target: SidecarTarget) -> tuple[int, str]: + with _directory(target.location) as (descriptor, directories): + if directories != target.directories: + raise ValueError("The sidecar folder changed. Preview again.") + if _existing(descriptor)[0] == target.content: + return os.dup(descriptor), "" + return _stage(target) + + +async def write_editor_sidecar_target( + session: AsyncSession, + binding: SidecarBinding, + target: SidecarTarget, + edited_fields: frozenset[str], +) -> SeriesSidecarTargetRead: + """Replay one retained destination, never discover or approve additional folders.""" + descriptor: int | None = None + stage = "" + try: + expected = render_series_sidecar( + binding.snapshot, + binding.aliases, + target.previous, + updated_at=binding.updated_at, + edited_fields=edited_fields, + ) + if target.content != expected or target.read.action not in {"create", "update"}: + raise ValueError("The retained sidecar plan is invalid. Preview again.") + descriptor, stage = await asyncio.to_thread(_stage_or_replay, target) + await lock_file_mutation_admission(session) + await ensure_no_active_import_file_mutation(session) + await session.execute( + select(Series.id).where(Series.id == binding.series_id).with_for_update() + ) + current = await _binding(session, binding.series_id) + if replace(current, snapshot=binding.snapshot) != binding or target.location not in ( + location for location in current.locations if location.managed + ): + raise ValueError("Saved library metadata or folder ownership changed. Preview again.") + await require_no_archive_publication( + session, Path(target.location.directory), include_descendants=True + ) + + def publish() -> None: + assert descriptor is not None + if stage: + _publish(target, descriptor, stage) + else: + with _directory(target.location) as (current_fd, directories): + if directories != target.directories or _existing(current_fd)[0] != expected: + raise ValueError("The sidecar changed before recovery. Preview again.") + _require_registered_folder(current_fd, target.location) + os.fsync(descriptor) + + await asyncio.to_thread(publish) + return target.read.model_copy(update={"action": "unchanged", "changes": []}) + except (ValueError, ValidationError, OSError) as exc: + return target.read.model_copy( + update={ + "action": "blocked", + "reason": ( + "Library metadata is saved. Sidecar writing needs retry; " + "check permissions and free space." + ) + if isinstance(exc, OSError) + else str(exc), + } + ) + finally: + await session.rollback() + if descriptor is not None: + try: + if stage: + os.unlink(stage, dir_fd=descriptor) + except FileNotFoundError: + pass + finally: + os.close(descriptor) + + def _stage(target: SidecarTarget) -> tuple[int, str]: with _directory(target.location) as (descriptor, directories): if directories != target.directories or _existing(descriptor) != ( diff --git a/src/pullbox/services/story_arc_placement_integration.py b/src/pullbox/services/story_arc_placement_integration.py index 7502db46..dd4d8f84 100644 --- a/src/pullbox/services/story_arc_placement_integration.py +++ b/src/pullbox/services/story_arc_placement_integration.py @@ -10,6 +10,7 @@ import asyncio import enum +import os from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -1518,6 +1519,17 @@ async def validate_story_arc_placement_policy_input( "Story-arc destination root must be within the selected library root", category="safety", ) + required_access = os.R_OK | os.X_OK + if mode is not StoryArcPlacementPolicyMode.REFERENCE_ONLY: + required_access |= os.W_OK + if not await asyncio.to_thread(os.access, resolved_destination, required_access): + operation = "read and write" if required_access & os.W_OK else "read" + raise StoryArcPlacementIntegrationError( + "destination_root_unavailable", + f"Pullbox cannot {operation} the Story Arc folder {resolved_destination}. " + "Check this folder's permissions for the container user, then recheck and retry.", + category="safety", + ) return StoryArcPlacementPolicy( configured=True, revision=revision, diff --git a/src/pullbox/services/story_arc_sync_queue.py b/src/pullbox/services/story_arc_sync_queue.py index 5f2315f0..ca1eef55 100644 --- a/src/pullbox/services/story_arc_sync_queue.py +++ b/src/pullbox/services/story_arc_sync_queue.py @@ -1380,18 +1380,9 @@ async def record_actions_adapter( return results[0] -async def discover_story_arc_sync_work( - session: AsyncSession, - *, - limit: int = DEFAULT_STORY_ARC_DISCOVERY_LIMIT, -) -> int: - """Boundedly recover eligible canonical files with no current work generation.""" - if isinstance(limit, bool) or not 1 <= limit <= DEFAULT_STORY_ARC_DISCOVERY_LIMIT: - raise ValueError( - f"Story-arc discrepancy limit must be from 1 to {DEFAULT_STORY_ARC_DISCOVERY_LIMIT}" - ) - - current_work = exists().where( +def current_story_arc_sync_conditions() -> Any: + """Match current membership, policy and source evidence without reading files.""" + return and_( StoryArcSyncWork.issue_story_arc_id == IssueStoryArc.id, StoryArcSyncWork.library_file_id == LibraryFile.id, StoryArcSyncWork.story_arc_revision == StoryArc.revision, @@ -1456,6 +1447,19 @@ async def discover_story_arc_sync_work( ), ), ) + + +async def discover_story_arc_sync_work( + session: AsyncSession, + *, + limit: int = DEFAULT_STORY_ARC_DISCOVERY_LIMIT, +) -> int: + """Boundedly recover eligible canonical files with no current work generation.""" + if isinstance(limit, bool) or not 1 <= limit <= DEFAULT_STORY_ARC_DISCOVERY_LIMIT: + raise ValueError( + f"Story-arc discrepancy limit must be from 1 to {DEFAULT_STORY_ARC_DISCOVERY_LIMIT}" + ) + current_work = exists().where(current_story_arc_sync_conditions()) rows = list( ( await session.execute( diff --git a/src/pullbox/services/story_arc_sync_recovery.py b/src/pullbox/services/story_arc_sync_recovery.py new file mode 100644 index 00000000..d1b3595c --- /dev/null +++ b/src/pullbox/services/story_arc_sync_recovery.py @@ -0,0 +1,242 @@ +"""User-requested recovery of current automatic Story Arc file work.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy import func, select, true, update + +from pullbox.core.exceptions import NotFoundError, ValidationError +from pullbox.models.library import LibraryFile +from pullbox.models.story_arc import ( + IssueStoryArc, + StoryArc, + StoryArcPlacement, + StoryArcPlacementState, + StoryArcResolutionState, +) +from pullbox.models.story_arc_sync import StoryArcSyncWork, StoryArcSyncWorkState +from pullbox.services.story_arc_membership_policy import order_review_filter +from pullbox.services.story_arc_placement_integration import ( + StoryArcPlacementPolicyInput, + StoryArcPlacementSyncService, + validate_story_arc_placement_policy_input, +) +from pullbox.services.story_arc_sync_queue import ( + _automatic_sync_enabled, + _desired_generation, + current_story_arc_sync_conditions, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql import Select + +RETRY_BATCH_SIZE = 200 + + +def current_story_arc_sync_work_ids( + story_arc_id: int | None = None, *, include_imports: bool = False +) -> Select[int]: + # Old revisions and source generations are history, not new user decisions. + return ( + select(func.max(StoryArcSyncWork.id)) + .join(IssueStoryArc, IssueStoryArc.id == StoryArcSyncWork.issue_story_arc_id) + .join(StoryArc, StoryArc.id == IssueStoryArc.story_arc_id) + .join(LibraryFile, LibraryFile.id == StoryArcSyncWork.library_file_id) + .where( + StoryArc.id == story_arc_id if story_arc_id is not None else true(), + LibraryFile.issue_id == IssueStoryArc.issue_id, + true() if include_imports else IssueStoryArc.sync_eligible.is_(True), + IssueStoryArc.resolution_state == StoryArcResolutionState.RESOLVED, + true() if include_imports else StoryArcSyncWork.origin_import_job_id.is_(None), + true() if include_imports else StoryArcSyncWork.claimable.is_(True), + current_story_arc_sync_conditions(), + ) + .group_by(StoryArcSyncWork.issue_story_arc_id, StoryArcSyncWork.library_file_id) + ) + + +@dataclass(frozen=True) +class StoryArcFileSyncStatus: + arc_id: int + revision: int + enabled: bool + destination: str + total: int + completed: int + pending: int + failed: int + destination_failures: int + review_count: int + + @property + def percent(self) -> int: + return int(100 * self.completed / self.total) if self.total else 0 + + +async def load_story_arc_file_sync_status( + session: AsyncSession, story_arc_id: int +) -> StoryArcFileSyncStatus: + """Project current file work without inspecting archives or loading history.""" + arc = await session.get(StoryArc, story_arc_id) + if arc is None: + raise NotFoundError("StoryArc", story_arc_id) + rows = ( + await session.execute( + select(StoryArcSyncWork.state, StoryArcSyncWork.last_error_code, func.count()) + .where(StoryArcSyncWork.id.in_(current_story_arc_sync_work_ids(story_arc_id))) + .group_by(StoryArcSyncWork.state, StoryArcSyncWork.last_error_code) + ) + ).all() + counts: dict[StoryArcSyncWorkState, int] = {} + destination_failures = 0 + for state, code, count in rows: + counts[state] = counts.get(state, 0) + count + if state == StoryArcSyncWorkState.FAILED and code in { + "root_unavailable", + "destination_root_unavailable", + "target_library_root_unavailable", + }: + destination_failures += count + return StoryArcFileSyncStatus( + arc_id=arc.id, + revision=arc.revision, + enabled=_automatic_sync_enabled(arc), + destination=str((arc.policy_snapshot or {}).get("destination_root") or ""), + total=sum(counts.values()), + completed=counts.get(StoryArcSyncWorkState.COMPLETED, 0), + pending=sum( + counts.get(state, 0) + for state in ( + StoryArcSyncWorkState.QUEUED, + StoryArcSyncWorkState.RUNNING, + StoryArcSyncWorkState.RETRY_WAIT, + ) + ), + failed=counts.get(StoryArcSyncWorkState.FAILED, 0), + destination_failures=destination_failures, + review_count=await _load_review_count(session, story_arc_id), + ) + + +async def _load_review_count(session: AsyncSession, story_arc_id: int) -> int: + membership_reviews = ( + select(func.count(IssueStoryArc.id)) + .where( + IssueStoryArc.story_arc_id == story_arc_id, + IssueStoryArc.resolution_state.in_( + ( + StoryArcResolutionState.PENDING, + StoryArcResolutionState.MISSING, + StoryArcResolutionState.CONFLICT, + StoryArcResolutionState.AMBIGUOUS, + ) + ) + | ( + (IssueStoryArc.resolution_state == StoryArcResolutionState.RESOLVED) + & order_review_filter() + ), + ) + .scalar_subquery() + ) + placement_reviews = ( + select(func.count(StoryArcPlacement.id)) + .join(IssueStoryArc, IssueStoryArc.id == StoryArcPlacement.issue_story_arc_id) + .where( + IssueStoryArc.story_arc_id == story_arc_id, + StoryArcPlacement.state.in_( + ( + StoryArcPlacementState.MISSING, + StoryArcPlacementState.DRIFTED, + StoryArcPlacementState.FAILED, + ) + ), + ) + .scalar_subquery() + ) + sync_reviews = ( + select(func.count(func.distinct(StoryArcSyncWork.issue_story_arc_id))) + .where( + StoryArcSyncWork.id.in_( + current_story_arc_sync_work_ids(story_arc_id, include_imports=True) + ), + StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED, + ) + .scalar_subquery() + ) + return int( + await session.scalar(select(membership_reviews + placement_reviews + sync_reviews)) or 0 + ) + + +async def retry_story_arc_files( + session: AsyncSession, story_arc_id: int, *, expected_revision: int +) -> int: + """Recheck the destination and reopen only current failed automatic work.""" + arc = await session.get(StoryArc, story_arc_id) + if arc is None: + raise NotFoundError("StoryArc", story_arc_id) + if arc.revision != expected_revision: + raise ValidationError("The Story Arc changed. Refresh the page before retrying.") + if not _automatic_sync_enabled(arc): + raise ValidationError("Automatic file synchronization is not enabled for this Story Arc.") + policy = await StoryArcPlacementSyncService().get_policy(session, story_arc_id) + await validate_story_arc_placement_policy_input( + session, + StoryArcPlacementPolicyInput( + mode=policy.mode, + target_library_root_id=policy.target_library_root_id, + destination_root=policy.destination_root, + folder_template=policy.folder_template, + file_template=policy.file_template, + symlink_style=policy.symlink_style, + synchronize=policy.synchronize, + ), + revision=expected_revision, + ) + rows = ( + await session.execute( + select(StoryArcSyncWork, LibraryFile, IssueStoryArc) + .join(LibraryFile, LibraryFile.id == StoryArcSyncWork.library_file_id) + .join(IssueStoryArc, IssueStoryArc.id == StoryArcSyncWork.issue_story_arc_id) + .where( + StoryArcSyncWork.id.in_(current_story_arc_sync_work_ids(story_arc_id)), + StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED, + ) + .order_by(StoryArcSyncWork.id) + .limit(RETRY_BATCH_SIZE) + ) + ).all() + ids = [ + work.id + for work, source, member in rows + if _desired_generation(source, member, arc)[0] == work.desired_generation + ] + if not ids: + return 0 + retried = ( + await session.scalars( + update(StoryArcSyncWork) + .where( + StoryArcSyncWork.id.in_(ids), + StoryArcSyncWork.id.in_(current_story_arc_sync_work_ids(story_arc_id)), + StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED, + ) + .values( + state=StoryArcSyncWorkState.QUEUED, + attempt_count=0, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + last_error_code=None, + last_error_category=None, + last_error_detail=None, + updated_at=datetime.now(UTC), + ) + .returning(StoryArcSyncWork.id) + ) + ).all() + return len(retried) diff --git a/src/pullbox/tasks/download_post_processing_metadata.py b/src/pullbox/tasks/download_post_processing_metadata.py index 09240ed9..1d671c52 100644 --- a/src/pullbox/tasks/download_post_processing_metadata.py +++ b/src/pullbox/tasks/download_post_processing_metadata.py @@ -12,7 +12,7 @@ from pullbox.core.exceptions import ValidationError from pullbox.core.file_safety import get_archive_size_limit_bytes from pullbox.models import LibraryFile -from pullbox.models.archive_metadata_publication import PublicationState +from pullbox.models.archive_metadata_publication import ArchiveMetadataPublication, PublicationState from pullbox.models.download import DownloadHistory, DownloadState from pullbox.models.library import FileFormat from pullbox.models.library_conversion import LibraryConversion @@ -34,6 +34,26 @@ from pullbox.tasks.download_post_processing_runtime import PostProcessingRuntime +async def require_unpublished_download(session: AsyncSession, download: DownloadHistory) -> None: + """A missing path must not turn retained publication history into a new transfer.""" + operation = uuid5(NAMESPACE_URL, f"pullbox:download-metadata:{download.id}") + publication = await session.scalar( + select(ArchiveMetadataPublication.id).where( + ArchiveMetadataPublication.operation_id == str(operation) + ) + ) + conversion = await session.scalar( + select(LibraryConversion.id).where( + LibraryConversion.operation_id == str(uuid5(operation, "conversion")) + ) + ) + if publication is not None or conversion is not None: + raise ValidationError( + "This download has retained publication history but lost its library-copy reference. " + "Review the issue's file metadata before retrying. Existing files were left unchanged." + ) + + async def finish_download_metadata( session: AsyncSession, download: DownloadHistory, runtime: PostProcessingRuntime ) -> Path: diff --git a/src/pullbox/tasks/download_post_processing_queue.py b/src/pullbox/tasks/download_post_processing_queue.py index 3ce8cede..46c75562 100644 --- a/src/pullbox/tasks/download_post_processing_queue.py +++ b/src/pullbox/tasks/download_post_processing_queue.py @@ -194,6 +194,19 @@ async def process_completed( ) await session.commit() + try: + from pullbox.services.direct_metadata_recovery import ( + reconcile_metadata_replacements, + ) + + await reconcile_metadata_replacements(session, download.issue_id) + except Exception: + # Recovery follow-up must not fail an already committed import. + log.warning( + "partial_issue_replacement_reconciliation_failed_after_completion", + download_id=dl_id, + exc_info=True, + ) try: request_story_arc_sync_now() except Exception: diff --git a/src/pullbox/tasks/download_task.py b/src/pullbox/tasks/download_task.py index 5030ffad..49664e8b 100644 --- a/src/pullbox/tasks/download_task.py +++ b/src/pullbox/tasks/download_task.py @@ -64,7 +64,10 @@ find_existing_destination_file, register_existing_destination_file, ) -from pullbox.tasks.download_post_processing_metadata import finish_download_metadata +from pullbox.tasks.download_post_processing_metadata import ( + finish_download_metadata, + require_unpublished_download, +) from pullbox.tasks.download_post_processing_runtime import PostProcessingRuntime from pullbox.tasks.download_post_processing_source_validation import ( ResolveLocalPath, @@ -287,6 +290,21 @@ async def monitor_downloads() -> None: recovery_due = recovery_checked_at - _last_recovery_check >= _RECOVERY_CHECK_INTERVAL direct_retried = 0 if recovery_due: + from pullbox.services.download_operation_reconciliation import reconcile_download_operations + + async with factory() as repair_session: + + async def repair_activity() -> int: + return await reconcile_download_operations(repair_session) + + repaired = await run_sqlite_transaction_with_retry( + repair_session, + repair_activity, + event_name="download_activity_reconciliation", + logger=logger, + ) + if repaired: + logger.info("download_activity_reconciled", repaired=repaired) direct_retried = await _process_direct_retry_pending() # ── Phase 1: Read — load active downloads and build registry ── @@ -500,6 +518,9 @@ def _publish_post_processing_phase( runtime.emit_summary(outcome="success") return + if paired_metadata: + await require_unpublished_download(session, download) + source_validation = await resolve_and_validate_source( session=session, download=download, diff --git a/src/pullbox/tasks/issue_import_task.py b/src/pullbox/tasks/issue_import_task.py index 882c4ffd..485ea36b 100644 --- a/src/pullbox/tasks/issue_import_task.py +++ b/src/pullbox/tasks/issue_import_task.py @@ -241,6 +241,7 @@ async def check_import_control() -> None: session, prepared, allow_resource_safety_exception=request.allow_resource_safety_exception, + metadata_approval=request.metadata_approval, preparation_progress_callback=emit_stage_progress, transfer_progress_callback=lambda current, total: emit_stage_progress( stage="transferring", @@ -259,6 +260,9 @@ async def check_import_control() -> None: cancel_requested_after_file_work = issue_id in _issue_import_cancel_requests _issue_import_cancel_requests.discard(issue_id) await session.commit() + from pullbox.services.direct_metadata_recovery import reconcile_metadata_replacements + + await reconcile_metadata_replacements(session, issue_id) completed = _set_issue_import_state( issue_id, state="completed", diff --git a/src/pullbox/ui/downloads_routes.py b/src/pullbox/ui/downloads_routes.py index ca3ff63e..5ca08f8a 100644 --- a/src/pullbox/ui/downloads_routes.py +++ b/src/pullbox/ui/downloads_routes.py @@ -75,6 +75,7 @@ class DownloadQueueRowView: progress_label: str progress_tone: str progress_indeterminate: bool + size_bytes: int | None speed_bytes: int | None eta_text: str | None is_active: bool @@ -384,6 +385,14 @@ def build_download_queue_row_view( speed_bytes = snapshot_value(progress, "speed_bytes") eta_seconds = snapshot_value(progress, "eta_seconds") bytes_transferred = snapshot_value(progress, "bytes_transferred") + size_bytes = next( + ( + size + for size in (snapshot_value(progress, "size_bytes"), download.file_size) + if isinstance(size, int) and not isinstance(size, bool) and size > 0 + ), + None, + ) progress_indeterminate = bool(snapshot_value(progress, "is_indeterminate")) source_slow = bool(snapshot_value(progress, "source_slow")) @@ -474,6 +483,7 @@ def build_download_queue_row_view( progress_label=progress_label, progress_tone=progress_tone, progress_indeterminate=progress_indeterminate, + size_bytes=size_bytes, speed_bytes=speed_bytes if isinstance(speed_bytes, int) else None, eta_text=eta_text, is_active=is_active, diff --git a/src/pullbox/ui/intervention_filter_helpers.py b/src/pullbox/ui/intervention_filter_helpers.py index d10d9f66..cddbacc5 100644 --- a/src/pullbox/ui/intervention_filter_helpers.py +++ b/src/pullbox/ui/intervention_filter_helpers.py @@ -7,9 +7,11 @@ from sqlalchemy import ColumnElement, and_, case, func, or_, true +from pullbox.core.partial_issue import needs_complete_copy from pullbox.models.indexer import IndexerConfig from pullbox.models.issue import Issue from pullbox.models.pending_match import PendingMatch, PendingMatchStatus +from pullbox.services.direct_artifact_pack import RECOVERABLE_PACK_FAILURES if TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -70,10 +72,10 @@ def intervention_lane_clause(lane: str) -> ColumnElement[bool]: """Separate failed direct acquisition from semantic matching decisions.""" source_kind = func.coalesce(PendingMatch.match_details["source_kind"].as_string(), "") failure_class = PendingMatch.match_details["failure_class"].as_string() - recovery = and_(source_kind == "direct", failure_class.is_not(None)) + recovery = and_(source_kind.in_(("direct", "dc")), failure_class.is_not(None)) if lane == "recovery": return recovery - return or_(source_kind != "direct", failure_class.is_(None)) + return or_(source_kind.not_in(("direct", "dc")), failure_class.is_(None)) def normalize_intervention_confidence_filter(confidence: str | None) -> str: @@ -310,15 +312,37 @@ def build_intervention_item_meta(pending_match: Any) -> dict[str, object]: lane = ( "recovery" - if details.get("source_kind") == "direct" and details.get("failure_class") + if details.get("source_kind") in {"direct", "dc"} and details.get("failure_class") else "review" ) + size_recovery = ( + lane == "recovery" and details.get("failure_code") == "artifact_resource_safety_review" + ) + safety_block = details.get("safety_block") + safety_block = safety_block if isinstance(safety_block, dict) else {} + replacement = needs_complete_copy(details, getattr(pending_match, "release_title", None)) return { + "replacement_recovery": lane == "recovery" and replacement, + "pack_recovery": lane == "recovery" + and details.get("failure_code") in RECOVERABLE_PACK_FAILURES, + "pack_review_reason": str(details.get("pack_review_reason") or ""), + "size_recovery": size_recovery, + "safety_recovery": lane == "recovery" and details.get("failure_class") == "safety", + "size_review_reason": str(safety_block.get("reason") or "") if size_recovery else "", + "can_allow_size": size_recovery + and safety_block.get("kind") == "archive_decompressed_size" + and safety_block.get("overrideable") is True, "protocol_label": intervention_protocol_label( bool(pending_match.is_torrent), str(details.get("source_kind") or ""), ), - "outcome_label": intervention_outcome_label(str(pending_match.status)), + "outcome_label": ( + "Dismissed (incomplete copy)" + if details.get("resolution_reason") == "incomplete_copy_dismissed" + else "Resolved by replacement" + if details.get("resolution_reason") == "metadata_superseded" + else intervention_outcome_label(str(pending_match.status)) + ), "review_reason_codes": reason_codes, "review_reason_labels": reason_labels, "review_reason_summary": intervention_review_reason_summary(reason_codes), @@ -327,7 +351,15 @@ def build_intervention_item_meta(pending_match: Any) -> dict[str, object]: "source_label": source_label.strip() or "Unknown", "rejection_reason": str(details.get("rejection_reason") or "").strip(), "lane": lane, - "recovery_summary": _direct_recovery_summary(details) if lane == "recovery" else "", + "recovery_summary": ( + "Incomplete copy: replacement needed" + if lane == "recovery" and replacement + else _direct_recovery_summary(details) + if lane == "recovery" + else "" + ), + "metadata_recovery": lane == "recovery" + and details.get("failure_code") == "direct_metadata_review_required", } @@ -349,6 +381,22 @@ def _direct_source_label(details: dict[str, object]) -> str: def _direct_recovery_summary(details: dict[str, object]) -> str: """Describe the acquisition failure without implying the semantic match was wrong.""" + if details.get("source_kind") == "dc": + from pullbox.services.airdcpp_recovery import RECOVERY_MESSAGES + + return RECOVERY_MESSAGES.get( + str(details.get("failure_code")), "AirDC++ request needs review." + ) + if details.get("failure_code") == "direct_pack_ownership_changed": + return "Library ownership changed during pack import; recheck the retained pack" + if details.get("failure_code") in RECOVERABLE_PACK_FAILURES: + return "Downloaded pack needs issue review; no new download is needed" + if details.get("failure_code") == "direct_metadata_review_required": + return "Downloaded file needs metadata review; no new download is needed" + if details.get("failure_code") == "artifact_resource_safety_review": + return "Downloaded file needs size approval" + if details.get("failure_class") == "safety": + return "Downloaded file needs safety review" host = _DIRECT_ARTIFACT_HOST_LABELS.get( str(details.get("artifact_host_kind") or "").strip(), "Artifact host", diff --git a/src/pullbox/ui/intervention_routes.py b/src/pullbox/ui/intervention_routes.py index 2e7531ca..95ad77fb 100644 --- a/src/pullbox/ui/intervention_routes.py +++ b/src/pullbox/ui/intervention_routes.py @@ -640,6 +640,13 @@ def _direct_approval_failure_message( return f"{provider_name} authentication needs attention before this result can download." if error.retryable or error.code in {"source_unavailable", "provider_timed_out"}: return f"{provider_name} is temporarily unavailable. Try approving this result again soon." + if error.code == "artifact_coverage_unverified": + return ( + f"{provider_name} returned download links, but Pullbox could not verify " + "which issues are included in those files. No download was started. " + "Update the provider and retry, or choose a result that explicitly includes " + "the requested issue." + ) if error.code == "no_eligible_complete_plan": return ( "Pullbox could not verify a complete, eligible download route for the requested " @@ -665,6 +672,9 @@ async def htmx_intervention_approve( return Response(status_code=404) release_title = pm.release_title + meta = build_intervention_item_meta(pm) + is_retained_recovery = bool(meta["size_recovery"] or meta["pack_recovery"]) + is_recovery = bool((pm.match_details or {}).get("failure_class")) form_getter = getattr(request, "form", None) form = await form_getter() if callable(form_getter) else {} reason = str(form.get("reason", "") or "") @@ -702,8 +712,24 @@ async def htmx_intervention_approve( download_svc, _configs = built svc = InterventionService(download_service=download_svc) + from pullbox.services.direct_artifact_quarantine import DirectArtifactValidationError + try: await svc.approve_match(session, pending_id) + except DirectArtifactValidationError as exc: + return _templates().TemplateResponse( + request, + "partials/intervention_action_result.html", + _ctx( + request, + user, + pending_id=pending_id, + heading="Could Not Approve", + title=release_title, + message=str(exc), + tone="error", + ), + ) except DirectAcquisitionPlanningError as exc: logger.warning( "htmx_direct_intervention_approve_unavailable", @@ -750,7 +776,7 @@ async def htmx_intervention_approve( user, **await load_intervention_context( session, - tab="queue", + tab="recovery" if is_recovery else "queue", reason_filter=reason, confidence_filter=confidence, protocol_filter=protocol, @@ -763,7 +789,20 @@ async def htmx_intervention_approve( if request.headers.get("HX-Target") == "intervention-queue-results" else "partials/intervention_content_bundle.html" ) - return _templates().TemplateResponse(request, template_name, ctx) + response = _templates().TemplateResponse(request, template_name, ctx) + if (pm.match_details or {}).get("resolution_reason") == "pack_fulfilled_by_library": + return _toast_response( + response, + "Already fulfilled by the verified library copy. No files were changed.", + "success", + ) + if is_retained_recovery: + return _toast_response( + response, + "Continuing with the retained file. No new download was queued.", + "success", + ) + return response return _templates().TemplateResponse( request, @@ -774,7 +813,13 @@ async def htmx_intervention_approve( pending_id=pending_id, heading="Approved", title=release_title, - message="Release sent to the download queue.", + message=( + "Already fulfilled by the verified library copy. No files were changed." + if (pm.match_details or {}).get("resolution_reason") == "pack_fulfilled_by_library" + else "Continuing with the retained file. No new download was queued." + if is_retained_recovery + else "Release sent to the download queue." + ), tone="success", ), ) @@ -797,6 +842,9 @@ async def htmx_intervention_reject( return Response(status_code=404) release_title = pm.release_title + from pullbox.core.partial_issue import needs_complete_copy + + incomplete_copy = needs_complete_copy(pm.match_details or {}, pm.release_title) form_getter = getattr(request, "form", None) form = await form_getter() if callable(form_getter) else {} reason = str(form.get("reason", "") or "") @@ -837,7 +885,9 @@ async def htmx_intervention_reject( return _toast_response( _templates().TemplateResponse(request, template_name, ctx), ( - "Release rejected and added to the blocklist." + "Recovery dismissed. The incomplete file and ownership were left unchanged." + if incomplete_copy + else "Release rejected and added to the blocklist." if blocklisted else "Failed release dismissed without blocklisting." ), @@ -863,6 +913,86 @@ async def htmx_intervention_reject( ) +@router.post( + "/htmx/intervention/{pending_id}/airdcpp/{action}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def htmx_intervention_airdcpp_recovery( + request: Request, + pending_id: int, + action: str, + user: AuthenticatedUser, + session: DbSession, +) -> Response: + """Keep queue recovery on the intervention surface, not the match-review path.""" + from pullbox.composition.airdcpp import get_airdcpp_supervisor_registry + from pullbox.models.airdcpp import AirDcppAcquisition + from pullbox.providers.airdcpp.supervisor import AirDcppSupervisorState + from pullbox.services.airdcpp_recovery import act_on_queue_recovery + + pending = await session.get(PendingMatch, pending_id) + if pending is None: + return Response(status_code=404) + details = pending.match_details or {} + download_id = details.get("dc_download_id") + if ( + details.get("source_kind") != "dc" + or type(download_id) is not int + or action not in {"recheck", "retry", "cancel"} + ): + return Response(status_code=400) + acquisition = await session.scalar( + select(AirDcppAcquisition).where(AirDcppAcquisition.download_history_id == download_id) + ) + registry = get_airdcpp_supervisor_registry() + supervisor = ( + registry.get(acquisition.client_config_id) + if registry and acquisition and acquisition.client_config_id + else None + ) + tone = "success" + if supervisor is None or supervisor.state is not AirDcppSupervisorState.READY: + message = ( + "AirDC++ is unavailable. The request was not retried or cancelled; " + "check the client connection, then recheck." + ) + tone = "error" + else: + try: + message = await act_on_queue_recovery( + session, pending_id, action, supervisor.api_client + ) + except ValueError as exc: + message = str(exc) + tone = "error" + form = await request.form() + try: + page = max(1, int(str(form.get("page", "1")))) + except ValueError: + page = 1 + ctx = _ctx( + request, + user, + **await load_intervention_context( + session, + tab="recovery", + protocol_filter=str(form.get("protocol", "")), + search_query=str(form.get("search", "")), + requested_page=page, + ), + ) + return _toast_response( + _templates().TemplateResponse( + request, + "partials/intervention_queue_results_bundle.html", + ctx, + ), + message, + tone, + ) + + @router.post( "/htmx/intervention/{pending_id}/retry-recovery", response_class=HTMLResponse, diff --git a/src/pullbox/ui/metadata_series_search.py b/src/pullbox/ui/metadata_series_search.py index 9f54f3fe..b36543ad 100644 --- a/src/pullbox/ui/metadata_series_search.py +++ b/src/pullbox/ui/metadata_series_search.py @@ -98,6 +98,16 @@ def source_messages(snapshot: SeriesDiscoveryRead | StoryArcDiscoveryRead) -> li messages = [] for outcome in snapshot.sources: detail = _OUTCOME_TEXT.get(outcome.status) + if outcome.source is MetadataSource.METRON_API and outcome.retry_after_seconds: + waiting = { + SourceStatus.RATE_LIMITED: "requests paused", + SourceStatus.TIMEOUT: "request timed out", + SourceStatus.UNAVAILABLE: "temporarily unavailable", + }.get(outcome.status) + if waiting: + seconds = outcome.retry_after_seconds + unit = "second" if seconds == 1 else "seconds" + detail = f"{waiting}; try again in {seconds} {unit}" if outcome.status is SourceStatus.UNSUPPORTED and isinstance( snapshot, StoryArcDiscoveryRead ): diff --git a/src/pullbox/ui/series_detail_routes.py b/src/pullbox/ui/series_detail_routes.py index 158009d1..8ec3ddd8 100644 --- a/src/pullbox/ui/series_detail_routes.py +++ b/src/pullbox/ui/series_detail_routes.py @@ -30,7 +30,10 @@ from pullbox.models.series import Series from pullbox.models.story_arc import IssueStoryArc, StoryArc from pullbox.schemas.whats_new import WhatsNewIssueSelection -from pullbox.services.airdcpp_route_tokens import get_airdcpp_route_token_store +from pullbox.services.airdcpp_route_tokens import ( + can_override_dc_rejection, + get_airdcpp_route_token_store, +) from pullbox.services.airdcpp_search_types import AirDcppSearchProgress from pullbox.services.reader_state_service import load_reader_state from pullbox.services.reading_query_service import ( @@ -618,6 +621,13 @@ async def htmx_issue_dc_search_results( return Response(status_code=404) template = _templates().get_template("partials/issue_dc_search_results.html") + has_library_file = ( + await session.scalar( + select(LibraryFile.id).where(LibraryFile.issue_id == issue_id).limit(1) + ) + is not None + ) + await session.commit() async def stream() -> AsyncIterator[str]: progress_queue: asyncio.Queue[AirDcppSearchProgress] = asyncio.Queue(maxsize=64) @@ -671,6 +681,20 @@ async def on_progress(progress: AirDcppSearchProgress) -> None: } for candidate in outcome.matched ] + dc_rejected_rows = [ + { + "candidate": candidate, + "route_token": route_store.issue( + candidate, + issue_id=target.issue_id, + user_id=user.id, + search_log_id=None, + ) + if can_override_dc_rejection(candidate) + else None, + } + for candidate in outcome.rejected + ] html = template.render( _ctx( request, @@ -679,9 +703,11 @@ async def on_progress(progress: AirDcppSearchProgress) -> None: "id": target.issue_id, "series_id": target.series_id, "issue_number": target.issue_number, + "has_library_file": has_library_file, }, outcome=outcome, dc_rows=dc_rows, + dc_rejected_rows=dc_rejected_rows, release_selection=discovery, ) ) @@ -803,6 +829,10 @@ async def htmx_issue_search_results( "id": bundle.issue.id, "series_id": bundle.target.series_id, "issue_number": bundle.issue.issue_number, + "has_library_file": await session.scalar( + select(LibraryFile.id).where(LibraryFile.issue_id == issue_id).limit(1) + ) + is not None, } if bundle.runtime is None: diff --git a/src/pullbox/ui/static/css/tailwind.css b/src/pullbox/ui/static/css/tailwind.css index 5dd64b1c..06d35b37 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)}.app-header{height:calc(var(--spacing) * 12);align-items:center;gap:calc(var(--spacing) * 3);padding-inline:calc(var(--spacing) * 4);display:flex;position:relative}@media (min-width:40rem){.app-header{padding-inline:calc(var(--spacing) * 6)}}.app-header>*{flex-shrink:0}.app-header__spacer{flex:1}.app-header>.app-header__user{flex-shrink:1;min-width:0}@media not all and (min-width:40rem){.app-header{flex-wrap:wrap;gap:0 .5rem;height:auto;padding-block:.25rem}.app-header__spacer{flex:0 0 100%}.app-header .header-add-menu{position:static}.app-header .header-add-menu__panel{width:17rem;min-width:0;max-width:calc(100% - 2rem);margin-top:.5rem;top:100%;left:1rem}}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);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));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{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:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.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{overflow-wrap:anywhere;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{overflow-wrap:anywhere;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-primary);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:var(--pb-success-dim)}.whats-new-cache-badge-error{background:var(--pb-error-dim)}.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}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.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}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.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;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--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;justify-content:center;align-items:center;display:inline-flex;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-auto{inset:auto}.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)}.top-full{top:100%}.-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-0{margin:0}.m-5{margin:calc(var(--spacing) * 5)}.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-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-28{height:calc(var(--spacing) * 28)}.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-40{max-height:calc(var(--spacing) * 40)}.max-h-60{max-height:calc(var(--spacing) * 60)}.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-96{max-height:calc(var(--spacing) * 96)}.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\(100dvh-5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.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-14{width:calc(var(--spacing) * 14)}.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-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.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-\[150px\]{width:150px}.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-\[560px\]{max-width:560px}.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-\[820px\]{min-width:820px}.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}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.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-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.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}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.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-2\.5>: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)))}: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-1\.5{column-gap:calc(var(--spacing) * 1.5)}.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-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.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)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}: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-full{border-radius:3.40282e38px!important}.\!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-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.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\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, 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\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, 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-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.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}.text-\[22px\]{font-size:22px}.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-\[18px\]{--tw-leading:18px;line-height:18px}.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)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.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-current{color:currentColor}.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)}.ring-1{--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)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.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-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;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)}.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-dim:hover{background-color:var(--pb-interactive-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\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.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:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.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\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.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 (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.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-\[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\: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-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\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.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-2{grid-template-columns:repeat(2,minmax(0,1fr))}.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-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);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)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);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)}.app-header{height:calc(var(--spacing) * 12);align-items:center;gap:calc(var(--spacing) * 3);padding-inline:calc(var(--spacing) * 4);display:flex;position:relative}@media (min-width:40rem){.app-header{padding-inline:calc(var(--spacing) * 6)}}.app-header>*{flex-shrink:0}.app-header__spacer{flex:1}.app-header>.app-header__user{flex-shrink:1;min-width:0}@media not all and (min-width:40rem){.app-header{flex-wrap:wrap;gap:0 .5rem;height:auto;padding-block:.25rem}.app-header__spacer{flex:0 0 100%}.app-header .header-add-menu{position:static}.app-header .header-add-menu__panel{width:17rem;min-width:0;max-width:calc(100% - 2rem);margin-top:.5rem;top:100%;left:1rem}}.header-add-menu{flex-shrink:0;display:inline-flex;position:relative}.header-add-menu__primary{gap:calc(var(--spacing) * 2);white-space:nowrap;border-top-right-radius:0;border-bottom-right-radius:0;padding-right:.625rem}.header-add-menu__trigger{gap:calc(var(--spacing) * 2);white-space:nowrap;border-left:1px solid var(--pb-text-inverse);border-top-left-radius:0;border-bottom-left-radius:0;justify-content:space-between;min-width:7rem;padding-inline:.5rem}@supports (color:color-mix(in lab, red, red)){.header-add-menu__trigger{border-left:1px solid color-mix(in srgb, var(--pb-text-inverse) 26%, transparent)}}.header-add-menu__chevron{width:.875rem;height:.875rem;transition:transform .15s}.header-add-menu__panel{z-index:50;padding:calc(var(--spacing) * 1.5);border:1px solid var(--pb-border);background:var(--pb-bg-card);min-width:17rem;box-shadow:var(--pb-shadow-2);border-radius:.75rem;position:absolute;top:calc(100% + .5rem);left:0}.header-add-menu__item{align-items:center;gap:calc(var(--spacing) * 2.5);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));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);min-height:2.5rem;color:var(--pb-text);border-radius:.5rem;transition:color .15s,background-color .15s;display:flex}.header-add-menu__item:hover,.header-add-menu__item:focus-visible,.header-add-menu__item--selected{color:var(--pb-interactive);background:var(--pb-interactive-dim)}.header-add-menu__item--disabled{color:var(--pb-text-ter);cursor:not-allowed;opacity:.72}.header-add-menu__item--disabled:hover{color:var(--pb-text-ter);background:0 0}.header-add-menu__item-label{flex:1;min-width:0}.header-add-menu__check{color:var(--pb-success)}.header-add-menu__soon{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:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;text-transform:uppercase;color:var(--pb-text-ter);flex-shrink:0}.header-add-menu__item svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}@media (prefers-reduced-motion:reduce){.header-add-menu__chevron,.header-add-menu__item{transition:none}}.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{overflow-wrap:anywhere;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{overflow-wrap:anywhere;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-primary);align-items:center;display:inline-flex}.whats-new-cache-badge-success{background:var(--pb-success-dim)}.whats-new-cache-badge-error{background:var(--pb-error-dim)}.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}.import-advanced-disclosure[open]>summary .import-advanced-disclosure-chevron{rotate:90deg}.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}.import-review-issue-choices-viewport{overscroll-behavior:contain;max-height:27.25rem;overflow:auto}.import-review-issue-choices-viewport .downloads-table th{z-index:1;position:sticky;top:0}.import-review-issue-choices-viewport .downloads-table td{padding-block:.25rem}.import-review-issue-choices-viewport .downloads-table tbody tr{height:2.5rem}.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;height:calc(var(--spacing) * 6);width:calc(var(--spacing) * 6);--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;justify-content:center;align-items:center;display:inline-flex;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-auto{inset:auto}.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)}.top-full{top:100%}.-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-0{margin:0}.m-5{margin:calc(var(--spacing) * 5)}.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-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-28{height:calc(var(--spacing) * 28)}.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-32{max-height:calc(var(--spacing) * 32)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-60{max-height:calc(var(--spacing) * 60)}.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-96{max-height:calc(var(--spacing) * 96)}.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\(100dvh-5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100dvh-6rem\)\]{max-height:calc(100dvh - 6rem)}.max-h-\[calc\(100dvh-8rem\)\]{max-height:calc(100dvh - 8rem)}.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-24{min-height:calc(var(--spacing) * 24)}.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-14{width:calc(var(--spacing) * 14)}.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-\[30\%\]{width:30%}.w-\[30rem\]{width:30rem}.w-\[32px\]{width:32px}.w-\[34\%\]{width:34%}.w-\[35\%\]{width:35%}.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-\[150px\]{width:150px}.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-\[560px\]{max-width:560px}.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-\[820px\]{min-width:820px}.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}.basis-\[360px\]{flex-basis:360px}.basis-\[420px\]{flex-basis:420px}.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-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.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}.grid-cols-\[auto_minmax\(0\,1fr\)\]{grid-template-columns:auto minmax(0,1fr)}.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-2\.5>: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)))}: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-1\.5{column-gap:calc(var(--spacing) * 1.5)}.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-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.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)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}: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-full{border-radius:3.40282e38px!important}.\!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-white\/60{border-color:#fff9}@supports (color:color-mix(in lab, red, red)){.border-white\/60{border-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.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\/5{background-color:var(--pb-success)}@supports (color:color-mix(in lab, red, red)){.bg-pb-success\/5{background-color:color-mix(in oklab, var(--pb-success) 5%, 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\/25{background-color:var(--pb-warning-dim)}@supports (color:color-mix(in lab, red, red)){.bg-pb-warning-dim\/25{background-color:color-mix(in oklab, var(--pb-warning-dim) 25%, 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-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.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}.text-\[22px\]{font-size:22px}.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-\[18px\]{--tw-leading:18px;line-height:18px}.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)}.text-pretty{text-wrap:pretty}.\[overflow-wrap\:anywhere\]{overflow-wrap:anywhere}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.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-current{color:currentColor}.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)}.ring-1{--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)}.ring-pb-interactive{--tw-ring-color:var(--pb-interactive)}.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-\[border-color\,background-color\,box-shadow\]{transition-property:border-color,background-color,box-shadow;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)}.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-dim:hover{background-color:var(--pb-interactive-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\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.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:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-pb-interactive:focus-visible{outline-color:var(--pb-interactive)}.focus-visible\:outline-white:focus-visible{outline-color:var(--color-white)}.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\:text-pb-text-dim:disabled{color:var(--pb-text-dim)}.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 (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media (min-width:40rem){.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:ml-auto{margin-left:auto}.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-\[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\: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-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\,2fr\)_minmax\(0\,3fr\)\]{grid-template-columns:minmax(0,2fr) minmax(0,3fr)}.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-2{grid-template-columns:repeat(2,minmax(0,1fr))}.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-secondary);margin-top:.25rem;font-size:.78rem}.downloads-release-name{max-width:260px;color:var(--pb-text-primary);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)}.import-review-row-menu-item{width:100%;color:var(--pb-text);text-align:left;cursor:pointer;background:0 0;border:0;border-radius:6px;padding:8px 12px;font-size:.875rem;font-weight:500;line-height:1.25rem;transition:background-color .14s,color .14s;display:block}.import-review-row-menu-item:hover,.import-review-row-menu-item:focus-visible{background:var(--pb-interactive-dim);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/issue-file-metadata.js b/src/pullbox/ui/static/js/issue-file-metadata.js index 04583a7b..e5b5a66f 100644 --- a/src/pullbox/ui/static/js/issue-file-metadata.js +++ b/src/pullbox/ui/static/js/issue-file-metadata.js @@ -1,10 +1,11 @@ /* Preview and approve one managed comic; native conversion is explicit. */ function issueFileMetadata(issueId) { - var endpoint = "/api/v1/issues/" + issueId + "/file-metadata"; + var endpoint = issueId ? "/api/v1/issues/" + issueId + "/file-metadata" : ""; return { + recovery: !issueId, resolved: false, editor: false, allFields: false, editorDrafts: {}, open: false, busy: false, error: "", preview: null, job: null, choices: {}, choicesDirty: false, timer: null, disposed: false, trigger: null, generation: 0, jobGeneration: 0, - init: function () { this.loadJob(); }, + init: function () { if (endpoint) this.loadJob(); }, destroy: function () { this.disposed = true; this.generation += 1; clearTimeout(this.timer); }, request: async function (url, body) { var response = await fetch(url, body === undefined ? { cache: "no-store" } : { @@ -23,7 +24,9 @@ function issueFileMetadata(issueId) { var data = await this.request(endpoint + "/job"); if (this.disposed || generation !== this.jobGeneration) return; this.job = data.job; + if (this.recovery) this.resolved = !!data.resolved; if (wasActive && this.job && this.job.state === "COMPLETED") { + if (this.recovery) return; window.dispatchEvent(new CustomEvent("issue-file-metadata-complete")); var target = document.getElementById("issue-metadata-content"); if (target) await htmx.ajax("GET", "/htmx/issues/" + issueId + "/metadata", { target: target, swap: "morph:outerHTML" }); @@ -31,23 +34,92 @@ function issueFileMetadata(issueId) { } catch (error) { if (!this.disposed) this.error = error.message; } if (!this.disposed && this.active()) this.timer = setTimeout(() => this.loadJob(), 1000); }, - show: async function () { + showRecovery: async function (pendingId) { + if (!this.recovery || this.busy || !Number.isSafeInteger(pendingId) || pendingId <= 0) return; + clearTimeout(this.timer); + this.jobGeneration += 1; + this.generation += 1; + endpoint = "/api/v1/intervention/" + pendingId + "/file-metadata"; + this.preview = null; this.job = null; this.choices = {}; this.error = ""; this.resolved = false; + this.trigger = document.activeElement; + this.open = true; this.busy = true; + this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true })); + await this.loadJob(); + this.busy = false; + if (this.open && !this.active() && !this.resolved && !(this.job && this.job.state === "PAUSED")) await this.loadPreview(); + }, + show: async function (editor = false) { + if (this.busy) return; + this.editor = editor; this.trigger = document.activeElement; this.open = true; this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true })); - if (!this.job || ["FAILED", "CANCELLED"].includes(this.job.state)) await this.loadPreview(); + if ((editor && !this.active() && !(this.job && this.job.state === "PAUSED")) || !this.job || ["FAILED", "CANCELLED"].includes(this.job.state)) await this.loadPreview(); }, close: function () { this.open = false; this.generation += 1; if (this.trigger && this.trigger.isConnected) this.trigger.focus({ preventScroll: true }); + if (this.recovery && this.resolved) htmx.trigger(document.body, "intervention:refresh"); }, choose: function (key, source) { this.choices[key] = source; this.choicesDirty = true; }, canWrite: function () { - return !!this.preview && this.preview.ready !== false && !this.choicesDirty; + return !!this.preview && this.preview.ready !== false && !this.choicesDirty && (!this.editor || this.preview.editor_changed); + }, + editorFields: function () { + return (this.preview ? this.preview.editor_fields || [] : []).filter(field => + this.allFields || field.differs || (this.editorDrafts[field.key] && this.editorDrafts[field.key].selected)); + }, + editorOptions: function (field) { + var options = (field.options || []).map(option => ({ value: option.source, label: option.source === "library" ? "Keep library value" : option.source })); + options.push({ value: "custom", label: "Enter correction" }, { value: "clear", label: "Clear value" }); + if (field.overridden) options.push({ value: "reset", label: "Remove manual override" }); + return options; + }, + selectEditorFields: function (selected) { + (this.preview.editor_fields || []).filter(field => field.editable).forEach(field => { this.editorDrafts[field.key].selected = selected; }); + this.choicesDirty = true; + }, + editorEdits: function () { + var edits = {}; + Object.keys(this.editorDrafts).forEach(key => { + var draft = this.editorDrafts[key]; + if (!draft.selected) return; + edits[key.slice(6)] = draft.source === "custom" ? { source: draft.source, value: draft.value } : { source: draft.source }; + }); + return edits; + }, + reviewEndpoint: function () { + return this.editor ? endpoint.replace("/file-metadata", "/metadata-editor") : endpoint; + }, + replacementUrl: function (action) { + return this.preview && Number.isSafeInteger(this.preview.issue_id) + ? "/issues/" + this.preview.issue_id + "?replacement=" + action : "#"; + }, + dismissReplacement: async function () { + if (this.busy || !this.recovery || !this.preview || !this.preview.replacement_required) return; + this.open = false; + await this.$nextTick(); + var confirmed = await pbConfirm({ title: "Dismiss recovery?", message: "This removes the recovery notification. It does not fix or delete the incomplete file, change ownership, or blocklist the release.", confirmText: "Dismiss recovery" }); + if (!confirmed) { + this.open = true; + this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true })); + return; + } + this.busy = true; + try { + await this.request(endpoint + "/dismiss", {}); + this.resolved = true; + this.close(); + } catch (error) { + this.error = error.message; + this.open = true; + this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true })); + } + finally { this.busy = false; } }, loadPreview: async function (reset = true) { if (this.busy) return; @@ -57,10 +129,17 @@ function issueFileMetadata(issueId) { this.busy = true; this.error = ""; this.choicesDirty = true; var choices = reset ? {} : Object.assign({}, this.choices); + var edits = reset ? {} : this.editorEdits(); try { - var data = await this.request(endpoint + "/preview", Object.keys(choices).length ? { choices: choices } : {}); + var data = await this.request(this.reviewEndpoint() + "/preview", this.editor ? { edits: edits } : Object.keys(choices).length ? { choices: choices } : {}); if (this.disposed || generation !== this.generation) return; this.preview = data; this.job = null; + if (this.editor && reset) { + this.allFields = false; this.editorDrafts = {}; + (data.editor_fields || []).filter(field => field.editable).forEach(field => { + this.editorDrafts[field.key] = { selected: false, source: "library", value: field.current || "" }; + }); + } this.choices = {}; (data.conflicts || []).forEach(conflict => { if (conflict.selected) this.choices[conflict.key] = conflict.selected; }); this.choicesDirty = false; @@ -72,8 +151,9 @@ function issueFileMetadata(issueId) { this.busy = true; this.error = ""; try { var approval = { review_key: this.preview.review_key }; - if (Object.keys(this.choices).length) approval.choices = Object.assign({}, this.choices); - var result = await this.request(endpoint + "/write", approval); + if (this.editor) approval.edits = this.editorEdits(); + else if (Object.keys(this.choices).length) approval.choices = Object.assign({}, this.choices); + var result = await this.request(this.reviewEndpoint() + "/write", approval); if (this.disposed) return; this.jobGeneration += 1; this.job = { id: result.job_id, state: result.state, percent: 0, message: "Queued", error: null }; @@ -90,7 +170,7 @@ function issueFileMetadata(issueId) { }, trapFocus: function (event) { if (!this.open) return; - var elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], [tabindex="0"]')).filter(el => el.getClientRects().length > 0); + var elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex="0"]')).filter(el => el.getClientRects().length > 0); var first = elements[0], last = elements[elements.length - 1]; if (!first) { event.preventDefault(); return; } if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.dialog)) { event.preventDefault(); last.focus(); } diff --git a/src/pullbox/ui/static/js/pullbox.js b/src/pullbox/ui/static/js/pullbox.js index fe0cab66..21d63c32 100644 --- a/src/pullbox/ui/static/js/pullbox.js +++ b/src/pullbox/ui/static/js/pullbox.js @@ -13450,11 +13450,21 @@ function appShell() { activityActiveCount: 0, activitySpinnerCount: 0, activityAttentionCount: 0, - activitySource: null, + activityTotalCount: 0, + activityOffset: 0, + activityPageOffset: 0, + activityPageSize: 20, activityPollTimer: null, - activityRefreshTimer: null, - activityStreamOpen: false, + activityExpiryTimer: null, + activityRequestController: null, + activityRequestTimer: null, + activityRefreshQueued: false, + activityDestroyed: false, activityRefreshing: false, + activityPageHidden: false, + activityVisibilityHandler: null, + activityPageHideHandler: null, + activityPageShowHandler: null, get collapsed() { return !this.sidebarOpen && !this.sidebarMobileOpen; @@ -13493,15 +13503,31 @@ function appShell() { }, destroy: function () { + this.activityDestroyed = true; + this.pauseActivity(); + document.removeEventListener("visibilitychange", this.activityVisibilityHandler); + document.removeEventListener("pullbox:styles-ready", this.activityVisibilityHandler); + window.removeEventListener("pagehide", this.activityPageHideHandler); + window.removeEventListener("pageshow", this.activityPageShowHandler); if (this.resizeHandler) { window.removeEventListener("resize", this.resizeHandler); } if (window.__pullboxUpdateUsageStatsPreference) { delete window.__pullboxUpdateUsageStatsPreference; } - this.disconnectActivityStream(); + }, + + canRefreshActivity: function () { + return !this.activityDestroyed && !this.activityPageHidden && !document.hidden && + document.documentElement.getAttribute("data-pullbox-stylesheet-ready") === "true"; + }, + + pauseActivity: function () { this.clearActivityTimer(); - this.clearActivityRefreshTimer(); + window.clearTimeout(this.activityExpiryTimer); + window.clearTimeout(this.activityRequestTimer); + this.activityRefreshQueued = false; + if (this.activityRequestController) this.activityRequestController.abort(); }, clearActivityTimer: function () { @@ -13513,6 +13539,7 @@ function appShell() { scheduleActivityPoll: function (delayMs) { var self = this; + if (!self.canRefreshActivity()) return; self.clearActivityTimer(); self.activityPollTimer = window.setTimeout(function () { self.activityPollTimer = null; @@ -13520,35 +13547,80 @@ function appShell() { }, delayMs || 3000); }, - clearActivityRefreshTimer: function () { - if (this.activityRefreshTimer) { - window.clearTimeout(this.activityRefreshTimer); - this.activityRefreshTimer = null; - } + bootstrapActivity: function () { + var self = this; + // Short requests leave HTTP/1.1 connections available to other tabs. + self.activityVisibilityHandler = function () { + if (self.canRefreshActivity()) self.refreshActivity(); + else self.pauseActivity(); + }; + self.activityPageHideHandler = function () { + self.activityPageHidden = true; + self.pauseActivity(); + }; + self.activityPageShowHandler = function (event) { + if (!self.activityPageHidden && !event.persisted) return; + self.activityPageHidden = false; + self.activityVisibilityHandler(); + }; + document.addEventListener("visibilitychange", self.activityVisibilityHandler); + document.addEventListener("pullbox:styles-ready", self.activityVisibilityHandler); + window.addEventListener("pagehide", self.activityPageHideHandler); + window.addEventListener("pageshow", self.activityPageShowHandler); + self.refreshActivity(); }, - scheduleActivityRefresh: function (delayMs) { - var self = this; - self.clearActivityRefreshTimer(); - self.activityRefreshTimer = window.setTimeout(function () { - self.activityRefreshTimer = null; - self.refreshActivity(); - }, delayMs || 500); + toggleActivity: function () { + this.activityOpen = !this.activityOpen; + if (this.activityOpen) this.refreshActivity(); }, - bootstrapActivity: function () { - this.connectActivityStream(); + changeActivityPage: function (direction) { + if (this.activityOffset !== this.activityPageOffset) return; + var offset = this.activityPageOffset + direction * this.activityPageSize; + if (offset < 0 || offset >= this.activityTotalCount) return; + this.activityOffset = offset; this.refreshActivity(); }, + activityPageLabel: function () { + if (!this.activityTotalCount) return "0 items"; + return (this.activityPageOffset + 1) + "-" + + (this.activityPageOffset + this.activityOperations.length) + + " of " + this.activityTotalCount; + }, + + scheduleActivityExpiry: function (delayMs, elapsedMs) { + var self = this; + window.clearTimeout(self.activityExpiryTimer); + self.activityExpiryTimer = null; + if (delayMs == null || !Number.isFinite(Number(delayMs))) return; + self.activityExpiryTimer = window.setTimeout(function () { + self.activityExpiryTimer = null; + self.refreshActivity(); + }, Math.max(100, Number(delayMs) - elapsedMs)); + }, + refreshActivity: function () { var self = this; + if (!self.canRefreshActivity()) return; if (self.activityRefreshing) { + self.activityRefreshQueued = true; return; } + self.clearActivityTimer(); self.activityRefreshing = true; - fetch("/api/v1/activity", { + self.activityRefreshQueued = false; + var offset = self.activityOffset; + var startedAt = Date.now(); + var controller = new AbortController(); + self.activityRequestController = controller; + self.activityRequestTimer = window.setTimeout(function () { + controller.abort(); + }, 15000); + fetch("/api/v1/activity" + (offset ? "?offset=" + offset : ""), { headers: { Accept: "application/json" }, + signal: controller.signal, }) .then(function (response) { if (!response.ok) { @@ -13557,8 +13629,19 @@ function appShell() { return response.json(); }) .then(function (payload) { + if (!self.canRefreshActivity()) return; + var operations = payload && Array.isArray(payload.operations) ? payload.operations : []; + var total = Math.max(0, Number(payload && payload.total_count) || operations.length); + var lastOffset = Math.max(0, Math.ceil(total / self.activityPageSize) - 1) * self.activityPageSize; + if (offset !== self.activityOffset || offset > lastOffset) { + self.activityOffset = Math.min(self.activityOffset, lastOffset); + self.activityRefreshQueued = true; + return; + } self.activityOperations = - payload && Array.isArray(payload.operations) ? payload.operations : []; + operations; + self.activityTotalCount = total; + self.activityPageOffset = offset; self.activityActiveCount = Math.max( 0, Number(payload && payload.active_count) || 0, @@ -13571,65 +13654,24 @@ function appShell() { 0, Number(payload && payload.attention_count) || 0, ); + self.scheduleActivityExpiry(payload.refresh_after_ms, Date.now() - startedAt); }) .catch(function () { // Activity is supplemental; a transient failure must not interrupt the page. }) .finally(function () { + window.clearTimeout(self.activityRequestTimer); + self.activityRequestController = null; self.activityRefreshing = false; - if (!self.activityStreamOpen) { - self.scheduleActivityPoll(3000); - } - if (!self.activitySource) { - self.connectActivityStream(); + if (!self.canRefreshActivity()) return; + if (self.activityRefreshQueued) { + self.refreshActivity(); + return; } + self.scheduleActivityPoll(3000); }); }, - connectActivityStream: function () { - var self = this; - if (self.activitySource) { - return; - } - var source = new EventSource("/api/v1/activity/stream"); - self.activitySource = source; - var refreshFromEvent = function () { - if (self.activitySource === source) { - 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; - source.onerror = function () { - if (self.activitySource !== source) { - return; - } - self.disconnectActivityStream(); - self.scheduleActivityPoll(3000); - }; - }, - - disconnectActivityStream: function () { - if (!this.activitySource) { - return; - } - try { - this.activitySource.close(); - } catch (_) { - // Closing a stale activity stream is best-effort. - } - this.activitySource = null; - this.activityStreamOpen = false; - }, - acknowledgeActivity: function (operationId) { var self = this; fetch("/api/v1/activity/" + operationId + "/acknowledge", { @@ -13689,7 +13731,8 @@ function appShell() { activitySummaryLabel: function () { if (this.activityAttentionCount > 0) { - return this.activityAttentionCount + " need attention"; + return this.activityAttentionCount + " need attention" + + (this.activityActiveCount > 0 ? " / " + this.activityActiveCount + " active" : ""); } if (this.activityActiveCount > 0) { return this.activityActiveCount + " active"; @@ -14123,6 +14166,7 @@ function issueSearchResultActions(config) { return { grabbing: false, + grabbed: false, blocking: false, blocked: false, @@ -14136,12 +14180,24 @@ function issueSearchResultActions(config) { } }, - grabRelease: function (button) { + grabRelease: async function (button) { var self = this; - if (self.grabbing || self.blocked) { + if (self.grabbing || self.grabbed || self.blocked) { return; } + if (cfg.hasLibraryFile) { + self.grabbing = true; + var confirmed = await pbConfirm({ + title: "Download replacement?", + message: "The selected download will replace the current library file after validation. The current file stays in place until replacement succeeds. Cancel leaves it unchanged.", + confirmText: "Download replacement", + destructive: true, + }); + self.grabbing = false; + if (!confirmed) return; + } + var directAttemptId = parseInt(button.dataset.directAttempt, 10) || 0; var dcRouteToken = button.dataset.dcRouteToken || ""; var host = document.getElementById("issue-search-controller"); @@ -14163,6 +14219,8 @@ function issueSearchResultActions(config) { } else if (dcRouteToken) { endpoint = "/api/v1/issues/" + cfg.issueId + "/dc-grab"; payload = { dc_route_token: dcRouteToken }; + if (button.dataset.dcOverride === "true") payload.override_rejection = true; + if (cfg.hasLibraryFile) payload.replace_existing_file = true; } if (cfg.releaseSelection) { endpoint = "/api/v1/whats-new/grab"; @@ -14182,6 +14240,7 @@ function issueSearchResultActions(config) { }) .then(function (response) { if (response.ok) { + if (dcRouteToken) self.grabbed = true; self.dispatchToast( directAttemptId || dcRouteToken ? "Direct download queued" @@ -14222,23 +14281,28 @@ function issueSearchResultActions(config) { grabRejectedRelease: function (button) { var self = this; - if (self.grabbing || self.blocked) { + if (self.grabbing || self.grabbed || self.blocked) { return; } var reason = button.dataset.rejectionReason || "Pullbox rejected this result."; var isDirect = Boolean(parseInt(button.dataset.directAttempt, 10) || 0); + var isDc = Boolean(button.dataset.dcRouteToken); + self.grabbing = true; pbConfirm({ title: "Grab Rejected Result", message: "Pullbox rejected this result: " + reason + - (isDirect + (isDc + ? " If you continue, Pullbox will queue this file in AirDC++. File safety and metadata checks still apply before import." + : isDirect ? " If you continue, Pullbox will plan and queue this direct result anyway." : " If you continue, Pullbox will send it to your download client anyway."), confirmText: "Grab anyway", destructive: false, }).then(function (ok) { + self.grabbing = false; if (!ok) { return; } @@ -19179,6 +19243,20 @@ function issueDetailPage(config) { var cfg = config || {}; return Object.assign(fileBrowserMixin(cfg), readerMixin(cfg), { + replacementAction: "", + init: function () { + readerMixin(cfg).init.call(this); + var url = new URL(window.location.href); + var action = url.searchParams.get("replacement"); + if (action !== "search" && action !== "import") return; + this.replacementAction = action; + url.searchParams.delete("replacement"); + window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash); + this.$nextTick(() => { + if (action === "search") this.openManualSearchModal(); + else this.openImportFileBrowser(); + }); + }, coverModalOpen: false, coverModalUrl: "", searching: false, @@ -19187,6 +19265,9 @@ function issueDetailPage(config) { importing: false, cancellingImport: false, importReplacementConfirmed: false, + importMetadataPreview: null, + importMetadataChoices: {}, + importPreviewController: null, importModalOpen: false, importPollTimer: null, importState: "idle", @@ -19281,6 +19362,8 @@ function issueDetailPage(config) { this.form.file_size = selection.size || 0; this.form.file_ext = selection.ext || ""; this.importReplacementConfirmed = false; + this.importMetadataPreview = null; + this.importMetadataChoices = {}; this.submitImport(); }, @@ -19288,6 +19371,19 @@ function issueDetailPage(config) { return this.importModalOpen; }, + trapImportFocus: function (event) { + var dialog = event.currentTarget; + var elements = Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], [tabindex="0"]')) + .filter(function (element) { return element.getClientRects().length > 0; }); + var first = elements[0], last = elements[elements.length - 1]; + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog)) { + event.preventDefault(); first.focus(); + } + }, + closeImportModal: function () { if (this.importState === "running") { return; @@ -19303,6 +19399,8 @@ function issueDetailPage(config) { completed: "Complete", failed: "Needs attention", safety_blocked: "Safety review", + metadata_review: "Metadata review", + checking: "Checking metadata", cancelled: "Cancelled", }; return labels[this.importState] || "Importing"; @@ -19488,6 +19586,14 @@ function issueDetailPage(config) { cancelIssueImport: async function () { if (this.cancellingImport || this.importState === "completed") return; + if (this.importState === "checking") { + if (this.importPreviewController) this.importPreviewController.abort(); + return; + } + if (this.importState === "metadata_review") { + this.closeImportModal(); + return; + } if (this.importState === "failed" || this.importState === "safety_blocked" || this.importState === "cancelled") { this.closeImportModal(); return; @@ -19531,7 +19637,17 @@ function issueDetailPage(config) { return (bytes / 1048576).toFixed(1) + " MB"; }, - submitImport: async function () { + canImportReviewedMetadata: function () { + var self = this; + return !self.importing && self.importMetadataPreview && + self.importMetadataPreview.conflicts.every(function (conflict) { + return conflict.options.some(function (option) { + return option.selectable && self.importMetadataChoices[conflict.key] === option.source; + }); + }); + }, + + submitImport: async function (approveMetadata) { if (!this.form.file_path || this.importing) return; if (cfg.hasLibraryFile && !this.importReplacementConfirmed) { @@ -19564,17 +19680,45 @@ function issueDetailPage(config) { }); try { + var approval = null; + if (cfg.pairedImportMetadata && cfg.importMetadataPreviewUrl) { + this.importState = "checking"; + this.importMessage = "Checking file metadata..."; + this.importPreviewController = new AbortController(); + var previewResponse = await fetch(cfg.importMetadataPreviewUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken() }, + signal: this.importPreviewController.signal, + body: JSON.stringify({ file_path: this.form.file_path, move_to_library: this.form.move_to_library, choices: this.importMetadataChoices }), + }); + var preview = await previewResponse.json(); + if (!previewResponse.ok) throw new Error(preview.detail || "Unable to review file metadata."); + var changed = preview && this.importMetadataPreview && + preview.context_key !== this.importMetadataPreview.context_key; + if (changed) this.importMetadataChoices = {}; + this.importMetadataPreview = preview; + if (preview && (changed || (preview.conflicts.length && (!approveMetadata || !preview.ready)))) { + this.importState = "metadata_review"; + this.importMessage = changed ? "File or catalog changed. Review the current values." : "Review file metadata"; + this.importing = false; + return; + } + if (preview) approval = { review_key: preview.review_key, choices: this.importMetadataChoices }; + } + this.importState = "running"; + var requestBody = { + file_path: this.form.file_path, + move_to_library: this.form.move_to_library, + allow_resource_safety_exception: allowSafetyException, + }; + if (approval) requestBody.metadata_approval = approval; var response = await fetch(cfg.importStartUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken(), }, - body: JSON.stringify({ - file_path: this.form.file_path, - move_to_library: this.form.move_to_library, - allow_resource_safety_exception: allowSafetyException, - }), + body: JSON.stringify(requestBody), }); if (!response.ok) { @@ -19594,18 +19738,25 @@ function issueDetailPage(config) { } catch (error) { this.stopImportPolling(); this.importing = false; + if (error.name === "AbortError") { + this.importState = "cancelled"; + this.importModalOpen = false; + return; + } this.importState = "failed"; this.importMessage = "Import failed."; this.importErrorMessage = error.message || "An error occurred during import"; this.dispatchToast(this.importErrorMessage, "error"); + } finally { + this.importPreviewController = null; } }, retryImportWithSafetyException: function () { if (!this.form.file_path || this.importing) return; this.importAllowSafetyException = true; - this.submitImport(); + this.submitImport(Boolean(this.importMetadataPreview)); }, deleteIssueFile: async function () { @@ -20731,6 +20882,10 @@ function interventionQueueRefreshEnabled() { return false; } + if (document.querySelector("[data-metadata-recovery-open='true']")) { + return false; + } + if (document.querySelector("[data-intervention-queue-expanded='true']")) { return false; } @@ -22379,9 +22534,15 @@ document.addEventListener("htmx:afterRequest", function (e) { return; } - if (e.detail.failed) { + if (e.detail.failed || (e.detail.xhr && e.detail.xhr.status === 0)) { _setContentSwapPhase(null); _importEventSourceRegistry.resumeSuspended(); + if ( + e.detail.xhr && e.detail.xhr.status === 0 && + e.detail.elt && e.detail.elt.matches("[data-story-arc-preview]") + ) { + showToast({ message: "Could not load story arc members. Try Preview members again.", level: "error" }); + } return; } diff --git a/src/pullbox/ui/static/js/series-metadata-editor.js b/src/pullbox/ui/static/js/series-metadata-editor.js new file mode 100644 index 00000000..5b4ee8b8 --- /dev/null +++ b/src/pullbox/ui/static/js/series-metadata-editor.js @@ -0,0 +1,197 @@ +/* Reviewed series corrections; comic metadata is deliberately outside this workflow. */ +function seriesMetadataEditor(seriesId) { + const endpoint = "/api/v1/series/" + seriesId + "/metadata-editor"; + return { + open: false, busy: false, checkingJob: true, error: "", preview: null, drafts: {}, + mode: "library_and_sidecars", allFields: false, reviewedInputs: null, + job: null, librarySaved: false, jobTargets: [], jobWarnings: [], partial: false, retryable: false, + trigger: null, timer: null, generation: 0, jobGeneration: 0, disposed: false, + init() { this.loadJob().finally(() => { this.checkingJob = false; }); }, + destroy() { this.disposed = true; this.generation += 1; this.jobGeneration += 1; clearTimeout(this.timer); }, + async request(url, body) { + const response = await fetch(url, body === undefined ? { cache: "no-store" } : { + method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": readCsrfTokenFromBody() }, + body: JSON.stringify(body), + }); + const data = await response.json(); + if (!response.ok) throw new Error(_extractApiErrorMessage(response, data, "Could not check series metadata. Try again.")); + return data; + }, + async show() { + this.trigger = document.activeElement; + this.open = true; + this.$nextTick(() => this.$refs.dialog.focus({ preventScroll: true })); + if (!this.preview && !this.job && !this.busy && !this.checkingJob) await this.loadPreview(); + }, + close() { + this.open = false; + this.generation += 1; + if (this.trigger && this.trigger.isConnected) this.trigger.focus({ preventScroll: true }); + }, + invalidate() { this.reviewedInputs = null; }, + fields() { + return (this.preview ? this.preview.editor_fields : []).filter(field => this.allFields || field.changed || + (field.sources || []).some(source => source.value !== field.current) || + (this.drafts[field.key] && this.drafts[field.key].selected)); + }, + sourceOptions(field) { + const options = (field.sources || []).filter(source => this.mode !== "library_only" || source.token === "library") + .map(source => ({ value: source.token, label: source.token === "library" ? "Keep library value" : source.label })); + options.push({ value: "custom", label: "Enter correction" }); + if (field.can_clear) options.push({ value: "clear", label: "Clear value" }); + if (field.overridden) options.push({ value: "reset", label: "Remove manual override" }); + return options; + }, + valueOptions(field) { return [{ value: "", label: "Choose a value", disabled: true }].concat(field.options); }, + sourceChanged(field) { + const draft = this.drafts[field.key]; + if (draft.source === "custom" && field.options.length && !field.options.some(option => option.value === draft.value)) draft.value = ""; + this.invalidate(); + }, + selectFields(selected) { + (this.preview.editor_fields || []).filter(field => field.editable).forEach(field => { this.drafts[field.key].selected = selected; }); + this.invalidate(); + }, + inputs() { + const edits = {}; + (this.preview ? this.preview.editor_fields : []).filter(field => field.editable).forEach(field => { + const draft = this.drafts[field.key]; + if (!draft || !draft.selected) return; + let edit; + if (draft.source === "custom") { + let value = draft.value; + if (field.input_type === "number" && String(value).trim() && Number.isSafeInteger(Number(value))) value = Number(value); + edit = { action: "set", value: value }; + } else if (["library", "clear", "reset"].includes(draft.source)) edit = { action: draft.source }; + else edit = { action: "sidecar", source: draft.source }; + edits[field.key.slice(7)] = edit; + }); + return { mode: this.mode, edits: edits }; + }, + canWrite() { + return !this.busy && !this.checkingJob && !this.job && this.preview && this.preview.ready && this.preview.editor_changed && + this.reviewedInputs === JSON.stringify(this.inputs()); + }, + async loadPreview(reset = false) { + if (this.busy || this.checkingJob || this.active() || (this.job && this.job.state === "PAUSED")) return; + const generation = ++this.generation; + this.busy = true; this.error = ""; this.invalidate(); + const body = reset ? { mode: this.mode, edits: {} } : this.inputs(); + const signature = JSON.stringify(body); + const missingChoice = (this.preview ? this.preview.editor_fields : []).find(field => { + const edit = body.edits[field.key.slice(7)]; + return field.options.length && edit && edit.action === "set" && !field.options.some(option => option.value === edit.value); + }); + if (missingChoice) { + this.error = "Choose a value for " + missingChoice.label + "."; + this.busy = false; + return; + } + if (this.mode === "library_only" && Object.values(body.edits).some(edit => edit.action === "sidecar")) { + this.error = "Sidecar values are not available with Library only. Choose another value source or include series.json files."; + this.busy = false; + return; + } + try { + const data = await this.request(endpoint + "/preview", body); + if (this.disposed || generation !== this.generation) return; + const initialize = reset || !this.preview; + this.preview = data; + if (initialize) { + this.drafts = {}; this.allFields = false; + data.editor_fields.filter(field => field.editable).forEach(field => { + this.drafts[field.key] = { selected: false, source: "library", value: field.current }; + }); + } + this.job = null; this.librarySaved = false; this.jobTargets = []; this.jobWarnings = []; this.retryable = false; this.partial = false; + if (JSON.stringify(this.inputs()) === signature) this.reviewedInputs = signature; + } catch (error) { if (!this.disposed && generation === this.generation) this.error = error.message; } + finally { if (!this.disposed) this.busy = false; } + }, + active() { return !!this.job && ["QUEUED", "RUNNING", "PAUSING", "CANCELLING"].includes(this.job.state); }, + async refreshSeries() { + const target = document.querySelector('[data-testid="series-detail-hero"]'); + try { + if (target) await htmx.ajax("GET", "/series/" + seriesId, { + target: target, select: '[data-testid="series-detail-hero"]', swap: "morph:outerHTML", + }); + } catch (_) { + if (!this.disposed) this.error = "Library changes are saved, but the page could not refresh. Reload to see the latest values."; + } + if (!this.disposed) window.dispatchEvent(new CustomEvent("series-metadata-editor-saved")); + }, + async loadJob() { + clearTimeout(this.timer); + const generation = this.jobGeneration; + const wasActive = this.active(); + try { + const data = await this.request(endpoint + "/job"); + if (this.disposed || generation !== this.jobGeneration) return; + this.job = data.job; + this.librarySaved = !!data.library_saved; + this.jobWarnings = data.warnings || []; + this.jobTargets = data.targets || []; this.partial = !!data.partial; this.retryable = !!data.retryable; + if (wasActive && this.job && !this.active()) await this.refreshSeries(); + } catch (error) { if (!this.disposed && generation === this.jobGeneration) this.error = error.message; } + if (!this.disposed && generation === this.jobGeneration && this.active()) this.timer = setTimeout(() => this.loadJob(), 1000); + }, + async write() { + if (!this.canWrite()) return; + this.busy = true; this.error = ""; + const approval = Object.assign({}, this.inputs(), { review_key: this.preview.review_key }); + this.invalidate(); + try { + const result = await this.request(endpoint + "/write", approval); + if (this.disposed) return; + this.jobGeneration += 1; + this.librarySaved = !!result.library_saved; + this.job = { id: result.job_id, state: result.state, percent: 0, message: "Queued", error: null }; + await this.refreshSeries(); + await this.loadJob(); + } catch (error) { if (!this.disposed) this.error = error.message; } + finally { if (!this.disposed) this.busy = false; } + }, + async retry() { + if (this.busy || !this.job || !this.retryable || this.active()) return; + this.busy = true; this.error = ""; + try { + const result = await this.request(endpoint + "/retry", { job_id: this.job.id }); + if (this.disposed) return; + this.jobGeneration += 1; + this.job = { id: result.job_id, state: result.state, percent: 0, message: "Queued", error: null }; + this.retryable = false; + await this.loadJob(); + } catch (error) { if (!this.disposed) this.error = error.message; } + finally { if (!this.disposed) this.busy = false; } + }, + async cancel() { + if (this.busy || !this.active() || this.job.state === "CANCELLING") return; + this.busy = true; this.error = ""; + try { await this.request("/api/v1/utilities/jobs/" + this.job.id + "/cancel", {}); await this.loadJob(); } + catch (error) { if (!this.disposed) this.error = error.message; } + finally { if (!this.disposed) this.busy = false; } + }, + targets() { return this.job ? this.jobTargets : this.preview && this.reviewedInputs && this.mode === this.preview.mode ? this.preview.targets : []; }, + targetLabel(target) { + const state = String(target.action || target.state || "").toLowerCase(); + return { create: "Create series.json", update: "Update series.json", unchanged: "Up to date", blocked: "Left unchanged", + written: "Written", completed: "Written", failed: "Needs attention", pending: "Pending", queued: "Queued", + running: "Writing", cancelled: "Cancelled", cancelling: "Cancelling", skipped: "Left unchanged" }[state] || state; + }, + escape(event) { + if (!this.open) return; + const dropdown = event.target.closest('[data-dropdown-select]'); + if (event.target.closest('[data-dropdown-select-panel]') || (dropdown && dropdown.querySelector('[aria-expanded="true"]'))) return; + event.preventDefault(); event.stopPropagation(); this.close(); + }, + trapFocus(event) { + if (!this.open || event.target.closest('[data-dropdown-select-panel]')) return; + const elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], summary, [tabindex="0"]')) + .filter(el => el.getClientRects().length > 0); + const first = elements[0], last = elements[elements.length - 1]; + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.dialog)) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && (document.activeElement === last || document.activeElement === this.$refs.dialog)) { event.preventDefault(); first.focus(); } + }, + }; +} diff --git a/src/pullbox/ui/static/js/series-metadata-propagation.js b/src/pullbox/ui/static/js/series-metadata-propagation.js new file mode 100644 index 00000000..5331d612 --- /dev/null +++ b/src/pullbox/ui/static/js/series-metadata-propagation.js @@ -0,0 +1,86 @@ +/* Explicit series-field propagation; selecting fields never starts a write. */ +function seriesMetadataPropagation(seriesId) { + const endpoint = "/api/v1/series/" + seriesId + "/metadata-propagation"; + return { + open: false, busy: false, error: "", fields: [], selected: [], report: null, + page: 1, timer: null, disposed: false, trigger: null, approved: false, generation: 0, + options: [{key:"title",label:"Series name"}, {key:"publisher",label:"Publisher"}, + {key:"language",label:"Language"}, {key:"format",label:"Publication format"}, + {key:"sort_title",label:"Series sort name"}, {key:"year_start",label:"Series start year"}, + {key:"volume",label:"Series volume"}], + destroy() { this.disposed = true; this.generation++; clearTimeout(this.timer); }, + async request(url, body) { + const response = await fetch(url, body === undefined ? {cache:"no-store"} : { + method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":readCsrfTokenFromBody()}, body:JSON.stringify(body)}); + const data = await response.json(); + if (!response.ok) throw new Error(_extractApiErrorMessage(response, data, "Could not check comic metadata. Try again.")); + return data; + }, + async show() { + this.trigger = document.activeElement; this.open = true; + this.$nextTick(() => this.$refs.dialog.focus({preventScroll:true})); + if (!this.report && !this.busy) { + this.busy = true; + try { await this.load(); } finally { this.busy = false; } + } + }, + close() { this.open = false; if (this.trigger && this.trigger.isConnected) this.trigger.focus({preventScroll:true}); }, + active() { return !!this.report && this.report.job && !["COMPLETED","FAILED","CANCELLED"].includes(this.report.job.state); }, + ready() { return this.report && this.report.mode === "preview" && !this.active() && this.approved; }, + invalidate() { this.approved = false; }, + count(key) { return this.report && this.report.counts ? this.report.counts[key] || 0 : 0; }, + summary() { + const cancelled = this.count("cancelled") ? "; " + this.count("cancelled") + " cancelled" : ""; + return (this.report && this.report.mode === "write" ? this.count("written") + " updated; " + this.count("current") + " already current; " + this.count("attention") + " need attention" : + this.count("ready") + " ready; " + this.count("current") + " already current; " + this.count("attention") + " need attention") + cancelled; + }, + toggle(id) { this.selected = this.selected.includes(id) ? this.selected.filter(value => value !== id) : this.selected.concat([id]); }, + async load() { + clearTimeout(this.timer); const generation = ++this.generation; + try { + const data = await this.request(endpoint + "/job?page=" + this.page); + if (this.disposed || generation !== this.generation) return; + const previous = this.report && this.report.job; + this.report = data.job ? data : null; + if (data.job && (!previous || previous.id !== data.job.id)) { + this.fields = data.fields || []; this.selected = data.ready_ids || []; this.approved = data.mode === "preview"; + } else if (data.job && data.mode === "preview" && previous && ["QUEUED","RUNNING","CANCELLING"].includes(previous.state)) { + this.selected = data.ready_ids || []; + } + } catch (error) { if (!this.disposed && generation === this.generation) this.error = error.message; } + if (!this.disposed && this.active()) this.timer = setTimeout(() => this.load(), 1200); + }, + async start(action, body) { + if (this.busy || this.active()) return; + this.busy = true; this.error = ""; + try { + await this.request(endpoint + "/" + action, body); + if (this.disposed) return; + this.report = null; this.page = 1; this.selected = []; this.approved = false; + await this.load(); + } catch (error) { if (!this.disposed) this.error = error.message; } + finally { if (!this.disposed) this.busy = false; } + }, + preview() { if (this.fields.length) return this.start("preview", {fields:this.fields}); }, + write() { if (this.ready() && this.selected.length) return this.start("write", {job_id:this.report.job.id, issue_ids:this.selected}); }, + retry() { if (this.report && this.report.retryable) return this.start("retry", {job_id:this.report.job.id}); }, + async cancel() { + if (this.busy || !this.active()) return; + this.busy = true; this.error = ""; + try { await this.request("/api/v1/utilities/jobs/" + this.report.job.id + "/cancel", {}); await this.load(); } + catch (error) { this.error = error.message; } + finally { this.busy = false; } + }, + async turnPage(delta) { this.page += delta; await this.load(); }, + label(outcome) { return {ready:"Ready to update", current:"Already current", written:"Updated", attention:"Needs attention", cancelled:"Cancelled", pending:"Pending", running:"Working"}[outcome] || outcome; }, + escape(event) { if (this.open) { event.preventDefault(); event.stopPropagation(); this.close(); } }, + trapFocus(event) { + if (!this.open) return; + const elements = Array.from(this.$refs.dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), a[href], summary, [tabindex="0"]')).filter(el => el.getClientRects().length > 0); + const first = elements[0], last = elements[elements.length - 1]; + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || document.activeElement === this.$refs.dialog)) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && (document.activeElement === last || document.activeElement === this.$refs.dialog)) { event.preventDefault(); first.focus(); } + }, + }; +} diff --git a/src/pullbox/ui/story_arc_presenters.py b/src/pullbox/ui/story_arc_presenters.py index 6035ee44..f7846a7e 100644 --- a/src/pullbox/ui/story_arc_presenters.py +++ b/src/pullbox/ui/story_arc_presenters.py @@ -43,6 +43,7 @@ StoryArcPlacementView, ) from pullbox.services.story_arc_search_targets import load_story_arc_search_eligible_counts +from pullbox.services.story_arc_sync_recovery import current_story_arc_sync_work_ids from pullbox.ui.reading_presenters import IssueReadingView, present_issue_reading if TYPE_CHECKING: @@ -411,10 +412,15 @@ def _sync_failure_counts_subquery() -> Subquery: return ( select( IssueStoryArc.story_arc_id.label("story_arc_id"), - func.count(StoryArcSyncWork.id).label("sync_failure_count"), + func.count(func.distinct(StoryArcSyncWork.issue_story_arc_id)).label( + "sync_failure_count" + ), ) .join(StoryArcSyncWork, StoryArcSyncWork.issue_story_arc_id == IssueStoryArc.id) - .where(StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED) + .where( + StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED, + StoryArcSyncWork.id.in_(current_story_arc_sync_work_ids(include_imports=True)), + ) .group_by(IssueStoryArc.story_arc_id) .subquery() ) @@ -961,11 +967,14 @@ async def load_story_arc_detail( ) sync_failure_count = int( await session.scalar( - select(func.count(StoryArcSyncWork.id)) + select(func.count(func.distinct(StoryArcSyncWork.issue_story_arc_id))) .join(IssueStoryArc, IssueStoryArc.id == StoryArcSyncWork.issue_story_arc_id) .where( IssueStoryArc.story_arc_id == story_arc_id, StoryArcSyncWork.state == StoryArcSyncWorkState.FAILED, + StoryArcSyncWork.id.in_( + current_story_arc_sync_work_ids(story_arc_id, include_imports=True) + ), ) ) or 0 diff --git a/src/pullbox/ui/story_arc_routes.py b/src/pullbox/ui/story_arc_routes.py index edfdb8f3..36b960cb 100644 --- a/src/pullbox/ui/story_arc_routes.py +++ b/src/pullbox/ui/story_arc_routes.py @@ -20,6 +20,7 @@ DbSession, ) from pullbox.config import get_settings +from pullbox.core.exceptions import NotFoundError, ValidationError from pullbox.core.metadata_identity import MetadataSource # noqa: TC001 from pullbox.core.story_arc_naming import ( DEFAULT_STORY_ARC_FILE_TEMPLATE, @@ -57,6 +58,11 @@ StoryArcServiceError, StoryArcValidationError, ) +from pullbox.services.story_arc_sync_queue import request_story_arc_sync_now +from pullbox.services.story_arc_sync_recovery import ( + load_story_arc_file_sync_status, + retry_story_arc_files, +) from pullbox.ui import story_arc_catalog_routes, story_arc_source_routes from pullbox.ui.metadata_arc_search import arc_search_context from pullbox.ui.story_arc_local_issue_search import search_story_arc_local_issues @@ -359,10 +365,73 @@ async def _render_story_arc_detail( reorder_preview=reorder_preview, reorder_message=reorder_message, show_placement_diagnostics=placement_proposal is not None, + file_sync=await load_story_arc_file_sync_status(session, story_arc_id), ) return _templates().TemplateResponse(request, "pages/story_arc_detail.html", context) +async def _file_sync_response( + request: Request, + user: AuthenticatedUser, + session: DbSession, + story_arc_id: int, + *, + message: str = "", +) -> Response: + try: + status = await load_story_arc_file_sync_status(session, story_arc_id) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail="Story arc not found") from exc + return _templates().TemplateResponse( + request, + "partials/story_arc_file_sync.html", + _ctx(request, user, file_sync=status, file_sync_message=message, file_sync_oob=True), + ) + + +@router.get("/story-arcs/{story_arc_id}/file-sync", include_in_schema=False) +async def story_arc_file_sync_status( + story_arc_id: int, + request: Request, + user: AuthenticatedUser, + session: DbSession, +) -> Response: + """Refresh only the small arc-file status surface, without filesystem inspection.""" + return await _file_sync_response(request, user, session, story_arc_id) + + +@router.post("/story-arcs/{story_arc_id}/file-sync/retry", include_in_schema=False) +async def story_arc_file_sync_retry( + story_arc_id: int, + request: Request, + user: AuthenticatedUser, + session: DbSession, + expected_revision: Annotated[int, Form(ge=1)], +) -> Response: + """Recheck saved destination access and queue current failures in the background.""" + try: + count = await retry_story_arc_files( + session, + story_arc_id, + expected_revision=expected_revision, + ) + await session.commit() + except NotFoundError as exc: + await session.rollback() + raise HTTPException(status_code=404, detail="Story arc not found") from exc + except (ValidationError, StoryArcPlacementIntegrationError) as exc: + await session.rollback() + return await _file_sync_response(request, user, session, story_arc_id, message=str(exc)) + request_story_arc_sync_now() + return await _file_sync_response( + request, + user, + session, + story_arc_id, + message=f"{count} files queued for recheck. Original library files stay unchanged.", + ) + + async def _nested_memberships( session: DbSession, *, diff --git a/src/pullbox/ui/templates/base.html b/src/pullbox/ui/templates/base.html index fc945458..44332184 100644 --- a/src/pullbox/ui/templates/base.html +++ b/src/pullbox/ui/templates/base.html @@ -25,52 +25,45 @@ var stylesheetRetryCount = 0; var stylesheetRetryLimit = 2; var stylesheetLoadTimeoutMs = 4000; - var stylesheetReloadKey = 'pullbox-stylesheet-reload-attempted'; + var stylesheetTimeout = null; + var stylesheetRetryTimer = null; + var stylesheetRecoveryPanel = null; function armStylesheetTimeout(link) { - window.setTimeout(function() { + window.clearTimeout(stylesheetTimeout); + stylesheetTimeout = window.setTimeout(function() { if (!appStylesReady && link && link.isConnected) { - retryStylesheet(link); + // A queued or slow request is not a failed stylesheet. Keep it alive. + if (!verifyStylesheet(link)) showStylesheetRecovery(true); } }, stylesheetLoadTimeoutMs); } - function clearStylesheetReloadGuard() { - try { - sessionStorage.removeItem(stylesheetReloadKey); - } catch (_error) {} - } - - function reloadForStylesheetRecovery() { - try { - if (sessionStorage.getItem(stylesheetReloadKey) !== 'true') { - sessionStorage.setItem(stylesheetReloadKey, 'true'); - window.location.reload(); - return true; - } - } catch (_error) {} - return false; - } - - function showStylesheetRecovery() { + function showStylesheetRecovery(waiting) { + if (appStylesReady) return; if (!document.body) { - window.addEventListener('DOMContentLoaded', showStylesheetRecovery, { once: true }); + window.addEventListener('DOMContentLoaded', function() { + showStylesheetRecovery(waiting); + }, { once: true }); return; } - document.body.removeAttribute('data-shell-pending'); - document.body.replaceChildren(); - document.body.style.cssText = 'min-height:100vh;margin:0;display:grid;place-items:center;background:#101824;color:#f7f1e8;font-family:sans-serif;padding:24px;'; - + if (stylesheetRecoveryPanel) stylesheetRecoveryPanel.remove(); + var overlay = document.createElement('div'); + overlay.id = 'pullbox-stylesheet-recovery'; + overlay.setAttribute('role', waiting ? 'status' : 'alert'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;visibility:visible;display:grid;place-items:center;background:#101824;color:#f7f1e8;font-family:sans-serif;padding:24px;overflow:auto;'; var panel = document.createElement('main'); - panel.style.cssText = 'width:min(100%,480px);border:1px solid #cbd5e133;border-radius:16px;background:#172231;padding:28px;box-shadow:0 18px 40px #070b1238;'; + panel.style.cssText = 'box-sizing:border-box;width:min(100%,480px);border:1px solid #cbd5e133;border-radius:16px;background:#172231;padding:28px;box-shadow:0 18px 40px #070b1238;'; var heading = document.createElement('h1'); - heading.textContent = 'Pullbox could not load its interface'; + heading.textContent = waiting ? 'Pullbox is still loading' : 'Pullbox could not load its interface'; heading.style.cssText = 'margin:0;font-size:22px;line-height:1.25;'; var message = document.createElement('p'); - message.textContent = 'The application is running, but its stylesheet did not load. Check the connection and try again.'; + message.textContent = waiting + ? 'The interface is taking longer than expected to load. This page will appear automatically when it is ready.' + : 'The stylesheet could not be loaded. Check the connection and retry. Your page has been preserved.'; message.style.cssText = 'margin:12px 0 20px;color:#cbd5e1;font-size:15px;line-height:1.6;'; var retryButton = document.createElement('button'); @@ -78,24 +71,28 @@ retryButton.textContent = 'Retry loading Pullbox'; retryButton.style.cssText = 'cursor:pointer;border:0;border-radius:8px;background:#8fb9ee;color:#101824;padding:11px 16px;font:700 14px sans-serif;'; retryButton.addEventListener('click', function() { - clearStylesheetReloadGuard(); - window.location.reload(); + stylesheetRetryCount = 0; + retryStylesheet(document.getElementById('pullbox-app-stylesheet')); }); panel.append(heading, message, retryButton); - document.body.append(panel); + overlay.append(panel); + document.body.append(overlay); + stylesheetRecoveryPanel = overlay; } function retryStylesheet(link) { - if (!link || appStylesReady) { + if (!link || !link.isConnected || appStylesReady || stylesheetRetryTimer) { return; } + window.clearTimeout(stylesheetTimeout); if (stylesheetRetryCount < stylesheetRetryLimit) { stylesheetRetryCount += 1; var retryNumber = stylesheetRetryCount; - window.setTimeout(function() { - if (!link.isConnected) { + stylesheetRetryTimer = window.setTimeout(function() { + stylesheetRetryTimer = null; + if (!link.isConnected || appStylesReady) { return; } @@ -106,7 +103,7 @@ retryLink.removeAttribute('onload'); retryLink.removeAttribute('onerror'); retryLink.addEventListener('load', function() { - verifyStylesheet(retryLink); + window.pullboxStylesheetLoaded(retryLink); }, { once: true }); retryLink.addEventListener('error', function() { retryStylesheet(retryLink); @@ -117,29 +114,30 @@ return; } - if (!reloadForStylesheetRecovery()) { - showStylesheetRecovery(); - } + showStylesheetRecovery(false); } function verifyStylesheet(link) { - requestAnimationFrame(function() { - var appSurface = window.getComputedStyle(document.documentElement) - .getPropertyValue('--pb-surface-app') - .trim(); - if (!appSurface) { - retryStylesheet(link); - return; - } - - appStylesReady = true; - clearStylesheetReloadGuard(); - document.documentElement.setAttribute('data-pullbox-stylesheet-ready', 'true'); - revealShell(); - }); + if (appStylesReady) return true; + if (!link || !link.isConnected) return false; + // Background tabs may pause animation frames even when CSS is loaded. + var appSurface = window.getComputedStyle(document.documentElement) + .getPropertyValue('--pb-surface-app') + .trim(); + if (!appSurface) return false; + + appStylesReady = true; + window.clearTimeout(stylesheetTimeout); + window.clearTimeout(stylesheetRetryTimer); + document.documentElement.setAttribute('data-pullbox-stylesheet-ready', 'true'); + document.dispatchEvent(new Event('pullbox:styles-ready')); + revealShell(); + return true; } - window.pullboxStylesheetLoaded = verifyStylesheet; + window.pullboxStylesheetLoaded = function(link) { + if (!verifyStylesheet(link)) retryStylesheet(link); + }; window.pullboxStylesheetFailed = retryStylesheet; window.pullboxArmStylesheetTimeout = armStylesheetTimeout; @@ -198,6 +196,10 @@ if (document.body) { document.body.removeAttribute('data-shell-pending'); } + if (stylesheetRecoveryPanel) { + stylesheetRecoveryPanel.remove(); + stylesheetRecoveryPanel = null; + } armBootTransitionRelease(); }); }); @@ -260,7 +262,6 @@ {#- Skip to main content (WCAG) -#} @@ -309,7 +310,7 @@ :data-tip="activityButtonLabel()" data-tip-pos="bottom" data-tip-size="narrow" - @click="activityOpen = !activityOpen" + @click="toggleActivity()" > -
+

Background work

Activity

@@ -396,7 +397,7 @@

Activity

-
+

No background work

Active operations and recent completions will appear here.

@@ -418,7 +419,7 @@

Activity

- diff --git a/src/pullbox/ui/templates/pages/issue_detail.html b/src/pullbox/ui/templates/pages/issue_detail.html index cde5cb72..8184165d 100644 --- a/src/pullbox/ui/templates/pages/issue_detail.html +++ b/src/pullbox/ui/templates/pages/issue_detail.html @@ -20,6 +20,7 @@ searchUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/download')|tojson|forceescape }}, deleteFileUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/file')|tojson|forceescape }}, importStartUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/import-file/start')|tojson|forceescape }}, + importMetadataPreviewUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/import-file/metadata-preview')|tojson|forceescape }}, importCancelUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/import-file/cancel')|tojson|forceescape }}, importProgressUrl: {{ ('/api/v1/issues/' ~ issue.id ~ '/import-file/progress')|tojson|forceescape }}, readerManifestUrl: {{ ('/api/v1/reader/issues/' ~ issue.id ~ '/manifest')|tojson|forceescape }}, @@ -65,6 +66,10 @@ {% endif %} {% include "partials/issue_metadata_content.html" %} + {% include "partials/issue_metadata_links.html" %} {% include "partials/issue_detail_library_file.html" %} diff --git a/src/pullbox/ui/templates/pages/series_detail.html b/src/pullbox/ui/templates/pages/series_detail.html index 0c74f94d..c9737c23 100644 --- a/src/pullbox/ui/templates/pages/series_detail.html +++ b/src/pullbox/ui/templates/pages/series_detail.html @@ -75,3 +75,8 @@ {% block footer_dock %} {% include "partials/series_detail_footer_dock.html" %} {% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/src/pullbox/ui/templates/pages/story_arc_detail.html b/src/pullbox/ui/templates/pages/story_arc_detail.html index 0cc617e2..ce0b3c26 100644 --- a/src/pullbox/ui/templates/pages/story_arc_detail.html +++ b/src/pullbox/ui/templates/pages/story_arc_detail.html @@ -37,6 +37,7 @@
{% include "partials/story_arc_detail_hero.html" %} + {% include "partials/story_arc_file_sync.html" %}
{% if notice_message %} diff --git a/src/pullbox/ui/templates/partials/download_queue.html b/src/pullbox/ui/templates/partials/download_queue.html index 60169f1e..63b29dd6 100644 --- a/src/pullbox/ui/templates/partials/download_queue.html +++ b/src/pullbox/ui/templates/partials/download_queue.html @@ -97,6 +97,7 @@

DOWNLOAD QUEUE

Issue Status Client + Size Progress Speed ETA @@ -130,6 +131,13 @@

DOWNLOAD QUEUE

{{ row.client_label }} + + {% if row.size_bytes is not none %} + {{ row.size_bytes|filesize }} + {% else %} + Unknown + {% endif %} +
diff --git a/src/pullbox/ui/templates/partials/intervention_content.html b/src/pullbox/ui/templates/partials/intervention_content.html index 353dab8e..aad6d848 100644 --- a/src/pullbox/ui/templates/partials/intervention_content.html +++ b/src/pullbox/ui/templates/partials/intervention_content.html @@ -18,5 +18,6 @@ x-data="interventionPage({ csrfToken: '{{ csrf_token }}', bulkActionsEnabled: {{ 'false' if tab == 'recovery' else 'true' }} })" > {% include "partials/intervention_results.html" %} + {% with recovery_mode=true %}{% include "partials/issue_file_metadata.html" %}{% endwith %}
{% endif %} diff --git a/src/pullbox/ui/templates/partials/intervention_list.html b/src/pullbox/ui/templates/partials/intervention_list.html index 823eca3b..22f7f3d0 100644 --- a/src/pullbox/ui/templates/partials/intervention_list.html +++ b/src/pullbox/ui/templates/partials/intervention_list.html @@ -92,6 +92,27 @@ {% if meta.lane == 'recovery' %} + {% if pm.match_details.get('source_kind') == 'dc' %} + {% for action, label in [('recheck', 'Recheck AirDC++'), ('retry', 'Retry request'), ('cancel', 'Cancel request')] %} + {% if action != 'retry' or details.get('failure_code') not in ['queue_hash_mismatch', 'queue_client_failed'] %} +
+ + + + +
+ {% endif %} + {% endfor %} + {% elif meta.replacement_recovery %} + Find complete copy + Import replacement + {% elif meta.metadata_recovery %} + + {% elif not meta.safety_recovery and not meta.pack_recovery %}
{% endif %} + {% endif %}
- {% if meta.lane != 'recovery' %} + {% elif meta.pack_recovery %} + + {% elif meta.lane != 'recovery' %}
+ {% endif %} @@ -172,6 +207,23 @@
{{ 'Recovery needed' if meta.lane == 'recovery' else 'Why it needs review' }}
{{ meta.recovery_summary if meta.lane == 'recovery' else meta.review_reason_summary }}
+ {% if meta.replacement_recovery %} +

This file contains a story-only extract. Find or import a complete copy, then confirm replacement. Changing metadata cannot restore missing stories. The current file stays in place until replacement succeeds.

+

Already replaced it? Recheck the library copy to finish recovery without another download.

+ + {% endif %} + {% if meta.pack_recovery %} +
{{ meta.pack_review_reason }}
+
No new download is needed. After correcting the issue match or pack contents, Retry import rechecks the retained pack. You can also choose the correct file using manual import on the issue page. Existing owned and skipped issues are not replaced automatically.
+ {% endif %} + {% if meta.size_recovery %} +
{{ meta.size_review_reason }}
+ {% if meta.can_allow_size %} +
No new download is needed. Allow and continue uses the retained file and approves only its size limit for this attempt. Other safety checks and your global limits remain unchanged.
+ {% else %} +
This safety finding cannot be approved. Review or replace the file before continuing.
+ {% endif %} + {% endif %} {% if meta.similarity_pct is not none %}
Similarity {{ meta.similarity_pct }}%
{% endif %} @@ -231,7 +283,7 @@ {% if has_filters %}

No {{ 'downloads needing recovery' if tab == 'recovery' else 'pending matches' }} match the current filters.

{% else %} -

{{ 'Failed direct downloads will appear here after their automatic source fallbacks are exhausted.' if tab == 'recovery' else 'Ambiguous search results will appear here for manual review.' }}

+

{{ 'Downloads that need your attention will appear here with recovery actions.' if tab == 'recovery' else 'Ambiguous search results will appear here for manual review.' }}

{% endif %}
diff --git a/src/pullbox/ui/templates/partials/issue_dc_search_results.html b/src/pullbox/ui/templates/partials/issue_dc_search_results.html index 286f2723..bde73ff1 100644 --- a/src/pullbox/ui/templates/partials/issue_dc_search_results.html +++ b/src/pullbox/ui/templates/partials/issue_dc_search_results.html @@ -41,6 +41,7 @@ x-data='window.issueSearchResultActions({ issueId: {{ issue.id }}, seriesId: {{ issue.series_id }}, + hasLibraryFile: {{ issue.has_library_file|default(false)|tojson }}, csrfToken: {{ csrf_token|tojson }}, releaseSelection: {{ release_selection|default(none)|tojson }} })' @@ -48,7 +49,7 @@ {% endfor %} - {% for candidate in outcome.rejected %} - + {% for row in dc_rejected_rows %} + {% set candidate = row.candidate %} + Rejected - {{ candidate.release.title }} + {{ candidate.release.title }}
{{ candidate.validation.rejection_reason or 'Not a semantic match' }}
{{ candidate.release.indexer_name }} @@ -73,7 +75,32 @@ {{ candidate.metrics.free_slots }} / {{ candidate.metrics.total_slots }} {{ candidate.release.size_bytes|filesize }} TTH …{{ candidate.route.tth[-6:] }} - No action + + {% if row.route_token %} + + {% else %} + Cannot override + {% endif %} + {% endfor %} diff --git a/src/pullbox/ui/templates/partials/issue_file_metadata.html b/src/pullbox/ui/templates/partials/issue_file_metadata.html index 6a39518a..52368596 100644 --- a/src/pullbox/ui/templates/partials/issue_file_metadata.html +++ b/src/pullbox/ui/templates/partials/issue_file_metadata.html @@ -1,20 +1,86 @@ -
- +{% from "components/dropdown_select.html" import dropdown_select %} +
+ {% if not recovery_mode|default(false) %} +
+ + +

File metadata is being written in the background.

+ {% endif %}