// ==UserScript== // @name Douban Plus // @namespace https://github.com/ZlatanCN/douban-plus // @version 1.8.1 // @author Gabriel Zhu // @description 适配 ScriptCat 和 Tampermonkey 的豆瓣作品详情页与人物页增强脚本,用 Preact 重排为 Apple TV 风格沉浸式暗色界面,并保留豆瓣原生登录、标记和跳转能力。 // @license MIT // @copyright Gabriel Zhu (MIT) // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iOTQiIGhlaWdodD0iOTQiIHJ4PSIyMCIgZmlsbD0iIzFjMWMxZSIvPgogIDxyZWN0IHg9IjE0IiB5PSIyNCIgd2lkdGg9IjcyIiBoZWlnaHQ9IjUyIiByeD0iNiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjEuOCIvPgogIDxjaXJjbGUgY3g9IjcyIiBjeT0iNjIiIHI9IjgiIGZpbGw9IiM0MmJkNTYiLz4KPC9zdmc+Cg== // @icon64 data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iOTQiIGhlaWdodD0iOTQiIHJ4PSIyMCIgZmlsbD0iIzFjMWMxZSIvPgogIDxyZWN0IHg9IjE0IiB5PSIyNCIgd2lkdGg9IjcyIiBoZWlnaHQ9IjUyIiByeD0iNiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjEuOCIvPgogIDxjaXJjbGUgY3g9IjcyIiBjeT0iNjIiIHI9IjgiIGZpbGw9IiM0MmJkNTYiLz4KPC9zdmc+Cg== // @homepageURL https://github.com/ZlatanCN/douban-plus // @supportURL https://github.com/ZlatanCN/douban-plus/issues // @match *://movie.douban.com/subject/* // @match *://www.douban.com/personage/* // @match *://accounts.douban.com/passport/login* // @exclude *://movie.douban.com/subject/*/photos* // @exclude *://movie.douban.com/subject/*/photos[?]* // @require https://cdn.jsdelivr.net/npm/systemjs@6.15.1/dist/system.min.js // @require https://cdn.jsdelivr.net/npm/systemjs@6.15.1/dist/extras/named-register.min.js // @require data:application/javascript,%3B(typeof%20System!%3D'undefined')%26%26(System%3Dnew%20System.constructor())%3B // @tag douban // @tag 豆瓣 // @tag movie // @tag 电影 // @tag enhancement // @tag 增强 // @tag rating // @tag 评分 // @tag apple-tv // @tag dark-mode // @tag 暗色模式 // @tag redesign // @tag tampermonkey // @tag scriptcat // @tag userscript // @tag 油猴脚本 // @connect douban.com // @connect movie.douban.com // @connect graphql.imdb.com // @connect www.rottentomatoes.com // @connect www.metacritic.com // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-start // @noframes // ==/UserScript== System.register("./___monkey.entry.js", [],(function(exports,module){'use strict';return{execute:(function(){const s$2 = new Set; const _css = async (t) => { if (s$2.has(t)) return; s$2.add(t); ((c) => { if (typeof GM_addStyle === "function") GM_addStyle(c); else (document.head || document.documentElement).appendChild(document.createElement("style")).append(c); })(t); };var styles_default = "/* ATV stylesheet manifest.\n Keep this file as the single CSS entry imported from main.ts.\n Files are ordered to preserve the original cascade from the former giant stylesheet.\n Do not wrap these imports in @layer: this userscript runs inside Douban pages,\n and unlayered host author CSS would outrank layered ATV normal declarations. */\n\n:root {\n /* ---------- Surfaces: page → card → modal → raised ---------- */\n --atv-bg-primary: #0c0a09;\n --atv-bg-card: #121214;\n --atv-bg-secondary: #1c1c1e;\n --atv-bg-tertiary: #2c2c2e;\n --atv-bg-elevated: rgb(255 255 255 / 6%);\n --atv-bg-hover: rgb(255 255 255 / 3%);\n --atv-bg-hover-strong: rgb(255 255 255 / 12%);\n --atv-bg-nav: rgb(10 10 12 / 74%);\n --atv-bg-nav-solid: rgb(10 10 12 / 95%);\n --atv-scrim-overlay: rgb(0 0 0 / 78%);\n --atv-scrim-overlay-solid: rgb(0 0 0 / 92%);\n --atv-surface-paper: #d9dce3;\n\n /* ---------- Text ladder: primary → emphasis → secondary → tertiary ---------- */\n --atv-text-primary: #fff;\n --atv-text-emphasis: rgb(255 255 255 / 85%);\n --atv-text-secondary: rgb(255 255 255 / 72%);\n --atv-text-tertiary: rgb(255 255 255 / 50%);\n\n /* ---------- Interaction accents ---------- */\n --atv-accent: #41be5d;\n --atv-accent-bright: #4cd97a;\n --atv-text-on-accent: #062811;\n --atv-accent-glow: rgb(65 190 93 / 35%);\n --atv-accent-hover: rgb(65 190 93 / 6%);\n --atv-accent-active: rgb(76 217 122 / 10%);\n --atv-accent-border: rgb(65 190 93 / 25%);\n --atv-accent-border-strong: rgb(76 217 122 / 58%);\n --atv-accent-outline: rgb(65 190 93 / 75%);\n\n /* ---------- Rating / status colors ---------- */\n --atv-rating-gold: #ffb800;\n --atv-rating-empty: rgb(255 255 255 / 35%);\n --atv-score-high: #3bb33b;\n --atv-score-low: #fa320a;\n --atv-danger: #ff453a;\n --atv-content-caution: #c68f4e;\n --atv-caution-surface: rgb(198 143 78 / 10%);\n\n /* ---------- Borders: subtle → medium → strong → emphasis ---------- */\n --atv-border-subtle: rgb(255 255 255 / 8%);\n --atv-border-medium: rgb(255 255 255 / 16%);\n --atv-border-strong: rgb(255 255 255 / 22%);\n --atv-border-emphasis: rgb(255 255 255 / 34%);\n --atv-radius-sm: 8px;\n --atv-radius-md: 12px;\n --atv-radius-lg: 16px;\n --atv-radius-xl: 24px;\n\n /* ---------- Typography: font stacks (system, no webfonts) ---------- */\n --atv-font-sans:\n -apple-system, \"BlinkMacSystemFont\", \"SF Pro Display\", \"PingFang SC\",\n \"Helvetica Neue\", \"Microsoft YaHei\", \"Inter\", system-ui, sans-serif;\n --atv-font-display:\n \"SF Pro Display\", -apple-system, \"BlinkMacSystemFont\", \"PingFang SC\",\n \"Microsoft YaHei\", system-ui, sans-serif;\n --atv-font-mono:\n ui-monospace, \"SFMono-Regular\", \"Cascadia Code\", \"Consolas\", monospace;\n\n /* ---------- Typography: role-based type scale ---------- */\n --atv-type-display: clamp(44px, 7vw, 92px); /* page hero titles */\n --atv-type-display-compact: clamp(\n 44px,\n 5.5vw,\n 80px\n ); /* hero beside poster / portrait */\n\n --atv-type-display-narrow: clamp(\n 34px,\n 10vw,\n 50px\n ); /* compact hero (mobile) */\n\n --atv-type-hero-orig: clamp(18px, 1.6vw, 22px); /* original-title line */\n --atv-type-title-group: clamp(22px, 2.4vw, 32px); /* gallery group headings */\n --atv-type-title-xl: 24px; /* section headings, modal titles */\n --atv-type-title-lg: 20px; /* modal titles, sub-headings */\n --atv-type-title-md: 18px; /* in-page headings */\n --atv-type-title: 16px; /* card titles */\n --atv-type-body-lg: 16px; /* CJK long-form reading (bio, review body) */\n --atv-type-body: 15px; /* body text */\n --atv-type-body-sm: 14px; /* dense text, values, actions */\n --atv-type-caption: 13px; /* bylines, metadata */\n --atv-type-caption-sm: 12px; /* footers, labels, small metadata */\n --atv-type-micro: 11px; /* kickers, tiny labels */\n\n /* ---------- Typography: display numerals ---------- */\n --atv-type-stat: clamp(32px, 4vw, 38px); /* rating panel score */\n --atv-type-stat-sm: 30px; /* RT score */\n --atv-type-stat-lg: clamp(28px, 3.5vw, 40px); /* timeline years */\n\n /* ---------- Typography: weight ladder (real faces on all platforms) ---------- */\n --atv-font-weight-regular: 400;\n --atv-font-weight-medium: 500;\n --atv-font-weight-semibold: 600;\n --atv-font-weight-bold: 700;\n\n /* ---------- Typography: line heights (unitless) ---------- */\n --atv-line-height-display: 1.1; /* wrapping display headings — ink-safe for CJK + Latin */\n --atv-line-height-body: 1.65; /* short-form reading */\n --atv-line-height-body-loose: 1.8; /* CJK long-form reading */\n --atv-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --atv-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --atv-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n --atv-duration-press: 160ms;\n --atv-duration-hover: 160ms;\n --atv-duration-feedback: 200ms;\n --atv-duration-content: 300ms;\n --atv-duration-overlay: 200ms;\n --atv-duration-modal-backdrop: 400ms;\n --atv-duration-modal-surface: 350ms;\n}\nbody.atv-enhanced > #wrapper {\n display: none !important;\n}\n\nbody.atv-enhanced {\n padding: 0 !important;\n margin: 0 !important;\n background: #000 !important;\n}\n\nbody.atv-enhanced #db-global-nav,\nbody.atv-enhanced #db-nav-movie,\nbody.atv-enhanced #db-nav-sns {\n display: none !important;\n}\n\nbody.atv-enhanced [id^=\"dale_\"],\nbody.atv-enhanced [class*=\"dale_\"] {\n display: none !important;\n}\n\n#atv-douban-root {\n position: relative;\n min-height: 100vh;\n animation: atv-fadein var(--atv-duration-feedback) var(--atv-ease-out)\n forwards;\n background: var(--atv-bg-primary);\n color: var(--atv-text-primary);\n font-family: var(--atv-font-sans);\n font-feature-settings: \"ss01\", \"cv11\";\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n line-height: 1.5;\n opacity: 0;\n}\n\n@keyframes atv-fadein {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\n#atv-douban-root *,\n#atv-douban-root *::before,\n#atv-douban-root *::after {\n box-sizing: border-box;\n}\n\n#atv-douban-root a {\n color: inherit;\n text-decoration: none;\n}\n\n#atv-douban-root a:hover {\n background: transparent;\n}\n\n#atv-douban-root img {\n display: block;\n max-width: 100%;\n}\n/* ---------- Sticky nav ---------- */\n.atv-stickynav {\n position: fixed;\n z-index: 9999;\n top: 0;\n right: 0;\n left: 0;\n display: flex;\n height: 56px;\n box-sizing: border-box;\n align-items: center;\n justify-content: space-between;\n padding: 0 max(28px, 5vw);\n border-bottom: 1px solid var(--atv-border-subtle);\n background: var(--atv-bg-nav-solid);\n gap: 24px;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-100%);\n}\n\n/* Frosted glass effect only when NOT actively scrolling — Apple-style.\n The backdrop-filter forces per-frame compositing; dropping it during\n scroll eliminates the main source of scroll jank. */\n.atv-stickynav.is-visible:not(.is-scrolling) {\n -webkit-backdrop-filter: saturate(180%) blur(24px);\n backdrop-filter: saturate(180%) blur(24px);\n background: var(--atv-bg-nav);\n}\n\n.atv-stickynav.is-visible {\n pointer-events: auto;\n}\n\n.atv-stickynav-title {\n overflow: hidden;\n min-width: 0;\n flex: 0 1 auto;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-stickynav-jumps {\n position: relative;\n display: flex;\n min-width: 0;\n flex: 0 1 auto;\n gap: 24px;\n overflow-x: auto;\n scrollbar-width: none;\n}\n\n.atv-stickynav-jumps::-webkit-scrollbar {\n display: none;\n}\n\n.atv-stickynav-jumps a {\n position: relative;\n cursor: pointer;\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n transition: color var(--atv-duration-feedback) ease;\n white-space: nowrap;\n}\n\n#atv-douban-root .atv-stickynav-jumps a {\n color: var(--atv-text-secondary);\n}\n\n#atv-douban-root .atv-stickynav-jumps a:hover {\n background: transparent;\n color: var(--atv-accent-bright);\n}\n\n#atv-douban-root .atv-stickynav-jumps a.is-active {\n color: var(--atv-accent-bright);\n}\n\n.atv-stickynav-marker {\n position: absolute;\n bottom: 0;\n left: 0;\n height: 2px;\n background: var(--atv-accent-bright);\n transform: translateX(0);\n transition: transform var(--atv-duration-feedback) var(--atv-ease-in-out);\n}\n\n@media (width <= 768px) {\n .atv-stickynav-title {\n flex: 0 1 32%;\n font-size: var(--atv-type-body-sm);\n }\n\n .atv-stickynav-jumps {\n flex: 1 1 auto;\n gap: 14px;\n }\n\n .atv-stickynav-jumps a {\n font-size: var(--atv-type-caption-sm);\n }\n}\n\n/* The sliding active marker jumps to the active item without sliding\n motion under reduced-motion preference. */\n@media (prefers-reduced-motion: reduce) {\n .atv-stickynav-marker {\n transition: none;\n }\n}\n/* ---------- Subject switcher ---------- */\n.atv-stickynav-subject-switcher {\n position: relative;\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.atv-subject-switcher-trigger,\n.atv-subject-switcher-close,\n.atv-subject-search-fallback,\n.atv-subject-suggestion {\n border: 0;\n appearance: none;\n color: inherit;\n cursor: pointer;\n font: inherit;\n}\n\n.atv-subject-switcher-trigger {\n display: inline-flex;\n height: 34px;\n flex: 0 0 auto;\n align-items: center;\n padding: 0 13px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 7px;\n letter-spacing: 0.01em;\n transition:\n background var(--atv-duration-hover) ease,\n border-color var(--atv-duration-hover) ease,\n color var(--atv-duration-hover) ease;\n}\n\n.atv-subject-switcher-trigger:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n outline: none;\n}\n\n.atv-subject-switcher-trigger:focus-visible {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 2px;\n}\n\n.atv-subject-switcher {\n display: flex;\n align-items: center;\n}\n\n.atv-subject-switcher-expanded {\n display: flex;\n overflow: hidden;\n width: min(44vw, 520px);\n max-width: 0;\n height: 38px;\n align-items: center;\n border: 1px solid var(--atv-border-strong);\n border-radius: 999px;\n background: var(--atv-bg-elevated);\n box-shadow: 0 12px 32px rgb(0 0 0 / 28%);\n opacity: 0;\n pointer-events: none;\n transition:\n max-width 0.4s cubic-bezier(0.23, 1, 0.32, 1),\n opacity 0.18s ease 0.02s;\n}\n\n.atv-subject-switcher[data-state=\"open\"] .atv-subject-switcher-trigger {\n opacity: 0;\n pointer-events: none;\n transform: scale(0.92);\n transition:\n opacity 0.15s var(--atv-ease-out),\n transform 0.15s var(--atv-ease-out);\n}\n\n.atv-subject-switcher[data-state=\"open\"] .atv-subject-switcher-expanded {\n position: fixed;\n z-index: 100;\n top: 9px;\n left: 50%;\n overflow: visible;\n max-width: min(44vw, 520px);\n opacity: 1;\n pointer-events: auto;\n transform: translateX(-50%);\n}\n\n.atv-subject-switcher-search-icon {\n display: inline-flex;\n flex: 0 0 auto;\n margin-left: 13px;\n color: var(--atv-text-tertiary);\n}\n\n.atv-subject-switcher-input {\n width: 100%;\n min-width: 0;\n height: 100%;\n padding: 0 8px;\n border: 0;\n background: transparent;\n color: var(--atv-text-primary);\n font: inherit;\n font-size: var(--atv-type-body-sm);\n outline: 0;\n}\n\n.atv-subject-switcher-input::placeholder {\n color: var(--atv-text-tertiary);\n}\n\n.atv-subject-switcher-close {\n height: 24px;\n padding: 0 9px;\n border-radius: 999px;\n margin-right: 6px;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.05em;\n}\n\n.atv-subject-switcher-close:hover {\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n outline: none;\n}\n\n.atv-subject-switcher-close:focus-visible {\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 2px;\n}\n\n@media (width <= 768px) {\n .atv-subject-switcher-expanded {\n width: min(78vw, 520px);\n }\n\n .atv-subject-switcher[data-state=\"open\"] .atv-subject-switcher-expanded {\n max-width: min(78vw, 520px);\n }\n}\n\n/* Prevent iOS zoom on focus */\n@media (hover: none) and (pointer: coarse) {\n .atv-subject-switcher-input {\n font-size: 16px;\n }\n}\n/* ---------- Subject suggestion results ---------- */\n.atv-subject-suggestion-rail {\n position: absolute;\n top: calc(100% + 10px);\n right: 0;\n overflow: hidden;\n width: 100%;\n border: 1px solid var(--atv-border-medium);\n border-radius: var(--atv-radius-md);\n background: var(--atv-bg-card);\n box-shadow: 0 24px 56px rgb(0 0 0 / 48%);\n}\n\n.atv-subject-suggestion {\n position: relative;\n display: flex;\n width: 100%;\n min-height: 70px;\n align-items: center;\n padding: 8px 18px 8px 9px;\n background: transparent;\n text-align: left;\n transition: background var(--atv-duration-hover) ease;\n}\n\n.atv-subject-suggestion + .atv-subject-suggestion {\n border-top: 1px solid var(--atv-border-subtle);\n}\n\n.atv-subject-suggestion:hover,\n.atv-subject-suggestion.is-active {\n background: var(--atv-bg-elevated);\n}\n\n.atv-subject-suggestion:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: -2px;\n}\n\n.atv-subject-suggestion-poster {\n position: relative;\n overflow: hidden;\n width: 36px;\n height: 54px;\n flex: 0 0 auto;\n border-radius: 4px;\n background: var(--atv-bg-tertiary);\n}\n\n.atv-subject-suggestion-poster img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.atv-subject-suggestion-copy {\n display: grid;\n min-width: 0;\n margin-left: 12px;\n gap: 4px;\n}\n\n.atv-subject-suggestion-title,\n.atv-subject-suggestion-original,\n.atv-subject-suggestion-metadata {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-subject-suggestion-title {\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n}\n\n.atv-subject-suggestion-metadata {\n margin-left: 7px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n}\n\n.atv-subject-suggestion-original {\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n}\n\n.atv-subject-suggestion-marker {\n position: absolute;\n top: 11px;\n bottom: 11px;\n left: 52px;\n width: 2px;\n border-radius: 2px;\n background: var(--atv-accent-bright);\n box-shadow: 0 0 12px var(--atv-accent-glow);\n opacity: 0;\n transform: scaleY(0.3);\n transform-origin: center;\n transition:\n opacity var(--atv-duration-hover) ease,\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-subject-suggestion-rail.is-keyboard-navigating .atv-subject-suggestion,\n.atv-subject-suggestion-rail.is-keyboard-navigating\n .atv-subject-suggestion-marker {\n transition: none;\n}\n\n.atv-subject-suggestion.is-active .atv-subject-suggestion-marker {\n opacity: 1;\n transform: scaleY(1);\n}\n\n.atv-subject-suggestion-skeletons {\n display: grid;\n padding: 9px;\n gap: 7px;\n}\n\n.atv-subject-suggestion-skeletons span {\n display: block;\n height: 54px;\n border-radius: 6px;\n background: var(--atv-bg-elevated);\n}\n\n.atv-subject-search-fallback {\n display: block;\n width: 100%;\n padding: 15px 18px;\n background: transparent;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n text-align: left;\n}\n\n.atv-subject-search-fallback:hover,\n.atv-subject-search-fallback:focus-visible {\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n outline: none;\n}\n\n@media (width <= 768px) {\n .atv-subject-switcher.is-open .atv-subject-suggestion-rail {\n right: -8px;\n width: calc(100vw - 24px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-subject-suggestion-marker {\n transition: none;\n }\n}\n/* ---------- Hero ---------- */\n.atv-hero {\n position: relative;\n display: flex;\n overflow: visible;\n min-height: 75vh;\n flex-direction: column;\n padding: 132px max(28px, 5vw) 56px;\n isolation: isolate;\n}\n\n.atv-hero-inner-section {\n flex: 0 0 auto;\n}\n\n.atv-hero-bg {\n position: absolute;\n z-index: -4;\n top: 0;\n right: 0;\n left: 0;\n overflow: hidden;\n height: 75vh;\n background: var(--atv-bg-primary);\n}\n\n.atv-hero-still {\n position: absolute;\n backface-visibility: hidden;\n background-position: center 30%;\n background-repeat: no-repeat;\n background-size: cover;\n inset: 0;\n transform: scale(1.04);\n}\n\n.atv-hero-still.is-thumb {\n filter: blur(12px) saturate(1.12) brightness(0.84);\n transform: scale(1.14);\n}\n\n.atv-hero-still.is-hd {\n filter: saturate(1.08) brightness(0.88);\n opacity: 0;\n transition: opacity var(--atv-duration-content) var(--atv-ease-out);\n}\n\n.atv-hero-still.is-hd.is-loaded {\n animation: atv-kenburns 22s linear forwards;\n opacity: 1;\n}\n\n.atv-hero-still.is-poster {\n background-position: center 22%;\n filter: blur(60px) saturate(1.25) brightness(0.78);\n transform: scale(1.25);\n}\n\n@keyframes atv-kenburns {\n from {\n transform: scale(1.04) translate(0, 0);\n }\n\n to {\n transform: scale(1.1) translate(-1.8%, -1.2%);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-hero-still.is-hd {\n transition: opacity var(--atv-duration-feedback) ease;\n }\n\n .atv-hero-still.is-hd.is-loaded {\n animation: none;\n transform: scale(1.04);\n }\n}\n\n.atv-hero-vignette {\n position: absolute;\n z-index: -3;\n top: 0;\n right: 0;\n left: 0;\n height: 75vh;\n background: radial-gradient(\n 120% 90% at 70% 30%,\n transparent 0%,\n rgb(0 0 0 / 55%) 100%\n );\n}\n\n.atv-hero-overlay-x {\n position: absolute;\n z-index: -2;\n top: 0;\n right: 0;\n left: 0;\n height: 75vh;\n background: linear-gradient(\n to right,\n rgb(0 0 0 / 96%) 0%,\n rgb(0 0 0 / 82%) 32%,\n rgb(0 0 0 / 50%) 62%,\n rgb(0 0 0 / 35%) 100%\n );\n}\n\n.atv-hero-overlay-y {\n position: absolute;\n z-index: -1;\n top: 0;\n right: 0;\n left: 0;\n height: 75vh;\n background: linear-gradient(\n to bottom,\n rgb(0 0 0 / 45%) 0%,\n transparent 28%,\n transparent 55%,\n var(--atv-bg-primary) 100%\n );\n}\n\n.atv-hero-inner {\n display: flex;\n width: 100%;\n max-width: 1100px;\n align-items: flex-start;\n margin: 0 auto;\n gap: 64px;\n}\n\n.atv-poster-card {\n display: flex;\n overflow: hidden;\n width: 360px;\n flex: 0 0 auto;\n padding: 0;\n border: none;\n border-radius: var(--atv-radius-lg);\n appearance: none;\n aspect-ratio: 2 / 3;\n background: var(--atv-bg-tertiary);\n cursor: pointer;\n}\n\n.atv-poster-card img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.atv-poster-placeholder {\n width: 100%;\n height: 100%;\n}\n\n.atv-hero-info {\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.atv-hero-title {\n margin: 0 0 8px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display-compact);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: clamp(-0.035em, -0.03em, -0.025em);\n line-height: var(--atv-line-height-display);\n text-shadow: 0 4px 30px rgb(0 0 0 / 50%);\n text-wrap: balance;\n}\n\n.atv-hero-orig {\n margin-bottom: 22px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-hero-orig);\n font-weight: var(--atv-font-weight-regular);\n letter-spacing: -0.01em;\n opacity: 0.85;\n}\n\n.atv-rank-label {\n display: inline-block;\n width: fit-content;\n max-width: 100%;\n margin: 0 0 25px;\n color: var(--atv-text-primary);\n text-decoration: none;\n}\n\n.atv-rank-label-entry {\n display: grid;\n min-height: 34px;\n align-items: center;\n padding: 5px 8px 5px 12px;\n border-left: 2px solid var(--atv-rating-gold);\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--atv-rating-gold) 14%, transparent) 0%,\n rgb(255 184 0 / 0%) 86%\n );\n column-gap: 12px;\n grid-template-columns: auto minmax(0, 1fr) auto;\n}\n\n.atv-rank-label-entry strong {\n color: var(--atv-rating-gold);\n font-family: var(--atv-font-mono);\n font-size: var(--atv-type-caption-sm);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.04em;\n white-space: nowrap;\n}\n\n.atv-rank-label-title {\n overflow: hidden;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-regular);\n letter-spacing: -0.01em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-rank-label-arrow {\n color: var(--atv-text-tertiary);\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-rank-label:hover,\n.atv-rank-label:focus-visible {\n outline: none;\n}\n\n.atv-rank-label:hover .atv-rank-label-entry,\n.atv-rank-label:focus-visible .atv-rank-label-entry {\n border-left-color: #ffd166;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--atv-rating-gold) 22%, transparent) 0%,\n rgb(255 184 0 / 0%) 86%\n );\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-rank-label:hover .atv-rank-label-arrow {\n color: var(--atv-text-primary);\n transform: translateX(3px);\n }\n}\n\n@media (width <= 480px) {\n .atv-rank-label {\n margin-bottom: 21px;\n }\n\n .atv-rank-label-entry {\n max-width: min(100%, 340px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-rank-label-arrow {\n transition: none;\n }\n\n .atv-rank-label:hover .atv-rank-label-arrow {\n transform: none;\n }\n}\n\n.atv-hero-meta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 24px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-medium);\n gap: 10px 14px;\n letter-spacing: 0.02em;\n text-transform: uppercase;\n}\n\n.atv-first-broadcast-platform {\n display: inline-flex;\n align-items: center;\n}\n\n.atv-first-broadcast-platform-mark {\n display: inline-flex;\n width: 32px;\n height: 32px;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--atv-border-medium);\n border-radius: 9px;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n}\n\n.atv-first-broadcast-platform-mark.is-catalog svg {\n width: 22px;\n height: 22px;\n fill: currentcolor;\n}\n\n.atv-first-broadcast-platform-mark.is-intrinsic svg {\n width: 22px;\n height: 22px;\n fill: initial;\n}\n\n.atv-first-broadcast-platform-mark.is-wordmark {\n width: 68px;\n}\n\n.atv-first-broadcast-platform-mark.is-wordmark svg {\n width: 52px;\n height: 20px;\n}\n\n.atv-first-broadcast-platform-mark.is-surface-paper {\n border-color: var(--atv-border-emphasis);\n background: var(--atv-surface-paper);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 56%);\n}\n\n.atv-first-broadcast-platform.is-unknown {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n letter-spacing: 0.08em;\n text-transform: none;\n}\n\n.atv-screen-reader-only {\n position: absolute;\n overflow: hidden;\n width: 1px;\n height: 1px;\n padding: 0;\n border: 0;\n margin: -1px;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.atv-meta-dot {\n display: inline-flex;\n align-items: center;\n}\n\n.atv-meta-dot + .atv-meta-dot::before {\n color: var(--atv-text-tertiary);\n content: \"·\";\n}\n\n.atv-meta-chips {\n display: inline-flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n.atv-chip {\n display: inline-flex;\n align-items: center;\n padding: 4px 11px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n text-transform: none;\n}\n/* ---------- Personage hero ---------- */\n.atv-personage-hero {\n position: relative;\n min-height: 70vh;\n padding: clamp(96px, 13vw, 152px) max(28px, 7vw) 80px;\n}\n\n.atv-personage-hero::after {\n position: absolute;\n right: max(28px, 7vw);\n bottom: 0;\n left: max(28px, 7vw);\n height: 1px;\n background: linear-gradient(\n to right,\n transparent,\n var(--atv-border-subtle) 20%,\n var(--atv-border-subtle) 80%,\n transparent\n );\n content: \"\";\n pointer-events: none;\n}\n\n.atv-personage-hero-inner {\n display: grid;\n width: min(100%, 1120px);\n align-items: start;\n margin: 0 auto;\n gap: clamp(32px, 5vw, 72px);\n grid-template-columns: minmax(160px, 240px) minmax(0, 1fr);\n}\n\n.atv-personage-portrait {\n width: 100%;\n border-radius: 50%;\n aspect-ratio: 1;\n background: var(--atv-bg-tertiary);\n object-fit: cover;\n}\n\n.atv-personage-portrait.is-empty {\n background: radial-gradient(\n circle at 30% 25%,\n #4a4a4d,\n var(--atv-bg-tertiary) 62%\n );\n}\n\n.atv-personage-portrait-trigger {\n display: block;\n width: 100%;\n padding: 0;\n border: 0;\n border-radius: 50%;\n margin: 0;\n appearance: none;\n background: transparent;\n cursor: pointer;\n}\n\n.atv-personage-kicker {\n margin: 2px 0 16px;\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.atv-personage-identity h1 {\n margin: 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display-compact);\n letter-spacing: -0.04em;\n line-height: var(--atv-line-height-display);\n text-wrap: balance;\n}\n\n.atv-personage-original-name {\n margin: 18px 0 0;\n color: var(--atv-text-secondary);\n font-size: clamp(19px, 2.1vw, 28px);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n line-height: 1.2;\n}\n\n.atv-personage-facts {\n display: flex;\n flex-wrap: wrap;\n margin: 32px 0 0;\n gap: 12px 32px;\n}\n\n.atv-personage-facts div {\n display: flex;\n gap: 8px;\n}\n\n.atv-personage-facts dt {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n letter-spacing: 0.03em;\n opacity: 0.7;\n}\n\n.atv-personage-facts dd {\n margin: 0;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n}\n\n.atv-personage-biography {\n max-width: 680px;\n margin-top: 36px;\n}\n\n.atv-personage-biography-content {\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-lg);\n letter-spacing: 0.01em;\n line-height: var(--atv-line-height-body-loose);\n}\n\n.atv-personage-biography-content p {\n margin: 0;\n}\n\n.atv-personage-biography-content p + p {\n margin-top: 1em;\n}\n\n.atv-personage-biography-content.is-clamped > p:not(:first-child) {\n display: none;\n}\n\n.atv-personage-biography-content.is-clamped > p:first-child {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 3;\n}\n\n.atv-personage-biography button {\n padding: 0;\n border: 0;\n margin-top: 12px;\n appearance: none;\n background: transparent;\n color: var(--atv-accent-bright);\n cursor: pointer;\n font: inherit;\n transition: opacity 160ms ease;\n}\n\n/* ---------- Entrance ---------- */\n@keyframes atv-hero-fade-in {\n from {\n opacity: 0;\n transform: translateY(12px);\n }\n\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes atv-hero-portrait-in {\n from {\n opacity: 0;\n transform: scale(0.92);\n }\n\n to {\n opacity: 1;\n transform: scale(1);\n }\n}\n\n.atv-personage-hero.is-revealed .atv-personage-portrait {\n animation: atv-hero-portrait-in 500ms var(--atv-ease-out) both;\n animation-delay: 80ms;\n}\n\n.atv-personage-hero.is-revealed .atv-personage-identity h1 {\n animation: atv-hero-fade-in 500ms var(--atv-ease-out) both;\n animation-delay: 180ms;\n}\n\n.atv-personage-hero.is-revealed .atv-personage-kicker {\n animation: atv-hero-fade-in 500ms var(--atv-ease-out) both;\n animation-delay: 260ms;\n}\n\n.atv-personage-hero.is-revealed .atv-personage-original-name {\n animation: atv-hero-fade-in 500ms var(--atv-ease-out) both;\n animation-delay: 260ms;\n}\n\n.atv-personage-hero.is-revealed .atv-personage-facts div {\n animation: atv-hero-fade-in 500ms var(--atv-ease-out) both;\n animation-delay: calc(340ms + var(--fact-index, 0) * 60ms);\n}\n\n.atv-personage-hero.is-revealed .atv-personage-biography {\n animation: atv-hero-fade-in 500ms var(--atv-ease-out) both;\n animation-delay: 500ms;\n}\n\n/* ---------- Hover ---------- */\n@media (hover: hover) and (pointer: fine) {\n .atv-personage-biography button:hover {\n opacity: 0.8;\n }\n}\n/* ---------- Collaborators ---------- */\n.atv-personage-collaborators {\n display: flex;\n flex-direction: column;\n padding: 0;\n margin: 20px 0 0;\n list-style: none;\n}\n\n.atv-personage-collaborator {\n position: relative;\n display: grid;\n overflow: hidden;\n align-items: center;\n padding: 14px 12px;\n border-radius: var(--atv-radius-sm, 8px);\n gap: 14px;\n grid-template-columns: 28px 48px 1fr auto;\n transition: background 160ms ease;\n}\n\n.atv-section-reveal.is-revealed .atv-personage-collaborator {\n animation: atv-collaborator-in 200ms var(--atv-ease-out) both;\n animation-delay: min(calc(var(--stagger-index, 0) * 40ms + 20ms), 100ms);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-personage-collaborator:hover {\n background: var(--atv-bg-secondary);\n }\n\n .atv-personage-collaborator::before {\n position: absolute;\n top: 14px;\n bottom: 14px;\n left: 0;\n width: 3px;\n border-radius: 0 3px 3px 0;\n background: var(--atv-accent);\n content: \"\";\n opacity: 0;\n transform: scaleY(0.35);\n transition:\n opacity var(--atv-duration-feedback) ease,\n transform var(--atv-duration-hover) var(--atv-ease-out);\n }\n\n .atv-personage-collaborator:hover::before {\n opacity: 1;\n transform: scaleY(1);\n }\n}\n\n.atv-personage-collaborator:active {\n transform: scale(0.98);\n}\n\n@keyframes atv-collaborator-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n.atv-personage-collaborator-rank {\n display: flex;\n justify-content: flex-end;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.02em;\n}\n\n.atv-personage-collaborator-avatar {\n width: 48px;\n height: 48px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 50%;\n background: var(--atv-bg-tertiary);\n object-fit: cover;\n transition: border-color 160ms ease;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-personage-collaborator:hover .atv-personage-collaborator-avatar {\n border-color: var(--atv-accent-bright);\n }\n}\n\n.atv-personage-collaborator-avatar.is-fallback {\n display: grid;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-md);\n font-weight: var(--atv-font-weight-semibold);\n place-items: center;\n}\n\n.atv-personage-collaborator-info {\n display: flex;\n min-width: 0;\n flex-direction: column;\n gap: 2px;\n}\n\n.atv-personage-collaborator-name {\n overflow: hidden;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.01em;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: color 160ms ease;\n white-space: nowrap;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-personage-collaborator-name:hover {\n color: var(--atv-accent-bright);\n }\n}\n\n.atv-personage-collaborator-count {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-regular);\n}\n\n.atv-personage-collaborator-action {\n flex-shrink: 0;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n text-decoration: none;\n transition:\n color 160ms ease,\n opacity 200ms var(--atv-ease-out),\n transform 200ms var(--atv-ease-out);\n white-space: nowrap;\n}\n\n.atv-personage-collaborator-arrow {\n display: inline-block;\n transition:\n opacity var(--atv-duration-feedback) ease,\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-personage-collaborator-action {\n opacity: 0;\n pointer-events: none;\n transform: translateX(8px);\n }\n\n .atv-personage-collaborator-arrow {\n opacity: 0.35;\n transform: translate(1px, -2px);\n }\n\n .atv-personage-collaborator:hover .atv-personage-collaborator-action,\n .atv-personage-collaborator:focus-within .atv-personage-collaborator-action {\n opacity: 1;\n pointer-events: auto;\n transform: translateX(0);\n }\n\n .atv-personage-collaborator:hover .atv-personage-collaborator-arrow,\n .atv-personage-collaborator:focus-within .atv-personage-collaborator-arrow {\n opacity: 1;\n transform: translate(0, 0);\n }\n\n .atv-personage-collaborator-action:hover {\n color: var(--atv-accent-bright);\n }\n}\n\n.atv-personage-collaborator-action:focus-visible {\n border-radius: 2px;\n color: var(--atv-accent-bright);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 3px;\n}\n.atv-personage .atv-section {\n /* Inter-group 56px (bottom) vs intra-group 28px (header margin) = 2:1.\n Horizontal inset matches the hero's max(28px, 7vw) so section content\n aligns with the identity column. */\n padding: 36px max(28px, 7vw) 56px;\n}\n\n.atv-personage .atv-section + .atv-section {\n padding-top: 0;\n}\n\n.atv-personage .atv-section-h {\n padding-left: 0;\n margin-bottom: 28px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-title-lg);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.02em;\n}\n\n.atv-personage .atv-section-h::before {\n display: none;\n}\n\n.atv-personage .atv-section-h-row {\n margin-bottom: 28px;\n}\n\n.atv-carousel.atv-personage-gallery-rail {\n /* Compound selector outranks the shared `.atv-carousel` gap (see work rail). */\n gap: 12px;\n list-style: none;\n}\n\n@keyframes atv-gallery-tile-in {\n from {\n opacity: 0;\n transform: translateY(6px) scale(0.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.atv-personage-gallery-rail li {\n min-width: 0;\n height: 256px;\n flex: 0 0 auto;\n aspect-ratio: var(--atv-personage-gallery-aspect-ratio, 4 / 3);\n scroll-snap-align: start;\n}\n\n.atv-section-reveal.is-revealed .atv-personage-gallery-rail li {\n animation: atv-gallery-tile-in 200ms var(--atv-ease-out) both;\n animation-delay: min(calc(var(--stagger-index, 0) * 40ms + 20ms), 100ms);\n}\n\n.atv-personage-gallery-rail button {\n display: block;\n overflow: hidden;\n width: 100%;\n min-width: 0;\n height: 100%;\n padding: 0;\n border: 0;\n border-radius: var(--atv-radius-sm);\n appearance: none;\n background: var(--atv-bg-tertiary);\n cursor: pointer;\n}\n\n.atv-personage-gallery-rail img {\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n/* ---------- WorkRail (代表作品) ---------- */\n\n.atv-carousel.atv-personage-work-rail {\n /* Compound selector outranks the shared `.atv-carousel` gap regardless of\n manifest import order (layout.css is imported later than this file). */\n gap: 20px;\n}\n\n.atv-personage-work-card {\n /* Five cards fill the rail at 20px gaps; larger selections overflow into\n the rail's own horizontal scroll instead of being clipped. */\n flex: 0 0 max(140px, calc((100% - 80px) / 5));\n color: inherit;\n cursor: pointer;\n opacity: 0;\n scroll-snap-align: start;\n text-decoration: none;\n transform: translateY(8px);\n transition:\n opacity 400ms var(--atv-ease-out) 200ms,\n transform 400ms var(--atv-ease-out) 200ms;\n}\n\n.atv-section-reveal.is-revealed .atv-personage-work-card {\n opacity: 1;\n transform: translateY(0);\n}\n\n.atv-personage-work-card:focus-visible {\n border-radius: var(--atv-radius-sm);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-personage-work-poster {\n position: relative;\n overflow: hidden;\n width: 100%;\n border-radius: var(--atv-radius-sm);\n aspect-ratio: 2 / 3;\n background: var(--atv-bg-tertiary);\n}\n\n.atv-personage-work-poster img,\n.atv-personage-work-poster .atv-poster-placeholder {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n/* Index number badge — 1‑based order on the card poster */\n.atv-work-index {\n position: absolute;\n z-index: 1;\n bottom: 0;\n left: 0;\n display: flex;\n width: 28px;\n height: 28px;\n align-items: center;\n justify-content: center;\n border-radius: 0 4px 4px 0;\n backdrop-filter: blur(2px);\n background: rgb(0 0 0 / 60%);\n color: var(--atv-text-primary);\n font-family: var(--atv-font-display);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.01em;\n line-height: 1;\n}\n\n/* Rating badge — pill badge on the card poster */\n.atv-work-rating {\n position: absolute;\n z-index: 1;\n top: 8px;\n right: 8px;\n display: flex;\n min-width: 28px;\n height: 22px;\n align-items: center;\n justify-content: center;\n padding: 0 7px;\n border-radius: 20px;\n backdrop-filter: blur(4px);\n background: rgb(0 0 0 / 65%);\n color: var(--atv-text-primary);\n font-family: var(--atv-font-display);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.02em;\n line-height: 1;\n}\n\n.atv-personage-work-title {\n display: block;\n overflow: hidden;\n margin-top: 10px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n line-height: 1.3;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-personage-work-year {\n display: block;\n margin-top: 4px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n}\n/* ---------- Timeline (Axis + Dots) ---------- */\n\n/* Vertical axis line — accent at top fades into the dark background toward\n the past, so newer years read as more present. */\n.atv-timeline {\n position: relative;\n margin-top: 20px;\n font-size: var(--atv-type-stat-lg);\n}\n\n.atv-timeline::before {\n position: absolute;\n z-index: 0;\n top: calc(0.5em);\n bottom: 10px;\n left: 15px;\n width: 1px;\n background: linear-gradient(\n to bottom,\n var(--atv-accent-bright) 0%,\n var(--atv-accent) 35%,\n var(--atv-border-subtle) 100%\n );\n content: \"\";\n pointer-events: none;\n}\n\n.atv-timeline-year-group {\n position: relative;\n padding-bottom: 24px;\n padding-left: 44px;\n font-size: var(--atv-type-stat-lg);\n}\n\n.atv-timeline-year-group:last-child {\n padding-bottom: 0;\n}\n\n.atv-timeline-year-group::before {\n position: absolute;\n z-index: 1;\n top: calc(0.5em - 5px);\n left: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--atv-accent-bright);\n box-shadow: 0 0 0 transparent;\n content: \"\";\n opacity: 0;\n transform: scale(0.5);\n transition:\n opacity 400ms var(--atv-ease-out),\n transform 400ms var(--atv-ease-out),\n box-shadow 400ms var(--atv-ease-out);\n transition-delay: calc(var(--group-index, 0) * 60ms);\n}\n\n.atv-section-reveal.is-revealed .atv-timeline-year-group::before {\n box-shadow:\n 0 0 10px var(--atv-accent),\n 0 0 24px color-mix(in srgb, var(--atv-accent) 25%, transparent);\n opacity: 1;\n transform: scale(1);\n}\n\n.atv-timeline-year-num {\n display: block;\n margin-bottom: 16px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-stat-lg);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.02em;\n line-height: 1;\n}\n\n.atv-section-reveal.is-revealed .atv-timeline-year-num {\n animation: atv-timeline-year-in 200ms var(--atv-ease-out) both;\n animation-delay: min(calc(var(--group-index, 0) * 40ms + 20ms), 100ms);\n}\n\n@keyframes atv-timeline-year-in {\n from {\n opacity: 0;\n transform: translateY(4px);\n }\n\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Card row */\n.atv-timeline-row {\n display: flex;\n flex-wrap: wrap;\n gap: 16px;\n}\n\n@keyframes atv-timeline-card-in {\n from {\n opacity: 0;\n transform: translateY(8px) scale(0.97);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.atv-timeline-card {\n min-width: 120px;\n max-width: 200px;\n flex: 1 1 160px;\n color: inherit;\n cursor: pointer;\n text-decoration: none;\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n will-change: transform;\n}\n\n.atv-section-reveal.is-revealed .atv-timeline-card {\n animation: atv-timeline-card-in 200ms var(--atv-ease-out) both;\n animation-delay: min(\n calc(var(--stagger-index, 0) * 40ms + var(--group-index, 0) * 40ms + 20ms),\n 100ms\n );\n}\n\n.atv-timeline-card:active {\n transform: scale(0.97);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-timeline-card:hover {\n transform: translateY(-3px);\n }\n}\n\n.atv-timeline-card:focus-visible {\n border-radius: var(--atv-radius-sm);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-timeline-card-poster {\n position: relative;\n overflow: hidden;\n width: 100%;\n border-radius: var(--atv-radius-sm);\n aspect-ratio: 2 / 3;\n background: var(--atv-bg-tertiary);\n}\n\n.atv-timeline-card-poster img,\n.atv-timeline-card-poster .atv-poster-placeholder {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.atv-timeline-card-rating {\n position: absolute;\n z-index: 1;\n top: 8px;\n right: 8px;\n display: flex;\n min-width: 28px;\n height: 22px;\n align-items: center;\n justify-content: center;\n padding: 0 7px;\n border-radius: 20px;\n backdrop-filter: blur(4px);\n background: rgb(0 0 0 / 65%);\n color: var(--atv-text-primary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.02em;\n line-height: 1;\n}\n\n.atv-timeline-card-title {\n display: block;\n overflow: hidden;\n margin-top: 10px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n line-height: 1.3;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.atv-personage-awards-list {\n padding: 0;\n margin: 0;\n list-style: none;\n}\n\n.atv-personage-awards-list li {\n display: grid;\n padding: 20px 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n gap: 24px;\n grid-template-columns: minmax(84px, auto) 1fr;\n}\n\n.atv-personage-awards-list li:last-child {\n border-bottom: none;\n}\n\n.atv-personage-award-year {\n padding-top: 4px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-title-xl);\n font-weight: var(--atv-font-weight-regular);\n letter-spacing: 0.02em;\n line-height: 1;\n}\n\n.atv-personage-award-detail {\n position: relative;\n min-width: 0;\n padding-left: 16px;\n border-left: 2px solid var(--atv-accent);\n}\n\n.atv-personage-award-ceremony,\n.atv-personage-award-ceremony a {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-personage-award-ceremony a {\n border-bottom: 1px solid transparent;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease;\n}\n\n.atv-personage-award-ceremony a:hover {\n border-bottom-color: var(--atv-accent-bright);\n color: var(--atv-accent-bright);\n}\n\n.atv-personage-award-name {\n margin: 6px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.4;\n}\n\n.atv-personage-award-work {\n margin: 6px 0 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-body-sm);\n}\n\n.atv-personage-award-work a {\n border-bottom: 1px solid transparent;\n color: inherit;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease;\n}\n\n.atv-personage-award-work a:hover {\n border-bottom-color: var(--atv-accent-bright);\n color: var(--atv-accent-bright);\n}\n@media (width <= 640px) {\n .atv-personage-hero-inner {\n gap: 24px;\n grid-template-columns: 112px minmax(0, 1fr);\n }\n\n .atv-personage-identity h1 {\n font-size: var(--atv-type-display-narrow);\n }\n\n .atv-personage-facts {\n margin-top: 20px;\n }\n\n .atv-personage-biography {\n margin-top: 0;\n grid-column: 1 / -1;\n }\n\n .atv-personage-collaborator {\n padding: 14px 8px;\n gap: 10px;\n grid-template-columns: 20px 42px 1fr auto;\n }\n\n .atv-personage-collaborator-avatar,\n .atv-personage-collaborator-avatar.is-fallback {\n width: 42px;\n height: 42px;\n }\n\n .atv-personage-gallery-rail li {\n height: 160px;\n }\n\n .atv-personage-awards-list li {\n padding: 16px 0;\n gap: 10px;\n grid-template-columns: 1fr;\n }\n\n .atv-personage-award-year {\n padding-top: 0;\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n }\n\n .atv-personage-award-detail {\n padding-left: 12px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-personage-hero .atv-personage-portrait,\n .atv-personage-hero .atv-personage-identity h1,\n .atv-personage-hero .atv-personage-original-name,\n .atv-personage-hero .atv-personage-facts div,\n .atv-personage-hero .atv-personage-biography {\n animation: none;\n opacity: 1;\n transform: none;\n }\n\n .atv-personage-collaborator {\n animation: none;\n opacity: 1;\n transform: none;\n }\n\n .atv-personage-collaborator:active {\n transform: none;\n }\n\n .atv-personage-gallery-rail li {\n animation: none;\n opacity: 1;\n transform: none;\n }\n\n /* Timeline: dots always visible, no entrance animations */\n .atv-timeline-year-group::before {\n opacity: 1;\n transform: scale(1);\n transition: none;\n }\n\n .atv-section-reveal.is-revealed .atv-timeline-year-num {\n animation: none;\n opacity: 1;\n }\n\n .atv-personage-work-card,\n .atv-section-reveal.is-revealed .atv-timeline-card {\n animation: none;\n opacity: 1;\n transform: none;\n }\n\n .atv-personage-work-card,\n .atv-timeline-card {\n transition: none;\n will-change: auto;\n }\n}\n/* ---------- Subject celebrities / Credit Atlas ---------- */\n.atv-celebrities {\n overflow: clip;\n padding: 0 max(28px, 5vw) 96px;\n}\n\n.atv-celebrities-hero,\n.atv-credit-groups {\n width: min(100%, 1280px);\n margin: 0 auto;\n}\n\n.atv-celebrities-hero {\n padding: clamp(112px, 15vw, 176px) 0 clamp(52px, 8vw, 96px);\n}\n\n.atv-celebrities-kicker,\n.atv-credit-works > span {\n margin: 0;\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.atv-celebrities-hero h1 {\n max-width: 980px;\n margin: 16px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.055em;\n line-height: var(--atv-line-height-display);\n text-wrap: balance;\n}\n\n.atv-celebrities-context {\n display: flex;\n align-items: center;\n margin-top: 30px;\n color: var(--atv-text-secondary);\n gap: 20px;\n}\n\n.atv-celebrities-context > p {\n margin: 0;\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n}\n\n.atv-credit-back {\n display: inline-flex;\n align-items: center;\n padding: 7px 10px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 5px;\n transition:\n border-color var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-credit-back:active,\n.atv-credit-person:active,\n.atv-credit-work:active {\n transform: scale(0.97);\n}\n\n.atv-credit-back:focus-visible,\n.atv-credit-person:focus-visible,\n.atv-credit-work:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-credit-groups {\n position: relative;\n}\n\n.atv-credit-groups::before {\n position: absolute;\n top: 0;\n bottom: 0;\n left: clamp(0px, 13.8vw, 176px);\n width: 1px;\n background: linear-gradient(\n to bottom,\n transparent,\n var(--atv-border-subtle) 2%,\n var(--atv-border-subtle) 98%,\n transparent\n );\n content: \"\";\n pointer-events: none;\n}\n\n.atv-credit-group {\n position: relative;\n display: grid;\n padding: clamp(42px, 6vw, 76px) 0;\n border-top: 1px solid var(--atv-border-subtle);\n gap: clamp(28px, 5vw, 72px);\n grid-template-columns: minmax(128px, 176px) minmax(0, 1fr);\n scroll-margin-top: 76px;\n}\n\n.atv-credit-group::before {\n position: absolute;\n top: clamp(42px, 6vw, 76px);\n left: clamp(0px, 13.8vw, 176px);\n width: 3px;\n height: 32px;\n border-radius: 3px;\n background: var(--atv-accent);\n box-shadow: 0 0 20px var(--atv-accent-glow);\n content: \"\";\n transform: translateX(-1px);\n}\n\n.atv-credit-group-heading {\n position: sticky;\n top: 84px;\n align-self: start;\n padding-right: 24px;\n}\n\n.atv-credit-group-heading > h2 {\n margin: 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-group);\n letter-spacing: -0.03em;\n line-height: var(--atv-line-height-display);\n}\n\n.atv-credit-group-heading > p {\n margin: 8px 0 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.08em;\n line-height: 1.3;\n}\n\n.atv-credit-grid {\n display: grid;\n gap: 0 clamp(18px, 2.5vw, 32px);\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));\n}\n\n.atv-credit-card {\n min-width: 0;\n padding: 0 0 22px;\n border-bottom: 1px solid var(--atv-border-subtle);\n margin-bottom: 22px;\n}\n\n.atv-credit-identity {\n min-width: 0;\n}\n\n.atv-credit-person {\n display: grid;\n min-width: 0;\n align-items: center;\n color: var(--atv-text-primary);\n gap: 12px;\n grid-template-columns: 56px minmax(0, 1fr);\n transition: transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-credit-person:not(.is-static) {\n cursor: pointer;\n}\n\n.atv-credit-avatar {\n width: 56px;\n height: 56px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-md);\n background: var(--atv-bg-tertiary);\n object-fit: cover;\n}\n\n.atv-credit-avatar.is-empty {\n background:\n radial-gradient(circle at 68% 22%, rgb(76 217 122 / 25%), transparent 38%),\n linear-gradient(145deg, #38383b, var(--atv-bg-tertiary));\n}\n\n.atv-credit-name {\n display: grid;\n overflow: hidden;\n min-width: 0;\n gap: 3px;\n}\n\n.atv-credit-name-primary,\n.atv-credit-name-original {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-credit-name-primary {\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.005em;\n line-height: 1.25;\n}\n\n.atv-credit-name-original {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.025em;\n line-height: 1.2;\n}\n\n.atv-credit-role {\n display: grid;\n margin: 9px 0 0 68px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n letter-spacing: 0.01em;\n line-height: 1.45;\n row-gap: 2px;\n}\n\n.atv-credit-role-title {\n color: var(--atv-text-secondary);\n}\n\n.atv-credit-role-detail,\n.atv-credit-character-prefix,\n.atv-credit-character-original {\n color: var(--atv-text-tertiary);\n}\n\n.atv-credit-character {\n display: grid;\n min-width: 0;\n column-gap: 4px;\n grid-template-columns: auto minmax(0, 1fr);\n}\n\n.atv-credit-character-name,\n.atv-credit-character-original {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-credit-character-original {\n font-size: var(--atv-type-micro);\n grid-column: 2;\n letter-spacing: 0.025em;\n line-height: 1.2;\n}\n\n.atv-credit-works {\n display: grid;\n margin: 14px 0 0 68px;\n gap: 5px;\n}\n\n.atv-credit-works > span {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n letter-spacing: 0.08em;\n}\n\n.atv-credit-works > div {\n display: flex;\n flex-wrap: wrap;\n gap: 4px 10px;\n}\n\n.atv-credit-work {\n border-bottom: 1px solid transparent;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n line-height: 1.4;\n transition:\n border-color var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-credit-back:hover {\n border-color: var(--atv-accent-border-strong);\n color: var(--atv-accent-bright);\n }\n\n .atv-credit-person:not(.is-static):hover {\n transform: translateX(3px);\n }\n\n .atv-credit-work:hover {\n border-bottom-color: var(--atv-accent-bright);\n color: var(--atv-accent-bright);\n }\n}\n\n.atv-celebrities-nav .atv-stickynav-jumps {\n min-width: 0;\n padding-bottom: 1px;\n gap: 20px;\n overflow-x: auto;\n scrollbar-width: none;\n}\n\n.atv-celebrities-nav .atv-stickynav-jumps::-webkit-scrollbar {\n display: none;\n}\n\n@media (width <= 768px) {\n .atv-celebrities {\n padding-right: 20px;\n padding-left: 20px;\n }\n\n .atv-celebrities-hero {\n padding-top: 104px;\n }\n\n .atv-credit-groups::before,\n .atv-credit-group::before {\n display: none;\n }\n\n .atv-credit-group {\n gap: 24px;\n grid-template-columns: 1fr;\n }\n\n .atv-credit-group-heading {\n position: static;\n padding-right: 0;\n }\n\n .atv-credit-grid {\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));\n }\n\n .atv-celebrities-nav {\n padding-right: 20px;\n padding-left: 20px;\n gap: 16px;\n }\n\n .atv-celebrities-nav .atv-stickynav-title {\n flex: 0 1 32%;\n }\n\n .atv-celebrities-nav .atv-stickynav-jumps {\n flex: 1 1 auto;\n gap: 16px;\n }\n}\n\n@media (width <= 460px) {\n .atv-credit-grid {\n grid-template-columns: 1fr;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-credit-back,\n .atv-credit-person,\n .atv-credit-work {\n transition: none;\n }\n\n .atv-credit-back:active,\n .atv-credit-person:active,\n .atv-credit-work:active {\n transform: none;\n }\n}\n/* ---------- Subject photo archive: frame and identity ---------- */\n.atv-photo-archive {\n min-height: 100vh;\n padding: 0 max(24px, 5vw) 96px;\n background:\n radial-gradient(circle at 82% -12%, rgb(65 190 93 / 12%), transparent 30%),\n var(--atv-bg-primary);\n}\n\n.atv-photo-archive-hero,\n.atv-photo-archive-groups {\n width: min(100%, 1320px);\n margin: 0 auto;\n}\n\n.atv-photo-archive-hero {\n padding: clamp(28px, 4vw, 56px) 0 clamp(52px, 8vw, 108px);\n}\n\n.atv-photo-archive-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 10px 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n gap: 18px;\n}\n\n.atv-photo-archive-back,\n.atv-photo-archive-upload,\n.atv-photo-archive-all {\n display: inline-flex;\n align-items: center;\n border: 1px solid transparent;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 7px;\n letter-spacing: 0.015em;\n transition:\n border-color var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-photo-archive-back:focus-visible,\n.atv-photo-archive-upload:focus-visible,\n.atv-photo-archive-all:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-photo-archive-back:active,\n.atv-photo-archive-upload:active,\n.atv-photo-archive-all:active {\n transform: scale(0.97);\n}\n\n.atv-photo-archive-upload {\n padding: 7px 10px;\n border-color: var(--atv-border-medium);\n border-radius: 999px;\n color: var(--atv-text-primary);\n}\n\n.atv-photo-archive-intro {\n padding-top: clamp(48px, 9vw, 128px);\n}\n\n.atv-photo-archive-intro > p {\n margin: 0;\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.08em;\n line-height: 1.2;\n text-transform: uppercase;\n}\n\n.atv-photo-archive-intro h1 {\n max-width: 980px;\n margin: 16px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.055em;\n line-height: var(--atv-line-height-display);\n text-wrap: balance;\n}\n\n.atv-photo-archive-index {\n display: flex;\n margin: clamp(34px, 5vw, 54px) 0 0;\n gap: clamp(28px, 5vw, 68px);\n}\n\n.atv-photo-archive-index > div {\n display: grid;\n min-width: 0;\n gap: 4px;\n}\n\n.atv-photo-archive-index dt {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.04em;\n}\n\n.atv-photo-archive-index dd {\n margin: 0;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-title-lg);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.02em;\n line-height: 1;\n}\n\n/* The shared `.atv-stickynav-jumps` now shrinks and scrolls on its own\n (sticky-nav.css); the archive page's local copies are gone. */\n\n@media (hover: hover) and (pointer: fine) {\n .atv-photo-archive-back:hover,\n .atv-photo-archive-all:hover {\n color: var(--atv-accent-bright);\n }\n\n .atv-photo-archive-upload:hover {\n border-color: var(--atv-accent-border-strong);\n color: var(--atv-accent-bright);\n }\n}\n\n@media (width <= 768px) {\n .atv-photo-archive {\n padding: 0 20px 64px;\n }\n\n .atv-photo-archive-hero {\n padding-top: 20px;\n }\n\n .atv-photo-archive-intro {\n padding-top: 56px;\n }\n\n .atv-photo-archive-nav {\n padding-right: 20px;\n padding-left: 20px;\n gap: 16px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-photo-archive-back,\n .atv-photo-archive-upload,\n .atv-photo-archive-all {\n transition: none;\n }\n\n .atv-photo-archive-back:active,\n .atv-photo-archive-upload:active,\n .atv-photo-archive-all:active {\n transform: none;\n }\n}\n\n@media (prefers-contrast: more) {\n .atv-photo-archive-upload {\n border-color: var(--atv-border-emphasis);\n }\n}\n/* ---------- Subject photo archive: contact sheets ---------- */\n.atv-photo-archive-group {\n display: grid;\n padding: clamp(38px, 6vw, 80px) 0;\n border-top: 1px solid var(--atv-border-subtle);\n gap: clamp(28px, 5vw, 72px);\n grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);\n scroll-margin-top: 76px;\n}\n\n.atv-photo-archive-group-heading {\n position: sticky;\n top: 84px;\n align-self: start;\n}\n\n.atv-photo-archive-group-heading h2 {\n margin: 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-group);\n letter-spacing: -0.035em;\n line-height: var(--atv-line-height-display);\n}\n\n.atv-photo-archive-count {\n margin: 10px 0 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.04em;\n}\n\n.atv-photo-archive-all {\n margin-top: 24px;\n color: var(--atv-text-secondary);\n}\n\n.atv-photo-archive-grid {\n padding: 0;\n margin: 0;\n column-gap: clamp(8px, 1.15vw, 16px);\n column-width: clamp(118px, 11vw, 176px);\n list-style: none;\n}\n\n.atv-photo-archive-item {\n --atv-photo-archive-aspect-ratio: 1;\n\n margin-bottom: clamp(8px, 1.15vw, 16px);\n aspect-ratio: var(--atv-photo-archive-aspect-ratio);\n break-inside: avoid;\n}\n\n.atv-photo-archive-tile {\n position: relative;\n display: block;\n overflow: hidden;\n height: 100%;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 2px;\n background: var(--atv-bg-card);\n box-shadow: 0 12px 28px rgb(0 0 0 / 25%);\n transition:\n border-color var(--atv-duration-feedback) ease,\n box-shadow var(--atv-duration-hover) var(--atv-ease-out),\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-photo-archive-tile::after {\n position: absolute;\n border: 1px solid var(--atv-border-subtle);\n content: \"\";\n inset: 0;\n pointer-events: none;\n}\n\n.atv-photo-archive-tile:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-photo-archive-tile:active {\n transform: scale(0.97);\n}\n\n.atv-photo-archive-tile img {\n width: 100%;\n height: 100%;\n object-fit: contain;\n transition: transform 240ms var(--atv-ease-out);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-photo-archive-tile:hover {\n border-color: var(--atv-border-strong);\n box-shadow: 0 22px 44px rgb(0 0 0 / 42%);\n transform: translateY(-3px);\n }\n\n .atv-photo-archive-tile:hover img {\n transform: scale(1.035);\n }\n}\n\n@media (width <= 768px) {\n .atv-photo-archive-group {\n gap: 28px;\n grid-template-columns: 1fr;\n }\n\n .atv-photo-archive-group-heading {\n position: static;\n display: flex;\n align-items: end;\n justify-content: space-between;\n gap: 24px;\n }\n\n .atv-photo-archive-all {\n flex: 0 0 auto;\n margin: 0 0 3px;\n }\n\n .atv-photo-archive-grid {\n columns: auto 2;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-photo-archive-tile,\n .atv-photo-archive-tile img {\n transition: none;\n }\n\n .atv-photo-archive-tile:active,\n .atv-photo-archive-tile:hover,\n .atv-photo-archive-tile:hover img {\n transform: none;\n }\n}\n\n@media (prefers-contrast: more) {\n .atv-photo-archive-tile {\n border-color: var(--atv-border-emphasis);\n }\n}\n/* ---------- Subject comments / Reading ledger ---------- */\n.atv-subject-comments {\n min-height: 100vh;\n padding: 0 max(28px, 5vw) 112px;\n}\n\n.atv-subject-comments-hero,\n.atv-subject-comments-layout {\n width: min(100%, 1280px);\n margin: 0 auto;\n}\n\n.atv-subject-comments-hero {\n padding: clamp(112px, 15vw, 176px) 0 clamp(48px, 7vw, 84px);\n}\n\n.atv-subject-comments-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n}\n\n.atv-subject-comments-back,\n.atv-subject-comments-write {\n display: inline-flex;\n align-items: center;\n padding: 8px 11px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n appearance: none;\n background: transparent;\n color: var(--atv-text-primary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 6px;\n transition:\n border-color var(--atv-duration-feedback) var(--atv-ease-out),\n background-color var(--atv-duration-feedback) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out),\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-subject-comments-write {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n}\n\n.atv-subject-comments-back:active,\n.atv-subject-comments-write:active,\n.atv-subject-comments-sort-option:active,\n.atv-subject-comments-score-option:active,\n.atv-subject-comments-pagination a:active {\n transform: scale(0.97);\n}\n\n.atv-subject-comments-back:focus-visible,\n.atv-subject-comments-write:focus-visible,\n.atv-subject-comments-status:focus-visible,\n.atv-subject-comments-sort-option:focus-visible,\n.atv-subject-comments-score-option:focus-visible,\n.atv-subject-comments-author:focus-visible,\n.atv-subject-comments-meta a:focus-visible,\n.atv-subject-comments-pagination a:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n.atv-subject-comments-kicker,\n.atv-subject-comments-controls h2 {\n margin: 0;\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.atv-subject-comments-kicker {\n margin-top: clamp(42px, 6vw, 72px);\n}\n\n.atv-subject-comments-hero h1 {\n max-width: 980px;\n margin: 16px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.055em;\n line-height: var(--atv-line-height-display);\n text-wrap: balance;\n}\n\n.atv-subject-comments-status-index {\n display: grid;\n width: min(100%, 600px);\n border-top: 1px solid var(--atv-border-subtle);\n margin-top: clamp(40px, 6vw, 72px);\n grid-template-columns: repeat(3, 1fr);\n}\n\n.atv-subject-comments-status {\n position: relative;\n display: grid;\n min-height: 78px;\n padding: 16px 18px;\n border-right: 1px solid var(--atv-border-subtle);\n border-bottom: 1px solid var(--atv-border-subtle);\n color: var(--atv-text-secondary);\n gap: 5px;\n transition:\n background-color var(--atv-duration-feedback) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out);\n}\n\n.atv-subject-comments-status:first-child {\n border-left: 1px solid var(--atv-border-subtle);\n}\n\n.atv-subject-comments-status span {\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.02em;\n}\n\n.atv-subject-comments-status strong {\n color: inherit;\n font-size: clamp(18px, 2vw, 24px);\n font-variant-numeric: tabular-nums;\n letter-spacing: -0.035em;\n line-height: 1;\n}\n\n.atv-subject-comments-status.is-active {\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n}\n\n.atv-subject-comments-status.is-active::after {\n position: absolute;\n top: -1px;\n right: -1px;\n left: -1px;\n height: 2px;\n background: var(--atv-accent-bright);\n content: \"\";\n}\n\n.atv-subject-comments-status[aria-disabled=\"true\"],\n.atv-subject-comments-sort-option[aria-disabled=\"true\"],\n.atv-subject-comments-score-option[aria-disabled=\"true\"],\n.atv-subject-comments-pagination a[aria-disabled=\"true\"] {\n cursor: progress;\n pointer-events: none;\n}\n\n.atv-subject-comments-layout {\n display: grid;\n justify-content: start;\n border-top: 1px solid var(--atv-border-subtle);\n gap: clamp(36px, 7vw, 104px);\n grid-template-columns: minmax(142px, 176px) minmax(0, 780px);\n}\n\n.atv-subject-comments-controls {\n position: sticky;\n top: 84px;\n display: grid;\n align-self: start;\n padding-top: clamp(32px, 4vw, 52px);\n gap: 30px;\n}\n\n.atv-subject-comments-controls section {\n min-width: 0;\n}\n\n.atv-subject-comments-control-options {\n display: grid;\n margin-top: 12px;\n gap: 2px;\n}\n\n.atv-subject-comments-sort-option,\n.atv-subject-comments-score-option {\n display: flex;\n width: fit-content;\n min-height: 30px;\n align-items: center;\n padding: 4px 8px;\n border-radius: 7px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n transition:\n background-color var(--atv-duration-feedback) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out),\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-subject-comments-sort-option.is-active,\n.atv-subject-comments-score-option.is-active {\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n}\n\n.atv-subject-comments-stream {\n position: relative;\n min-width: 0;\n}\n\n.atv-subject-comments-stream::before {\n position: absolute;\n z-index: 1;\n top: 0;\n right: 0;\n left: 0;\n height: 2px;\n background: var(--atv-accent-bright);\n content: \"\";\n opacity: 0;\n transform: scaleX(0);\n transform-origin: left;\n transition:\n opacity var(--atv-duration-feedback) ease,\n transform var(--atv-duration-feedback) var(--atv-ease-out);\n}\n\n.atv-subject-comments-stream.is-loading::before {\n opacity: 1;\n transform: scaleX(1);\n}\n\n.atv-subject-comments-results {\n opacity: 1;\n transition: opacity 160ms var(--atv-ease-out);\n}\n\n@starting-style {\n .atv-subject-comments-results {\n opacity: 0.72;\n }\n}\n\n.atv-subject-comments-stream.is-loading .atv-subject-comments-results {\n opacity: 0.72;\n}\n\n.atv-subject-comments-live {\n position: absolute;\n overflow: hidden;\n width: 1px;\n height: 1px;\n padding: 0;\n border: 0;\n margin: -1px;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.atv-subject-comments-navigation-failure {\n display: flex;\n min-height: 42px;\n align-items: center;\n padding: 8px 0;\n border-bottom: 1px solid\n color-mix(in srgb, var(--atv-rating-gold) 24%, transparent);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n gap: 12px;\n}\n\n.atv-subject-comments-navigation-failure span {\n margin-right: auto;\n}\n\n.atv-subject-comments-navigation-failure button {\n padding: 4px 0;\n border: 0;\n appearance: none;\n background: transparent;\n color: var(--atv-accent-bright);\n cursor: pointer;\n font: inherit;\n font-weight: var(--atv-font-weight-bold);\n transition: color var(--atv-duration-feedback) var(--atv-ease-out);\n}\n\n.atv-subject-comments-navigation-failure button:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 3px;\n}\n\n.atv-subject-comments-item {\n --atv-subject-comments-avatar-size: 42px;\n --atv-subject-comments-track-gap: 12px;\n\n padding: clamp(32px, 4vw, 52px) 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n}\n\n.atv-subject-comments-item-header {\n display: grid;\n min-width: 0;\n gap: 8px var(--atv-subject-comments-track-gap);\n grid-template-columns: var(--atv-subject-comments-avatar-size) minmax(0, 1fr);\n}\n\n.atv-subject-comments-author {\n display: inline-flex;\n min-width: 0;\n max-width: 100%;\n align-items: center;\n color: var(--atv-text-primary);\n gap: var(--atv-subject-comments-track-gap);\n grid-column: 1 / -1;\n justify-self: start;\n}\n\n.atv-subject-comments-author.is-static {\n cursor: default;\n}\n\n.atv-subject-comments-avatar {\n width: var(--atv-subject-comments-avatar-size);\n height: var(--atv-subject-comments-avatar-size);\n flex: 0 0 auto;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 50%;\n object-fit: cover;\n}\n\n.atv-subject-comments-avatar.is-fallback {\n display: grid;\n background:\n radial-gradient(circle at 70% 20%, rgb(76 217 122 / 30%), transparent 38%),\n var(--atv-bg-tertiary);\n color: var(--atv-text-primary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-bold);\n place-items: center;\n}\n\n.atv-subject-comments-author-name {\n overflow: hidden;\n font-optical-sizing: auto;\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.01em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-subject-comments-meta {\n display: flex;\n min-width: 0;\n align-items: center;\n justify-content: flex-start;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n gap: 8px;\n grid-column: 2;\n letter-spacing: 0.02em;\n}\n\n.atv-subject-comments-meta a {\n color: inherit;\n}\n\n.atv-subject-comments-rating {\n color: var(--atv-rating-gold);\n font-size: var(--atv-type-micro);\n letter-spacing: 0.07em;\n line-height: 1;\n}\n\n.atv-subject-comments-time,\n.atv-subject-comments-location {\n overflow: hidden;\n max-width: 22ch;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-subject-comments-time::before,\n.atv-subject-comments-location::before {\n padding-right: 8px;\n color: var(--atv-text-tertiary);\n content: \"·\";\n}\n\n.atv-subject-comments-reading-row {\n display: grid;\n margin-top: 18px;\n column-gap: var(--atv-subject-comments-track-gap);\n grid-template-columns: var(--atv-subject-comments-avatar-size) minmax(\n 0,\n 1fr\n ) 82px;\n}\n\n.atv-subject-comments-content {\n max-width: 62ch;\n margin: 0;\n color: var(--atv-text-secondary);\n font-optical-sizing: auto;\n font-size: clamp(15px, 1.35vw, 17px);\n grid-column: 2;\n line-height: 1.8;\n overflow-wrap: anywhere;\n}\n\n.atv-subject-comments-consensus {\n display: grid;\n grid-column: 3;\n place-self: end end;\n}\n\n.atv-subject-comments-pagination {\n display: flex;\n flex-wrap: wrap;\n padding: 32px 0 0;\n gap: 8px;\n}\n\n.atv-subject-comments-pagination a {\n display: inline-flex;\n min-height: 34px;\n align-items: center;\n padding: 0 11px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 8px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n transition:\n border-color var(--atv-duration-feedback) var(--atv-ease-out),\n background-color var(--atv-duration-feedback) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out),\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-subject-comments-pagination span {\n display: inline-flex;\n min-height: 34px;\n align-items: center;\n padding: 0 11px;\n border: 1px solid var(--atv-accent-border);\n border-radius: 8px;\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-bold);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-subject-comments-back:hover,\n .atv-subject-comments-write:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n }\n\n .atv-subject-comments-status:hover {\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n }\n\n .atv-subject-comments-status.is-active:hover {\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n }\n\n .atv-subject-comments-sort-option:hover,\n .atv-subject-comments-score-option:hover {\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n }\n\n .atv-subject-comments-pagination a:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n }\n}\n\n@media (width <= 720px) {\n .atv-subject-comments {\n padding-right: 20px;\n padding-left: 20px;\n }\n\n .atv-subject-comments-hero {\n padding-top: 100px;\n }\n\n .atv-subject-comments-status-index {\n width: 100%;\n }\n\n .atv-subject-comments-status {\n min-height: 72px;\n padding: 14px 12px;\n }\n\n .atv-subject-comments-layout {\n display: block;\n }\n\n .atv-subject-comments-controls {\n position: relative;\n top: auto;\n display: grid;\n padding: 24px 0;\n gap: 16px;\n grid-template-columns: max-content max-content;\n overflow-x: auto;\n scrollbar-width: none;\n }\n\n .atv-subject-comments-controls.is-overflowing::after {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n width: 30px;\n background: linear-gradient(to right, transparent, var(--atv-bg-primary));\n content: \"\";\n pointer-events: none;\n }\n\n .atv-subject-comments-controls::-webkit-scrollbar {\n display: none;\n }\n\n .atv-subject-comments-controls section {\n min-width: max-content;\n }\n\n .atv-subject-comments-control-options {\n display: flex;\n margin-top: 8px;\n gap: 3px;\n }\n\n .atv-subject-comments-sort-option,\n .atv-subject-comments-score-option {\n white-space: nowrap;\n }\n\n .atv-subject-comments-item {\n padding: 28px 0;\n }\n\n .atv-subject-comments-item-header {\n display: grid;\n align-items: start;\n gap: 10px;\n grid-template-columns: var(--atv-subject-comments-avatar-size) minmax(\n 0,\n 1fr\n );\n }\n\n .atv-subject-comments-meta {\n flex-wrap: wrap;\n justify-content: flex-start;\n margin-left: 0;\n }\n\n .atv-subject-comments-reading-row {\n display: block;\n margin-top: 18px;\n }\n\n .atv-subject-comments-content {\n max-width: none;\n grid-column: auto;\n }\n\n .atv-subject-comments-consensus {\n display: flex;\n align-items: center;\n margin-top: 14px;\n margin-left: auto;\n gap: 8px;\n grid-column: auto;\n justify-self: end;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-subject-comments-back,\n .atv-subject-comments-write,\n .atv-subject-comments-status,\n .atv-subject-comments-sort-option,\n .atv-subject-comments-score-option,\n .atv-subject-comments-pagination a {\n transition: none;\n }\n\n .atv-subject-comments-results,\n .atv-subject-comments-stream::before,\n .atv-subject-comments-navigation-failure button {\n transition: none;\n }\n}\n.atv-subject-reviews {\n min-height: 100vh;\n padding: 0 max(28px, 5vw) 112px;\n scroll-margin-top: 84px;\n}\n\n.atv-skip-link {\n position: fixed;\n z-index: 999;\n top: 12px;\n left: 12px;\n overflow: hidden;\n width: 1px;\n height: 1px;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.atv-skip-link:focus-visible,\n.atv-skip-link:focus {\n width: auto;\n height: auto;\n padding: 8px 16px;\n border-radius: 8px;\n backdrop-filter: blur(12px);\n background: var(--atv-bg-elevated);\n clip-path: none;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 2px;\n text-decoration: none;\n white-space: normal;\n}\n\n.atv-subject-reviews-hero,\n.atv-subject-reviews-layout {\n width: min(100%, 1280px);\n margin: 0 auto;\n}\n\n.atv-subject-reviews-hero {\n padding: clamp(112px, 15vw, 176px) 0 clamp(48px, 7vw, 84px);\n}\n\n.atv-subject-reviews-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n}\n\n.atv-subject-reviews-back,\n.atv-subject-reviews-write {\n display: inline-flex;\n align-items: center;\n padding: 8px 11px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n background: transparent;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 6px;\n transition:\n border-color var(--atv-duration-feedback) var(--atv-ease-out),\n background-color var(--atv-duration-feedback) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out),\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n#atv-douban-root .atv-subject-reviews-write {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n}\n\n.atv-subject-reviews-back:active,\n.atv-subject-reviews-write:active,\n.atv-subject-reviews-option:active {\n transform: scale(0.97);\n}\n\n.atv-subject-reviews-kicker,\n.atv-subject-reviews-controls h2 {\n margin: 0;\n color: var(--atv-accent-bright);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.atv-subject-reviews-kicker {\n margin-top: clamp(42px, 6vw, 72px);\n}\n\n.atv-subject-reviews-hero h1 {\n max-width: 980px;\n margin: 16px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-display);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.055em;\n line-height: var(--atv-line-height-display);\n text-wrap: balance;\n}\n\n.atv-subject-reviews-layout {\n display: grid;\n border-top: 1px solid var(--atv-border-subtle);\n gap: clamp(36px, 7vw, 104px);\n grid-template-columns: minmax(142px, 176px) 1fr;\n}\n\n.atv-subject-reviews-controls {\n position: sticky;\n top: 84px;\n display: grid;\n align-self: start;\n padding-top: clamp(32px, 4vw, 52px);\n gap: 30px;\n}\n\n.atv-subject-reviews-controls section > div {\n display: grid;\n margin-top: 12px;\n gap: 2px;\n}\n\n.atv-subject-reviews-option {\n display: flex;\n width: fit-content;\n min-height: 30px;\n align-items: center;\n padding: 4px 8px;\n border-radius: 7px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n transition:\n transform var(--atv-duration-press) var(--atv-ease-out),\n color var(--atv-duration-feedback) var(--atv-ease-out),\n background-color var(--atv-duration-feedback) var(--atv-ease-out);\n}\n\n.atv-subject-reviews-option.is-active {\n background: var(--atv-accent-active);\n color: var(--atv-accent-bright);\n}\n\n.atv-subject-reviews-option[aria-disabled=\"true\"],\n.atv-subject-reviews-pagination-prev[aria-disabled=\"true\"],\n.atv-subject-reviews-pagination-next[aria-disabled=\"true\"],\n.atv-subject-reviews-pagination a[aria-disabled=\"true\"] {\n cursor: progress;\n pointer-events: none;\n}\n\n.atv-subject-reviews-stream {\n position: relative;\n min-width: 0;\n}\n\n.atv-subject-reviews-stream::before {\n position: absolute;\n z-index: 1;\n top: 0;\n right: 0;\n left: 0;\n height: 2px;\n background: var(--atv-accent-bright);\n content: \"\";\n opacity: 0;\n transform: scaleX(0);\n transform-origin: left;\n transition:\n opacity 160ms ease,\n transform 160ms var(--atv-ease-out);\n}\n\n.atv-subject-reviews-stream.is-loading::before {\n opacity: 1;\n transform: scaleX(1);\n}\n\n.atv-subject-reviews-results {\n opacity: 1;\n transition: opacity 160ms var(--atv-ease-out);\n}\n\n.atv-subject-reviews-stream.is-loading .atv-subject-reviews-results {\n opacity: 0.72;\n}\n\n.atv-subject-reviews-live {\n position: absolute;\n overflow: hidden;\n width: 1px;\n height: 1px;\n margin: -1px;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n.atv-subject-reviews-failure {\n display: flex;\n min-height: 42px;\n align-items: center;\n border-bottom: 1px solid\n color-mix(in srgb, var(--atv-rating-gold) 24%, transparent);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n gap: 12px;\n}\n\n.atv-subject-reviews-failure span {\n margin-right: auto;\n}\n\n.atv-subject-reviews-failure button {\n padding: 4px 0;\n border: 0;\n background: transparent;\n color: var(--atv-accent-bright);\n cursor: pointer;\n font: inherit;\n font-weight: var(--atv-font-weight-bold);\n}\n\n.atv-subject-reviews-empty {\n padding: clamp(44px, 7vw, 72px) 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n color: var(--atv-text-secondary);\n}\n\n.atv-subject-reviews-empty p {\n margin: 0 0 14px;\n}\n\n.atv-subject-reviews-empty a {\n color: var(--atv-accent-bright);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-subject-reviews-pagination {\n display: flex;\n align-items: center;\n padding: 30px clamp(20px, 2.5vw, 32px) 0;\n}\n\n/* Condensed mode: prev | indicator | next spread across */\n.atv-subject-reviews-pagination.is-condensed {\n justify-content: space-between;\n}\n\n/* Full mode: show all page numbers, spread across */\n.atv-subject-reviews-pagination:not(.is-condensed) {\n flex-wrap: wrap;\n justify-content: space-between;\n gap: 2px;\n}\n\n/* All pagination links share base styling */\n.atv-subject-reviews-pagination a {\n display: inline-flex;\n align-items: center;\n padding: 4px 6px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-regular);\n text-decoration: none;\n transition: color var(--atv-duration-feedback) ease;\n}\n\n.atv-subject-reviews-pagination a:hover {\n color: var(--atv-text-primary);\n}\n\n.atv-subject-reviews-pagination a:active {\n opacity: 0.65;\n}\n\n/* Condensed mode: prev/next need vertical padding only */\n.atv-subject-reviews-pagination-prev,\n.atv-subject-reviews-pagination-next {\n padding: 6px 0;\n color: var(--atv-text-secondary);\n font-weight: var(--atv-font-weight-medium);\n}\n\n.atv-subject-reviews-pagination-disabled {\n display: inline-flex;\n align-items: center;\n color: var(--atv-text-tertiary);\n opacity: 0.35;\n pointer-events: none;\n}\n\n/* Full mode: current page vs non-current page */\n.atv-subject-reviews-pagination span[aria-current=\"page\"] {\n padding: 4px 6px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-caption);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-bold);\n}\n\n.atv-subject-reviews-pagination\n span:not(\n [aria-current],\n .atv-subject-reviews-pagination-indicator,\n .atv-subject-reviews-pagination-sep\n ) {\n display: inline-flex;\n align-items: center;\n padding: 4px 2px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-regular);\n}\n\n/* Condensed mode: page indicator */\n.atv-subject-reviews-pagination-indicator {\n display: inline-flex;\n align-items: center;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-variant-numeric: tabular-nums;\n gap: 2px;\n pointer-events: none;\n}\n\n.atv-subject-reviews-pagination-sep {\n margin: 0 1px;\n color: var(--atv-border-subtle);\n font-size: var(--atv-type-micro);\n}\n\n.atv-review-card.is-directory {\n position: relative;\n display: grid;\n padding: clamp(27px, 3.2vw, 36px) clamp(20px, 2.5vw, 32px)\n clamp(31px, 3.6vw, 40px);\n border-radius: 0;\n background: transparent;\n column-gap: clamp(24px, 3.8vw, 48px);\n grid-template-columns: minmax(146px, 168px) minmax(0, 1fr);\n}\n\n.atv-review-card.is-directory::after {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n height: 1px;\n background: linear-gradient(\n to right,\n transparent,\n rgb(255 255 255 / 8%) 20%,\n rgb(255 255 255 / 8%) 80%,\n transparent\n );\n content: \"\";\n pointer-events: none;\n}\n\n.atv-review-card.is-directory:focus-visible,\n.atv-review-card.is-directory:has(.atv-review-open-button:focus-visible) {\n background: var(--atv-bg-hover);\n outline: 1px solid var(--atv-border-medium);\n outline-offset: 2px;\n}\n\n.atv-review-card.is-directory:has(.atv-review-open-button:active) {\n transform: scale(0.99);\n}\n\n.atv-review-directory-identity {\n position: relative;\n z-index: 1;\n display: flex;\n min-width: 0;\n padding-top: 3px;\n gap: 10px;\n pointer-events: none;\n}\n\n.atv-review-card.is-directory .atv-review-avatar {\n width: 32px;\n height: 32px;\n font-size: var(--atv-type-caption-sm);\n}\n\n.atv-review-card.is-directory .atv-review-meta {\n gap: 4px;\n}\n\n.atv-review-card.is-directory .atv-review-author {\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-review-card.is-directory .atv-review-directory-meta-line {\n width: fit-content;\n min-height: 14px;\n}\n\n.atv-review-card.is-directory .atv-review-stars {\n gap: 1px;\n}\n\n.atv-review-card.is-directory .atv-review-stars svg {\n width: 11px;\n height: 11px;\n}\n\n.atv-review-card.is-directory .atv-review-time {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.01em;\n line-height: 1.38;\n}\n\n.atv-review-directory-reading {\n position: relative;\n z-index: 1;\n min-width: 0;\n pointer-events: none;\n}\n\n.atv-review-card.is-directory .atv-review-title {\n margin: 0 0 8px;\n font-size: clamp(18px, 1.7vw, 21px);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.018em;\n -webkit-line-clamp: 2;\n line-height: 1.28;\n}\n\n.atv-review-card.is-directory .atv-review-excerpt {\n max-width: 672px;\n margin: 0;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n -webkit-line-clamp: 4;\n line-height: 1.76;\n}\n\n.atv-review-card.is-directory .atv-review-foot {\n max-width: 672px;\n min-height: 28px;\n justify-content: flex-start;\n padding-top: 0;\n border-top: 0;\n margin-top: 16px;\n gap: 12px;\n}\n\n.atv-review-card.is-directory .atv-review-readmore {\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.03em;\n opacity: 0.5;\n transform: none;\n}\n\n.atv-review-card.is-directory:has(.atv-review-open-button:focus-visible)\n .atv-review-readmore {\n opacity: 0.8;\n}\n\n@media (hover: hover) and (pointer: fine) {\n #atv-douban-root .atv-subject-reviews-back:hover,\n #atv-douban-root .atv-subject-reviews-write:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n }\n\n .atv-review-card.is-directory:hover {\n background: var(--atv-bg-hover);\n }\n\n .atv-review-card.is-directory:hover .atv-review-readmore {\n opacity: 0.8;\n }\n}\n\n.atv-review-meta-line {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.atv-review-reply {\n position: relative;\n z-index: 2;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n}\n\n.atv-review-spoiler-note {\n width: fit-content;\n max-width: 100%;\n padding: 8px 12px 8px 10px;\n border-radius: 0 6px 6px 0;\n border-left: 2px solid var(--atv-content-caution);\n margin: 0 0 12px;\n animation: atv-spoiler-fadein 250ms var(--atv-ease-out) both;\n background: var(--atv-caution-surface);\n color: var(--atv-content-caution);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.03em;\n line-height: 1.45;\n pointer-events: none;\n}\n\n.atv-review-spoiler-note.is-compact {\n padding: 0 0 0 8px;\n border-radius: 0;\n margin-bottom: 8px;\n background: none;\n font-size: var(--atv-type-caption-sm);\n letter-spacing: 0.02em;\n}\n\n@keyframes atv-spoiler-fadein {\n from {\n opacity: 0;\n transform: translateY(-4px) scale(0.97);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n@media (width <= 720px) {\n .atv-review-spoiler-note {\n animation: none;\n opacity: 1;\n transform: none;\n }\n\n .atv-subject-reviews {\n padding: 0 20px 72px;\n }\n\n .atv-subject-reviews-hero {\n padding-top: 96px;\n }\n\n .atv-subject-reviews-layout {\n display: block;\n }\n\n .atv-subject-reviews-controls {\n position: relative;\n top: auto;\n padding: 22px 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n gap: 18px;\n grid-template-columns: repeat(2, max-content);\n overflow-x: auto;\n }\n\n .atv-subject-reviews-controls section > div {\n display: flex;\n }\n\n .atv-subject-reviews-stream {\n padding-top: 0;\n }\n\n .atv-review-card.is-directory .atv-review-actions {\n margin-left: auto;\n }\n\n .atv-review-card.is-directory {\n display: block;\n padding: 26px 20px 28px;\n }\n\n .atv-review-directory-identity {\n margin-bottom: 16px;\n }\n\n .atv-subject-reviews-hero h1 {\n font-size: clamp(42px, 13vw, 64px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-subject-reviews-results,\n .atv-subject-reviews-stream::before {\n transition: none;\n }\n\n .atv-review-spoiler-note {\n animation: none;\n opacity: 1;\n transform: none;\n }\n}\n/* Shared SafeImage implementation styles.\n Page modules own crop, geometry, and motion around this primitive. */\n\n.atv-safe-image-container {\n width: 100%;\n height: 100%;\n}\n\n.atv-safe-image-container > img {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.atv-safe-image-fallback {\n width: 100%;\n height: 100%;\n min-height: 48px;\n background: var(--atv-bg-tertiary);\n}\n/* ── Ratings Panel ──────────────────────────────── */\n\n/* Unified card presenting Douban + IMDb + RT side by side */\n\n.atv-rating-panel {\n display: flex;\n width: fit-content;\n min-width: 320px;\n flex-wrap: wrap;\n align-items: stretch;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-md);\n margin-bottom: 28px;\n gap: 0;\n}\n\n.atv-rating-panel-douban,\n.atv-rating-panel-imdb,\n.atv-rating-panel-rt,\n.atv-rating-panel-mc {\n display: grid;\n flex: 1;\n padding: 16px 24px 14px;\n grid-template-rows: 28px 44px 20px 1fr;\n place-items: center center;\n text-align: center;\n transition: background var(--atv-duration-feedback) ease;\n}\n\n.atv-rating-panel-douban:hover,\n.atv-rating-panel-imdb:hover,\n.atv-rating-panel-rt:hover,\n.atv-rating-panel-mc:hover {\n background: var(--atv-bg-hover);\n}\n\n.atv-rating-panel-douban {\n border-right: 1px solid var(--atv-border-subtle);\n}\n\n.atv-rating-panel-imdb {\n border-right: 1px solid var(--atv-border-subtle);\n}\n\n.atv-rating-panel-mc {\n border-right: 1px solid var(--atv-border-subtle);\n}\n\n.atv-rating-panel-logo {\n display: inline-flex;\n align-items: center;\n align-self: center;\n opacity: 0.85;\n transition: opacity var(--atv-duration-feedback) ease;\n}\n\n.atv-rating-panel-logo:hover {\n opacity: 1;\n}\n\n.atv-rating-panel-logo svg {\n display: block;\n}\n\n.atv-rating-panel .atv-rating-panel-score {\n color: var(--atv-text-primary);\n font-family: var(--atv-font-display);\n font-size: var(--atv-type-stat);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.03em;\n line-height: 1;\n}\n\n/* ── MC Score color by range ────────────────────── */\n\n.atv-rating-panel-score.is-high {\n color: var(--atv-score-high);\n}\n\n.atv-rating-panel-score.is-medium {\n color: var(--atv-rating-gold);\n}\n\n.atv-rating-panel-score.is-low {\n color: var(--atv-score-low);\n}\n\n/* ── MC label row (score bar + Chinese word label) ── */\n\n.atv-mc-label-row {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.atv-mc-bar-track {\n display: inline-block;\n overflow: hidden;\n width: 40px;\n height: 4px;\n flex-shrink: 0;\n border-radius: 2px;\n background: var(--atv-bg-elevated);\n}\n\n.atv-mc-bar-fill {\n display: block;\n height: 100%;\n border-radius: 2px;\n}\n\n.atv-mc-bar-fill.is-high {\n background: var(--atv-score-high);\n}\n\n.atv-mc-bar-fill.is-medium {\n background: var(--atv-rating-gold);\n}\n\n.atv-mc-bar-fill.is-low {\n background: var(--atv-score-low);\n}\n\n.atv-mc-word-label {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n line-height: 1;\n white-space: nowrap;\n}\n\n.atv-rating-stars {\n display: inline-flex;\n color: var(--atv-rating-gold);\n gap: 2px;\n}\n\n.atv-rating-stars svg {\n display: block;\n}\n\n/* ── RT Score Row (values side by side) ─────────── */\n\n.atv-rt-score-row,\n.atv-rt-label-row,\n.atv-rt-count-row {\n display: grid;\n width: 100%;\n align-items: center;\n gap: 8px;\n grid-template-columns: 1fr auto 1fr;\n}\n\n.atv-rt-score-value {\n font-family: var(--atv-font-display);\n font-size: var(--atv-type-stat-sm);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.03em;\n line-height: 1;\n}\n\n.atv-rt-score-value.is-fresh {\n color: var(--atv-text-primary);\n}\n\n.atv-rt-score-row > .atv-rt-score-value.is-rotten:first-child {\n color: rgb(255 107 91 / 85%);\n}\n\n.atv-rt-score-row > .atv-rt-score-value.is-rotten:last-child {\n color: rgb(255 167 38 / 85%);\n}\n\n.atv-rt-score-row > .atv-rt-score-value:first-child {\n justify-self: center;\n text-align: right;\n}\n\n.atv-rt-score-row > .atv-rt-score-value:last-child {\n justify-self: center;\n text-align: left;\n}\n\n/* ── RT Label Row (icons + text) ────────────────── */\n\n.atv-rt-label-row {\n gap: 8px;\n grid-template-columns: 1fr 1fr;\n}\n\n.atv-rt-label-item {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n}\n\n.atv-rt-label-row > .atv-rt-label-item:first-child {\n justify-self: center;\n}\n\n.atv-rt-label-row > .atv-rt-label-item:last-child {\n justify-self: center;\n}\n\n.atv-rt-score-icon {\n display: inline-flex;\n width: 16px;\n height: 16px;\n color: var(--atv-text-tertiary);\n opacity: 0.7;\n}\n\n.atv-rt-score-icon svg {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.atv-rt-label-item.is-critics.is-fresh .atv-rt-score-icon {\n color: #ff6b5b;\n opacity: 1;\n}\n\n.atv-rt-label-item.is-critics.is-rotten .atv-rt-score-icon {\n color: #50b85e;\n opacity: 0.6;\n}\n\n.atv-rt-label-item.is-audience.is-fresh .atv-rt-score-icon {\n color: var(--atv-rating-gold);\n opacity: 1;\n}\n\n.atv-rt-label-item.is-audience.is-rotten .atv-rt-score-icon {\n color: #888;\n opacity: 0.6;\n}\n\n.atv-rt-score-label {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n white-space: nowrap;\n}\n\n/* ── Shared count text (Douban / IMDb) ──────────── */\n\n.atv-rating-panel-count {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n white-space: nowrap;\n}\n\n/* ── RT Count Row (e.g. 评价 300 | 50,000) ──────── */\n\n.atv-rt-count-row {\n gap: 8px;\n grid-template-columns: 1fr 1fr;\n}\n\n.atv-rt-count-value {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n white-space: nowrap;\n}\n\n.atv-rt-count-row > .atv-rt-count-value:first-child {\n justify-self: center;\n}\n\n.atv-rt-count-row > .atv-rt-count-value:last-child {\n justify-self: center;\n}\n\n/* ── RT Divider ─────────────────────────────────── */\n\n.atv-rt-divider {\n width: 1px;\n flex-shrink: 0;\n align-self: stretch;\n margin: 2px 0;\n background: var(--atv-border-medium);\n}\n\n@media (width <= 768px) {\n .atv-rating-panel .atv-rating-panel-score {\n font-size: 32px;\n }\n\n .atv-rating-panel-douban,\n .atv-rating-panel-imdb,\n .atv-rating-panel-rt,\n .atv-rating-panel-mc {\n padding: 12px 14px 10px;\n grid-template-rows: 24px 38px 18px 1fr;\n }\n\n .atv-rt-score-value {\n font-size: 26px;\n }\n\n .atv-rt-score-icon {\n width: 14px;\n height: 14px;\n }\n\n .atv-rt-score-label {\n font-size: var(--atv-type-micro);\n }\n\n .atv-mc-bar-track {\n width: 32px;\n }\n\n .atv-mc-word-label {\n font-size: var(--atv-type-micro);\n }\n\n .atv-rating-panel {\n min-width: 0;\n }\n}\n\n.atv-rating-empty {\n padding: 10px 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n letter-spacing: 0.03em;\n}\n\n/* ── Skeleton (loading) ─────────────────────────── */\n\n.atv-rating-panel-skeleton {\n width: 120px;\n height: 22px;\n border-radius: 4px;\n background: var(--atv-bg-elevated);\n}\n.atv-actions {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 26px;\n gap: 12px;\n}\n\n.atv-btn {\n display: inline-flex;\n height: 44px;\n align-items: center;\n padding: 0 22px;\n border: none;\n border-radius: 999px;\n appearance: none;\n background: none;\n color: inherit;\n cursor: pointer;\n font-family: inherit;\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n gap: 8px;\n letter-spacing: 0.01em;\n -webkit-tap-highlight-color: transparent;\n transition:\n transform var(--atv-duration-press) var(--atv-ease-out),\n background var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease;\n}\n\n.atv-btn:active {\n transform: scale(0.97);\n}\n\n.atv-btn-primary {\n background: var(--atv-accent);\n box-shadow: 0 8px 24px var(--atv-accent-glow);\n color: var(--atv-text-on-accent);\n}\n\n.atv-btn-secondary {\n border: 1px solid var(--atv-border-medium);\n backdrop-filter: blur(10px);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n}\n\n.atv-btn.is-active {\n border: 1px solid var(--atv-accent);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n}\n\n.atv-btn.is-active:hover {\n border-color: var(--atv-accent-bright);\n background: var(--atv-bg-hover-strong);\n}\n\n.atv-hero-summary {\n margin-top: 16px;\n}\n\n.atv-hero-teaser {\n display: -webkit-box;\n overflow: hidden;\n max-width: 660px;\n margin: 0 0 12px;\n -webkit-box-orient: vertical;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body);\n line-height: var(--atv-line-height-body-loose);\n overflow-wrap: break-word;\n white-space: pre-wrap;\n}\n\n.atv-hero-teaser.is-clamped {\n -webkit-line-clamp: 3;\n}\n\n.atv-hero-more {\n display: inline-flex;\n align-items: center;\n padding: 0;\n border: none;\n appearance: none;\n background: none;\n color: var(--atv-accent-bright);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n gap: 6px;\n letter-spacing: 0.02em;\n}\n\n.atv-hero-more:hover {\n color: var(--atv-accent);\n}\n\n.atv-hero-more svg {\n transition: transform var(--atv-duration-hover) var(--atv-ease-in-out);\n}\n\n.atv-hero-more.is-open svg {\n transform: rotate(180deg);\n}\n/* ---------- Subject interest panel ---------- */\n.atv-interest-panel {\n display: flex;\n width: 100%;\n flex-direction: column;\n gap: 10px;\n}\n\n.atv-interest-panel-header {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 12px;\n}\n\n.atv-interest-badge {\n cursor: pointer;\n}\n\n.atv-interest-badge svg {\n color: var(--atv-accent);\n}\n\n.atv-interest-panel-stars {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n}\n\n.atv-interest-panel-stars svg {\n display: block;\n width: 18px;\n height: 18px;\n}\n\n.atv-interest-panel-date {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n letter-spacing: 0.02em;\n}\n\n.atv-interest-panel-comment {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n padding: 6px 0 2px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n font-style: italic;\n gap: 8px;\n line-height: 1.6;\n}\n\n.atv-interest-panel-tags {\n min-width: 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n letter-spacing: 0.02em;\n}\n\n.atv-useful-badge {\n display: inline-flex;\n align-items: center;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-style: normal;\n gap: 3px;\n letter-spacing: 0.02em;\n white-space: nowrap;\n}\n/* ---------- Section ---------- */\n.atv-section {\n max-width: 1280px;\n padding: 52px max(28px, 5vw);\n margin: 0 auto;\n contain-intrinsic-size: auto 400px;\n content-visibility: auto;\n scroll-margin-top: 64px;\n}\n\n.atv-section + .atv-section {\n padding-top: 0;\n}\n\n.atv-section-h {\n position: relative;\n display: flex;\n align-items: center;\n padding-left: 14px;\n margin: 0 0 24px;\n font-size: var(--atv-type-title-xl);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: -0.02em;\n}\n\n.atv-section-h::before {\n position: absolute;\n top: 50%;\n left: 0;\n width: 3px;\n height: 20px;\n border-radius: 2px;\n background: var(--atv-accent);\n content: \"\";\n transform: translateY(-50%);\n}\n\n.atv-section-h-row {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n margin-bottom: 24px;\n gap: 16px;\n}\n\n.atv-section-h-row .atv-section-h {\n margin-bottom: 0;\n}\n\n.atv-section-more {\n display: inline-flex;\n flex: 0 0 auto;\n align-items: center;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-medium);\n gap: 4px;\n letter-spacing: 0.02em;\n transition:\n color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-hover) var(--atv-ease-out);\n white-space: nowrap;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-section-more:hover {\n color: var(--atv-accent-bright);\n transform: translateX(3px);\n }\n}\n\n.atv-section-more:focus-visible {\n color: var(--atv-accent-bright);\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 3px;\n}\n\n/* ---------- Section reveal on scroll ---------- */\n\n/* Below-the-fold sections start offset and fade in once they enter the\n viewport. The motion here is opacity + a small translateY only. */\n.atv-section-reveal {\n opacity: 0;\n transform: translateY(12px);\n}\n\n.atv-section-reveal.is-revealed {\n opacity: 1;\n transform: none;\n transition:\n opacity var(--atv-duration-content) var(--atv-ease-out),\n transform var(--atv-duration-content) var(--atv-ease-out);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-section-reveal,\n .atv-section-reveal.is-revealed {\n opacity: 1;\n transform: none;\n transition: none;\n }\n}\n\n/* ---------- Carousel ---------- */\n.atv-carousel {\n display: flex;\n padding: 4px 4px 16px;\n margin: 0 -4px;\n contain: paint layout;\n gap: 16px;\n overflow-x: auto;\n scroll-behavior: smooth;\n scrollbar-width: none;\n}\n\n.atv-carousel::-webkit-scrollbar {\n display: none;\n}\n\n/* ---------- Page scrollbar ---------- */\n:root {\n color-scheme: dark;\n}\n\n::-webkit-scrollbar {\n width: 5px;\n height: 5px;\n}\n\n::-webkit-scrollbar-track {\n background: var(--atv-bg-secondary);\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 3px;\n background: #3a3a3c;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: #48484a;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n* {\n scrollbar-color: #3a3a3c var(--atv-bg-secondary);\n scrollbar-width: thin;\n}\n/* ---------- Series ---------- */\n.atv-series-card {\n min-width: 0;\n flex: 0 0 158px;\n cursor: pointer;\n scroll-snap-align: start;\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n/* ── Active season (user is currently on this season's page) ── */\n.atv-series-card.is-active .atv-series-poster {\n box-shadow:\n inset 0 0 0 2px var(--atv-accent),\n 0 0 20px var(--atv-accent-glow);\n}\n\n.atv-series-card.is-active .atv-series-info::after {\n width: 20px;\n opacity: 1;\n}\n\n/* ── Poster ── */\n.atv-series-poster {\n position: relative;\n overflow: hidden;\n width: 100%;\n border-radius: var(--atv-radius-sm);\n aspect-ratio: 2 / 3;\n background: var(--atv-bg-tertiary);\n}\n\n/* Poster bottom gradient overlay (Apple TV+ style depth) */\n.atv-series-poster::after {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n height: 45%;\n background: linear-gradient(to top, rgb(0 0 0 / 55%) 0%, transparent 100%);\n content: \"\";\n pointer-events: none;\n}\n\n.atv-series-poster img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n/* ── Rating badge (overlay on poster top-right) ── */\n.atv-series-badge {\n position: absolute;\n z-index: 1;\n top: 8px;\n right: 8px;\n display: flex;\n min-width: 28px;\n height: 22px;\n align-items: center;\n justify-content: center;\n padding: 0 7px;\n border-radius: 20px;\n backdrop-filter: blur(4px);\n background: rgb(0 0 0 / 65%);\n color: var(--atv-text-primary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-bold);\n letter-spacing: 0.02em;\n line-height: 1;\n}\n\n/* ── Info row ── */\n.atv-series-info {\n position: relative;\n margin-top: 10px;\n}\n\n.atv-series-info::after {\n display: block;\n width: 100%;\n height: 2px;\n border-radius: 1px;\n margin-top: 4px;\n background: var(--atv-accent);\n content: \"\";\n opacity: 0;\n transform: scaleX(0);\n transform-origin: left;\n transition: opacity var(--atv-duration-hover) ease;\n}\n\n.atv-series-title {\n display: block;\n overflow: hidden;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n line-height: 1.3;\n text-overflow: ellipsis;\n transition: color var(--atv-duration-feedback) ease;\n white-space: nowrap;\n}\n\n/* ---------- Cast ---------- */\n.atv-cast-card {\n min-width: 0;\n flex: 0 0 160px;\n cursor: pointer;\n scroll-snap-align: start;\n text-align: center;\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-cast-avatar {\n width: 160px;\n height: 160px;\n border: 2px solid transparent;\n border-radius: 50%;\n background-color: var(--atv-bg-tertiary);\n background-position: center top;\n background-size: cover;\n}\n\n.atv-cast-name {\n overflow: hidden;\n margin-top: 16px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.3;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-cast-role {\n display: -webkit-box;\n overflow: hidden;\n margin-top: 4px;\n -webkit-box-orient: vertical;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-regular);\n -webkit-line-clamp: 2;\n line-height: 1.35;\n}\n\n/* ---------- Photos ---------- */\n.atv-photos {\n gap: 12px;\n}\n\n@keyframes atv-photo-in {\n from {\n opacity: 0;\n transform: translateY(6px) scale(0.98);\n }\n\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n.atv-photo-tile {\n display: flex;\n overflow: hidden;\n height: 225px;\n flex: 0 0 auto;\n padding: 0;\n border: none;\n border-radius: var(--atv-radius-md);\n appearance: none;\n aspect-ratio: var(--atv-photo-aspect-ratio, 16 / 9);\n background: var(--atv-bg-tertiary);\n cursor: pointer;\n scroll-snap-align: start;\n}\n\n.atv-photo-tile-content {\n display: block;\n width: 100%;\n height: 100%;\n animation: atv-photo-in 400ms var(--atv-ease-out) both;\n animation-delay: calc(var(--stagger-index, 0) * 60ms + 100ms);\n}\n\n.atv-photo-tile img {\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n.atv-photo-rail-reserve {\n height: 225px;\n flex: 0 0 100%;\n}\n\n/* ---------- Recommendations ---------- */\n.atv-recs {\n display: grid;\n gap: 24px;\n grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));\n}\n\n.atv-rec-card {\n cursor: pointer;\n}\n\n.atv-rec-poster {\n overflow: hidden;\n width: 100%;\n border-radius: var(--atv-radius-sm);\n aspect-ratio: 2 / 3;\n background: var(--atv-bg-tertiary);\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-rec-poster img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-series-card:hover .atv-series-title {\n color: var(--atv-accent-bright);\n }\n}\n\n.atv-rec-title {\n display: -webkit-box;\n overflow: hidden;\n margin-top: 12px;\n -webkit-box-orient: vertical;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n -webkit-line-clamp: 2;\n text-overflow: ellipsis;\n}\n/* ---------- Info grid ---------- */\n.atv-info-grid {\n display: grid;\n gap: 20px 48px;\n grid-template-columns: 140px 1fr;\n}\n\n/* Each row = label + value. We alternate even/odd children.\n The grid children are label/value siblings, so row pairs are (4n+1, 4n+2).\n Stagger: each row enters 24ms after the previous one, capped at 120ms.\n The animation itself is handled by the section-reveal —\n this just adds per-row delay on top. */\n.atv-section-reveal.is-revealed .atv-info-grid > * {\n animation: atv-info-row-enter 180ms var(--atv-ease-out) both;\n}\n\n.atv-info-grid > :nth-child(1),\n.atv-info-grid > :nth-child(2) {\n animation-delay: 0ms;\n}\n\n.atv-info-grid > :nth-child(3),\n.atv-info-grid > :nth-child(4) {\n animation-delay: 24ms;\n}\n\n.atv-info-grid > :nth-child(5),\n.atv-info-grid > :nth-child(6) {\n animation-delay: 48ms;\n}\n\n.atv-info-grid > :nth-child(7),\n.atv-info-grid > :nth-child(8) {\n animation-delay: 72ms;\n}\n\n.atv-info-grid > :nth-child(9),\n.atv-info-grid > :nth-child(10) {\n animation-delay: 96ms;\n}\n\n.atv-info-grid > :nth-child(11),\n.atv-info-grid > :nth-child(12) {\n animation-delay: 120ms;\n}\n\n.atv-info-grid > :nth-child(13),\n.atv-info-grid > :nth-child(14) {\n animation-delay: 120ms;\n}\n\n.atv-info-grid > :nth-child(15),\n.atv-info-grid > :nth-child(16) {\n animation-delay: 120ms;\n}\n\n.atv-info-grid > :nth-child(17),\n.atv-info-grid > :nth-child(18) {\n animation-delay: 120ms;\n}\n\n.atv-info-grid > :nth-child(19),\n.atv-info-grid > :nth-child(20) {\n animation-delay: 120ms;\n}\n\n@keyframes atv-info-row-enter {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* ---------- Labels ---------- */\n.atv-info-label {\n padding-top: 3px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n/* ---------- Values ---------- */\n.atv-info-value {\n color: var(--atv-text-emphasis);\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-regular);\n line-height: var(--atv-line-height-body);\n overflow-wrap: break-word;\n}\n\n/* Links inside values — subtle underline that glows on hover */\n.atv-info-value a {\n border-bottom: 1px solid var(--atv-border-medium);\n color: inherit;\n text-decoration: none;\n transition:\n border-color var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease;\n}\n\n.atv-info-value a:hover {\n border-color: var(--atv-accent-bright);\n color: var(--atv-accent-bright);\n}\n\n/* Separator between linked items: a thin middle-dot */\n.atv-info-sep {\n margin: 0 2px;\n color: rgb(255 255 255 / 18%);\n}\n\n/* ---------- Awards (gold accent row) ---------- */\n.atv-info-label-award {\n color: var(--atv-rating-gold);\n}\n\n.atv-info-label-award a {\n border-bottom: 1px solid transparent;\n color: inherit;\n text-decoration: none;\n transition: border-color var(--atv-duration-feedback) ease;\n}\n\n.atv-info-label-award a:hover {\n border-color: var(--atv-rating-gold);\n}\n\n/* ---------- Reduced motion ---------- */\n@media (prefers-reduced-motion: reduce) {\n .atv-section-reveal.is-revealed .atv-info-grid > * {\n animation: none;\n opacity: 1;\n transform: none;\n }\n}\n\n/* ---------- Mobile (<=768px) ----------\n Single column: label sits close to its value (4px), 12px label padding\n separates value→label pairs (16px). The pairing depends on the label\n describing the value BELOW it, so keep the two close. */\n@media (width <= 768px) {\n .atv-info-grid {\n grid-template-columns: 1fr;\n row-gap: 4px;\n }\n\n .atv-info-grid > :first-child {\n padding-top: 0;\n }\n\n .atv-info-label {\n padding-top: 12px;\n }\n\n .atv-section-reveal.is-revealed .atv-info-grid > * {\n animation: none;\n opacity: 1;\n transform: none;\n }\n}\n/* ---------- Streaming ---------- */\n.atv-stream-row {\n display: flex;\n flex-wrap: wrap;\n gap: 12px;\n}\n\n.atv-stream-card {\n --atv-stream-brand: var(--atv-accent-bright);\n\n position: relative;\n display: inline-flex;\n max-width: 240px;\n height: 52px;\n align-items: center;\n padding: 0 20px 0 14px;\n border: 1px solid var(--atv-border-medium);\n border-radius: var(--atv-radius-md);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n cursor: pointer;\n font-family: inherit;\n font-size: var(--atv-type-title-lg);\n font-weight: var(--atv-font-weight-semibold);\n gap: 11px;\n letter-spacing: 0.01em;\n -webkit-tap-highlight-color: transparent;\n transition:\n background var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n/* Center-glow overlay — subtle brand bloom from card center */\n.atv-stream-card::after {\n position: absolute;\n border-radius: inherit;\n background: radial-gradient(\n circle at center,\n var(--atv-stream-brand, var(--atv-accent-bright)) 0%,\n transparent 70%\n );\n content: \"\";\n inset: 0;\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--atv-duration-feedback) ease;\n}\n\n/* ── Combined SVG cards ── */\n.atv-stream-card-combined {\n height: 52px;\n padding: 0 16px;\n gap: 0;\n}\n\n.atv-stream-card-combined svg {\n display: block;\n width: auto;\n max-width: 160px;\n height: 32px;\n}\n\n.atv-stream-logo.is-catalog svg {\n display: block;\n width: 18px;\n height: 18px;\n fill: currentcolor;\n}\n\n.atv-stream-logo.is-intrinsic svg {\n display: block;\n width: 18px;\n height: 18px;\n fill: initial;\n}\n\n.atv-stream-vendor-icon {\n display: block;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n object-fit: contain;\n}\n\n.atv-stream-logo {\n display: inline-flex;\n overflow: hidden;\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 9px;\n background:\n linear-gradient(180deg, rgb(255 255 255 / 12%), rgb(255 255 255 / 0%)),\n var(--atv-bg-elevated);\n box-shadow:\n inset 0 1px 0 rgb(255 255 255 / 12%),\n 0 8px 18px rgb(0 0 0 / 18%);\n color: var(--atv-stream-brand, var(--atv-accent-bright));\n}\n\n.atv-stream-logo.is-surface-paper {\n border-color: var(--atv-border-emphasis);\n background: var(--atv-surface-paper);\n box-shadow:\n inset 0 1px 0 rgb(255 255 255 / 56%),\n 0 8px 18px rgb(0 0 0 / 18%);\n}\n\n.atv-stream-logo-fallback {\n display: inline-flex;\n width: 100%;\n height: 100%;\n align-items: center;\n justify-content: center;\n color: currentcolor;\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-bold);\n line-height: 1;\n text-transform: uppercase;\n}\n\n.atv-stream-name {\n overflow: hidden;\n min-width: 0;\n text-overflow: ellipsis;\n transition: color var(--atv-duration-feedback) ease;\n white-space: nowrap;\n}\n\n.atv-stream-card:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 3px;\n}\n/* ---------- Comments ---------- */\n.atv-comments {\n display: grid;\n gap: 16px;\n grid-template-columns: repeat(2, 1fr);\n}\n\n.atv-comment-card {\n display: flex;\n flex-direction: column;\n padding: 16px 20px;\n border: none;\n border-radius: var(--atv-radius-sm);\n background: var(--atv-bg-card);\n transition:\n transform var(--atv-duration-hover) var(--atv-ease-out),\n background var(--atv-duration-hover) ease;\n}\n\n.atv-comment-top {\n display: flex;\n align-items: center;\n margin-bottom: 14px;\n gap: 12px;\n}\n\n.atv-comment-avatar {\n display: flex;\n overflow: hidden;\n width: 36px;\n height: 36px;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 50%;\n background-color: var(--atv-accent);\n background-position: center;\n background-size: cover;\n color: var(--atv-text-on-accent);\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-comment-meta {\n display: flex;\n min-width: 0;\n flex-direction: column;\n gap: 6px;\n}\n\n.atv-comment-author,\na.atv-comment-author {\n overflow: hidden;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\na.atv-comment-author:hover {\n background: none;\n color: var(--atv-text-primary);\n}\n\n.atv-comment-stars {\n display: inline-flex;\n align-items: center;\n color: var(--atv-rating-gold);\n gap: 2px;\n}\n\n.atv-comment-stars svg {\n display: block;\n width: 14px;\n height: 14px;\n}\n\n.atv-comment-body {\n position: relative;\n display: flex;\n width: 100%;\n flex: 1 1 auto;\n flex-direction: column;\n padding: 0;\n border: none;\n margin: 0 0 14px;\n appearance: none;\n background: none;\n color: var(--atv-text-emphasis);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-body);\n line-height: var(--atv-line-height-body);\n overflow-wrap: break-word;\n text-align: left;\n}\n\n.atv-comment-body-text {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 4;\n overflow-wrap: break-word;\n}\n\n.atv-comment-foot {\n display: flex;\n align-items: center;\n justify-content: space-between;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n gap: 12px;\n letter-spacing: 0.02em;\n}\n\n.atv-comment-votes {\n display: inline-flex;\n align-items: center;\n padding: 4px 10px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 8px;\n background: none;\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font-family: inherit;\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n gap: 5px;\n letter-spacing: 0.02em;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-comment-votes:hover {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-comment-votes:focus-visible {\n outline: 2px solid var(--atv-accent-outline);\n outline-offset: 3px;\n}\n\n.atv-comment-votes:active {\n transform: scale(0.97);\n}\n\n.atv-comment-votes.is-voted {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-comment-votes svg {\n display: block;\n width: 13px;\n height: 13px;\n}\n\n.atv-vote-count {\n font-variant-numeric: tabular-nums;\n}\n\n/* ---------- Comment Expand Overlay ---------- */\n.atv-comment-foot-right {\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.atv-comment-expand {\n display: none;\n width: 24px;\n height: 24px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 6px;\n appearance: none;\n backdrop-filter: blur(4px);\n background: rgb(0 0 0 / 30%);\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font: inherit;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-comment-card.has-overflow .atv-comment-expand {\n display: inline-flex;\n}\n\n.atv-comment-expand:hover {\n border-color: var(--atv-accent);\n background: var(--atv-accent-active);\n color: var(--atv-accent);\n}\n\n.atv-comment-expand:active {\n transform: scale(0.97);\n}\n\n.atv-comment-expand svg {\n display: block;\n width: 12px;\n height: 12px;\n}\n\n/* ── Overlay ── */\n.atv-comment-overlay {\n position: fixed;\n z-index: 10000;\n display: flex;\n width: 100vw;\n height: 100vh;\n align-items: center;\n justify-content: center;\n padding: 24px;\n border: none;\n margin: 0;\n background: var(--atv-scrim-overlay);\n inset: 0;\n}\n\n.atv-comment-overlay-inner {\n position: relative;\n width: 100%;\n max-width: 580px;\n max-height: 80vh;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 20px;\n background: var(--atv-bg-secondary);\n box-shadow: 0 24px 80px rgb(0 0 0 / 55%);\n overflow-y: auto;\n}\n\n.atv-modal-close.atv-comment-overlay-close {\n position: absolute;\n z-index: 2;\n top: 16px;\n right: 16px;\n width: 32px;\n height: 32px;\n border-color: var(--atv-border-subtle);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n}\n\n.atv-modal-close.atv-comment-overlay-close:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n}\n\n.atv-modal-close.atv-comment-overlay-close svg {\n width: 16px;\n height: 16px;\n}\n\n.atv-comment-overlay-top {\n display: flex;\n align-items: center;\n padding: 28px 28px 0;\n gap: 14px;\n}\n\n.atv-comment-overlay-avatar {\n display: flex;\n overflow: hidden;\n width: 44px;\n height: 44px;\n flex: 0 0 auto;\n align-items: center;\n justify-content: center;\n border: 2px solid var(--atv-border-subtle);\n border-radius: 50%;\n background-color: var(--atv-accent);\n background-position: center;\n background-size: cover;\n box-shadow: 0 2px 6px rgb(0 0 0 / 15%);\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-md);\n font-weight: var(--atv-font-weight-semibold);\n transition:\n border-color var(--atv-duration-feedback) ease,\n box-shadow var(--atv-duration-feedback) ease;\n}\n\n.atv-comment-overlay-meta {\n display: flex;\n min-width: 0;\n flex-direction: column;\n gap: 6px;\n}\n\n.atv-comment-overlay-author,\na.atv-comment-overlay-author {\n overflow: hidden;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\na.atv-comment-overlay-author:hover {\n background: none;\n color: var(--atv-text-primary);\n}\n\n.atv-comment-overlay-stars {\n display: inline-flex;\n color: var(--atv-rating-gold);\n gap: 2px;\n}\n\n.atv-comment-overlay-stars svg {\n display: block;\n width: 16px;\n height: 16px;\n}\n\n.atv-comment-overlay-body {\n padding: 20px 28px;\n color: var(--atv-text-emphasis);\n font-size: var(--atv-type-body);\n line-height: var(--atv-line-height-body-loose);\n overflow-wrap: break-word;\n white-space: pre-wrap;\n}\n\n.atv-comment-overlay-foot {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 28px 28px;\n}\n\n.atv-comment-overlay-time {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n}\n\n.atv-comment-overlay-votes {\n display: inline-flex;\n align-items: center;\n padding: 6px 14px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 100px;\n appearance: none;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-medium);\n gap: 5px;\n letter-spacing: 0.02em;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-comment-overlay-votes:hover {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-comment-overlay-votes:focus-visible {\n outline: 2px solid var(--atv-accent-outline);\n outline-offset: 3px;\n}\n\n.atv-comment-overlay-votes:active {\n transform: scale(0.97);\n}\n\n.atv-comment-overlay-votes.is-voted {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-comment-overlay-votes svg {\n display: block;\n width: 14px;\n height: 14px;\n}\n.atv-modal-overlay {\n position: fixed;\n z-index: 10000;\n display: flex;\n width: 100vw;\n height: 100vh;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: none;\n margin: 0;\n -webkit-backdrop-filter: blur(16px) saturate(1.15);\n backdrop-filter: blur(16px) saturate(1.15);\n background: var(--atv-scrim-overlay);\n inset: 0;\n}\n\n.atv-modal-img {\n max-width: 90vw;\n max-height: 90vh;\n border-radius: var(--atv-radius-lg);\n box-shadow: 0 40px 80px rgb(0 0 0 / 75%);\n object-fit: contain;\n transition: opacity var(--atv-duration-feedback) ease;\n}\n\n.atv-image-modal-frame {\n position: relative;\n display: grid;\n max-width: 90vw;\n max-height: 90vh;\n place-items: center;\n}\n\n.atv-image-modal-frame > .atv-modal-img,\n.atv-image-modal-frame > .atv-modal-preview,\n.atv-image-modal-loading {\n grid-area: 1 / 1;\n}\n\n.atv-modal-preview {\n max-width: 90vw;\n max-height: 90vh;\n border-radius: var(--atv-radius-lg);\n box-shadow: 0 40px 80px rgb(0 0 0 / 75%);\n object-fit: contain;\n transition:\n filter var(--atv-duration-feedback) ease,\n opacity var(--atv-duration-feedback) ease,\n transform var(--atv-duration-feedback) ease;\n}\n\n.atv-image-modal-frame.is-loading > .atv-modal-preview {\n filter: blur(10px) saturate(0.82);\n opacity: 0.72;\n transform: scale(1.02);\n}\n\n.atv-image-modal-frame.is-loading > .atv-modal-img {\n opacity: 0;\n}\n\n.atv-image-modal-frame.is-preview > .atv-modal-img {\n opacity: 0;\n pointer-events: none;\n}\n\n.atv-image-modal-frame.is-loaded > .atv-modal-preview {\n opacity: 0;\n pointer-events: none;\n}\n\n.atv-image-modal-loading {\n z-index: 1;\n display: flex;\n flex-direction: column;\n align-items: center;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n gap: 10px;\n text-shadow: 0 1px 8px rgb(0 0 0 / 65%);\n}\n\n.atv-modal-accent-bar {\n position: absolute;\n z-index: 1;\n top: 0;\n right: 0;\n left: 0;\n height: 3px;\n background: linear-gradient(\n to right,\n transparent,\n var(--atv-accent) 15%,\n var(--atv-accent) 85%,\n transparent\n );\n opacity: 1;\n}\n\n.atv-modal-close {\n position: fixed;\n z-index: 10001;\n top: 24px;\n right: 24px;\n display: flex;\n width: 44px;\n height: 44px;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--atv-border-strong);\n border-radius: 50%;\n appearance: none;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n cursor: pointer;\n font: inherit;\n -webkit-tap-highlight-color: transparent;\n touch-action: manipulation;\n transition:\n transform var(--atv-duration-press) var(--atv-ease-out),\n color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease;\n}\n\n.atv-modal-close.atv-image-modal-close {\n position: absolute;\n z-index: 2;\n top: 12px;\n right: 12px;\n}\n\n.atv-modal-close:hover {\n border-color: var(--atv-border-emphasis);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n}\n\n.atv-modal-close:active {\n transform: scale(0.97);\n}\n\n.atv-modal-close svg {\n display: block;\n}\n.atv-comment-overlay,\n.atv-review-modal {\n -webkit-backdrop-filter: blur(16px) saturate(1.15);\n backdrop-filter: blur(16px) saturate(1.15);\n}\n\n@keyframes atv-spin {\n from {\n transform: rotate(0deg);\n }\n\n to {\n transform: rotate(360deg);\n }\n}\n\n.atv-spinner {\n width: 32px;\n height: 32px;\n border: 3px solid var(--atv-border-strong);\n border-radius: 50%;\n border-top-color: var(--atv-accent);\n animation: atv-spin 0.8s linear infinite;\n}\n\n.atv-modal-loading {\n display: flex;\n flex-direction: column;\n align-items: center;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body);\n gap: 16px;\n}\n\n.atv-footer-spacer {\n height: 64px;\n}\n/* ---------- Login Prompt Modal ---------- */\ndialog::backdrop {\n background: transparent;\n}\n\n.atv-login-modal {\n position: fixed;\n z-index: 10000;\n display: flex;\n width: 100vw;\n min-height: 100vh;\n align-items: center;\n justify-content: center;\n padding: max(24px, env(safe-area-inset-top))\n max(24px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom))\n max(24px, env(safe-area-inset-left));\n border: none;\n margin: 0;\n -webkit-backdrop-filter: blur(16px) saturate(1.15);\n backdrop-filter: blur(16px) saturate(1.15);\n background: var(--atv-scrim-overlay);\n inset: 0;\n overscroll-behavior: contain;\n}\n\n.atv-login-modal-inner {\n position: relative;\n overflow: auto;\n width: min(100%, 424px);\n max-height: min(736px, calc(100dvh - 48px));\n box-sizing: border-box;\n padding: 30px 22px 22px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 20px;\n background: var(--atv-bg-secondary);\n box-shadow:\n inset 0 1px 0 var(--atv-border-subtle),\n 0 24px 80px rgb(0 0 0 / 55%);\n color: var(--atv-text-primary);\n scrollbar-width: none;\n text-align: center;\n}\n\n.atv-login-modal-inner::-webkit-scrollbar {\n display: none;\n}\n\n.atv-modal-close.atv-login-modal-close {\n position: absolute;\n z-index: 2;\n top: 12px;\n right: 12px;\n width: 32px;\n height: 32px;\n border-color: var(--atv-border-subtle);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n}\n\n.atv-modal-close.atv-login-modal-close:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n}\n\n.atv-login-modal-title {\n margin: 0 42px 6px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-md);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.35;\n text-wrap: balance;\n}\n\n.atv-login-modal-desc {\n max-width: 300px;\n margin: 0 auto 14px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n line-height: 1.55;\n text-wrap: pretty;\n}\n\n.atv-login-modal-status {\n min-height: 20px;\n margin: 8px 0 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n line-height: 1.5;\n}\n\n.atv-login-modal-status[hidden] {\n display: none;\n}\n\n.atv-login-modal-native {\n position: relative;\n overflow: hidden;\n width: 100%;\n box-sizing: border-box;\n padding: 10px;\n border-radius: 16px;\n margin-top: 10px;\n}\n\n.atv-login-modal-native::before {\n position: absolute;\n z-index: 2;\n border-radius: 10px;\n background: var(--atv-bg-hover);\n content: \"\";\n inset: 10px;\n opacity: 1;\n pointer-events: none;\n transition: opacity var(--atv-duration-feedback) ease;\n}\n\n.atv-login-modal-native:empty {\n min-height: 360px;\n}\n\n.atv-login-modal-native[aria-busy=\"true\"] {\n min-height: 360px;\n}\n\n.atv-login-modal-native.is-ready::before {\n opacity: 0;\n}\n\n.atv-login-modal-native > iframe,\n.atv-login-modal-iframe {\n position: relative;\n z-index: 1;\n display: block !important;\n overflow: hidden !important;\n width: 340px !important;\n max-width: 100% !important;\n height: 448px !important;\n max-height: calc(100vh - 168px) !important;\n box-sizing: border-box !important;\n border: 0 !important;\n border-radius: 10px !important;\n margin: 0 auto !important;\n background: #fff !important;\n box-shadow: none !important;\n\n /* Cross-origin iframe dark-mode override (first-principles):\n invert(0.89) maps white #fff → #1c1c1c, which matches\n --atv-bg-secondary: #1c1c1e within 2 RGB points — no more\n pure-black void. Text #333 → #bbb, borders #e0 → #343434.\n hue-rotate(180deg) counter-rotates the color shift from\n invert so green stays green-ish and blue stays blue-ish.\n invert(0.89) is the precise value calculated to hit the\n existing modal surface color while keeping text readable. */\n filter: invert(0.89) hue-rotate(180deg) !important;\n opacity: 0;\n transition: opacity var(--atv-duration-feedback) ease;\n}\n\n.atv-login-modal-native.is-ready > iframe {\n opacity: 1;\n}\n\n.atv-login-modal-close:focus-visible {\n outline: 2px solid var(--atv-accent);\n outline-offset: 3px;\n}\n\n@media (width <= 1024px) {\n .atv-login-modal {\n align-items: flex-end;\n padding: 16px;\n }\n\n .atv-login-modal-inner {\n width: 100%;\n padding: 28px 20px 20px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-login-modal,\n .atv-login-modal-inner,\n .atv-login-modal.is-open .atv-login-modal-inner {\n transform: none;\n transition: none;\n }\n}\n\n@media (prefers-reduced-transparency: reduce) {\n .atv-login-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n}\n\n@media (prefers-contrast: more) {\n .atv-login-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n}\n/* ---------- Interest Modal ---------- */\n.atv-interest-modal {\n position: fixed;\n z-index: 10000;\n display: flex;\n width: 100vw;\n height: 100vh;\n align-items: center;\n justify-content: center;\n padding: 24px;\n border: none;\n margin: 0;\n -webkit-backdrop-filter: blur(16px) saturate(1.15);\n backdrop-filter: blur(16px) saturate(1.15);\n background: var(--atv-scrim-overlay);\n inset: 0;\n}\n\n.atv-interest-modal-inner {\n position: relative;\n width: 100%;\n max-width: 456px;\n max-height: 90vh;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 20px;\n background: var(--atv-bg-secondary);\n box-shadow: 0 24px 80px rgb(0 0 0 / 55%);\n color-scheme: dark;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n\n.atv-interest-modal-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n justify-content: flex-start;\n padding: 28px 56px 0 24px;\n}\n\n.atv-interest-modal-header-title {\n margin: 3px 0 0;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-lg);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n line-height: 1.35;\n}\n\n.atv-interest-modal-eyebrow {\n margin: 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n.atv-modal-close.atv-interest-modal-close {\n position: absolute;\n z-index: 2;\n top: 14px;\n right: 14px;\n width: 32px;\n height: 32px;\n border-color: var(--atv-border-subtle);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n}\n\n.atv-modal-close.atv-interest-modal-close:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n}\n\n.atv-modal-close.atv-interest-modal-close:active {\n border-color: var(--atv-border-emphasis);\n background: var(--atv-bg-hover-strong);\n}\n\n.atv-interest-modal-body {\n padding: 24px 20px 0;\n}\n\n.atv-interest-modal-statuses {\n position: relative;\n display: grid;\n min-width: 0;\n padding: 0;\n border: 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n margin-bottom: 24px;\n gap: 0;\n grid-template-columns: repeat(var(--atv-interest-status-count), 1fr);\n}\n\n.atv-interest-modal-status-indicator {\n position: absolute;\n bottom: -1px;\n left: 0;\n width: calc(100% / var(--atv-interest-status-count));\n height: 2px;\n border-radius: 2px 2px 0 0;\n background: var(--atv-accent);\n pointer-events: none;\n transform: translateX(calc(var(--atv-interest-status-index) * 100%));\n transition: transform var(--atv-duration-feedback) var(--atv-ease-in-out);\n}\n\n.atv-interest-modal-status {\n position: relative;\n z-index: 1;\n display: inline-flex;\n height: 42px;\n flex: 1;\n align-items: center;\n justify-content: center;\n padding: 0 12px;\n border: none;\n border-radius: var(--atv-radius-sm) var(--atv-radius-sm) 0 0;\n appearance: none;\n background: transparent;\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n -webkit-tap-highlight-color: transparent;\n transition: color var(--atv-duration-feedback) ease;\n}\n\n.atv-interest-modal-status:hover {\n color: var(--atv-text-secondary);\n}\n\n.atv-interest-modal-status.is-active {\n color: var(--atv-text-primary);\n}\n\n.atv-interest-modal-rating {\n min-width: 0;\n padding: 0 0 20px;\n border: 0;\n border-bottom: 1px solid var(--atv-border-subtle);\n margin-bottom: 20px;\n}\n\n.atv-interest-modal-rating-header {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n margin-bottom: 8px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n}\n\n.atv-interest-modal-rating-header > span:last-child {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n}\n\n.atv-interest-modal-stars {\n display: flex;\n gap: 4px;\n}\n\n.atv-interest-modal-star {\n display: flex;\n width: 32px;\n height: 32px;\n padding: 0;\n border: none;\n appearance: none;\n background: none;\n color: var(--atv-rating-empty);\n cursor: pointer;\n transition:\n color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-interest-modal-star.is-full {\n color: var(--atv-rating-gold);\n}\n\n.atv-interest-modal-star svg {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.atv-interest-modal-comment {\n display: block;\n width: 100%;\n min-height: 78px;\n box-sizing: border-box;\n padding: 10px 14px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 10px;\n margin-bottom: 10px;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-primary);\n font-family: inherit;\n font-size: var(--atv-type-caption);\n line-height: 1.5;\n resize: none;\n -webkit-tap-highlight-color: transparent;\n transition:\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n box-shadow var(--atv-duration-feedback) ease;\n}\n\n.atv-interest-modal-comment::placeholder {\n color: var(--atv-text-tertiary);\n}\n\n.atv-interest-modal-comment:focus {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-elevated);\n outline: none;\n}\n\n.atv-interest-modal-comment:focus-visible {\n border-color: var(--atv-accent-bright);\n box-shadow: 0 0 0 2px var(--atv-accent-outline);\n}\n\n.atv-interest-modal-submit {\n display: flex;\n width: 100%;\n height: 44px;\n align-items: center;\n justify-content: center;\n padding: 0 24px;\n border: none;\n border-radius: var(--atv-radius-md);\n margin-bottom: 0;\n appearance: none;\n background: var(--atv-accent);\n color: var(--atv-text-on-accent);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n -webkit-tap-highlight-color: transparent;\n transition:\n background var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n}\n\n.atv-interest-modal-submit:hover {\n background: var(--atv-accent-bright);\n}\n\n.atv-interest-modal-submit:active {\n transform: scale(0.97);\n}\n\n.atv-interest-modal-submit:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.atv-interest-modal-remove {\n display: flex;\n width: 100%;\n height: 36px;\n align-items: center;\n justify-content: center;\n padding: 0 24px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-md);\n margin: 0;\n appearance: none;\n background: transparent;\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n -webkit-tap-highlight-color: transparent;\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease;\n}\n\n.atv-interest-modal-remove:hover {\n border-color: var(--atv-border-medium);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n}\n\n.atv-interest-modal-remove:active {\n background: var(--atv-bg-elevated);\n}\n\n.atv-interest-modal-error {\n border-radius: 10px;\n color: var(--atv-danger);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n text-align: center;\n}\n\n.atv-interest-modal-error:empty {\n display: none;\n}\n\n.atv-interest-modal-error:not(:empty) {\n padding: 8px 16px;\n margin-top: 8px;\n background: color-mix(in srgb, var(--atv-danger) 8%, transparent);\n}\n\n.atv-interest-modal-field-header {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n margin: 0 0 8px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.01em;\n}\n\n.atv-interest-modal-field-header > span:last-child {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.02em;\n}\n\n.atv-interest-modal-tags {\n padding-bottom: 20px;\n border-bottom: 1px solid var(--atv-border-subtle);\n margin-bottom: 20px;\n}\n\n.atv-interest-modal-tag-input-wrap {\n display: flex;\n min-height: 42px;\n flex-wrap: wrap;\n align-items: center;\n padding: 5px 8px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 10px;\n background: var(--atv-bg-elevated);\n gap: 5px;\n transition:\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n box-shadow var(--atv-duration-feedback) ease;\n}\n\n.atv-interest-modal-tag-input-wrap:focus-within {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-elevated);\n}\n\n.atv-interest-modal-tag-input-wrap:has(\n .atv-interest-modal-tag-input:focus-visible\n) {\n border-color: var(--atv-accent-bright);\n box-shadow: 0 0 0 2px var(--atv-accent-outline);\n}\n\n.atv-interest-modal-tag-input {\n min-width: 84px;\n flex: 1 1 84px;\n padding: 4px 2px;\n border: 0;\n background: transparent;\n color: var(--atv-text-primary);\n font: inherit;\n font-size: var(--atv-type-caption);\n outline: 0;\n}\n\n.atv-interest-modal-tag-input::placeholder {\n color: var(--atv-text-tertiary);\n}\n\n.atv-interest-modal-tag {\n display: inline-flex;\n min-height: 26px;\n align-items: center;\n padding: 3px 9px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n appearance: none;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption-sm);\n line-height: 1;\n transition:\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease;\n}\n\n.atv-interest-modal-tag.is-current {\n border-color: color-mix(in srgb, var(--atv-accent) 45%, transparent);\n background: color-mix(in srgb, var(--atv-accent) 16%, transparent);\n color: #c3f6d0;\n}\n\n.atv-interest-modal-tag.is-selected {\n border-color: color-mix(in srgb, var(--atv-accent) 38%, transparent);\n background: color-mix(in srgb, var(--atv-accent) 12%, transparent);\n color: var(--atv-text-primary);\n}\n\n.atv-interest-modal-tag-suggestions {\n display: grid;\n margin-top: 10px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-micro);\n gap: 6px;\n grid-template-columns: 48px minmax(0, 1fr);\n}\n\n.atv-interest-modal-tag-suggestions > span {\n padding-top: 5px;\n letter-spacing: 0.02em;\n}\n\n.atv-interest-modal-tag-suggestions > div {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n\n.atv-interest-modal-tag-skeleton {\n display: flex;\n height: 90px;\n flex-wrap: wrap;\n align-content: center;\n padding: 12px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 10px;\n margin-bottom: 18px;\n background: var(--atv-bg-hover);\n gap: 8px;\n}\n\n.atv-interest-modal-tag-skeleton span {\n display: block;\n height: 22px;\n border-radius: 999px;\n background: var(--atv-bg-elevated);\n}\n\n.atv-interest-modal-tag-skeleton span:nth-child(1) {\n width: 78px;\n}\n\n.atv-interest-modal-tag-skeleton span:nth-child(2) {\n width: 54px;\n}\n\n.atv-interest-modal-tag-skeleton span:nth-child(3) {\n width: 96px;\n}\n\n.atv-interest-modal-source-error {\n display: flex;\n min-height: 90px;\n align-items: center;\n justify-content: space-between;\n padding: 12px;\n border: 1px solid color-mix(in srgb, var(--atv-danger) 22%, transparent);\n border-radius: 10px;\n margin-bottom: 18px;\n background: color-mix(in srgb, var(--atv-danger) 8%, transparent);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n gap: 12px;\n}\n\n.atv-interest-modal-source-error button {\n padding: 0;\n border: 0;\n appearance: none;\n background: transparent;\n color: var(--atv-accent-bright);\n cursor: pointer;\n font: inherit;\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-interest-modal-visibility {\n display: grid;\n padding: 0;\n border: 0;\n margin: 0;\n gap: 10px;\n}\n\n.atv-interest-modal-visibility legend {\n padding: 0;\n margin-bottom: 10px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-interest-modal-visibility-option,\n.atv-interest-modal-broadcast-option {\n display: flex;\n align-items: center;\n color: var(--atv-text-secondary);\n cursor: pointer;\n font-size: var(--atv-type-caption);\n gap: 8px;\n}\n\n.atv-interest-modal-visibility input {\n width: 16px;\n height: 16px;\n margin: 0;\n accent-color: var(--atv-accent);\n}\n\n.atv-interest-modal-broadcast-option {\n padding-left: 24px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n}\n\n.atv-interest-modal-visibility-note {\n padding-left: 24px;\n margin: -3px 0 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n}\n\n.atv-interest-modal-footer {\n position: sticky;\n z-index: 1;\n bottom: 0;\n padding: 16px 20px 20px;\n border-top: 1px solid var(--atv-border-subtle);\n margin: 20px -20px 0;\n background: var(--atv-bg-secondary);\n box-shadow: 0 -12px 24px rgb(0 0 0 / 22%);\n}\n\n.atv-interest-modal-actions {\n display: grid;\n gap: 8px;\n}\n\n.atv-interest-modal-removal-confirmation {\n display: grid;\n padding: 12px;\n border: 1px solid color-mix(in srgb, var(--atv-danger) 22%, transparent);\n border-radius: 10px;\n background: color-mix(in srgb, var(--atv-danger) 8%, transparent);\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n gap: 10px;\n}\n\n.atv-interest-modal-removal-confirmation > div {\n display: grid;\n gap: 8px;\n grid-template-columns: 1fr 1fr;\n}\n\n.atv-interest-modal-removal-confirmation button {\n height: 34px;\n border: 1px solid var(--atv-border-medium);\n border-radius: 999px;\n appearance: none;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-interest-modal-removal-confirmation button:last-child {\n border-color: color-mix(in srgb, var(--atv-danger) 35%, transparent);\n background: color-mix(in srgb, var(--atv-danger) 18%, transparent);\n color: #ff9f99;\n}\n\n.atv-interest-modal button:focus-visible,\n.atv-interest-modal-visibility input:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 2px;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-interest-modal-star:hover {\n transform: scale(1.15);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-interest-modal {\n transition: none;\n }\n\n .atv-interest-modal-star:hover,\n .atv-interest-modal-submit:active {\n transform: none;\n transition: none;\n }\n\n .atv-interest-modal-status-indicator {\n opacity: 0;\n transform: none;\n }\n\n .atv-interest-modal-status.is-active {\n background: var(--atv-bg-hover-strong);\n }\n}\n\n@media (prefers-reduced-transparency: reduce) {\n .atv-interest-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n}\n\n@media (prefers-contrast: more) {\n .atv-interest-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n}\n\n/* Prevent iOS zoom on focus for native inputs */\n@media (hover: none) and (pointer: coarse) {\n .atv-interest-modal-comment,\n .atv-interest-modal-tag-input {\n font-size: 16px;\n }\n}\n/* ---------- Trailer Tile ---------- */\n.atv-trailer-tile {\n position: relative;\n overflow: hidden;\n flex: 0 0 400px;\n border-radius: var(--atv-radius-md);\n aspect-ratio: 16 / 9;\n background: var(--atv-bg-tertiary);\n background-position: center;\n background-size: cover;\n cursor: pointer;\n scroll-snap-align: start;\n transition: transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-trailer-play-overlay {\n position: absolute;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgb(0 0 0 / 12%);\n inset: 0;\n}\n\n.atv-trailer-play-btn {\n display: flex;\n width: 56px;\n height: 56px;\n align-items: center;\n justify-content: center;\n border-radius: 50%;\n background: rgb(0 0 0 / 60%);\n}\n\n.atv-trailer-play-btn svg {\n display: block;\n width: 24px;\n height: 24px;\n margin-left: 3px;\n color: var(--atv-text-primary);\n}\n\n.atv-trailer-label {\n position: absolute;\n bottom: 12px;\n left: 12px;\n padding: 4px 10px;\n border-radius: 6px;\n backdrop-filter: blur(4px);\n background: rgb(0 0 0 / 60%);\n color: var(--atv-text-primary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n}\n\n/* ---------- Video Modal ---------- */\n.atv-modal-overlay.is-video {\n backdrop-filter: none;\n background: #000;\n}\n\n.atv-modal-video {\n display: block;\n max-width: 95vw;\n max-height: 90vh;\n border-radius: var(--atv-radius-lg);\n box-shadow: 0 40px 80px rgb(0 0 0 / 75%);\n}\n/* ---------- Reviews ---------- */\n.atv-reviews {\n display: grid;\n gap: 20px;\n grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));\n}\n\n/* Two 400px columns + 20px gap + 48px section padding fit from 868px up;\n below that a single column avoids the lone-wide-card band. */\n@media (width <= 868px) {\n .atv-reviews {\n grid-template-columns: 1fr;\n }\n}\n\n.atv-review-card {\n position: relative;\n display: flex;\n flex-direction: column;\n padding: 20px;\n border: none;\n border-radius: var(--atv-radius-sm);\n appearance: none;\n background: var(--atv-bg-card);\n color: inherit;\n cursor: pointer;\n font: inherit;\n -webkit-tap-highlight-color: transparent;\n text-align: left;\n touch-action: manipulation;\n transition:\n transform var(--atv-duration-hover) var(--atv-ease-out),\n background var(--atv-duration-hover) ease;\n}\n\n.atv-review-open-button {\n position: absolute;\n z-index: 0;\n padding: 0;\n border: 0;\n border-radius: var(--atv-radius-md);\n appearance: none;\n background: transparent;\n cursor: pointer;\n inset: 0;\n}\n\n.atv-review-content {\n position: relative;\n z-index: 1;\n display: contents;\n}\n\n.atv-review-card:active {\n box-shadow: none;\n transform: translateY(0);\n}\n\n.atv-review-card:focus-visible {\n background: var(--atv-bg-elevated);\n box-shadow: 0 0 0 5px rgb(65 190 93 / 12%);\n outline: 2px solid var(--atv-accent);\n outline-offset: 4px;\n}\n\n.atv-review-open-button:focus-visible {\n outline: 2px solid var(--atv-accent);\n outline-offset: 4px;\n}\n\n.atv-review-top {\n display: flex;\n align-items: center;\n margin-bottom: 12px;\n gap: 10px;\n pointer-events: none;\n}\n\n.atv-review-avatar {\n display: flex;\n width: 36px;\n height: 36px;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 50%;\n background: var(--atv-accent);\n background-position: center;\n background-size: cover;\n color: var(--atv-text-on-accent);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-review-meta {\n display: flex;\n min-width: 0;\n flex-direction: column;\n gap: 3px;\n pointer-events: none;\n}\n\n.atv-review-author {\n position: relative;\n z-index: 2;\n overflow: hidden;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n pointer-events: auto;\n text-decoration: none;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.atv-review-author:hover {\n color: var(--atv-accent);\n}\n\n.atv-review-stars {\n display: inline-flex;\n align-items: center;\n color: var(--atv-rating-gold);\n gap: 2px;\n}\n\n.atv-review-stars svg {\n display: block;\n width: 14px;\n height: 14px;\n}\n\n.atv-review-title {\n display: -webkit-box;\n overflow: hidden;\n margin-bottom: 8px;\n -webkit-box-orient: vertical;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.01em;\n -webkit-line-clamp: 2;\n line-height: 1.35;\n pointer-events: none;\n}\n\n.atv-review-excerpt {\n display: -webkit-box;\n overflow: hidden;\n flex: 1;\n margin-bottom: 14px;\n -webkit-box-orient: vertical;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n -webkit-line-clamp: 4;\n line-height: 1.7;\n pointer-events: none;\n}\n\n.atv-review-foot {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-top: 14px;\n border-top: 1px solid var(--atv-border-subtle);\n gap: 8px;\n pointer-events: none;\n}\n\n.atv-review-time {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-regular);\n letter-spacing: 0.02em;\n}\n\n.atv-review-readmore {\n color: var(--atv-accent);\n font-size: var(--atv-type-micro);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: 0.04em;\n opacity: 0;\n pointer-events: none;\n transform: translateX(-3px);\n transition:\n opacity var(--atv-duration-hover) var(--atv-ease-out),\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n.atv-review-card:focus-visible .atv-review-readmore,\n.atv-review-card:hover .atv-review-readmore {\n opacity: 1;\n transform: translateX(0);\n}\n\n.atv-review-card:has(.atv-review-open-button:focus-visible) {\n background: var(--atv-bg-elevated);\n box-shadow: 0 0 0 5px rgb(65 190 93 / 12%);\n}\n\n.atv-review-card:has(.atv-review-open-button:focus-visible)\n .atv-review-readmore {\n opacity: 1;\n transform: translateX(0);\n}\n\n.atv-review-actions {\n position: relative;\n z-index: 2;\n display: flex;\n margin-left: auto;\n gap: 6px;\n pointer-events: auto;\n}\n\n.atv-vote-btn {\n display: inline-flex;\n align-items: center;\n padding: 5px 12px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 8px;\n appearance: none;\n background: var(--atv-bg-hover);\n color: var(--atv-text-tertiary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-medium);\n gap: 5px;\n line-height: 1;\n transition:\n color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n transform var(--atv-duration-press) var(--atv-ease-out);\n white-space: nowrap;\n}\n\n.atv-vote-btn:hover {\n border-color: var(--atv-accent);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-vote-btn:focus-visible {\n outline: 2px solid var(--atv-accent-outline);\n outline-offset: 3px;\n}\n\n.atv-vote-btn:active {\n transform: scale(0.97);\n}\n\n.atv-vote-btn.is-lg {\n padding: 8px 20px;\n font-size: var(--atv-type-body-sm);\n}\n\n.atv-vote-btn svg {\n display: block;\n width: 11px;\n height: 11px;\n transform-box: fill-box;\n transform-origin: center;\n}\n\n.atv-vote-btn.down:hover {\n border-color: var(--atv-danger);\n background: color-mix(in srgb, var(--atv-danger) 6%, transparent);\n color: var(--atv-danger);\n}\n\n.atv-vote-btn.down svg {\n transform: rotate(180deg);\n}\n\n.atv-vote-btn.is-voted {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n cursor: default;\n}\n\n.atv-vote-btn.down.is-voted {\n border-color: color-mix(in srgb, var(--atv-danger) 22%, transparent);\n background: color-mix(in srgb, var(--atv-danger) 6%, transparent);\n color: var(--atv-danger);\n}\n\n/* ---------- Review Modal ---------- */\n.atv-review-modal {\n position: fixed;\n z-index: 10000;\n display: flex;\n width: 100vw;\n height: 100vh;\n align-items: center;\n justify-content: center;\n padding: 48px;\n border: none;\n margin: 0;\n background: var(--atv-scrim-overlay);\n inset: 0;\n}\n\n.atv-review-modal .atv-modal-close {\n position: absolute;\n z-index: 2;\n top: 16px;\n right: 16px;\n width: 32px;\n height: 32px;\n border-color: var(--atv-border-subtle);\n background: var(--atv-bg-elevated);\n color: var(--atv-text-tertiary);\n}\n\n.atv-review-modal .atv-modal-close:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n color: var(--atv-text-primary);\n}\n\n.atv-review-modal-scroll {\n position: relative;\n width: 100%;\n max-width: 800px;\n max-height: 85vh;\n padding: 48px 56px 32px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-lg);\n background: var(--atv-bg-secondary);\n box-shadow: 0 24px 80px rgb(0 0 0 / 55%);\n overflow-y: auto;\n scrollbar-color: var(--atv-border-medium) transparent;\n scrollbar-width: thin;\n}\n\n.atv-review-modal-scroll::before {\n position: absolute;\n z-index: 1;\n top: 0;\n right: 0;\n left: 0;\n height: 3px;\n background: linear-gradient(\n to right,\n transparent,\n var(--atv-accent) 15%,\n var(--atv-accent) 85%,\n transparent\n );\n content: \"\";\n}\n\n.atv-review-modal-header {\n position: relative;\n padding-bottom: 18px;\n margin-bottom: 20px;\n}\n\n.atv-review-modal-header::after {\n position: absolute;\n right: 0;\n bottom: 0;\n left: 0;\n height: 1px;\n background: linear-gradient(\n to right,\n transparent,\n rgb(255 255 255 / 5%) 20%,\n rgb(255 255 255 / 5%) 80%,\n transparent\n );\n content: \"\";\n}\n\n.atv-review-modal-header-primary {\n margin-bottom: 14px;\n}\n\n.atv-review-modal-title {\n margin-bottom: 4px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-xl);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: -0.02em;\n line-height: 1.32;\n text-wrap: balance;\n}\n\n.atv-review-modal-title:focus {\n outline: none;\n}\n\n.atv-review-modal-header-meta {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n}\n\n.atv-review-modal-byline {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.atv-review-modal-avatar {\n display: flex;\n width: 28px;\n height: 28px;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border: 1.5px solid var(--atv-border-subtle);\n border-radius: 50%;\n background: var(--atv-accent);\n background-position: center;\n background-size: cover;\n color: var(--atv-text-on-accent);\n font-size: var(--atv-type-caption-sm);\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-review-modal-byline-text {\n display: flex;\n align-items: center;\n font-size: var(--atv-type-caption);\n gap: 4px;\n line-height: 1;\n}\n\n.atv-review-modal-byline-name {\n color: var(--atv-text-secondary);\n}\n\n.atv-review-modal-byline-time {\n color: var(--atv-text-tertiary);\n}\n\n.atv-review-modal-body {\n color: var(--atv-text-emphasis);\n font-size: var(--atv-type-body-lg);\n line-height: var(--atv-line-height-body-loose);\n}\n\n.atv-review-modal-body h2 {\n margin-bottom: 16px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-lg);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.4;\n}\n\n.atv-review-modal-body h2 a {\n background: transparent;\n color: inherit;\n text-decoration: none;\n}\n\n.atv-review-modal-body h2 a:hover {\n background: transparent;\n color: var(--atv-accent);\n}\n\n.atv-review-modal-body p {\n margin-bottom: 16px;\n}\n\n.atv-review-modal-body blockquote {\n padding: 16px 20px;\n border-radius: 0 var(--atv-radius-sm) var(--atv-radius-sm) 0;\n border-left: 3px solid var(--atv-accent);\n margin: 20px 0;\n background: var(--atv-bg-hover);\n color: var(--atv-text-emphasis);\n}\n\n.atv-review-modal-body blockquote p {\n margin-bottom: 8px;\n}\n\n.atv-review-modal-body blockquote p:last-child {\n margin-bottom: 0;\n}\n\n.atv-review-modal-body blockquote cite,\n.atv-review-modal-body blockquote footer {\n display: block;\n margin-top: 8px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-style: normal;\n font-weight: var(--atv-font-weight-medium);\n}\n\n.atv-review-modal-body blockquote cite::before,\n.atv-review-modal-body blockquote footer::before {\n content: \"\\2014\\00a0\";\n}\n\n.atv-review-modal-body blockquote blockquote {\n border-left-color: var(--atv-border-strong);\n margin-left: 0;\n background: var(--atv-bg-elevated);\n}\n/* ---------- Group discussions ---------- */\n.atv-discussion-board {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.atv-discussion-row {\n position: relative;\n display: flex;\n overflow: hidden;\n flex-direction: column;\n padding: 18px 20px;\n border: none;\n border-radius: var(--atv-radius-sm);\n background: var(--atv-bg-card);\n gap: 6px;\n isolation: isolate;\n transition:\n transform var(--atv-duration-press) var(--atv-ease-out),\n background var(--atv-duration-hover) ease;\n}\n\n/* Accent bar — reveals on hover/focus */\n.atv-discussion-row::before {\n position: absolute;\n top: 14px;\n bottom: 14px;\n left: 0;\n width: 3px;\n border-radius: 0 3px 3px 0;\n background: var(--atv-accent);\n content: \"\";\n opacity: 0;\n transform: scaleY(0);\n transform-origin: top;\n transition:\n opacity var(--atv-duration-hover) var(--atv-ease-out),\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n/* Entire row is clickable */\n.atv-discussion-topic-link {\n position: absolute;\n z-index: 1;\n border-radius: inherit;\n inset: 0;\n}\n\n.atv-discussion-topic-link:focus-visible {\n outline: 2px solid var(--atv-accent);\n outline-offset: -3px;\n}\n\n/* ---------- Copy (title + metadata) ---------- */\n.atv-discussion-copy {\n position: relative;\n z-index: 2;\n display: flex;\n min-width: 0;\n flex-direction: column;\n gap: 6px;\n pointer-events: none;\n}\n\n/* Title + arrow in one row */\n.atv-discussion-title-row {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n}\n\n.atv-discussion-title {\n display: -webkit-box;\n overflow: hidden;\n min-width: 0;\n flex: 1;\n margin: 0;\n -webkit-box-orient: vertical;\n color: var(--atv-text-emphasis);\n font-size: var(--atv-type-body);\n font-weight: var(--atv-font-weight-medium);\n letter-spacing: -0.015em;\n -webkit-line-clamp: 2;\n line-height: 1.5;\n overflow-wrap: break-word;\n transition: color var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n/* Arrow — always in flow, hidden until hover */\n.atv-discussion-arrow {\n flex-shrink: 0;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-body);\n line-height: 1.5;\n opacity: 0;\n transform: translateX(-6px);\n transition:\n opacity var(--atv-duration-hover) var(--atv-ease-out),\n transform var(--atv-duration-hover) var(--atv-ease-out);\n}\n\n/* ---------- Metadata row ---------- */\n.atv-discussion-meta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n gap: 0;\n line-height: 1.35;\n}\n\n.atv-discussion-author {\n display: block;\n overflow: hidden;\n width: fit-content;\n max-width: 100%;\n color: inherit;\n font-size: inherit;\n line-height: inherit;\n pointer-events: auto;\n text-decoration: none;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\na.atv-discussion-author:hover {\n text-decoration: underline;\n}\n\na.atv-discussion-author:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 2px;\n text-decoration: underline;\n}\n\n/* Activity wraps replies + time inline */\n.atv-discussion-activity {\n display: inline;\n color: inherit;\n font-size: inherit;\n line-height: inherit;\n pointer-events: none;\n}\n\n/* Replies — plain text, no pill */\n.atv-discussion-replies {\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.01em;\n white-space: nowrap;\n}\n\n/* Time — date + time on one line */\n.atv-discussion-time {\n display: inline-flex;\n align-items: center;\n font-variant-numeric: tabular-nums;\n gap: 3px;\n white-space: nowrap;\n}\n\n/* ---------- Dot separators ---------- */\n.atv-discussion-author + .atv-discussion-activity::before {\n margin: 0 7px;\n color: rgb(255 255 255 / 20%);\n content: \"·\";\n}\n\n.atv-discussion-replies + .atv-discussion-time::before {\n margin: 0 7px;\n color: rgb(255 255 255 / 20%);\n content: \"·\";\n}\n\n/* ---------- Interactive states ---------- */\n\n/* Row hover → accent bar reveals, bg lifts */\n.atv-discussion-row:has(.atv-discussion-topic-link:focus-visible)::before,\n.atv-discussion-row:has(.atv-discussion-author:focus-visible)::before {\n opacity: 1;\n transform: scaleY(1);\n}\n\n.atv-discussion-row:has(.atv-discussion-topic-link:focus-visible)\n .atv-discussion-title,\n.atv-discussion-row:has(.atv-discussion-author:focus-visible)\n .atv-discussion-title {\n color: var(--atv-text-primary);\n}\n\n.atv-discussion-row:has(.atv-discussion-topic-link:focus-visible)\n .atv-discussion-arrow,\n.atv-discussion-row:has(.atv-discussion-author:focus-visible)\n .atv-discussion-arrow {\n opacity: 1;\n transform: translateX(0);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-discussion-row:hover .atv-discussion-title {\n color: var(--atv-text-primary);\n }\n\n .atv-discussion-row:hover .atv-discussion-arrow {\n opacity: 1;\n transform: translateX(0);\n }\n}\n\n/* ---------- Mobile (<=768px) ---------- */\n@media (width <= 768px) {\n .atv-discussion-board {\n gap: 6px;\n }\n\n .atv-discussion-row {\n padding: 14px 16px;\n gap: 5px;\n }\n\n .atv-discussion-copy {\n gap: 5px;\n }\n\n .atv-discussion-title-row {\n gap: 8px;\n }\n\n .atv-discussion-title {\n overflow-wrap: anywhere;\n }\n\n .atv-discussion-arrow {\n font-size: var(--atv-type-body-sm);\n line-height: 1.45;\n }\n\n .atv-discussion-meta {\n flex-wrap: wrap;\n row-gap: 2px;\n }\n}\n\n/* ---------- Reduced motion ---------- */\n@media (prefers-reduced-motion: reduce) {\n .atv-discussion-row::before,\n .atv-discussion-title,\n .atv-discussion-arrow,\n .atv-discussion-row {\n transition: none;\n }\n\n .atv-discussion-row::before {\n opacity: 0;\n transform: none;\n }\n\n .atv-discussion-row:hover::before,\n .atv-discussion-row:has(.atv-discussion-topic-link:focus-visible)::before,\n .atv-discussion-row:has(.atv-discussion-author:focus-visible)::before {\n opacity: 1;\n }\n\n .atv-discussion-arrow {\n opacity: 0;\n transform: none;\n }\n\n .atv-discussion-row:hover .atv-discussion-arrow {\n opacity: 1;\n }\n\n .atv-discussion-row:active {\n transform: none;\n }\n}\n/* ── Review content images (Douban image-container) ── */\n.atv-review-modal-body .image-container {\n margin: 24px 0;\n clear: both;\n}\n\n.atv-review-modal-body .image-container.image-float-left {\n max-width: 50%;\n margin: 8px 24px 8px 0;\n float: left;\n}\n\n.atv-review-modal-body .image-container.image-float-right {\n max-width: 50%;\n margin: 8px 0 8px 24px;\n float: right;\n}\n\n.atv-review-modal-body .image-wrapper {\n overflow: hidden;\n border-radius: var(--atv-radius-sm);\n line-height: 0;\n}\n\n.atv-review-modal-body .image-wrapper img {\n display: block;\n width: auto;\n max-width: 100%;\n height: auto;\n margin: 0 auto;\n}\n\n.atv-review-modal-body .image-caption-wrapper {\n margin-top: 8px;\n text-align: center;\n}\n\n.atv-review-modal-body .image-caption {\n display: inline-block;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-style: italic;\n font-weight: var(--atv-font-weight-regular);\n letter-spacing: 0.02em;\n line-height: 1.6;\n}\n\n/* ── Review content subject reference (Douban subject-container) ── */\n.atv-review-modal-body .subject-container {\n overflow: hidden;\n padding-left: 2px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-md);\n border-left: 2px solid var(--atv-accent);\n margin: 24px 0;\n background: linear-gradient(\n 105deg,\n var(--atv-accent-hover),\n color-mix(in srgb, var(--atv-bg-card) 94%, transparent) 42%\n );\n}\n\n.atv-review-modal-body .subject-wrapper > a,\n.atv-review-modal-body .subject-wrapper > a:link,\n.atv-review-modal-body .subject-wrapper > a:visited {\n display: grid;\n padding: 12px;\n border-bottom: 0;\n background: transparent;\n color: inherit;\n gap: 12px;\n grid-template-columns: 56px minmax(0, 1fr);\n text-decoration: none;\n transition: none;\n}\n\n.atv-review-modal-body .subject-wrapper > a:hover,\n.atv-review-modal-body .subject-wrapper > a:active {\n border-bottom: 0;\n background: var(--atv-bg-hover);\n color: inherit;\n}\n\n.atv-review-modal-body .subject-wrapper > a:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: -2px;\n}\n\n.atv-review-modal-body .subject-cover {\n overflow: hidden;\n width: 56px;\n height: 84px;\n border-radius: 5px;\n background: var(--atv-bg-tertiary);\n box-shadow: 0 8px 20px rgb(0 0 0 / 30%);\n}\n\n.atv-review-modal-body .subject-cover img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n.atv-review-modal-body .subject-info {\n display: flex;\n min-width: 0;\n flex-direction: column;\n justify-content: center;\n}\n\n.atv-review-modal-body .subject-title {\n display: flex;\n min-width: 0;\n flex-wrap: wrap;\n align-items: baseline;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n gap: 4px;\n letter-spacing: 0.01em;\n line-height: 1.35;\n}\n\n.atv-review-modal-body .title-tail {\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-medium);\n}\n\n.atv-review-modal-body .subject-rating {\n display: flex;\n align-items: center;\n margin-top: 7px;\n gap: 1px;\n line-height: 1;\n}\n\n.atv-review-modal-body .rating-star1::before,\n.atv-review-modal-body .rating-star2::before,\n.atv-review-modal-body .rating-star0::before {\n content: \"★\";\n font-size: var(--atv-type-caption);\n}\n\n.atv-review-modal-body .rating-star1::before {\n color: var(--atv-rating-gold);\n}\n\n.atv-review-modal-body .rating-star2::before {\n background: linear-gradient(\n 90deg,\n var(--atv-rating-gold) 50%,\n var(--atv-rating-empty) 50%\n );\n -webkit-background-clip: text;\n background-clip: text;\n color: transparent;\n}\n\n.atv-review-modal-body .rating-star0::before {\n color: var(--atv-rating-empty);\n}\n\n.atv-review-modal-body .rating-score {\n margin-left: 6px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-variant-numeric: tabular-nums;\n font-weight: var(--atv-font-weight-semibold);\n}\n\n.atv-review-modal-body .subject-summary {\n margin-top: 7px;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption-sm);\n line-height: 1.55;\n}\n\n.atv-review-modal-body .subject-caption-wrapper {\n padding: 0 12px 12px 80px;\n}\n\n.atv-review-modal-body .subject-caption {\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-caption-sm);\n font-style: italic;\n line-height: 1.55;\n}\n\n/* ── Review content links (Douban a.link / generic) ── */\n.atv-review-modal-body a:link,\n.atv-review-modal-body a:visited {\n border-bottom: 1px solid var(--atv-accent-border);\n background: transparent;\n color: var(--atv-accent);\n overflow-wrap: break-word;\n text-decoration: none;\n transition:\n border-color var(--atv-duration-feedback) ease,\n color var(--atv-duration-feedback) ease;\n}\n\n.atv-review-modal-body a:hover,\n.atv-review-modal-body a:active {\n border-bottom-color: var(--atv-accent-bright);\n background: transparent;\n color: var(--atv-accent-bright);\n}\n\n/* ============================================\n 7. Review Content Typography — All Elements\n Target ALL HTML tags that may appear inside\n Douban review rich-text content.\n ============================================ */\n\n/* --- Headings --- */\n.atv-review-modal-body h3 {\n margin: 28px 0 12px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title-md);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.4;\n}\n\n.atv-review-modal-body h4 {\n margin: 24px 0 10px;\n color: var(--atv-text-primary);\n font-size: var(--atv-type-title);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.4;\n}\n\n.atv-review-modal-body h5,\n.atv-review-modal-body h6 {\n margin: 20px 0 8px;\n color: var(--atv-text-secondary);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-semibold);\n line-height: 1.4;\n}\n\n/* --- Horizontal Rule --- */\n.atv-review-modal-body hr {\n height: 0;\n border: none;\n border-top: 1px solid var(--atv-border-subtle);\n margin: 28px 0;\n}\n\n/* --- Lists --- */\n.atv-review-modal-body ul,\n.atv-review-modal-body ol {\n padding-left: 24px;\n margin: 0 0 16px;\n line-height: 1.7;\n}\n\n.atv-review-modal-body ul {\n list-style: disc;\n}\n\n.atv-review-modal-body ol {\n list-style: decimal;\n}\n\n.atv-review-modal-body li {\n margin-bottom: 6px;\n line-height: 1.7;\n}\n\n.atv-review-modal-body li:last-child {\n margin-bottom: 0;\n}\n\n/* --- Inline Text Semantics --- */\n.atv-review-modal-body strong,\n.atv-review-modal-body b {\n color: var(--atv-text-primary);\n font-weight: var(--atv-font-weight-bold);\n}\n\n.atv-review-modal-body em,\n.atv-review-modal-body i {\n font-style: italic;\n}\n\n.atv-review-modal-body small {\n color: var(--atv-text-tertiary);\n font-size: 0.85em;\n}\n\n.atv-review-modal-body q {\n font-style: italic;\n}\n\n.atv-review-modal-body q::before {\n content: \"\\201C\";\n}\n\n.atv-review-modal-body q::after {\n content: \"\\201D\";\n}\n\n.atv-review-modal-body u {\n text-decoration: underline;\n text-decoration-thickness: 1px;\n text-underline-offset: 2px;\n}\n\n.atv-review-modal-body s,\n.atv-review-modal-body del {\n color: var(--atv-text-tertiary);\n text-decoration: line-through;\n}\n\n.atv-review-modal-body sup {\n font-size: 0.75em;\n line-height: 1;\n vertical-align: super;\n}\n\n.atv-review-modal-body sub {\n font-size: 0.75em;\n line-height: 1;\n vertical-align: sub;\n}\n\n/* --- Code --- */\n.atv-review-modal-body code {\n padding: 2px 6px;\n border-radius: 4px;\n background: var(--atv-bg-elevated);\n font-family: var(--atv-font-mono);\n font-size: 0.9em;\n overflow-wrap: break-word;\n}\n\n.atv-review-modal-body pre {\n padding: 16px 20px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: var(--atv-radius-sm);\n margin: 0 0 20px;\n background: var(--atv-bg-card);\n line-height: 1.6;\n -webkit-overflow-scrolling: touch;\n overflow-x: auto;\n}\n\n.atv-review-modal-body pre code {\n padding: 0;\n background: none;\n font-size: var(--atv-type-body-sm);\n word-break: normal;\n}\n\n/* --- Tables --- */\n.atv-review-modal-body table {\n width: 100%;\n margin: 20px 0;\n border-collapse: collapse;\n line-height: 1.6;\n}\n\n.atv-review-modal-body thead {\n border-bottom: 2px solid var(--atv-border-medium);\n}\n\n.atv-review-modal-body th {\n padding: 10px 14px;\n color: var(--atv-text-primary);\n font-weight: var(--atv-font-weight-semibold);\n text-align: left;\n white-space: nowrap;\n}\n\n.atv-review-modal-body td {\n padding: 10px 14px;\n border-bottom: 1px solid var(--atv-border-subtle);\n color: var(--atv-text-emphasis);\n}\n\n.atv-review-modal-body tbody tr:last-child td {\n border-bottom: none;\n}\n\n.atv-review-modal-body .review-content,\n.atv-review-modal-body .review-content p,\n.atv-review-modal-body .review-content div,\n.atv-review-modal-body .review-content span {\n color: var(--atv-text-emphasis);\n}\n\n.atv-review-modal-body .spoiler-tip {\n margin-bottom: 12px;\n color: var(--atv-content-caution);\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n letter-spacing: 0.03em;\n}\n\n.atv-review-modal-body .main-hd {\n display: flex;\n align-items: center;\n margin-bottom: 16px;\n gap: 10px;\n}\n\n.atv-review-modal-body .main-hd a.name {\n color: var(--atv-accent);\n font-size: var(--atv-type-body-sm);\n font-weight: var(--atv-font-weight-medium);\n text-decoration: none;\n}\n\n.atv-review-modal-body .main-hd a.name:hover {\n text-decoration: underline;\n}\n\n.atv-review-modal-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding-top: 20px;\n border-top: 1px solid var(--atv-border-subtle);\n margin-top: 28px;\n}\n\n.atv-review-modal-votes {\n display: flex;\n gap: 14px;\n}\n\n.atv-review-modal-link {\n display: flex;\n align-items: center;\n}\n\n#atv-douban-root .atv-review-modal-link-a {\n background: transparent;\n color: var(--atv-text-tertiary);\n font-size: var(--atv-type-caption);\n text-decoration: none;\n transition: color var(--atv-duration-feedback) ease;\n}\n\n#atv-douban-root .atv-review-modal-link-a:link,\n#atv-douban-root .atv-review-modal-link-a:visited {\n background: transparent;\n color: var(--atv-text-tertiary);\n}\n\n#atv-douban-root .atv-review-modal-link-a:hover,\n#atv-douban-root .atv-review-modal-link-a:active {\n background: transparent;\n color: var(--atv-accent);\n}\n\n.atv-review-modal-stars {\n display: inline-flex;\n align-items: center;\n color: var(--atv-rating-gold);\n gap: 2px;\n}\n\n.atv-review-modal-stars svg {\n display: block;\n width: 14px;\n height: 14px;\n}\n\n.atv-review-modal-body.is-loading {\n position: relative;\n min-height: 140px;\n color: transparent;\n}\n\n.atv-review-modal-body.is-loading::before,\n.atv-review-modal-body.is-loading::after {\n display: block;\n height: 14px;\n border-radius: 4px;\n background: var(--atv-bg-elevated);\n content: \"\";\n}\n\n.atv-review-modal-body.is-loading::before {\n width: 92%;\n margin-bottom: 14px;\n}\n\n.atv-review-modal-body.is-loading::after {\n width: 68%;\n}\n\n.atv-review-modal-body.is-error {\n display: flex;\n min-height: 140px;\n align-items: center;\n justify-content: center;\n color: var(--atv-text-tertiary);\n text-align: center;\n}\n\n.atv-review-modal-error {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 14px;\n}\n\n.atv-review-modal-error p {\n margin: 0;\n}\n\n.atv-review-modal-retry {\n display: inline-flex;\n min-height: 34px;\n align-items: center;\n justify-content: center;\n padding: 0 18px;\n border: 1px solid var(--atv-border-subtle);\n border-radius: 999px;\n appearance: none;\n background: var(--atv-bg-elevated);\n color: var(--atv-text-secondary);\n cursor: pointer;\n font: inherit;\n font-size: var(--atv-type-caption);\n font-weight: var(--atv-font-weight-semibold);\n transition:\n color var(--atv-duration-feedback) ease,\n border-color var(--atv-duration-feedback) ease,\n background var(--atv-duration-feedback) ease;\n}\n\n.atv-review-modal-retry:hover,\n.atv-review-modal-retry:focus-visible {\n border-color: var(--atv-accent-border);\n background: var(--atv-accent-hover);\n color: var(--atv-accent);\n}\n\n.atv-review-modal-retry:focus-visible,\n.atv-review-modal .atv-modal-close:focus-visible,\n#atv-douban-root .atv-review-modal-link-a:focus-visible {\n outline: 2px solid var(--atv-accent);\n outline-offset: 3px;\n}\n/* ---------- Responsive ---------- */\n@media (width <= 1024px) {\n .atv-hero {\n min-height: 64vh;\n padding: 104px 24px 48px;\n }\n\n .atv-hero-inner {\n flex-direction: column;\n align-items: flex-start;\n gap: 28px;\n }\n\n .atv-poster-card {\n width: 220px;\n }\n\n .atv-section {\n padding: 44px 24px;\n }\n\n /* Tablet two-column info grid — bounded to 769–1024px so the phone\n single-column rule in details.css stays the sole source below 768px\n (responsive.css is imported after details.css and would otherwise win). */\n @media (width > 768px) and (width <= 1024px) {\n .atv-info-grid {\n column-gap: 24px;\n grid-template-columns: 160px 1fr;\n }\n }\n}\n\n@media (width <= 768px) {\n .atv-hero {\n min-height: 56vh;\n padding: 88px 20px 40px;\n }\n\n .atv-poster-card {\n width: 180px;\n }\n\n .atv-section {\n padding: 36px 20px;\n }\n\n .atv-recs {\n gap: 18px;\n grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n }\n\n .atv-cast-card {\n flex-basis: 120px;\n }\n\n .atv-cast-avatar {\n width: 120px;\n height: 120px;\n }\n\n .atv-series-card {\n flex-basis: 120px;\n }\n\n .atv-photo-tile:not(.atv-trailer-tile),\n .atv-photo-rail-reserve {\n height: 157.5px;\n }\n\n .atv-rating-panel .atv-rating-panel-score {\n font-size: 32px;\n }\n\n .atv-rating-panel-douban,\n .atv-rating-panel-imdb,\n .atv-rating-panel-rt,\n .atv-rating-panel-mc {\n padding: 12px 14px 10px;\n }\n\n .atv-rating-panel {\n min-width: 0;\n }\n\n .atv-comments {\n grid-template-columns: 1fr;\n }\n\n .atv-comment-overlay-inner {\n max-width: 95vw;\n border-radius: 16px;\n }\n\n .atv-comment-overlay-top {\n padding: 24px 20px 0;\n }\n\n .atv-comment-overlay-body {\n padding: 16px 20px;\n font-size: var(--atv-type-body-sm);\n }\n\n .atv-comment-overlay-foot {\n padding: 0 20px 24px;\n }\n\n .atv-review-modal-scroll {\n max-width: 100vw;\n max-height: 92vh;\n padding: 24px 18px 22px;\n border-radius: 20px 20px 0 0;\n }\n\n .atv-review-modal {\n align-items: flex-end;\n padding: 0;\n overscroll-behavior: contain;\n }\n\n .atv-review-modal .atv-modal-close {\n top: 14px;\n right: 14px;\n }\n\n .atv-review-modal-body blockquote {\n padding: 12px 14px;\n margin: 16px 0;\n }\n\n .atv-review-modal-body blockquote blockquote {\n padding: 10px 12px;\n }\n\n .atv-review-modal-body .image-container {\n margin: 16px 0;\n }\n\n .atv-review-modal-body .image-container.image-float-left,\n .atv-review-modal-body .image-container.image-float-right {\n max-width: 100%;\n margin: 16px 0;\n float: none;\n }\n\n .atv-review-modal-body .subject-wrapper > a {\n padding: 10px;\n gap: 10px;\n grid-template-columns: 44px minmax(0, 1fr);\n }\n\n .atv-review-modal-body .subject-cover {\n width: 44px;\n height: 66px;\n }\n\n .atv-review-modal-body .subject-title {\n font-size: var(--atv-type-body);\n }\n\n .atv-review-modal-body .subject-caption-wrapper {\n padding: 0 10px 10px 66px;\n }\n\n /* Review content typography — responsive */\n .atv-review-modal-body h3 {\n margin-top: 22px;\n }\n\n .atv-review-modal-body h4 {\n margin-top: 18px;\n }\n\n .atv-review-modal-body hr {\n margin: 22px 0;\n }\n\n .atv-review-modal-body pre {\n padding: 12px 16px;\n }\n\n .atv-review-modal-body th,\n .atv-review-modal-body td {\n padding: 8px 10px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-section {\n animation: none;\n }\n\n .atv-trailer-tile,\n .atv-trailer-tile:hover {\n filter: none;\n transform: none;\n }\n\n .atv-trailer-play-overlay {\n transition: none;\n }\n\n .atv-trailer-play-btn {\n transition: none;\n }\n\n .atv-trailer-tile:hover .atv-trailer-play-btn {\n box-shadow: none;\n transform: none;\n }\n\n .atv-modal-overlay.is-open .atv-modal-video {\n transform: none;\n }\n\n .atv-review-card,\n .atv-review-card:hover,\n .atv-review-card:active {\n box-shadow: none;\n transform: none;\n transition: none;\n }\n\n .atv-review-modal-scroll {\n transform: none;\n transition: none;\n }\n\n .atv-review-modal.is-open .atv-review-modal-scroll {\n transform: none;\n }\n\n .atv-review-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n transition: none;\n }\n\n /* Modal overlay fades */\n .atv-modal-overlay,\n .atv-comment-overlay {\n transition: none;\n }\n\n /* Accent edge glow */\n .atv-modal-accent-bar {\n transform: scaleX(1);\n transition: none;\n }\n\n .atv-review-modal-scroll::before {\n opacity: 1;\n }\n}\n\n@media (width <= 768px) {\n .atv-trailer-tile {\n height: 157.5px;\n flex-basis: 280px;\n }\n}\n/* Cross-experience motion policy: pointer capability and motion preferences. */\n\n@media (hover: hover) and (pointer: fine) {\n .atv-btn-primary:hover {\n background: var(--atv-accent-bright);\n transform: translateY(-1px);\n }\n\n .atv-btn-secondary:hover {\n background: var(--atv-bg-hover-strong);\n transform: translateY(-1px);\n }\n\n .atv-comment-card:hover {\n background: var(--atv-bg-hover);\n }\n\n .atv-review-card:hover {\n background: var(--atv-bg-hover);\n }\n\n .atv-review-card:hover .atv-review-readmore {\n opacity: 1;\n transform: translateX(0);\n }\n\n .atv-stream-card:not(.atv-stream-card-combined):hover::after {\n opacity: 0.18;\n }\n\n .atv-stream-card:hover {\n border-color: var(--atv-border-strong);\n background: var(--atv-bg-hover-strong);\n }\n\n .atv-discussion-row:hover {\n background: var(--atv-bg-hover);\n }\n\n .atv-discussion-row:hover::before {\n opacity: 1;\n transform: scaleY(1);\n }\n\n .atv-discussion-row:hover .atv-discussion-arrow {\n opacity: 1;\n transform: translateX(0);\n }\n}\n\n.atv-hero-teaser-content {\n display: block;\n}\n\n/* Shared affordance for every control that opens the image-preview modal. */\n.atv-image-preview-trigger {\n box-shadow: 0 16px 40px rgb(0 0 0 / 46%);\n transition:\n transform var(--atv-duration-hover) var(--atv-ease-out),\n box-shadow var(--atv-duration-hover) var(--atv-ease-out);\n will-change: transform;\n}\n\n.atv-image-preview-trigger:focus-visible {\n outline: 2px solid var(--atv-accent-bright);\n outline-offset: 4px;\n}\n\n@media (hover: hover) and (pointer: fine) {\n .atv-image-preview-trigger:hover {\n box-shadow: 0 24px 64px rgb(0 0 0 / 56%);\n transform: translateY(-3px);\n }\n}\n\n.atv-image-preview-trigger:active {\n transform: scale(0.97);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .atv-image-preview-trigger {\n transition: none;\n will-change: auto;\n }\n\n .atv-carousel {\n scroll-behavior: auto;\n }\n\n .atv-spinner {\n animation: none;\n }\n\n .atv-modal-accent-bar {\n opacity: 1;\n }\n\n .atv-review-modal-scroll::before {\n opacity: 1;\n }\n\n .atv-btn-primary:hover,\n .atv-btn-secondary:hover,\n .atv-btn:active,\n .atv-modal-close:active,\n .atv-image-preview-trigger:hover,\n .atv-image-preview-trigger:active,\n .atv-comment-card:hover,\n .atv-comment-votes:active,\n .atv-comment-overlay-votes:active,\n .atv-comment-expand:active,\n .atv-review-card:hover,\n .atv-review-card:hover .atv-review-readmore,\n .atv-review-card:focus-visible .atv-review-readmore,\n .atv-review-card:has(.atv-review-open-button:focus-visible)\n .atv-review-readmore,\n .atv-vote-btn:active,\n .atv-stream-card:hover,\n .atv-discussion-row:hover::before,\n .atv-discussion-row:has(.atv-discussion-topic-link:focus-visible)::before,\n .atv-discussion-row:has(.atv-discussion-author:focus-visible)::before,\n .atv-discussion-row:active,\n .atv-series-card:hover,\n .atv-cast-card:hover,\n .atv-rec-card:hover .atv-rec-poster,\n .atv-trailer-tile:hover,\n .atv-hero-more.is-open svg {\n transform: none;\n }\n\n .atv-photo-tile {\n animation: none;\n opacity: 1;\n transform: none;\n }\n}\n\n/* ---------- Accessibility: reduced transparency ---------- */\n@media (prefers-reduced-transparency: reduce) {\n .atv-stickynav.is-visible:not(.is-scrolling) {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-bg-primary);\n }\n\n .atv-modal-overlay,\n .atv-comment-overlay,\n .atv-review-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n\n .atv-btn-secondary {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n }\n\n .atv-chip {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n }\n\n .atv-series-badge {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay);\n }\n\n .atv-trailer-label {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay);\n }\n\n .atv-comment-expand {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n }\n}\n\n/* ---------- Accessibility: high contrast ---------- */\n@media (prefers-contrast: more) {\n .atv-modal-overlay,\n .atv-comment-overlay,\n .atv-review-modal {\n -webkit-backdrop-filter: none;\n backdrop-filter: none;\n background: var(--atv-scrim-overlay-solid);\n }\n\n .atv-comment-card,\n .atv-review-card {\n background: var(--atv-bg-card);\n }\n\n .atv-review-card,\n .atv-stream-card,\n .atv-chip,\n .atv-comment-votes {\n border-color: var(--atv-border-strong);\n }\n\n .atv-discussion-row {\n border: 1px solid var(--atv-border-strong);\n border-radius: var(--atv-radius-sm);\n background: var(--atv-bg-card);\n }\n\n .atv-chip {\n color: var(--atv-text-primary);\n }\n\n .atv-comment-expand {\n border-color: var(--atv-border-strong);\n background: rgb(0 0 0 / 50%);\n }\n\n .atv-btn-secondary {\n border-color: var(--atv-border-emphasis);\n background: var(--atv-bg-hover-strong);\n }\n\n .atv-series-badge {\n background: rgb(0 0 0 / 85%);\n }\n\n .atv-trailer-label {\n background: rgb(0 0 0 / 85%);\n }\n}\n\n/* ---------- Card press states ---------- */\n.atv-poster-card:active {\n transform: scale(0.97);\n}\n\n.atv-stream-card:active {\n transform: scale(0.97);\n}\n\n.atv-photo-tile:active {\n transform: scale(0.97);\n}\n\n.atv-cast-card:active {\n transform: scale(0.97);\n}\n\n.atv-series-card:active {\n transform: scale(0.97);\n}\n\n.atv-comment-card:active {\n transform: scale(0.97);\n}\n\n.atv-discussion-row:active {\n transform: scale(0.97);\n}\n"; var n; var l$1; var u$2; var i$2; var r$1; var o$1; var e$1; var f$2; var c$1; var a$1; var s$1; var h$1; var p$1; var v$1; var y$1; var d$1 = {}; var w$1 = []; var _$1 = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; var g$1 = Array.isArray; function m$1(n, l) { for (var u in l) n[u] = l[u]; return n; } function b(n) { n && n.parentNode && n.parentNode.removeChild(n); } function k$1(l, u, t) { var i, r, o, e = {}; for (o in u) "key" == o ? i = u[o] : "ref" == o ? r = u[o] : e[o] = u[o]; if (arguments.length > 2 && (e.children = arguments.length > 3 ? n.call(arguments, 2) : t), "function" == typeof l && null != l.defaultProps) for (o in l.defaultProps) void 0 === e[o] && (e[o] = l.defaultProps[o]); return x$1(l, e, i, r, null); } function x$1(n, t, i, r, o) { var e = { type: n, props: t, key: i, ref: r, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: null == o ? ++u$2 : o, __i: -1, __u: 0 }; return null == o && null != l$1.vnode && l$1.vnode(e), e; } function S(n) { return n.children; } function C$1(n, l) { this.props = n, this.context = l; } function $$1(n, l) { if (null == l) return n.__ ? $$1(n.__, n.__i + 1) : null; for (var u; l < n.__k.length; l++) if (null != (u = n.__k[l]) && null != u.__e) return u.__e; return "function" == typeof n.type ? $$1(n) : null; } function I(n) { if (n.__P && n.__d) { var u = n.__v, t = u.__e, i = [], r = [], o = m$1({}, u); o.__v = u.__v + 1, l$1.vnode && l$1.vnode(o), q$1(n.__P, o, u, n.__n, n.__P.namespaceURI, 32 & u.__u ? [t] : null, i, null == t ? $$1(u) : t, !!(32 & u.__u), r), o.__v = u.__v, o.__.__k[o.__i] = o, D$1(i, o, r), u.__e = u.__ = null, o.__e != t && P(o); } } function P(n) { if (null != (n = n.__) && null != n.__c) return n.__e = n.__c.base = null, n.__k.some(function(l) { if (null != l && null != l.__e) return n.__e = n.__c.base = l.__e; }), P(n); } function A$1(n) { (!n.__d && (n.__d = true) && i$2.push(n) && !H.__r++ || r$1 != l$1.debounceRendering) && ((r$1 = l$1.debounceRendering) || o$1)(H); } function H() { try { for (var n, l = 1; i$2.length;) i$2.length > l && i$2.sort(e$1), n = i$2.shift(), l = i$2.length, I(n); } finally { i$2.length = H.__r = 0; } } function L(n, l, u, t, i, r, o, e, f, c, a) { var s, h, p, v, y, _, g, m = t && t.__k || w$1, b = l.length; for (f = T$1(u, l, m, f, b), s = 0; s < b; s++) null != (p = u.__k[s]) && (h = -1 != p.__i && m[p.__i] || d$1, p.__i = s, _ = q$1(n, p, h, i, r, o, e, f, c, a), v = p.__e, p.ref && h.ref != p.ref && (h.ref && J(h.ref, null, p), a.push(p.ref, p.__c || v, p)), null == y && null != v && (y = v), (g = !!(4 & p.__u)) || h.__k === p.__k ? (f = j$1(p, f, n, g), g && h.__e && (h.__e = null)) : "function" == typeof p.type && void 0 !== _ ? f = _ : v && (f = v.nextSibling), p.__u &= -7); return u.__e = y, f; } function T$1(n, l, u, t, i) { var r, o, e, f, c, a = u.length, s = a, h = 0; for (n.__k = new Array(i), r = 0; r < i; r++) null != (o = l[r]) && "boolean" != typeof o && "function" != typeof o ? ("string" == typeof o || "number" == typeof o || "bigint" == typeof o || o.constructor == String ? o = n.__k[r] = x$1(null, o, null, null, null) : g$1(o) ? o = n.__k[r] = x$1(S, { children: o }, null, null, null) : void 0 === o.constructor && o.__b > 0 ? o = n.__k[r] = x$1(o.type, o.props, o.key, o.ref ? o.ref : null, o.__v) : n.__k[r] = o, f = r + h, o.__ = n, o.__b = n.__b + 1, e = null, -1 != (c = o.__i = O(o, u, f, s)) && (s--, (e = u[c]) && (e.__u |= 2)), null == e || null == e.__v ? (-1 == c && (i > a ? h-- : i < a && h++), "function" != typeof o.type && (o.__u |= 4)) : c != f && (c == f - 1 ? h-- : c == f + 1 ? h++ : (c > f ? h-- : h++, o.__u |= 4))) : n.__k[r] = null; if (s) for (r = 0; r < a; r++) null != (e = u[r]) && 0 == (2 & e.__u) && (e.__e == t && (t = $$1(e)), K(e, e)); return t; } function j$1(n, l, u, t) { var i, r; if ("function" == typeof n.type) { for (i = n.__k, r = 0; i && r < i.length; r++) i[r] && (i[r].__ = n, l = j$1(i[r], l, u, t)); return l; } n.__e != l && (t && (l && n.type && !l.parentNode && (l = $$1(n)), u.insertBefore(n.__e, l || null)), l = n.__e); do l = l && l.nextSibling; while (null != l && 8 == l.nodeType); return l; } function O(n, l, u, t) { var i, r, o, e = n.key, f = n.type, c = l[u], a = null != c && 0 == (2 & c.__u); if (null === c && null == e || a && e == c.key && f == c.type) return u; if (t > (a ? 1 : 0)) { for (i = u - 1, r = u + 1; i >= 0 || r < l.length;) if (null != (c = l[o = i >= 0 ? i-- : r++]) && 0 == (2 & c.__u) && e == c.key && f == c.type) return o; } return -1; } function z$1(n, l, u) { "-" == l[0] ? n.setProperty(l, null == u ? "" : u) : n[l] = null == u ? "" : "number" != typeof u || _$1.test(l) ? u : u + "px"; } function N(n, l, u, t, i) { var r, o; n: if ("style" == l) if ("string" == typeof u) n.style.cssText = u; else { if ("string" == typeof t && (n.style.cssText = t = ""), t) for (l in t) u && l in u || z$1(n.style, l, ""); if (u) for (l in u) t && u[l] == t[l] || z$1(n.style, l, u[l]); } else if ("o" == l[0] && "n" == l[1]) r = l != (l = l.replace(s$1, "$1")), o = l.toLowerCase(), l = o in n || "onFocusOut" == l || "onFocusIn" == l ? o.slice(2) : l.slice(2), n.l || (n.l = {}), n.l[l + r] = u, u ? t ? u[a$1] = t[a$1] : (u[a$1] = h$1, n.addEventListener(l, r ? v$1 : p$1, r)) : n.removeEventListener(l, r ? v$1 : p$1, r); else { if ("http://www.w3.org/2000/svg" == i) l = l.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s"); else if ("width" != l && "height" != l && "href" != l && "list" != l && "form" != l && "tabIndex" != l && "download" != l && "rowSpan" != l && "colSpan" != l && "role" != l && "popover" != l && l in n) try { n[l] = null == u ? "" : u; break n; } catch (n) {} "function" == typeof u || (null == u || false === u && "-" != l[4] ? n.removeAttribute(l) : n.setAttribute(l, "popover" == l && 1 == u ? "" : u)); } } function V(n) { return function(u) { if (this.l) { var t = this.l[u.type + n]; if (null == u[c$1]) u[c$1] = h$1++; else if (u[c$1] < t[a$1]) return; return t(l$1.event ? l$1.event(u) : u); } }; } function q$1(n, u, t, i, r, o, e, f, c, a) { var s, h, p, v, y, d, _, k, x, M, $, I, P, A, H, T, j = u.type; if (void 0 !== u.constructor) return null; 128 & t.__u && (c = !!(32 & t.__u), o = [f = u.__e = t.__e]), (s = l$1.__b) && s(u); n: if ("function" == typeof j) { h = e.length; try { if (x = u.props, M = j.prototype && j.prototype.render, $ = (s = j.contextType) && i[s.__c], I = s ? $ ? $.props.value : s.__ : i, t.__c ? k = (p = u.__c = t.__c).__ = p.__E : (M ? u.__c = p = new j(x, I) : (u.__c = p = new C$1(x, I), p.constructor = j, p.render = Q), $ && $.sub(p), p.state || (p.state = {}), p.__n = i, v = p.__d = !0, p.__h = [], p._sb = []), M && null == p.__s && (p.__s = p.state), M && null != j.getDerivedStateFromProps && (p.__s == p.state && (p.__s = m$1({}, p.__s)), m$1(p.__s, j.getDerivedStateFromProps(x, p.__s))), y = p.props, d = p.state, p.__v = u, v) M && null == j.getDerivedStateFromProps && null != p.componentWillMount && p.componentWillMount(), M && null != p.componentDidMount && p.__h.push(p.componentDidMount); else { if (M && null == j.getDerivedStateFromProps && x !== y && null != p.componentWillReceiveProps && p.componentWillReceiveProps(x, I), u.__v == t.__v || !p.__e && null != p.shouldComponentUpdate && !1 === p.shouldComponentUpdate(x, p.__s, I)) { u.__v != t.__v && (p.props = x, p.state = p.__s, p.__d = !1), u.__e = t.__e, u.__k = t.__k, u.__k.some(function(n) { n && (n.__ = u); }), w$1.push.apply(p.__h, p._sb), p._sb = [], p.__h.length && e.push(p); break n; } null != p.componentWillUpdate && p.componentWillUpdate(x, p.__s, I), M && null != p.componentDidUpdate && p.__h.push(function() { p.componentDidUpdate(y, d, _); }); } if (p.context = I, p.props = x, p.__P = n, p.__e = !1, P = l$1.__r, A = 0, M) p.state = p.__s, p.__d = !1, P && P(u), s = p.render(p.props, p.state, p.context), w$1.push.apply(p.__h, p._sb), p._sb = []; else do p.__d = !1, P && P(u), s = p.render(p.props, p.state, p.context), p.state = p.__s; while (p.__d && ++A < 25); p.state = p.__s, null != p.getChildContext && (i = m$1(m$1({}, i), p.getChildContext())), M && !v && null != p.getSnapshotBeforeUpdate && (_ = p.getSnapshotBeforeUpdate(y, d)), H = null != s && s.type === S && null == s.key ? E(s.props.children) : s, f = L(n, g$1(H) ? H : [H], u, t, i, r, o, e, f, c, a), p.base = u.__e, u.__u &= -161, p.__h.length && e.push(p), k && (p.__E = p.__ = null); } catch (n) { if (e.length = h, u.__v = null, c || null != o) { if (n.then) { for (u.__u |= c ? 160 : 128; f && 8 == f.nodeType && f.nextSibling;) f = f.nextSibling; null != o && (o[o.indexOf(f)] = null), u.__e = f; } else if (null != o) for (T = o.length; T--;) b(o[T]); } else u.__e = t.__e; u.__k ??= t.__k || [], n.then || B$1(u), l$1.__e(n, u, t); } } else null == o && u.__v == t.__v ? (u.__k = t.__k, u.__e = t.__e) : f = u.__e = G(t.__e, u, t, i, r, o, e, c, a); return (s = l$1.diffed) && s(u), 128 & u.__u ? void 0 : f; } function B$1(n) { n && (n.__c && (n.__c.__e = true), n.__k && n.__k.some(B$1)); } function D$1(n, u, t) { for (var i = 0; i < t.length; i++) J(t[i], t[++i], t[++i]); l$1.__c && l$1.__c(u, n), n.some(function(u) { try { n = u.__h, u.__h = [], n.some(function(n) { n.call(u); }); } catch (n) { l$1.__e(n, u.__v); } }); } function E(n) { return "object" != typeof n || null == n || n.__b > 0 ? n : g$1(n) ? n.map(E) : void 0 !== n.constructor ? null : m$1({}, n); } function G(u, t, i, r, o, e, f, c, a) { var s, h, p, v, y, w, _, m = i.props || d$1, k = t.props, x = t.type; if ("svg" == x ? o = "http://www.w3.org/2000/svg" : "math" == x ? o = "http://www.w3.org/1998/Math/MathML" : o || (o = "http://www.w3.org/1999/xhtml"), null != e) { for (s = 0; s < e.length; s++) if ((y = e[s]) && "setAttribute" in y == !!x && (x ? y.localName == x : 3 == y.nodeType)) { u = y, e[s] = null; break; } } if (null == u) { if (null == x) return document.createTextNode(k); u = document.createElementNS(o, x, k.is && k), c && (l$1.__m && l$1.__m(t, e), c = false), e = null; } if (null == x) m === k || c && u.data == k || (u.data = k); else { if (e = "textarea" == x && null != k.defaultValue ? null : e && n.call(u.childNodes), !c && null != e) for (m = {}, s = 0; s < u.attributes.length; s++) m[(y = u.attributes[s]).name] = y.value; for (s in m) y = m[s], "dangerouslySetInnerHTML" == s ? p = y : "children" == s || s in k || "value" == s && "defaultValue" in k || "checked" == s && "defaultChecked" in k || N(u, s, null, y, o); for (s in k) y = k[s], "children" == s ? v = y : "dangerouslySetInnerHTML" == s ? h = y : "value" == s ? w = y : "checked" == s ? _ = y : c && "function" != typeof y || m[s] === y || N(u, s, y, m[s], o); if (h) c || p && (h.__html == p.__html || h.__html == u.innerHTML) || (u.innerHTML = h.__html), t.__k = []; else if (p && (u.innerHTML = ""), L("template" == t.type ? u.content : u, g$1(v) ? v : [v], t, i, r, "foreignObject" == x ? "http://www.w3.org/1999/xhtml" : o, e, f, e ? e[0] : i.__k && $$1(i, 0), c, a), null != e) for (s = e.length; s--;) b(e[s]); c && "textarea" != x || (s = "value", "progress" == x && null == w ? u.removeAttribute("value") : null != w && (w !== u[s] || "progress" == x && !w || "option" == x && w != m[s]) && N(u, s, w, m[s], o), s = "checked", null != _ && _ != u[s] && N(u, s, _, m[s], o)); } return u; } function J(n, u, t) { try { if ("function" == typeof n) { var i = "function" == typeof n.__u; i && n.__u(), i && null == u || (n.__u = n(u)); } else n.current = u; } catch (n) { l$1.__e(n, t); } } function K(n, u, t) { var i, r; if (l$1.unmount && l$1.unmount(n), (i = n.ref) && (i.current && i.current != n.__e || J(i, null, u)), null != (i = n.__c)) { if (i.componentWillUnmount) try { i.componentWillUnmount(); } catch (n) { l$1.__e(n, u); } i.base = i.__P = i.__n = null; } if (i = n.__k) for (r = 0; r < i.length; r++) i[r] && K(i[r], u, t || "function" != typeof n.type); t || b(n.__e), n.__c = n.__ = n.__e = void 0; } function Q(n, l, u) { return this.constructor(n, u); } function R(u, t, i) { var r, o, e, f; t == document && (t = document.documentElement), l$1.__ && l$1.__(u, t), o = (r = "function" == "undefined") ? null : t.__k, e = [], f = [], q$1(t, u = (t).__k = k$1(S, null, [u]), o || d$1, d$1, t.namespaceURI, o ? null : t.firstChild ? n.call(t.childNodes) : null, e, o ? o.__e : t.firstChild, r, f), D$1(e, u, f), u.props.children = null; } function X(n) { function l(n) { var u, t; return this.getChildContext || (u = /* @__PURE__ */ new Set(), (t = {})[l.__c] = this, this.getChildContext = function() { return t; }, this.componentWillUnmount = function() { u = null; }, this.shouldComponentUpdate = function(n) { this.props.value != n.value && u.forEach(function(n) { n.__e = true, A$1(n); }); }, this.sub = function(n) { u.add(n); var l = n.componentWillUnmount; n.componentWillUnmount = function() { u && u.delete(n), l && l.call(n); }; }), n.children; } return l.__c = "__cC" + y$1++, l.__ = n, l.Provider = l.__l = (l.Consumer = function(n, l) { return n.children(l); }).contextType = l, l; } n = w$1.slice, l$1 = { __e: function(n, l, u, t) { for (var i, r, o; l = l.__;) if ((i = l.__c) && !i.__) try { if ((r = i.constructor) && null != r.getDerivedStateFromError && (i.setState(r.getDerivedStateFromError(n)), o = i.__d), null != i.componentDidCatch && (i.componentDidCatch(n, t || {}), o = i.__d), o) return i.__E = i; } catch (l) { n = l; } throw n; } }, u$2 = 0, C$1.prototype.setState = function(n, l) { var u = null != this.__s && this.__s != this.state ? this.__s : this.__s = m$1({}, this.state); "function" == typeof n && (n = n(m$1({}, u), this.props)), n && m$1(u, n), null != n && this.__v && (l && this._sb.push(l), A$1(this)); }, C$1.prototype.forceUpdate = function(n) { this.__v && (this.__e = true, n && this.__h.push(n), A$1(this)); }, C$1.prototype.render = S, i$2 = [], o$1 = "function" == typeof Promise ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout, e$1 = function(n, l) { return n.__v.__b - l.__v.__b; }, H.__r = 0, f$2 = Math.random().toString(8), c$1 = "__d" + f$2, a$1 = "__a" + f$2, s$1 = /(PointerCapture)$|Capture$/i, h$1 = 0, p$1 = V(false), v$1 = V(true), y$1 = 0; var $ = (selector, ctx) => (ctx ?? document).querySelector(selector); var $$ = (selector, ctx) => [...(ctx ?? document).querySelectorAll(selector)]; var safeText = (el) => el ? (el.textContent ?? "").trim() : ""; var personageIdFromPath = (pathname) => pathname.match(/^\/personage\/(?\d+)\/?$/u)?.groups?.id ?? null; var imageUrl = (element) => { if (!element) return null; if (element.localName === "img") { const image = element; return image.currentSrc || image.src || null; } return (element.getAttribute("style") ?? "").match(/url\(["']?(?[^"')]+)["']?\)/u)?.groups?.url ?? null; }; var largePhotoUrl = (src) => { if (src.includes("/view/photo/photo/")) return src.replace("/view/photo/photo/", "/view/photo/l/"); return src; }; var extractFacts = (target) => $$(".subject-property li", target).flatMap((item) => { const label = safeText(item.querySelector(".label")); const value = safeText(item.querySelector(".value")); return label && value ? [{ label, value }] : []; }); var extractBiography = (doc) => { const biography = $$(".subject-intro p", doc).map(safeText).filter((paragraph) => paragraph && paragraph !== "暂无"); return biography.length > 0 ? biography : null; }; var extractCollaborators = (doc) => { const source = doc.querySelector(".partners-mod-mod"); if (!source) return null; const collaborators = $$(".partners-mod-item", source).flatMap((item, nativeIndex) => { const profileLink = item.querySelector(".partners-mod-info a[title][href]"); const sharedWorksLink = item.querySelector(".partners-mod-info a[href*=\"partners#\"]"); const name = safeText(profileLink); const sharedWorkCount = Number(safeText(sharedWorksLink)); if (!(profileLink && name && Number.isFinite(sharedWorkCount))) return []; return [{ avatar: imageUrl(item.querySelector("img")), href: profileLink.href, name, nativeIndex, sharedWorkCount, sharedWorksHref: sharedWorksLink?.href ?? null }]; }).toSorted((left, right) => right.sharedWorkCount - left.sharedWorkCount || left.nativeIndex - right.nativeIndex).map(({ nativeIndex: _nativeIndex, ...collaborator }) => collaborator); const allCollaboratorsHref = source.querySelector("h2 a[href]")?.href ?? null; const totalCountMatch = source.querySelector("h2 a[href]")?.textContent?.match(/\d+/u)?.[0]; return { allCollaboratorsHref, ...totalCountMatch ? { totalCount: Number(totalCountMatch) } : {}, collaborators }; }; var hrefOf = (element) => element?.localName === "a" ? element.href : null; var extractAwards$1 = (doc) => { const source = doc.querySelector(".subject-awards"); if (!source) return null; const awards = $$("li", source).flatMap((item) => { const [yearElement, ceremonyElement, awardElement, workElement] = [...item.children]; const year = safeText(yearElement); const ceremony = safeText(ceremonyElement); const award = safeText(awardElement); if (!(year && ceremony && award)) return []; return [{ award, ceremony, ceremonyHref: hrefOf(ceremonyElement), work: safeText(workElement) || null, workHref: hrefOf(workElement), year }]; }); const allAwardsLink = source.querySelector("h2 a[href]"); const allAwardsHref = allAwardsLink?.href ?? null; const awardsCountMatch = allAwardsLink?.textContent?.match(/\d+/u)?.[0]; return { allAwardsHref, ...awardsCountMatch ? { totalCount: Number(awardsCountMatch) } : {}, awards }; }; var extractGallery = (doc, personageId) => { const galleryPath = `/personage/${personageId}/photos`; const source = doc.querySelector(".subject-picture"); if (!source) return null; const allImagesLink = $$("a[href]", source).find((link) => new URL(link.getAttribute("href") ?? "", doc.baseURI).pathname.replace(/\/$/u, "") === galleryPath); const images = $$("li.picture", source).flatMap((image) => { const src = imageUrl(image); if (!src) return []; return [{ alt: image.getAttribute("aria-label")?.trim() ?? "", largeSrc: largePhotoUrl(src), src }]; }); return { allImagesHref: allImagesLink?.href ?? null, images }; }; var extractWorkRail = (doc, matchesHeading) => { const source = $$(".subject-creations", doc).find((section) => matchesHeading(safeText(section.querySelector("h2")))); if (!source) return null; let lastYear = null; const works = $$("li.creation", source).flatMap((item) => { const posterLink = item.querySelector("a.img_wrap[href]"); const title = posterLink?.title.trim() ?? ""; if (!posterLink || !title) return []; const yearElement = item.querySelector(".timeline-year") ?? item.querySelector(".pl"); const rawYear = yearElement ? safeText(yearElement).trim() : ""; const currentYear = /^\d{4}$/u.test(rawYear) ? rawYear : lastYear; if (/^\d{4}$/u.test(rawYear)) lastYear = rawYear; return [{ href: posterLink.href, poster: imageUrl(posterLink.querySelector("img")) ?? "", rating: safeText(item.querySelector(".rating-val")) || null, title, year: currentYear }]; }); const allWorksLink = $$("a[href]", source).find((link) => new URL(link.href).pathname.includes("/creations")); const allWorksHref = allWorksLink?.href; const totalCountMatch = allWorksLink?.textContent?.match(/\d+/u)?.[0]; return { allWorksHref: allWorksHref ?? null, ...totalCountMatch ? { totalCount: Number(totalCountMatch) } : {}, works }; }; var extractPersonageProfile = (doc) => { const target = doc.querySelector(".subject-target"); const name = safeText(target?.querySelector(".subject-name")); const id = personageIdFromPath(doc.defaultView?.location.pathname ?? ""); if (!target || !name || !id) return null; return { awards: extractAwards$1(doc), biography: extractBiography(doc), collaborators: extractCollaborators(doc), facts: extractFacts(target), gallery: extractGallery(doc, id), id, name, portrait: imageUrl(target.querySelector(".avatar img, .subject-avatar img") ?? target.querySelector(".avatar, .subject-avatar")), recentWorks: extractWorkRail(doc, (heading) => heading.includes("最近")), representativeWorks: extractWorkRail(doc, (heading) => heading.includes("收藏人数最多")) }; }; var activateEnhancedDocument = (doc) => { doc.body.classList.add("atv-enhanced"); }; var installEnhancedRoot = (doc, renderRoot) => { const root = doc.createElement("div"); root.id = "atv-douban-root"; try { renderRoot(root); doc.body.insertBefore(root, doc.body.firstChild); } catch (error) { root.remove(); console.warn("[ATV-Douban] 页面挂载失败:", error); return false; } activateEnhancedDocument(doc); return true; }; var t; var r; var u$1; var i$1; var o = 0; var f$1 = []; var c = l$1; var e = c.__b; var a = c.__r; var v = c.diffed; var l = c.__c; var m = c.unmount; var p = c.__; function s(n, t) { c.__h && c.__h(r, n, o || t), o = 0; var u = r.__H || (r.__H = { __: [], __h: [] }); return n >= u.__.length && u.__.push({}), u.__[n]; } function d(n) { return o = 1, y(D, n); } function y(n, u, i) { var o = s(t++, 2); if (o.t = n, !o.__c && (o.__ = [D(void 0, u), function(n) { var t = o.__N ? o.__N[0] : o.__[0], r = o.t(t, n); t !== r && (o.__N = [r, o.__[1]], o.__c.setState({})); }], o.__c = r, !r.__f)) { var f = function(n, t, r) { if (!o.__c.__H) return true; var u = false, i = o.__c.props !== n; if (o.__c.__H.__.some(function(n) { if (n.__N) { u = true; var t = n.__[0]; n.__ = n.__N, n.__N = void 0, t !== n.__[0] && (i = true); } }), c) { var f = c.call(this, n, t, r); return u ? f || i : f; } return !u || i; }; r.__f = true; var c = r.shouldComponentUpdate, e = r.componentWillUpdate; r.componentWillUpdate = function(n, t, r) { if (this.__e) { var u = c; c = void 0, f(n, t, r), c = u; } e && e.call(this, n, t, r); }, r.shouldComponentUpdate = f; } return o.__N || o.__; } function h(n, u) { var i = s(t++, 3); !c.__s && C(i.__H, u) && (i.__ = n, i.u = u, r.__H.__h.push(i)); } function _(n, u) { var i = s(t++, 4); !c.__s && C(i.__H, u) && (i.__ = n, i.u = u, r.__h.push(i)); } function A(n) { return o = 5, T(function() { return { current: n }; }, []); } function T(n, r) { var u = s(t++, 7); return C(u.__H, r) && (u.__ = n(), u.__H = r, u.__h = n), u.__; } function q(n, t) { return o = 8, T(function() { return n; }, t); } function x(n) { var u = r.context[n.__c], i = s(t++, 9); return i.c = n, u ? (i.__ ?? (i.__ = true, u.sub(r)), u.props.value) : n.__; } function g() { var n = s(t++, 11); if (!n.__) { for (var u = r.__v; null !== u && !u.__m && null !== u.__;) u = u.__; var i = u.__m || (u.__m = [0, 0]); n.__ = "P" + i[0] + "-" + i[1]++; } return n.__; } function j() { for (var n; n = f$1.shift();) { var t = n.__H; if (n.__P && t) try { t.__h.some(z), t.__h.some(B), t.__h = []; } catch (r) { t.__h = [], c.__e(r, n.__v); } } } c.__b = function(n) { r = null, e && e(n); }, c.__ = function(n, t) { n && t.__k && t.__k.__m && (n.__m = t.__k.__m), p && p(n, t); }, c.__r = function(n) { a && a(n), t = 0; var i = (r = n.__c).__H; i && (u$1 === r ? (i.__h = [], r.__h = [], i.__.some(function(n) { n.__N && (n.__ = n.__N), n.u = n.__N = void 0; })) : (i.__h.some(z), i.__h.some(B), i.__h = [], t = 0)), u$1 = r; }, c.diffed = function(n) { v && v(n); var t = n.__c; t && t.__H && (t.__H.__h.length && (1 !== f$1.push(t) && i$1 === c.requestAnimationFrame || ((i$1 = c.requestAnimationFrame) || w)(j)), t.__H.__.some(function(n) { n.u && (n.__H = n.u, n.u = void 0); })), u$1 = r = null; }, c.__c = function(n, t) { t.some(function(n) { try { n.__h.some(z), n.__h = n.__h.filter(function(n) { return !n.__ || B(n); }); } catch (r) { t.some(function(n) { n.__h && (n.__h = []); }), t = [], c.__e(r, n.__v); } }), l && l(n, t); }, c.unmount = function(n) { m && m(n); var t, r = n.__c; r && r.__H && (r.__H.__.some(function(n) { try { z(n); } catch (n) { t = n; } }), r.__H = void 0, t && c.__e(t, r.__v)); }; var k = "function" == typeof requestAnimationFrame; function w(n) { var t, r = function() { clearTimeout(u), k && cancelAnimationFrame(t), setTimeout(n); }, u = setTimeout(r, 35); k && (t = requestAnimationFrame(r)); } function z(n) { var t = r, u = n.__c; "function" == typeof u && (n.__c = void 0, u()), r = t; } function B(n) { var t = r; n.__c = n.__(), r = t; } function C(n, t) { return !n || n.length !== t.length || t.some(function(t, r) { return t !== n[r]; }); } function D(n, t) { return "function" == typeof t ? t(n) : t; } var f = 0; function u(e, t, n, o, i, u) { t || (t = {}); var a, c, p = t; if ("ref" in p) for (c in p = {}, t) "ref" == c ? a = t[c] : p[c] = t[c]; var l = { type: e, props: p, key: n, ref: a, __k: null, __: null, __b: 0, __e: null, __c: null, constructor: void 0, __v: --f, __i: -1, __u: 0, __source: i, __self: u }; if ("function" == typeof e && (a = e.defaultProps)) for (c in a) void 0 === p[c] && (p[c] = a[c]); return l$1.vnode && l$1.vnode(l), l; } /** * Computes the offset (from the container's scroll origin) and width of the * currently-active jump link so a single sliding marker can be positioned * over it. Pure and layout-only — easy to unit test without a real browser. */ var computeIndicatorMetrics = (activeEl, containerEl) => { const containerRect = containerEl.getBoundingClientRect(); const activeRect = activeEl.getBoundingClientRect(); return { left: activeRect.left - containerRect.left + containerEl.scrollLeft, width: activeRect.width }; }; var StickyNav = ({ activeSectionId = "", accessory, className = "", navRef, onJump, scrolling = false, sections, title, visible = false }) => { const markerRef = A(null); _(() => { if (!activeSectionId) return; const nav = navRef?.current; const marker = markerRef.current; if (!nav || !marker) return; const container = nav.querySelector(".atv-stickynav-jumps"); const activeLink = nav.querySelector(`[data-section-id="${activeSectionId}"]`); if (!container || !activeLink) return; const frame = requestAnimationFrame(() => { const { left, width } = computeIndicatorMetrics(activeLink, container); marker.style.transform = `translateX(${left}px)`; marker.style.width = `${width}px`; }); return () => { cancelAnimationFrame(frame); }; }, [activeSectionId, navRef]); return /* @__PURE__ */ u("nav", { ...navRef ? { ref: navRef } : {}, class: `atv-stickynav${visible ? " is-visible" : ""}${scrolling ? " is-scrolling" : ""}${className ? ` ${className}` : ""}`, inert: !visible, children: [ /* @__PURE__ */ u("div", { class: "atv-stickynav-title", children: title }), accessory, /* @__PURE__ */ u("div", { class: "atv-stickynav-jumps", children: [sections.map((section) => /* @__PURE__ */ u("a", { class: activeSectionId === section.id ? "is-active" : void 0, "data-section-id": section.id, href: `#${section.id}`, onClick: (event) => { if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; event.preventDefault(); onJump(section.id); }, children: section.label }, section.id)), /* @__PURE__ */ u("div", { "aria-hidden": "true", class: "atv-stickynav-marker", ref: markerRef })] }) ] }); }; var IconStarFull = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", d: "M8 1.2l2.06 4.18 4.61.67-3.34 3.25.79 4.6L8 11.74l-4.12 2.16.79-4.6L1.33 6.05l4.61-.67L8 1.2z" }) }); var IconStarHalf = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", ...props, children: [/* @__PURE__ */ u("defs", { children: /* @__PURE__ */ u("linearGradient", { id: "atvHalfStar", children: [/* @__PURE__ */ u("stop", { offset: "50%", "stop-color": "currentColor" }), /* @__PURE__ */ u("stop", { offset: "50%", "stop-color": "currentColor", "stop-opacity": "0.22" })] }) }), /* @__PURE__ */ u("path", { fill: "url(#atvHalfStar)", d: "M8 1.2l2.06 4.18 4.61.67-3.34 3.25.79 4.6L8 11.74l-4.12 2.16.79-4.6L1.33 6.05l4.61-.67L8 1.2z" })] }); var IconStarEmpty = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", "fill-opacity": "0.22", d: "M8 1.2l2.06 4.18 4.61.67-3.34 3.25.79 4.6L8 11.74l-4.12 2.16.79-4.6L1.33 6.05l4.61-.67L8 1.2z" }) }); var IconPlay = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 14 14", width: "14", height: "14", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", d: "M3 1.6v10.8c0 .8.86 1.27 1.5.83l8.1-5.4a1 1 0 0 0 0-1.66L4.5.77C3.86.33 3 .8 3 1.6z" }) }); var IconCheck = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 14 14", width: "14", height: "14", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M2.5 7.5l3 3 6-7" }) }); var IconChevron = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 12 12", width: "10", height: "10", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "none", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M2.5 4l3.5 4 3.5-4" }) }); var IconArrow = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "15", height: "15", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "none", stroke: "currentColor", "stroke-width": "1.8", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M3 8h9m0 0l-3.5-3.5M12 8l-3.5 3.5" }) }); var IconThumb = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "13", height: "13", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", d: "M6.3 6.6L9 1.3c.7-.1 1.4.5 1.4 1.3v2.6h3c.9 0 1.5.8 1.3 1.6l-1.2 5.1c-.1.6-.7 1-1.3 1H6.3V6.6zM4.7 6.7v7H2.5c-.6 0-1-.4-1-1v-5c0-.6.4-1 1-1h2.2z" }) }); var IconVoteTriangle = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 12 12", width: "12", height: "12", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", d: "M6 2.2 10.4 9H1.6L6 2.2z" }) }); var IconExpand = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 14 14", width: "12", height: "12", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M3 5.5l4 4 4-4" }) }); var IconTomato = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("circle", { cx: "8", cy: "8", r: "5.5", fill: "currentColor" }) }); var IconPopcorn = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", ...props, children: /* @__PURE__ */ u("path", { fill: "currentColor", d: "M8 2l6 6-6 6-6-6z" }) }); var IconClose = ({ size = 22, ...props }) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", ...props, children: /* @__PURE__ */ u("path", { d: "M6 6l12 12M18 6l-12 12" }) }); var IconFilmPlaceholder = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 64 96", width: "100%", height: "100%", preserveAspectRatio: "xMidYMid slice", "aria-hidden": "true", ...props, children: [ /* @__PURE__ */ u("defs", { children: /* @__PURE__ */ u("linearGradient", { id: "atvFilmGrad", x1: "0", y1: "0", x2: "1", y2: "1", children: [/* @__PURE__ */ u("stop", { offset: "0%", "stop-color": "#2c2c2e" }), /* @__PURE__ */ u("stop", { offset: "100%", "stop-color": "#1c1c1e" })] }) }), /* @__PURE__ */ u("rect", { width: "64", height: "96", fill: "url(#atvFilmGrad)" }), /* @__PURE__ */ u("g", { fill: "rgba(255,255,255,0.12)", children: [ /* @__PURE__ */ u("rect", { x: "4", y: "6", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "18", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "30", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "42", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "54", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "66", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "4", y: "78", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "6", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "18", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "30", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "42", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "54", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "66", width: "6", height: "6", rx: "1" }), /* @__PURE__ */ u("rect", { x: "54", y: "78", width: "6", height: "6", rx: "1" }) ] }), /* @__PURE__ */ u("path", { d: "M26 38l14 10-14 10z", fill: "rgba(255,255,255,0.28)" }) ] }); var LogoDouban = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: "24", height: "24", "aria-label": "豆瓣", fill: "#2D963D", ...props, children: /* @__PURE__ */ u("path", { d: "M.51 3.06h22.98V.755H.51V3.06Zm20.976 2.537v9.608h-2.137l-1.669 5.76H24v2.28H0v-2.28h6.32l-1.67-5.76H2.515V5.597h18.972Zm-5.066 9.608H7.58l1.67 5.76h5.501l1.67-5.76ZM18.367 7.9H5.634v5.025h12.733V7.9Z" }) }); var LogoImdb = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: "24", height: "24", "aria-label": "IMDb", fill: "#F5C518", ...props, children: /* @__PURE__ */ u("path", { d: "M22.3781 0H1.6218C.7411.0583.0587.7437.0018 1.5953l-.001 20.783c.0585.8761.7125 1.543 1.5559 1.6191A.337.337 0 0 0 1.6016 24h20.7971a.4579.4579 0 0 0 .0437-.002c.8727-.0768 1.5568-.8271 1.5568-1.7085V1.7098c0-.8914-.696-1.6416-1.584-1.7078A.3294.3294 0 0 0 22.3781 0zm0 .496a1.2144 1.2144 0 0 1 1.1252 1.2139v20.5797c0 .6377-.4875 1.1602-1.1045 1.2145H1.6016c-.5967-.0543-1.0645-.5297-1.1053-1.1258V1.6284C.5371 1.0185 1.0184.5364 1.6217.496h20.7564zM4.7954 8.2603v7.3636H2.8899V8.2603h1.9055zm6.5367 0v7.3636H9.6707v-4.9704l-.6711 4.9704H7.813l-.6986-4.8618-.0066 4.8618h-1.668V8.2603h2.468c.0748.4476.1492.9694.2307 1.5734l.2712 1.8713.4407-3.4447h2.4817zm2.9772 1.3289c.0742.0404.122.108.1417.2034.0279.0953.0345.3118.0345.6442v2.8548c0 .4881-.0345.7867-.0955.8954-.0609.1152-.2304.1695-.5018.1695V9.5211c.204 0 .3457.0205.4211.0681zm-.0211 6.0347c.4543 0 .8006-.0265 1.0245-.0742.2304-.0477.4204-.1357.5694-.2648.1556-.1218.2642-.298.3251-.5219.0611-.2238.1021-.6648.1021-1.3224v-2.5832c0-.6986-.0271-1.1668-.0742-1.4039-.041-.237-.1431-.4543-.3126-.6437-.1695-.1973-.4198-.3324-.7456-.421-.3191-.0808-.8542-.1285-1.7694-.1285h-1.4244v7.3636h2.3051zm5.14-1.7827c0 .3523-.0199.5762-.0544.6708-.033.0947-.1894.1424-.3046.1424-.1086 0-.19-.0477-.2238-.1351-.041-.0887-.0609-.2986-.0609-.6238v-1.9469c0-.3324.0199-.5423.0543-.6237.0338-.0808.1086-.122.2171-.122.1153 0 .2709.0412.3114.1425.041.0947.0609.2986.0609.6032v1.8926zm-2.4747-5.5809v7.3636h1.7157l.1152-.4675c.1556.1894.3251.3324.5152.4271.1828.0881.4608.1357.678.1357.3047 0 .5629-.0748.7802-.237.2165-.1562.3589-.3462.4198-.5628.0543-.2173.0887-.543.0887-.9841v-2.0675c0-.4409-.0139-.7324-.0344-.8681-.0199-.1357-.0742-.2781-.1695-.4204-.1021-.1425-.2437-.251-.4272-.3325-.1834-.0742-.3999-.1152-.6576-.1152-.2172 0-.4952.0477-.6846.1285-.1835.0887-.353.2238-.5086.4007V8.2603h-1.8309z" }) }); var LogoMetacritic = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: "24", height: "24", "aria-label": "Metacritic", fill: "#FFD500", ...props, children: /* @__PURE__ */ u("path", { d: "M11.99 0A12 12 0 1 0 24 12v-.014A12 12 0 0 0 11.99 0Zm-.055 2.564a9.399 9.399 0 0 1 9.407 9.389v.01a9.399 9.399 0 1 1-9.408-9.399Zm-1.61 17.198 2.046-2.046-3.94-3.94c-.165-.166-.345-.373-.442-.608-.221-.47-.318-1.203.221-1.742.664-.664 1.548-.387 2.406.47l3.788 3.788 2.046-2.046-3.954-3.954a2.48 2.48 0 0 1-.456-.622c-.263-.539-.25-1.216.235-1.7.677-.678 1.562-.429 2.544.553l3.677 3.677 2.046-2.046-3.982-3.982c-2.018-2.018-3.912-1.949-5.212-.65-.498.499-.802 1.024-.954 1.618a4.026 4.026 0 0 0-.055 1.686l-.027.028c-.996-.414-2.13-.166-3 .705-1.162 1.161-1.12 2.392-.982 3.11l-.042.043-1.009-.816-1.77 1.77a64.1 64.1 0 0 1 2.213 2.1z" }) }); var LogoRT = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: "24", height: "24", "aria-label": "Rotten Tomatoes", fill: "#FA320A", ...props, children: /* @__PURE__ */ u("path", { d: "M5.866 0L4.335 1.262l2.082 1.8c-2.629-.989-4.842 1.4-5.012 2.338 1.384-.323 2.24-.422 3.344-.335-7.042 4.634-4.978 13.148-1.434 16.094 5.784 4.612 13.77 3.202 17.91-1.316C27.26 13.363 22.993.65 10.86 2.766c.107-1.17.633-1.503 1.243-1.602-.89-1.493-3.67-.734-4.556 1.374C7.52 2.602 5.866 0 5.866 0zM4.422 7.217H6.9c2.673 0 2.898.012 3.55.202 1.06.307 1.868.973 2.313 1.904.05.106.092.206.13.305l7.623.008.027 2.912-2.745-.024v7.549l-2.982-.016v-7.522l-2.127.016a2.92 2.92 0 0 1-1.056 1.134c-.287.176-.3.19-.254.264.127.2 2.125 3.642 2.125 3.659l-3.39.019-2.013-3.376c-.034-.047-.122-.068-.344-.084l-.297-.02.037 3.48-3.075-.038zm3.016 2.288l.024.338c.014.186.024.729.024 1.206v.867l.582-.025c.32-.013.695-.049.833-.078.694-.146 1.048-.478 1.087-1.018.027-.378-.063-.636-.303-.87-.318-.309-.761-.416-1.733-.418Z" }) }); var PlayIcon = (props) => /* @__PURE__ */ u("svg", { viewBox: "0 0 24 24", width: "24", height: "24", fill: "none", stroke: "white", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", ...props, children: /* @__PURE__ */ u("polygon", { points: "6 3 20 12 6 21 6 3", fill: "white", stroke: "none" }) }); var ModalCloseButton = ({ ariaLabel, className = "atv-modal-close", onClick, size = 22 }) => { return /* @__PURE__ */ u("button", { "aria-label": ariaLabel, class: className.split(/\s+/u).includes("atv-modal-close") ? className : `atv-modal-close ${className}`, onClick, type: "button", children: /* @__PURE__ */ u(IconClose, { size }) }); }; var ModalSessionContext = X(0); var ModalSession = ({ children, request }) => { const [session, setSession] = d({ request, sequence: 0 }); _(() => { setSession((current) => current.request === request ? current : { request, sequence: current.sequence + 1 }); }, [request]); return /* @__PURE__ */ u(ModalSessionContext.Provider, { value: session.sequence, children }); }; var useModalSession = () => x(ModalSessionContext); var ModalSessionContent = ({ children }) => /* @__PURE__ */ u(S, { children }, useModalSession()); function addUniqueItem(arr, item) { if (arr.indexOf(item) === -1) arr.push(item); } function removeItem(arr, item) { const index = arr.indexOf(item); if (index > -1) arr.splice(index, 1); } var clamp = (min, max, v) => { if (v > max) return max; if (v < min) return min; return v; }; var MotionGlobalConfig = {}; /** * Check if value is a numerical string, ie a string that is purely a number eg "100" or "-100.1" */ var isNumericalString = (v) => /^-?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(v); var isObject = (value) => typeof value === "object" && value !== null; /** * Check if the value is a zero value string like "0px" or "0%" */ var isZeroValueString = (v) => /^0[^.\s]+$/u.test(v); /*#__NO_SIDE_EFFECTS__*/ function memo(callback) { let result; return () => { if (result === void 0) result = callback(); return result; }; } var noop$4 = /* @__NO_SIDE_EFFECTS__ */ (any) => any; /** * Pipe * Compose other transformers to run linearily * pipe(min(20), max(40)) * @param {...functions} transformers * @return {function} */ var pipe = (...transformers) => transformers.reduce((a, b) => (v) => b(a(v))); var progress = /* @__NO_SIDE_EFFECTS__ */ (from, to, value) => { const range = to - from; return range ? (value - from) / range : 1; }; var SubscriptionManager = class { constructor() { this.subscriptions = []; } add(handler) { addUniqueItem(this.subscriptions, handler); return () => removeItem(this.subscriptions, handler); } notify(a, b, c) { const numSubscriptions = this.subscriptions.length; if (!numSubscriptions) return; if (numSubscriptions === 1) /** * If there's only a single handler we can just call it without invoking a loop. */ this.subscriptions[0](a, b, c); else for (let i = 0; i < numSubscriptions; i++) { /** * Check whether the handler exists before firing as it's possible * the subscriptions were modified during this loop running. */ const handler = this.subscriptions[i]; handler && handler(a, b, c); } } getSize() { return this.subscriptions.length; } clear() { this.subscriptions.length = 0; } }; /** * Converts seconds to milliseconds * * @param seconds - Time in seconds. * @return milliseconds - Converted time in milliseconds. */ var secondsToMilliseconds = /* @__NO_SIDE_EFFECTS__ */ (seconds) => seconds * 1e3; var millisecondsToSeconds = /* @__NO_SIDE_EFFECTS__ */ (milliseconds) => milliseconds / 1e3; var velocityPerSecond = /* @__NO_SIDE_EFFECTS__ */ (velocity, frameDuration) => frameDuration ? velocity * (1e3 / frameDuration) : 0; var wrap = (min, max, v) => { const rangeSize = max - min; return ((v - min) % rangeSize + rangeSize) % rangeSize + min; }; var calcBezier = (t, a1, a2) => (((1 - 3 * a2 + 3 * a1) * t + (3 * a2 - 6 * a1)) * t + 3 * a1) * t; var subdivisionPrecision = 1e-7; var subdivisionMaxIterations = 12; function binarySubdivide(x, lowerBound, upperBound, mX1, mX2) { let currentX; let currentT; let i = 0; do { currentT = lowerBound + (upperBound - lowerBound) / 2; currentX = calcBezier(currentT, mX1, mX2) - x; if (currentX > 0) upperBound = currentT; else lowerBound = currentT; } while (Math.abs(currentX) > subdivisionPrecision && ++i < subdivisionMaxIterations); return currentT; } /*#__NO_SIDE_EFFECTS__*/ function cubicBezier(mX1, mY1, mX2, mY2) { if (mX1 === mY1 && mX2 === mY2) return noop$4; const getTForX = (aX) => binarySubdivide(aX, 0, 1, mX1, mX2); return (t) => t === 0 || t === 1 ? t : calcBezier(getTForX(t), mY1, mY2); } var mirrorEasing = /* @__NO_SIDE_EFFECTS__ */ (easing) => (p) => p <= .5 ? easing(2 * p) / 2 : (2 - easing(2 * (1 - p))) / 2; var reverseEasing = /* @__NO_SIDE_EFFECTS__ */ (easing) => (p) => 1 - easing(1 - p); var backOut = /*@__PURE__*/ cubicBezier(.33, 1.53, .69, .99); var backIn = /*@__PURE__*/ reverseEasing(backOut); var backInOut = /*@__PURE__*/ mirrorEasing(backIn); var anticipate = (p) => p >= 1 ? 1 : (p *= 2) < 1 ? .5 * backIn(p) : .5 * (2 - Math.pow(2, -10 * (p - 1))); var circIn = (p) => 1 - Math.sin(Math.acos(p)); var circOut = /* @__PURE__ */ reverseEasing(circIn); var circInOut = /* @__PURE__ */ mirrorEasing(circIn); var easeIn = /*@__PURE__*/ cubicBezier(.42, 0, 1, 1); var easeOut = /*@__PURE__*/ cubicBezier(0, 0, .58, 1); var easeInOut = /*@__PURE__*/ cubicBezier(.42, 0, .58, 1); var isEasingArray = /* @__NO_SIDE_EFFECTS__ */ (ease) => { return Array.isArray(ease) && typeof ease[0] !== "number"; }; /*#__NO_SIDE_EFFECTS__*/ function getEasingForSegment(easing, i) { return /* @__PURE__ */ isEasingArray(easing) ? easing[wrap(0, easing.length, i)] : easing; } var isBezierDefinition = /* @__NO_SIDE_EFFECTS__ */ (easing) => Array.isArray(easing) && typeof easing[0] === "number"; var easingLookup = { linear: noop$4, easeIn, easeInOut, easeOut, circIn, circInOut, circOut, backIn, backInOut, backOut, anticipate }; var isValidEasing = (easing) => { return typeof easing === "string"; }; var easingDefinitionToFunction = (definition) => { if (/* @__PURE__ */ isBezierDefinition(definition)) { definition.length; const [x1, y1, x2, y2] = definition; return /* @__PURE__ */ cubicBezier(x1, y1, x2, y2); } else if (isValidEasing(definition)) { return easingLookup[definition]; } return definition; }; var stepsOrder = [ "setup", "read", "resolveKeyframes", "preUpdate", "update", "preRender", "render", "postRender" ]; function createRenderStep(runNextFrame) { /** * We create and reuse two queues, one to queue jobs for the current frame * and one for the next. We reuse to avoid triggering GC after x frames. */ let thisFrame = /* @__PURE__ */ new Set(); let nextFrame = /* @__PURE__ */ new Set(); /** * Track whether we're currently processing jobs in this step. This way * we can decide whether to schedule new jobs for this frame or next. */ let isProcessing = false; let flushNextFrame = false; /** * A set of processes which were marked keepAlive when scheduled. */ const toKeepAlive = /* @__PURE__ */ new WeakSet(); let latestFrameData = { delta: 0, timestamp: 0, isProcessing: false }; function triggerCallback(callback) { if (toKeepAlive.has(callback)) { step.schedule(callback); runNextFrame(); } callback(latestFrameData); } const step = { /** * Schedule a process to run on the next frame. */ schedule: (callback, keepAlive = false, immediate = false) => { const queue = immediate && isProcessing ? thisFrame : nextFrame; if (keepAlive) toKeepAlive.add(callback); queue.add(callback); return callback; }, /** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { nextFrame.delete(callback); toKeepAlive.delete(callback); }, /** * Execute all schedule callbacks. */ process: (frameData) => { latestFrameData = frameData; /** * If we're already processing we've probably been triggered by a flushSync * inside an existing process. Instead of executing, mark flushNextFrame * as true and ensure we flush the following frame at the end of this one. */ if (isProcessing) { flushNextFrame = true; return; } isProcessing = true; const prevFrame = thisFrame; thisFrame = nextFrame; nextFrame = prevFrame; thisFrame.forEach(triggerCallback); thisFrame.clear(); isProcessing = false; if (flushNextFrame) { flushNextFrame = false; step.process(frameData); } } }; return step; } var maxElapsed = 40; function createRenderBatcher(scheduleNextBatch, allowKeepAlive) { let runNextFrame = false; let useDefaultElapsed = true; const state = { delta: 0, timestamp: 0, isProcessing: false }; const flagRunNextFrame = () => runNextFrame = true; const steps = stepsOrder.reduce((acc, key) => { acc[key] = createRenderStep(flagRunNextFrame); return acc; }, {}); const { setup, read, resolveKeyframes, preUpdate, update, preRender, render, postRender } = steps; const processBatch = () => { const useManualTiming = MotionGlobalConfig.useManualTiming; const timestamp = useManualTiming ? state.timestamp : performance.now(); runNextFrame = false; if (!useManualTiming) state.delta = useDefaultElapsed ? 1e3 / 60 : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1); state.timestamp = timestamp; state.isProcessing = true; setup.process(state); read.process(state); resolveKeyframes.process(state); preUpdate.process(state); update.process(state); preRender.process(state); render.process(state); postRender.process(state); state.isProcessing = false; if (runNextFrame && allowKeepAlive) { useDefaultElapsed = false; scheduleNextBatch(processBatch); } }; const wake = () => { runNextFrame = true; useDefaultElapsed = true; if (!state.isProcessing) scheduleNextBatch(processBatch); }; const schedule = stepsOrder.reduce((acc, key) => { const step = steps[key]; acc[key] = (process, keepAlive = false, immediate = false) => { if (!runNextFrame) wake(); return step.schedule(process, keepAlive, immediate); }; return acc; }, {}); const cancel = (process) => { for (let i = 0; i < stepsOrder.length; i++) steps[stepsOrder[i]].cancel(process); }; return { schedule, cancel, state, steps }; } var { schedule: frame, cancel: cancelFrame, state: frameData} = /* @__PURE__ */ createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop$4, true); var now; function clearTime() { now = void 0; } /** * An eventloop-synchronous alternative to performance.now(). * * Ensures that time measurements remain consistent within a synchronous context. * Usually calling performance.now() twice within the same synchronous context * will return different values which isn't useful for animations when we're usually * trying to sync animations to the same frame. */ var time = { now: () => { if (now === void 0) time.set(frameData.isProcessing || MotionGlobalConfig.useManualTiming ? frameData.timestamp : performance.now()); return now; }, set: (newTime) => { now = newTime; queueMicrotask(clearTime); } }; var checkStringStartsWith = (token) => (key) => typeof key === "string" && key.startsWith(token); var isCSSVariableName = /*@__PURE__*/ checkStringStartsWith("--"); var startsAsVariableToken = /*@__PURE__*/ checkStringStartsWith("var(--"); var isCSSVariableToken = (value) => { if (!startsAsVariableToken(value)) return false; return singleCssVariableRegex.test(value.split("/*")[0].trim()); }; var singleCssVariableRegex = /var\(--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)$/iu; /** * Check if a value contains a CSS variable anywhere (e.g. inside calc()). * Unlike isCSSVariableToken which checks if the value IS a var() token, * this checks if the value CONTAINS var() somewhere in the string. */ function containsCSSVariable(value) { if (typeof value !== "string") return false; return value.split("/*")[0].includes("var(--"); } var number = { test: (v) => typeof v === "number", parse: parseFloat, transform: (v) => v }; var alpha = { ...number, transform: (v) => clamp(0, 1, v) }; var scale = { ...number, default: 1 }; var sanitize = (v) => Math.round(v * 1e5) / 1e5; var floatRegex = /-?(?:\d+(?:\.\d+)?|\.\d+)/gu; function isNullish(v) { return v == null; } var singleColorRegex = /^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))$/iu; /** * Returns true if the provided string is a color, ie rgba(0,0,0,0) or #000, * but false if a number or multiple colors */ var isColorString = (type, testProp) => (v) => { return Boolean(typeof v === "string" && singleColorRegex.test(v) && v.startsWith(type) || testProp && !isNullish(v) && Object.prototype.hasOwnProperty.call(v, testProp)); }; var splitColor = (aName, bName, cName) => (v) => { if (typeof v !== "string") return v; const [a, b, c, alpha] = v.match(floatRegex); return { [aName]: parseFloat(a), [bName]: parseFloat(b), [cName]: parseFloat(c), alpha: alpha !== void 0 ? parseFloat(alpha) : 1 }; }; var clampRgbUnit = (v) => clamp(0, 255, v); var rgbUnit = { ...number, transform: (v) => Math.round(clampRgbUnit(v)) }; var rgba = { test: /*@__PURE__*/ isColorString("rgb", "red"), parse: /*@__PURE__*/ splitColor("red", "green", "blue"), transform: ({ red, green, blue, alpha: alpha$1 = 1 }) => "rgba(" + rgbUnit.transform(red) + ", " + rgbUnit.transform(green) + ", " + rgbUnit.transform(blue) + ", " + sanitize(alpha.transform(alpha$1)) + ")" }; function parseHex(v) { let r = ""; let g = ""; let b = ""; let a = ""; if (v.length > 5) { r = v.substring(1, 3); g = v.substring(3, 5); b = v.substring(5, 7); a = v.substring(7, 9); } else { r = v.substring(1, 2); g = v.substring(2, 3); b = v.substring(3, 4); a = v.substring(4, 5); r += r; g += g; b += b; a += a; } return { red: parseInt(r, 16), green: parseInt(g, 16), blue: parseInt(b, 16), alpha: a ? parseInt(a, 16) / 255 : 1 }; } var hex = { test: /*@__PURE__*/ isColorString("#"), parse: parseHex, transform: rgba.transform }; var createUnitType = /* @__NO_SIDE_EFFECTS__ */ (unit) => ({ test: (v) => typeof v === "string" && v.endsWith(unit) && v.split(" ").length === 1, parse: parseFloat, transform: (v) => `${v}${unit}` }); var degrees = /*@__PURE__*/ createUnitType("deg"); var percent = /*@__PURE__*/ createUnitType("%"); var px = /*@__PURE__*/ createUnitType("px"); var vh = /*@__PURE__*/ createUnitType("vh"); var vw = /*@__PURE__*/ createUnitType("vw"); var progressPercentage = /*@__PURE__*/ (() => ({ ...percent, parse: (v) => percent.parse(v) / 100, transform: (v) => percent.transform(v * 100) }))(); var hsla = { test: /*@__PURE__*/ isColorString("hsl", "hue"), parse: /*@__PURE__*/ splitColor("hue", "saturation", "lightness"), transform: ({ hue, saturation, lightness, alpha: alpha$1 = 1 }) => { return "hsla(" + Math.round(hue) + ", " + percent.transform(sanitize(saturation)) + ", " + percent.transform(sanitize(lightness)) + ", " + sanitize(alpha.transform(alpha$1)) + ")"; } }; var color = { test: (v) => rgba.test(v) || hex.test(v) || hsla.test(v), parse: (v) => { if (rgba.test(v)) return rgba.parse(v); else if (hsla.test(v)) return hsla.parse(v); else return hex.parse(v); }, transform: (v) => { return typeof v === "string" ? v : v.hasOwnProperty("red") ? rgba.transform(v) : hsla.transform(v); }, getAnimatableNone: (v) => { const parsed = color.parse(v); parsed.alpha = 0; return color.transform(parsed); } }; var colorRegex = /(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\))/giu; function test(v) { return isNaN(v) && typeof v === "string" && (v.match(floatRegex)?.length || 0) + (v.match(colorRegex)?.length || 0) > 0; } var NUMBER_TOKEN = "number"; var COLOR_TOKEN = "color"; var VAR_TOKEN = "var"; var VAR_FUNCTION_TOKEN = "var("; var SPLIT_TOKEN = "${}"; var complexRegex = /var\s*\(\s*--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)|#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\)|-?(?:\d+(?:\.\d+)?|\.\d+)/giu; function analyseComplexValue(value) { const originalValue = value.toString(); const values = []; const indexes = { color: [], number: [], var: [] }; const types = []; let i = 0; return { values, split: originalValue.replace(complexRegex, (parsedValue) => { if (color.test(parsedValue)) { indexes.color.push(i); types.push(COLOR_TOKEN); values.push(color.parse(parsedValue)); } else if (parsedValue.startsWith(VAR_FUNCTION_TOKEN)) { indexes.var.push(i); types.push(VAR_TOKEN); values.push(parsedValue); } else { indexes.number.push(i); types.push(NUMBER_TOKEN); values.push(parseFloat(parsedValue)); } ++i; return SPLIT_TOKEN; }).split(SPLIT_TOKEN), indexes, types }; } function parseComplexValue(v) { return analyseComplexValue(v).values; } function buildTransformer({ split, types }) { const numSections = split.length; return (v) => { let output = ""; for (let i = 0; i < numSections; i++) { output += split[i]; if (v[i] !== void 0) { const type = types[i]; if (type === NUMBER_TOKEN) output += sanitize(v[i]); else if (type === COLOR_TOKEN) output += color.transform(v[i]); else output += v[i]; } } return output; }; } function createTransformer(source) { return buildTransformer(analyseComplexValue(source)); } var convertNumbersToZero = (v) => typeof v === "number" ? 0 : color.test(v) ? color.getAnimatableNone(v) : v; /** * Convert a parsed value to its zero equivalent, but preserve numbers * that act as divisors in CSS calc() expressions. * * analyseComplexValue extracts numbers from CSS strings and puts the * surrounding text into a `split` template array. For example: * "calc(var(--gap) / 5)" → values: [var(--gap), 5] * split: ["calc(", " / ", ")"] * * When building a zero-equivalent for animation, naively zeroing all * numbers turns the divisor into 0 → "calc(var(--gap) / 0)" → NaN. * We detect this by checking whether the text preceding a number * (split[i]) ends with "/" — the CSS calc division operator. */ var convertToZero = (value, splitBefore) => { if (typeof value === "number") return splitBefore?.trim().endsWith("/") ? value : 0; return convertNumbersToZero(value); }; function getAnimatableNone$1(v) { const info = analyseComplexValue(v); return buildTransformer(info)(info.values.map((value, i) => convertToZero(value, info.split[i]))); } var complex = { test, parse: parseComplexValue, createTransformer, getAnimatableNone: getAnimatableNone$1 }; function hueToRgb(p, q, t) { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; } function hslaToRgba({ hue, saturation, lightness, alpha }) { hue /= 360; saturation /= 100; lightness /= 100; let red = 0; let green = 0; let blue = 0; if (!saturation) red = green = blue = lightness; else { const q = lightness < .5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation; const p = 2 * lightness - q; red = hueToRgb(p, q, hue + 1 / 3); green = hueToRgb(p, q, hue); blue = hueToRgb(p, q, hue - 1 / 3); } return { red: Math.round(red * 255), green: Math.round(green * 255), blue: Math.round(blue * 255), alpha }; } function mixImmediate(a, b) { return (p) => p > 0 ? b : a; } var mixNumber$1 = (from, to, progress) => { return from + (to - from) * progress; }; var mixLinearColor = (from, to, v) => { const fromExpo = from * from; const expo = v * (to * to - fromExpo) + fromExpo; return expo < 0 ? 0 : Math.sqrt(expo); }; var colorTypes = [ hex, rgba, hsla ]; var getColorType = (v) => colorTypes.find((type) => type.test(v)); function asRGBA(color) { const type = getColorType(color); if (!Boolean(type)) return false; let model = type.parse(color); if (type === hsla) model = hslaToRgba(model); return model; } var mixColor = (from, to) => { const fromRGBA = asRGBA(from); const toRGBA = asRGBA(to); if (!fromRGBA || !toRGBA) return mixImmediate(from, to); const blended = { ...fromRGBA }; return (v) => { blended.red = mixLinearColor(fromRGBA.red, toRGBA.red, v); blended.green = mixLinearColor(fromRGBA.green, toRGBA.green, v); blended.blue = mixLinearColor(fromRGBA.blue, toRGBA.blue, v); blended.alpha = mixNumber$1(fromRGBA.alpha, toRGBA.alpha, v); return rgba.transform(blended); }; }; var invisibleValues = /* @__PURE__ */ new Set(["none", "hidden"]); /** * Returns a function that, when provided a progress value between 0 and 1, * will return the "none" or "hidden" string only when the progress is that of * the origin or target. */ function mixVisibility(origin, target) { if (invisibleValues.has(origin)) return (p) => p <= 0 ? origin : target; else return (p) => p >= 1 ? target : origin; } function mixNumber(a, b) { return (p) => mixNumber$1(a, b, p); } function getMixer(a) { if (typeof a === "number") return mixNumber; else if (typeof a === "string") return isCSSVariableToken(a) ? mixImmediate : color.test(a) ? mixColor : mixComplex; else if (Array.isArray(a)) return mixArray; else if (typeof a === "object") return color.test(a) ? mixColor : mixObject; return mixImmediate; } function mixArray(a, b) { const output = [...a]; const numValues = output.length; const blendValue = a.map((v, i) => getMixer(v)(v, b[i])); return (p) => { for (let i = 0; i < numValues; i++) output[i] = blendValue[i](p); return output; }; } function mixObject(a, b) { const output = { ...a, ...b }; const blendValue = {}; for (const key in output) if (a[key] !== void 0 && b[key] !== void 0) blendValue[key] = getMixer(a[key])(a[key], b[key]); return (v) => { for (const key in blendValue) output[key] = blendValue[key](v); return output; }; } function matchOrder(origin, target) { const orderedOrigin = []; const pointers = { color: 0, var: 0, number: 0 }; for (let i = 0; i < target.values.length; i++) { const type = target.types[i]; const originIndex = origin.indexes[type][pointers[type]]; orderedOrigin[i] = origin.values[originIndex] ?? 0; pointers[type]++; } return orderedOrigin; } var mixComplex = (origin, target) => { const template = complex.createTransformer(target); const originStats = analyseComplexValue(origin); const targetStats = analyseComplexValue(target); if (originStats.indexes.var.length === targetStats.indexes.var.length && originStats.indexes.color.length === targetStats.indexes.color.length && originStats.indexes.number.length >= targetStats.indexes.number.length) { if (invisibleValues.has(origin) && !targetStats.values.length || invisibleValues.has(target) && !originStats.values.length) return mixVisibility(origin, target); return pipe(mixArray(matchOrder(originStats, targetStats), targetStats.values), template); } else { return mixImmediate(origin, target); } }; function mix(from, to, p) { if (typeof from === "number" && typeof to === "number" && typeof p === "number") return mixNumber$1(from, to, p); return getMixer(from)(from, to); } var frameloopDriver = (update) => { const passTimestamp = ({ timestamp }) => update(timestamp); return { start: (keepAlive = true) => frame.update(passTimestamp, keepAlive), stop: () => cancelFrame(passTimestamp), /** * If we're processing this frame we can use the * framelocked timestamp to keep things in sync. */ now: () => frameData.isProcessing ? frameData.timestamp : time.now() }; }; var generateLinearEasing = (easing, duration, resolution = 10) => { let points = ""; const numPoints = Math.max(Math.round(duration / resolution), 2); for (let i = 0; i < numPoints; i++) points += Math.round(easing(i / (numPoints - 1)) * 1e4) / 1e4 + ", "; return `linear(${points.substring(0, points.length - 2)})`; }; /** * Implement a practical max duration for keyframe generation * to prevent infinite loops */ var maxGeneratorDuration = 2e4; function calcGeneratorDuration(generator) { let duration = 0; const timeStep = 50; let state = generator.next(duration); while (!state.done && duration < 2e4) { duration += timeStep; state = generator.next(duration); } return duration >= 2e4 ? Infinity : duration; } /** * Create a progress => progress easing function from a generator. */ function createGeneratorEasing(options, scale = 100, createGenerator) { const generator = createGenerator({ ...options, keyframes: [0, scale] }); const duration = Math.min(calcGeneratorDuration(generator), maxGeneratorDuration); return { type: "keyframes", ease: (progress) => { return generator.next(duration * progress).value / scale; }, duration: /* @__PURE__ */ millisecondsToSeconds(duration) }; } var springDefaults = { stiffness: 100, damping: 10, mass: 1, velocity: 0, duration: 800, bounce: .3, visualDuration: .3, restSpeed: { granular: .01, default: 2 }, restDelta: { granular: .005, default: .5 }, minDuration: .01, maxDuration: 10, minDamping: .05, maxDamping: 1 }; function calcAngularFreq(undampedFreq, dampingRatio) { return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio); } var rootIterations = 12; function approximateRoot(envelope, derivative, initialGuess) { let result = initialGuess; for (let i = 1; i < rootIterations; i++) result = result - envelope(result) / derivative(result); return result; } /** * This is ported from the Framer implementation of duration-based spring resolution. */ var safeMin = .001; function findSpring({ duration = springDefaults.duration, bounce = springDefaults.bounce, velocity = springDefaults.velocity, mass = springDefaults.mass }) { let envelope; let derivative; let dampingRatio = 1 - bounce; /** * Restrict dampingRatio and duration to within acceptable ranges. */ dampingRatio = clamp(springDefaults.minDamping, springDefaults.maxDamping, dampingRatio); duration = clamp(springDefaults.minDuration, springDefaults.maxDuration, /* @__PURE__ */ millisecondsToSeconds(duration)); if (dampingRatio < 1) { /** * Underdamped spring */ envelope = (undampedFreq) => { const exponentialDecay = undampedFreq * dampingRatio; const delta = exponentialDecay * duration; const a = exponentialDecay - velocity; const b = calcAngularFreq(undampedFreq, dampingRatio); const c = Math.exp(-delta); return safeMin - a / b * c; }; derivative = (undampedFreq) => { const delta = undampedFreq * dampingRatio * duration; const d = delta * velocity + velocity; const e = Math.pow(dampingRatio, 2) * Math.pow(undampedFreq, 2) * duration; const f = Math.exp(-delta); const g = calcAngularFreq(Math.pow(undampedFreq, 2), dampingRatio); return (-envelope(undampedFreq) + safeMin > 0 ? -1 : 1) * ((d - e) * f) / g; }; } else { /** * Critically-damped spring */ envelope = (undampedFreq) => { return -1e-3 + Math.exp(-undampedFreq * duration) * ((undampedFreq - velocity) * duration + 1); }; derivative = (undampedFreq) => { return Math.exp(-undampedFreq * duration) * ((velocity - undampedFreq) * (duration * duration)); }; } const initialGuess = 5 / duration; const undampedFreq = approximateRoot(envelope, derivative, initialGuess); duration = /* @__PURE__ */ secondsToMilliseconds(duration); if (isNaN(undampedFreq)) return { stiffness: springDefaults.stiffness, damping: springDefaults.damping, duration }; else { const stiffness = Math.pow(undampedFreq, 2) * mass; return { stiffness, damping: dampingRatio * 2 * Math.sqrt(mass * stiffness), duration }; } } var durationKeys = ["duration", "bounce"]; var physicsKeys = [ "stiffness", "damping", "mass" ]; function isSpringType(options, keys) { return keys.some((key) => options[key] !== void 0); } function getSpringOptions(options) { let springOptions = { velocity: springDefaults.velocity, stiffness: springDefaults.stiffness, damping: springDefaults.damping, mass: springDefaults.mass, isResolvedFromDuration: false, ...options }; if (!isSpringType(options, physicsKeys) && isSpringType(options, durationKeys)) { springOptions.velocity = 0; if (options.visualDuration) { const visualDuration = options.visualDuration; const root = 2 * Math.PI / (visualDuration * 1.2); const stiffness = root * root; const damping = 2 * clamp(.05, 1, 1 - (options.bounce || 0)) * Math.sqrt(stiffness); springOptions = { ...springOptions, mass: springDefaults.mass, stiffness, damping }; } else { const derived = findSpring({ ...options, velocity: 0 }); springOptions = { ...springOptions, ...derived, mass: springDefaults.mass }; springOptions.isResolvedFromDuration = true; } } return springOptions; } function spring(optionsOrVisualDuration = springDefaults.visualDuration, bounce = springDefaults.bounce) { const options = typeof optionsOrVisualDuration !== "object" ? { visualDuration: optionsOrVisualDuration, keyframes: [0, 1], bounce } : optionsOrVisualDuration; let { restSpeed, restDelta } = options; const origin = options.keyframes[0]; const target = options.keyframes[options.keyframes.length - 1]; /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator * to reduce GC during animation. */ const state = { done: false, value: origin }; const { stiffness, damping, mass, duration, velocity, isResolvedFromDuration } = getSpringOptions({ ...options, velocity: -/* @__PURE__ */ millisecondsToSeconds(options.velocity || 0) }); const initialVelocity = velocity || 0; const dampingRatio = damping / (2 * Math.sqrt(stiffness * mass)); const initialDelta = target - origin; const undampedAngularFreq = /* @__PURE__ */ millisecondsToSeconds(Math.sqrt(stiffness / mass)); /** * If we're working on a granular scale, use smaller defaults for determining * when the spring is finished. * * These defaults have been selected emprically based on what strikes a good * ratio between feeling good and finishing as soon as changes are imperceptible. */ const isGranularScale = Math.abs(initialDelta) < 5; restSpeed || (restSpeed = isGranularScale ? springDefaults.restSpeed.granular : springDefaults.restSpeed.default); restDelta || (restDelta = isGranularScale ? springDefaults.restDelta.granular : springDefaults.restDelta.default); let resolveSpring; let resolveVelocity; let angularFreq; let A; let sinCoeff; let cosCoeff; if (dampingRatio < 1) { angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio); A = (initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq; resolveSpring = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); return target - envelope * (A * Math.sin(angularFreq * t) + initialDelta * Math.cos(angularFreq * t)); }; sinCoeff = dampingRatio * undampedAngularFreq * A + initialDelta * angularFreq; cosCoeff = dampingRatio * undampedAngularFreq * initialDelta - A * angularFreq; resolveVelocity = (t) => { return Math.exp(-dampingRatio * undampedAngularFreq * t) * (sinCoeff * Math.sin(angularFreq * t) + cosCoeff * Math.cos(angularFreq * t)); }; } else if (dampingRatio === 1) { resolveSpring = (t) => target - Math.exp(-undampedAngularFreq * t) * (initialDelta + (initialVelocity + undampedAngularFreq * initialDelta) * t); const C = initialVelocity + undampedAngularFreq * initialDelta; resolveVelocity = (t) => Math.exp(-undampedAngularFreq * t) * (undampedAngularFreq * C * t - initialVelocity); } else { const dampedAngularFreq = undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1); resolveSpring = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); const freqForT = Math.min(dampedAngularFreq * t, 300); return target - envelope * ((initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) * Math.sinh(freqForT) + dampedAngularFreq * initialDelta * Math.cosh(freqForT)) / dampedAngularFreq; }; const P = (initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / dampedAngularFreq; const sinhCoeff = dampingRatio * undampedAngularFreq * P - initialDelta * dampedAngularFreq; const coshCoeff = dampingRatio * undampedAngularFreq * initialDelta - P * dampedAngularFreq; resolveVelocity = (t) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); const freqForT = Math.min(dampedAngularFreq * t, 300); return envelope * (sinhCoeff * Math.sinh(freqForT) + coshCoeff * Math.cosh(freqForT)); }; } const generator = { calculatedDuration: isResolvedFromDuration ? duration || null : null, velocity: (t) => /* @__PURE__ */ secondsToMilliseconds(resolveVelocity(t)), next: (t) => { /** * For underdamped physics springs we need both position and * velocity each tick. Compute shared trig values once to avoid * duplicate Math.exp/sin/cos calls on the hot path. */ if (!isResolvedFromDuration && dampingRatio < 1) { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t); const sin = Math.sin(angularFreq * t); const cos = Math.cos(angularFreq * t); const current = target - envelope * (A * sin + initialDelta * cos); const currentVelocity = /* @__PURE__ */ secondsToMilliseconds(envelope * (sinCoeff * sin + cosCoeff * cos)); state.done = Math.abs(currentVelocity) <= restSpeed && Math.abs(target - current) <= restDelta; state.value = state.done ? target : current; return state; } const current = resolveSpring(t); if (!isResolvedFromDuration) { const currentVelocity = /* @__PURE__ */ secondsToMilliseconds(resolveVelocity(t)); state.done = Math.abs(currentVelocity) <= restSpeed && Math.abs(target - current) <= restDelta; } else state.done = t >= duration; state.value = state.done ? target : current; return state; }, toString: () => { const calculatedDuration = Math.min(calcGeneratorDuration(generator), maxGeneratorDuration); const easing = generateLinearEasing((progress) => generator.next(calculatedDuration * progress).value, calculatedDuration, 30); return calculatedDuration + "ms " + easing; }, toTransition: () => {} }; return generator; } spring.applyToOptions = (options) => { const generatorOptions = createGeneratorEasing(options, 100, spring); options.ease = generatorOptions.ease; options.duration = /* @__PURE__ */ secondsToMilliseconds(generatorOptions.duration); options.type = "keyframes"; return options; }; var velocitySampleDuration = 5; function getGeneratorVelocity(resolveValue, t, current) { const prevT = Math.max(t - velocitySampleDuration, 0); return /* @__PURE__ */ velocityPerSecond(current - resolveValue(prevT), t - prevT); } function inertia({ keyframes, velocity = 0, power = .8, timeConstant = 325, bounceDamping = 10, bounceStiffness = 500, modifyTarget, min, max, restDelta = .5, restSpeed }) { const origin = keyframes[0]; const state = { done: false, value: origin }; const isOutOfBounds = (v) => min !== void 0 && v < min || max !== void 0 && v > max; const nearestBoundary = (v) => { if (min === void 0) return max; if (max === void 0) return min; return Math.abs(min - v) < Math.abs(max - v) ? min : max; }; let amplitude = power * velocity; const ideal = origin + amplitude; const target = modifyTarget === void 0 ? ideal : modifyTarget(ideal); /** * If the target has changed we need to re-calculate the amplitude, otherwise * the animation will start from the wrong position. */ if (target !== ideal) amplitude = target - origin; const calcDelta = (t) => -amplitude * Math.exp(-t / timeConstant); const calcLatest = (t) => target + calcDelta(t); const applyFriction = (t) => { const delta = calcDelta(t); const latest = calcLatest(t); state.done = Math.abs(delta) <= restDelta; state.value = state.done ? target : latest; }; /** * Ideally this would resolve for t in a stateless way, we could * do that by always precalculating the animation but as we know * this will be done anyway we can assume that spring will * be discovered during that. */ let timeReachedBoundary; let spring$1; const checkCatchBoundary = (t) => { if (!isOutOfBounds(state.value)) return; timeReachedBoundary = t; spring$1 = spring({ keyframes: [state.value, nearestBoundary(state.value)], velocity: getGeneratorVelocity(calcLatest, t, state.value), damping: bounceDamping, stiffness: bounceStiffness, restDelta, restSpeed }); }; checkCatchBoundary(0); return { calculatedDuration: null, next: (t) => { /** * We need to resolve the friction to figure out if we need a * spring but we don't want to do this twice per frame. So here * we flag if we updated for this frame and later if we did * we can skip doing it again. */ let hasUpdatedFrame = false; if (!spring$1 && timeReachedBoundary === void 0) { hasUpdatedFrame = true; applyFriction(t); checkCatchBoundary(t); } /** * If we have a spring and the provided t is beyond the moment the friction * animation crossed the min/max boundary, use the spring. */ if (timeReachedBoundary !== void 0 && t >= timeReachedBoundary) return spring$1.next(t - timeReachedBoundary); else { !hasUpdatedFrame && applyFriction(t); return state; } } }; } function createMixers(output, ease, customMixer) { const mixers = []; const mixerFactory = customMixer || MotionGlobalConfig.mix || mix; const numMixers = output.length - 1; for (let i = 0; i < numMixers; i++) { let mixer = mixerFactory(output[i], output[i + 1]); if (ease) mixer = pipe(Array.isArray(ease) ? ease[i] || noop$4 : ease, mixer); mixers.push(mixer); } return mixers; } /** * Create a function that maps from a numerical input array to a generic output array. * * Accepts: * - Numbers * - Colors (hex, hsl, hsla, rgb, rgba) * - Complex (combinations of one or more numbers or strings) * * ```jsx * const mixColor = interpolate([0, 1], ['#fff', '#000']) * * mixColor(0.5) // 'rgba(128, 128, 128, 1)' * ``` * * TODO Revisit this approach once we've moved to data models for values, * probably not needed to pregenerate mixer functions. * * @public */ function interpolate(input, output, { clamp: isClamp = true, ease, mixer } = {}) { const inputLength = input.length; output.length; /** * If we're only provided a single input, we can just make a function * that returns the output. */ if (inputLength === 1) return () => output[0]; if (inputLength === 2 && output[0] === output[1]) return () => output[1]; const isZeroDeltaRange = input[0] === input[1]; if (input[0] > input[inputLength - 1]) { input = [...input].reverse(); output = [...output].reverse(); } const mixers = createMixers(output, ease, mixer); const numMixers = mixers.length; const interpolator = (v) => { if (isZeroDeltaRange && v < input[0]) return output[0]; let i = 0; if (numMixers > 1) { for (; i < input.length - 2; i++) if (v < input[i + 1]) break; } const progressInRange = /* @__PURE__ */ progress(input[i], input[i + 1], v); return mixers[i](progressInRange); }; return isClamp ? (v) => interpolator(clamp(input[0], input[inputLength - 1], v)) : interpolator; } function fillOffset(offset, remaining) { const min = offset[offset.length - 1]; for (let i = 1; i <= remaining; i++) { const offsetProgress = /* @__PURE__ */ progress(0, remaining, i); offset.push(mixNumber$1(min, 1, offsetProgress)); } } function defaultOffset(arr) { const offset = [0]; fillOffset(offset, arr.length - 1); return offset; } function convertOffsetToTimes(offset, duration) { return offset.map((o) => o * duration); } function defaultEasing(values, easing) { return values.map(() => easing || easeInOut).splice(0, values.length - 1); } function keyframes({ duration = 300, keyframes: keyframeValues, times, ease = "easeInOut" }) { /** * Easing functions can be externally defined as strings. Here we convert them * into actual functions. */ const easingFunctions = /* @__PURE__ */ isEasingArray(ease) ? ease.map(easingDefinitionToFunction) : easingDefinitionToFunction(ease); /** * This is the Iterator-spec return value. We ensure it's mutable rather than using a generator * to reduce GC during animation. */ const state = { done: false, value: keyframeValues[0] }; const mapTimeToKeyframe = interpolate(convertOffsetToTimes(times && times.length === keyframeValues.length ? times : defaultOffset(keyframeValues), duration), keyframeValues, { ease: Array.isArray(easingFunctions) ? easingFunctions : defaultEasing(keyframeValues, easingFunctions) }); return { calculatedDuration: duration, next: (t) => { state.value = mapTimeToKeyframe(t); state.done = t >= duration; return state; } }; } var isNotNull = (value) => value !== null; function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe, speed = 1) { const resolvedKeyframes = keyframes.filter(isNotNull); const index = speed < 0 || repeat && repeatType !== "loop" && repeat % 2 === 1 ? 0 : resolvedKeyframes.length - 1; return !index || finalKeyframe === void 0 ? resolvedKeyframes[index] : finalKeyframe; } var transitionTypeMap = { decay: inertia, inertia, tween: keyframes, keyframes, spring }; function replaceTransitionType(transition) { if (typeof transition.type === "string") transition.type = transitionTypeMap[transition.type]; } var WithPromise = class { constructor() { this.updateFinished(); } get finished() { return this._finished; } updateFinished() { this._finished = new Promise((resolve) => { this.resolve = resolve; }); } notifyFinished() { this.resolve(); } /** * Allows the animation to be awaited. * * @deprecated Use `finished` instead. */ then(onResolve, onReject) { return this.finished.then(onResolve, onReject); } }; var percentToProgress = (percent) => percent / 100; var JSAnimation = class extends WithPromise { constructor(options) { super(); this.state = "idle"; this.startTime = null; this.isStopped = false; /** * The current time of the animation. */ this.currentTime = 0; /** * The time at which the animation was paused. */ this.holdTime = null; /** * Playback speed as a factor. 0 would be stopped, -1 reverse and 2 double speed. */ this.playbackSpeed = 1; /** * Reusable state object for the delay phase to avoid * allocating a new object every frame. */ this.delayState = { done: false, value: void 0 }; /** * This method is bound to the instance to fix a pattern where * animation.stop is returned as a reference from a useEffect. */ this.stop = () => { const { motionValue } = this.options; if (motionValue && motionValue.updatedAt !== time.now()) this.tick(time.now()); this.isStopped = true; if (this.state === "idle") return; this.teardown(); this.options.onStop?.(); }; this.options = options; this.initAnimation(); this.play(); if (options.autoplay === false) this.pause(); } initAnimation() { const { options } = this; replaceTransitionType(options); const { type = keyframes, repeat = 0, repeatDelay = 0, repeatType, velocity = 0 } = options; let { keyframes: keyframes$1 } = options; const generatorFactory = type || keyframes; if (generatorFactory !== keyframes && typeof keyframes$1[0] !== "number") { this.mixKeyframes = pipe(percentToProgress, mix(keyframes$1[0], keyframes$1[1])); keyframes$1 = [0, 100]; } const generator = generatorFactory({ ...options, keyframes: keyframes$1 }); /** * If we have a mirror repeat type we need to create a second generator that outputs the * mirrored (not reversed) animation and later ping pong between the two generators. */ if (repeatType === "mirror") this.mirroredGenerator = generatorFactory({ ...options, keyframes: [...keyframes$1].reverse(), velocity: -velocity }); /** * If duration is undefined and we have repeat options, * we need to calculate a duration from the generator. * * We set it to the generator itself to cache the duration. * Any timeline resolver will need to have already precalculated * the duration by this step. */ if (generator.calculatedDuration === null) generator.calculatedDuration = calcGeneratorDuration(generator); const { calculatedDuration } = generator; this.calculatedDuration = calculatedDuration; this.resolvedDuration = calculatedDuration + repeatDelay; this.totalDuration = this.resolvedDuration * (repeat + 1) - repeatDelay; this.generator = generator; } updateTime(timestamp) { const animationTime = Math.round(timestamp - this.startTime) * this.playbackSpeed; if (this.holdTime !== null) this.currentTime = this.holdTime; else this.currentTime = animationTime; } tick(timestamp, sample = false) { const { generator, totalDuration, mixKeyframes, mirroredGenerator, resolvedDuration, calculatedDuration } = this; if (this.startTime === null) return generator.next(0); const { delay = 0, keyframes, repeat, repeatType, repeatDelay, type, onUpdate, finalKeyframe } = this.options; /** * requestAnimationFrame timestamps can come through as lower than * the startTime as set by performance.now(). Here we prevent this, * though in the future it could be possible to make setting startTime * a pending operation that gets resolved here. */ if (this.speed > 0) this.startTime = Math.min(this.startTime, timestamp); else if (this.speed < 0) this.startTime = Math.min(timestamp - totalDuration / this.speed, this.startTime); if (sample) this.currentTime = timestamp; else this.updateTime(timestamp); const timeWithoutDelay = this.currentTime - delay * (this.playbackSpeed >= 0 ? 1 : -1); const isInDelayPhase = this.playbackSpeed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration; this.currentTime = Math.max(timeWithoutDelay, 0); if (this.state === "finished" && this.holdTime === null) this.currentTime = totalDuration; let elapsed = this.currentTime; let frameGenerator = generator; if (repeat) { /** * Get the current progress (0-1) of the animation. If t is > * than duration we'll get values like 2.5 (midway through the * third iteration) */ const progress = Math.min(this.currentTime, totalDuration) / resolvedDuration; /** * Get the current iteration (0 indexed). For instance the floor of * 2.5 is 2. */ let currentIteration = Math.floor(progress); /** * Get the current progress of the iteration by taking the remainder * so 2.5 is 0.5 through iteration 2 */ let iterationProgress = progress % 1; /** * If iteration progress is 1 we count that as the end * of the previous iteration. */ if (!iterationProgress && progress >= 1) iterationProgress = 1; iterationProgress === 1 && currentIteration--; currentIteration = Math.min(currentIteration, repeat + 1); if (Boolean(currentIteration % 2)) { if (repeatType === "reverse") { iterationProgress = 1 - iterationProgress; if (repeatDelay) iterationProgress -= repeatDelay / resolvedDuration; } else if (repeatType === "mirror") frameGenerator = mirroredGenerator; } elapsed = clamp(0, 1, iterationProgress) * resolvedDuration; } /** * If we're in negative time, set state as the initial keyframe. * This prevents delay: x, duration: 0 animations from finishing * instantly. */ let state; if (isInDelayPhase) { this.delayState.value = keyframes[0]; state = this.delayState; } else state = frameGenerator.next(elapsed); if (mixKeyframes && !isInDelayPhase) state.value = mixKeyframes(state.value); let { done } = state; if (!isInDelayPhase && calculatedDuration !== null) done = this.playbackSpeed >= 0 ? this.currentTime >= totalDuration : this.currentTime <= 0; const isAnimationFinished = this.holdTime === null && (this.state === "finished" || this.state === "running" && done); if (isAnimationFinished && type !== inertia) state.value = getFinalKeyframe(keyframes, this.options, finalKeyframe, this.speed); if (onUpdate) onUpdate(state.value); if (isAnimationFinished) this.finish(); return state; } /** * Allows the returned animation to be awaited or promise-chained. Currently * resolves when the animation finishes at all but in a future update could/should * reject if its cancels. */ then(resolve, reject) { return this.finished.then(resolve, reject); } get duration() { return /* @__PURE__ */ millisecondsToSeconds(this.calculatedDuration); } get iterationDuration() { const { delay = 0 } = this.options || {}; return this.duration + /* @__PURE__ */ millisecondsToSeconds(delay); } get time() { return /* @__PURE__ */ millisecondsToSeconds(this.currentTime); } set time(newTime) { newTime = /* @__PURE__ */ secondsToMilliseconds(newTime); this.currentTime = newTime; if (this.startTime === null || this.holdTime !== null || this.playbackSpeed === 0) this.holdTime = newTime; else if (this.driver) this.startTime = this.driver.now() - newTime / this.playbackSpeed; if (this.driver) this.driver.start(false); else { this.startTime = 0; this.state = "paused"; this.holdTime = newTime; this.tick(newTime); } } /** * Returns the generator's velocity at the current time in units/second. * Uses the analytical derivative when available (springs), avoiding * the MotionValue's frame-dependent velocity estimation. */ getGeneratorVelocity() { const t = this.currentTime; if (t <= 0) return this.options.velocity || 0; if (this.generator.velocity) return this.generator.velocity(t); const current = this.generator.next(t).value; return getGeneratorVelocity((s) => this.generator.next(s).value, t, current); } get speed() { return this.playbackSpeed; } set speed(newSpeed) { const hasChanged = this.playbackSpeed !== newSpeed; if (hasChanged && this.driver) this.updateTime(time.now()); this.playbackSpeed = newSpeed; if (hasChanged && this.driver) this.time = /* @__PURE__ */ millisecondsToSeconds(this.currentTime); } play() { if (this.isStopped) return; const { driver = frameloopDriver, startTime } = this.options; if (!this.driver) this.driver = driver((timestamp) => this.tick(timestamp)); this.options.onPlay?.(); const now = this.driver.now(); if (this.state === "finished") { this.updateFinished(); this.startTime = now; } else if (this.holdTime !== null) this.startTime = now - this.holdTime; else if (!this.startTime) this.startTime = startTime ?? now; if (this.state === "finished" && this.speed < 0) this.startTime += this.calculatedDuration; this.holdTime = null; /** * Set playState to running only after we've used it in * the previous logic. */ this.state = "running"; this.driver.start(); } pause() { this.state = "paused"; this.updateTime(time.now()); this.holdTime = this.currentTime; } complete() { if (this.state !== "running") this.play(); this.state = "finished"; this.holdTime = null; } finish() { this.notifyFinished(); this.teardown(); this.state = "finished"; this.options.onComplete?.(); } cancel() { this.holdTime = null; this.startTime = 0; this.tick(0); this.teardown(); this.options.onCancel?.(); } teardown() { this.state = "idle"; this.stopDriver(); this.startTime = this.holdTime = null; } stopDriver() { if (!this.driver) return; this.driver.stop(); this.driver = void 0; } sample(sampleTime) { this.startTime = 0; return this.tick(sampleTime, true); } attachTimeline(timeline) { if (this.options.allowFlatten) { this.options.type = "keyframes"; this.options.ease = "linear"; this.initAnimation(); } this.driver?.stop(); return timeline.observe(this); } }; function fillWildcards(keyframes) { for (let i = 1; i < keyframes.length; i++) keyframes[i] ?? (keyframes[i] = keyframes[i - 1]); } var radToDeg = (rad) => rad * 180 / Math.PI; var rotate = (v) => { return rebaseAngle(radToDeg(Math.atan2(v[1], v[0]))); }; var matrix2dParsers = { x: 4, y: 5, translateX: 4, translateY: 5, scaleX: 0, scaleY: 3, scale: (v) => (Math.abs(v[0]) + Math.abs(v[3])) / 2, rotate, rotateZ: rotate, skewX: (v) => radToDeg(Math.atan(v[1])), skewY: (v) => radToDeg(Math.atan(v[2])), skew: (v) => (Math.abs(v[1]) + Math.abs(v[2])) / 2 }; var rebaseAngle = (angle) => { angle = angle % 360; if (angle < 0) angle += 360; return angle; }; var rotateZ = rotate; var scaleX = (v) => Math.sqrt(v[0] * v[0] + v[1] * v[1]); var scaleY = (v) => Math.sqrt(v[4] * v[4] + v[5] * v[5]); var matrix3dParsers = { x: 12, y: 13, z: 14, translateX: 12, translateY: 13, translateZ: 14, scaleX, scaleY, scale: (v) => (scaleX(v) + scaleY(v)) / 2, rotateX: (v) => rebaseAngle(radToDeg(Math.atan2(v[6], v[5]))), rotateY: (v) => rebaseAngle(radToDeg(Math.atan2(-v[2], v[0]))), rotateZ, rotate: rotateZ, skewX: (v) => radToDeg(Math.atan(v[4])), skewY: (v) => radToDeg(Math.atan(v[1])), skew: (v) => (Math.abs(v[1]) + Math.abs(v[4])) / 2 }; function defaultTransformValue(name) { return name.includes("scale") ? 1 : 0; } function parseValueFromTransform(transform, name) { if (!transform || transform === "none") return defaultTransformValue(name); const matrix3dMatch = transform.match(/^matrix3d\(([-\d.e\s,]+)\)$/u); let parsers; let match; if (matrix3dMatch) { parsers = matrix3dParsers; match = matrix3dMatch; } else { const matrix2dMatch = transform.match(/^matrix\(([-\d.e\s,]+)\)$/u); parsers = matrix2dParsers; match = matrix2dMatch; } if (!match) return defaultTransformValue(name); const valueParser = parsers[name]; const values = match[1].split(",").map(convertTransformToNumber); return typeof valueParser === "function" ? valueParser(values) : values[valueParser]; } var readTransformValue = (instance, name) => { const { transform = "none" } = getComputedStyle(instance); return parseValueFromTransform(transform, name); }; function convertTransformToNumber(value) { return parseFloat(value.trim()); } /** * Generate a list of every possible transform key. */ var transformPropOrder = [ "transformPerspective", "x", "y", "z", "translateX", "translateY", "translateZ", "scale", "scaleX", "scaleY", "rotate", "rotateX", "rotateY", "rotateZ", "skew", "skewX", "skewY" ]; /** * A quick lookup for transform props. * * `pathRotation` is a transform for routing purposes (skipped from raw * style application, wired to the transform composite, flags transform * dirty) but is intentionally NOT in `transformPropOrder` — it is * composed onto `rotate` at the build sites, not serialized in its own * slot, and must stay out of the order-array consumers (parse-transform, * unit-conversion, keys-position). */ var transformProps = /*@__PURE__*/ (() => /* @__PURE__ */ new Set([...transformPropOrder, "pathRotation"]))(); var isNumOrPxType = (v) => v === number || v === px; var transformKeys = /* @__PURE__ */ new Set([ "x", "y", "z" ]); var nonTranslationalTransformKeys = transformPropOrder.filter((key) => !transformKeys.has(key)); function removeNonTranslationalTransform(visualElement) { const removedTransforms = []; nonTranslationalTransformKeys.forEach((key) => { const value = visualElement.getValue(key); if (value !== void 0) { removedTransforms.push([key, value.get()]); value.set(key.startsWith("scale") ? 1 : 0); } }); return removedTransforms; } var positionalValues = { width: ({ x }, { paddingLeft = "0", paddingRight = "0", boxSizing }) => { const width = x.max - x.min; return boxSizing === "border-box" ? width : width - parseFloat(paddingLeft) - parseFloat(paddingRight); }, height: ({ y }, { paddingTop = "0", paddingBottom = "0", boxSizing }) => { const height = y.max - y.min; return boxSizing === "border-box" ? height : height - parseFloat(paddingTop) - parseFloat(paddingBottom); }, top: (_bbox, { top }) => parseFloat(top), left: (_bbox, { left }) => parseFloat(left), bottom: ({ y }, { top }) => parseFloat(top) + (y.max - y.min), right: ({ x }, { left }) => parseFloat(left) + (x.max - x.min), x: (_bbox, { transform }) => parseValueFromTransform(transform, "x"), y: (_bbox, { transform }) => parseValueFromTransform(transform, "y") }; positionalValues.translateX = positionalValues.x; positionalValues.translateY = positionalValues.y; var toResolve = /* @__PURE__ */ new Set(); var isScheduled = false; var anyNeedsMeasurement = false; var isForced = false; function measureAllKeyframes() { if (anyNeedsMeasurement) { const resolversToMeasure = Array.from(toResolve).filter((resolver) => resolver.needsMeasurement); const elementsToMeasure = new Set(resolversToMeasure.map((resolver) => resolver.element)); const transformsToRestore = /* @__PURE__ */ new Map(); /** * Write pass * If we're measuring elements we want to remove bounding box-changing transforms. */ elementsToMeasure.forEach((element) => { const removedTransforms = removeNonTranslationalTransform(element); if (!removedTransforms.length) return; transformsToRestore.set(element, removedTransforms); element.render(); }); resolversToMeasure.forEach((resolver) => resolver.measureInitialState()); elementsToMeasure.forEach((element) => { element.render(); const restore = transformsToRestore.get(element); if (restore) restore.forEach(([key, value]) => { element.getValue(key)?.set(value); }); }); resolversToMeasure.forEach((resolver) => resolver.measureEndState()); resolversToMeasure.forEach((resolver) => { if (resolver.suspendedScrollY !== void 0) window.scrollTo(0, resolver.suspendedScrollY); }); } anyNeedsMeasurement = false; isScheduled = false; toResolve.forEach((resolver) => resolver.complete(isForced)); toResolve.clear(); } function readAllKeyframes() { toResolve.forEach((resolver) => { resolver.readKeyframes(); if (resolver.needsMeasurement) anyNeedsMeasurement = true; }); } function flushKeyframeResolvers() { isForced = true; readAllKeyframes(); measureAllKeyframes(); isForced = false; } var KeyframeResolver = class { constructor(unresolvedKeyframes, onComplete, name, motionValue, element, isAsync = false) { this.state = "pending"; /** * Track whether this resolver is async. If it is, it'll be added to the * resolver queue and flushed in the next frame. Resolvers that aren't going * to trigger read/write thrashing don't need to be async. */ this.isAsync = false; /** * Track whether this resolver needs to perform a measurement * to resolve its keyframes. */ this.needsMeasurement = false; this.unresolvedKeyframes = [...unresolvedKeyframes]; this.onComplete = onComplete; this.name = name; this.motionValue = motionValue; this.element = element; this.isAsync = isAsync; } scheduleResolve() { this.state = "scheduled"; if (this.isAsync) { toResolve.add(this); if (!isScheduled) { isScheduled = true; frame.read(readAllKeyframes); frame.resolveKeyframes(measureAllKeyframes); } } else { this.readKeyframes(); this.complete(); } } readKeyframes() { const { unresolvedKeyframes, name, element, motionValue } = this; if (unresolvedKeyframes[0] === null) { const currentValue = motionValue?.get(); const finalKeyframe = unresolvedKeyframes[unresolvedKeyframes.length - 1]; if (currentValue !== void 0) unresolvedKeyframes[0] = currentValue; else if (element && name) { const valueAsRead = element.readValue(name, finalKeyframe); if (valueAsRead !== void 0 && valueAsRead !== null) unresolvedKeyframes[0] = valueAsRead; } if (unresolvedKeyframes[0] === void 0) unresolvedKeyframes[0] = finalKeyframe; if (motionValue && currentValue === void 0) motionValue.set(unresolvedKeyframes[0]); } fillWildcards(unresolvedKeyframes); } setFinalKeyframe() {} measureInitialState() {} renderEndStyles() {} measureEndState() {} complete(isForcedComplete = false) { this.state = "complete"; this.onComplete(this.unresolvedKeyframes, this.finalKeyframe, isForcedComplete); toResolve.delete(this); } cancel() { if (this.state === "scheduled") { toResolve.delete(this); this.state = "pending"; } } resume() { if (this.state === "pending") this.scheduleResolve(); } }; var isCSSVar = (name) => name.startsWith("--"); function setStyle(element, name, value) { isCSSVar(name) ? element.style.setProperty(name, value) : element.style[name] = value; } /** * Add the ability for test suites to manually set support flags * to better test more environments. */ var supportsFlags = {}; function memoSupports(callback, supportsFlag) { const memoized = /* @__PURE__ */ memo(callback); return () => supportsFlags[supportsFlag] ?? memoized(); } var supportsScrollTimeline = /* @__PURE__ */ memoSupports(() => window.ScrollTimeline !== void 0, "scrollTimeline"); var supportsLinearEasing = /*@__PURE__*/ memoSupports(() => { try { document.createElement("div").animate({ opacity: 0 }, { easing: "linear(0, 1)" }); } catch (e) { return false; } return true; }, "linearEasing"); var cubicBezierAsString = ([a, b, c, d]) => `cubic-bezier(${a}, ${b}, ${c}, ${d})`; var supportedWaapiEasing = { linear: "linear", ease: "ease", easeIn: "ease-in", easeOut: "ease-out", easeInOut: "ease-in-out", circIn: /*@__PURE__*/ cubicBezierAsString([ 0, .65, .55, 1 ]), circOut: /*@__PURE__*/ cubicBezierAsString([ .55, 0, 1, .45 ]), backIn: /*@__PURE__*/ cubicBezierAsString([ .31, .01, .66, -0.59 ]), backOut: /*@__PURE__*/ cubicBezierAsString([ .33, 1.53, .69, .99 ]) }; function mapEasingToNativeEasing(easing, duration) { if (!easing) return; else if (typeof easing === "function") return supportsLinearEasing() ? generateLinearEasing(easing, duration) : "ease-out"; else if (/* @__PURE__ */ isBezierDefinition(easing)) return cubicBezierAsString(easing); else if (Array.isArray(easing)) return easing.map((segmentEasing) => mapEasingToNativeEasing(segmentEasing, duration) || supportedWaapiEasing.easeOut); else return supportedWaapiEasing[easing]; } function startWaapiAnimation(element, valueName, keyframes, { delay = 0, duration = 300, repeat = 0, repeatType = "loop", ease = "easeOut", times } = {}, pseudoElement = void 0) { const keyframeOptions = { [valueName]: keyframes }; if (times) keyframeOptions.offset = times; const easing = mapEasingToNativeEasing(ease, duration); /** * If this is an easing array, apply to keyframes, not animation as a whole */ if (Array.isArray(easing)) keyframeOptions.easing = easing; const options = { delay, duration, easing: !Array.isArray(easing) ? easing : "linear", fill: "both", iterations: repeat + 1, direction: repeatType === "reverse" ? "alternate" : "normal" }; if (pseudoElement) options.pseudoElement = pseudoElement; return element.animate(keyframeOptions, options); } function isGenerator(type) { return typeof type === "function" && "applyToOptions" in type; } function applyGeneratorOptions({ type, ...options }) { if (isGenerator(type) && supportsLinearEasing()) return type.applyToOptions(options); else { options.duration ?? (options.duration = 300); options.ease ?? (options.ease = "easeOut"); } return options; } /** * NativeAnimation implements AnimationPlaybackControls for the browser's Web Animations API. */ var NativeAnimation = class extends WithPromise { constructor(options) { super(); this.finishedTime = null; this.isStopped = false; /** * Tracks a manually-set start time that takes precedence over WAAPI's * dynamic startTime. This is cleared when play() or time setter is called, * allowing WAAPI to take over timing. */ this.manualStartTime = null; if (!options) return; const { element, name, keyframes, pseudoElement, allowFlatten = false, finalKeyframe, onComplete } = options; this.isPseudoElement = Boolean(pseudoElement); this.allowFlatten = allowFlatten; this.options = options; options.type; const transition = applyGeneratorOptions(options); this.animation = startWaapiAnimation(element, name, keyframes, transition, pseudoElement); if (transition.autoplay === false) this.animation.pause(); this.animation.onfinish = () => { this.finishedTime = this.time; if (!pseudoElement) { const keyframe = getFinalKeyframe(keyframes, this.options, finalKeyframe, this.speed); if (this.updateMotionValue) this.updateMotionValue(keyframe); /** * If we can, we want to commit the final style as set by the user, * rather than the computed keyframe value supplied by the animation. * We always do this, even when a motion value is present, to prevent * a visual flash in Firefox where the WAAPI animation's fill is removed * during cancel() before the scheduled render can apply the correct value. */ setStyle(element, name, keyframe); this.animation.cancel(); } onComplete?.(); this.notifyFinished(); }; } play() { if (this.isStopped) return; this.manualStartTime = null; this.animation.play(); if (this.state === "finished") this.updateFinished(); } pause() { this.animation.pause(); } complete() { this.animation.finish?.(); } cancel() { try { this.animation.cancel(); } catch (e) {} } stop() { if (this.isStopped) return; this.isStopped = true; const { state } = this; if (state === "idle" || state === "finished") return; if (this.updateMotionValue) this.updateMotionValue(); else this.commitStyles(); if (!this.isPseudoElement) this.cancel(); } /** * WAAPI doesn't natively have any interruption capabilities. * * In this method, we commit styles back to the DOM before cancelling * the animation. * * This is designed to be overridden by NativeAnimationExtended, which * will create a renderless JS animation and sample it twice to calculate * its current value, "previous" value, and therefore allow * Motion to also correctly calculate velocity for any subsequent animation * while deferring the commit until the next animation frame. */ commitStyles() { const element = this.options?.element; if (!this.isPseudoElement && element?.isConnected) this.animation.commitStyles?.(); } get duration() { const duration = this.animation.effect?.getComputedTiming?.().duration || 0; return /* @__PURE__ */ millisecondsToSeconds(Number(duration)); } get iterationDuration() { const { delay = 0 } = this.options || {}; return this.duration + /* @__PURE__ */ millisecondsToSeconds(delay); } get time() { return /* @__PURE__ */ millisecondsToSeconds(Number(this.animation.currentTime) || 0); } set time(newTime) { const wasFinished = this.finishedTime !== null; this.manualStartTime = null; this.finishedTime = null; this.animation.currentTime = /* @__PURE__ */ secondsToMilliseconds(newTime); if (wasFinished) this.animation.pause(); } /** * The playback speed of the animation. * 1 = normal speed, 2 = double speed, 0.5 = half speed. */ get speed() { return this.animation.playbackRate; } set speed(newSpeed) { if (newSpeed < 0) this.finishedTime = null; this.animation.playbackRate = newSpeed; } get state() { return this.finishedTime !== null ? "finished" : this.animation.playState; } get startTime() { return this.manualStartTime ?? Number(this.animation.startTime); } set startTime(newStartTime) { this.manualStartTime = this.animation.startTime = newStartTime; } /** * Attaches a timeline to the animation, for instance the `ScrollTimeline`. */ attachTimeline({ timeline, rangeStart, rangeEnd, observe }) { if (this.allowFlatten) this.animation.effect?.updateTiming({ easing: "linear" }); this.animation.onfinish = null; if (timeline && supportsScrollTimeline()) { this.animation.timeline = timeline; if (rangeStart) this.animation.rangeStart = rangeStart; if (rangeEnd) this.animation.rangeEnd = rangeEnd; return noop$4; } else return observe(this); } }; var unsupportedEasingFunctions = { anticipate, backInOut, circInOut }; function isUnsupportedEase(key) { return key in unsupportedEasingFunctions; } function replaceStringEasing(transition) { if (typeof transition.ease === "string" && isUnsupportedEase(transition.ease)) transition.ease = unsupportedEasingFunctions[transition.ease]; } /** * 10ms is chosen here as it strikes a balance between smooth * results (more than one keyframe per frame at 60fps) and * keyframe quantity. */ var sampleDelta = 10; var NativeAnimationExtended = class extends NativeAnimation { constructor(options) { /** * The base NativeAnimation function only supports a subset * of Motion easings, and WAAPI also only supports some * easing functions via string/cubic-bezier definitions. * * This function replaces those unsupported easing functions * with a JS easing function. This will later get compiled * to a linear() easing function. */ replaceStringEasing(options); /** * Ensure we replace the transition type with a generator function * before passing to WAAPI. * * TODO: Does this have a better home? It could be shared with * JSAnimation. */ replaceTransitionType(options); super(options); /** * Only set startTime when the animation should autoplay. * Setting startTime on a paused WAAPI animation unpauses it * (per the WAAPI spec), which breaks autoplay: false. */ if (options.startTime !== void 0 && options.autoplay !== false) this.startTime = options.startTime; this.options = options; } /** * WAAPI doesn't natively have any interruption capabilities. * * Rather than read committed styles back out of the DOM, we can * create a renderless JS animation and sample it twice to calculate * its current value, "previous" value, and therefore allow * Motion to calculate velocity for any subsequent animation. */ updateMotionValue(value) { const { motionValue, onUpdate, onComplete, element, ...options } = this.options; if (!motionValue) return; if (value !== void 0) { motionValue.set(value); return; } const sampleAnimation = new JSAnimation({ ...options, autoplay: false }); /** * Use wall-clock elapsed time for sampling. * Under CPU load, WAAPI's currentTime may not reflect actual * elapsed time, causing incorrect sampling and visual jumps. */ const sampleTime = Math.max(sampleDelta, time.now() - this.startTime); const delta = clamp(0, sampleDelta, sampleTime - sampleDelta); const current = sampleAnimation.sample(sampleTime).value; /** * Write the estimated value to inline style so it persists * after cancel(), covering the async gap before the next * animation starts. */ const { name } = this.options; if (element && name) setStyle(element, name, current); motionValue.setWithVelocity(sampleAnimation.sample(Math.max(0, sampleTime - delta)).value, current, delta); sampleAnimation.stop(); } }; /** * Check if a value is animatable. Examples: * * ✅: 100, "100px", "#fff" * ❌: "block", "url(2.jpg)" * @param value * * @internal */ var isAnimatable = (value, name) => { if (name === "zIndex") return false; if (typeof value === "number" || Array.isArray(value)) return true; if (typeof value === "string" && (complex.test(value) || value === "0") && !value.startsWith("url(")) return true; return false; }; function hasKeyframesChanged(keyframes) { const current = keyframes[0]; if (keyframes.length === 1) return true; for (let i = 0; i < keyframes.length; i++) if (keyframes[i] !== current) return true; } function canAnimate(keyframes, name, type, velocity) { /** * Check if we're able to animate between the start and end keyframes, * and throw a warning if we're attempting to animate between one that's * animatable and another that isn't. */ const originKeyframe = keyframes[0]; if (originKeyframe === null) return false; /** * These aren't traditionally animatable but we do support them. * In future we could look into making this more generic or replacing * this function with mix() === mixImmediate */ if (name === "display" || name === "visibility") return true; const targetKeyframe = keyframes[keyframes.length - 1]; const isOriginAnimatable = isAnimatable(originKeyframe, name); const isTargetAnimatable = isAnimatable(targetKeyframe, name); if (!isOriginAnimatable || !isTargetAnimatable) return false; return hasKeyframesChanged(keyframes) || (type === "spring" || isGenerator(type)) && velocity; } function makeAnimationInstant(options) { options.duration = 0; options.type = "keyframes"; } /** * A list of values that can be hardware-accelerated. */ var acceleratedValues = /* @__PURE__ */ new Set([ "opacity", "clipPath", "filter", "transform" ]); var browserColorFunctions = /^(?:oklch|oklab|lab|lch|color|color-mix|light-dark)\(/; function hasBrowserOnlyColors(keyframes) { for (let i = 0; i < keyframes.length; i++) if (typeof keyframes[i] === "string" && browserColorFunctions.test(keyframes[i])) return true; return false; } var colorProperties = /* @__PURE__ */ new Set([ "color", "backgroundColor", "outlineColor", "fill", "stroke", "borderColor", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor" ]); var supportsWaapi = /*@__PURE__*/ memo(() => Object.hasOwnProperty.call(Element.prototype, "animate")); function supportsBrowserAnimation(options) { const { motionValue, name, repeatDelay, repeatType, damping, type, keyframes } = options; /** * We use this check instead of isHTMLElement() because we explicitly * **don't** want elements in different timing contexts (i.e. popups) * to be accelerated, as it's not possible to sync these animations * properly with those driven from the main window frameloop. */ if (!(motionValue?.owner?.current instanceof HTMLElement)) return false; const { onUpdate, transformTemplate } = motionValue.owner.getProps(); return supportsWaapi() && name && (acceleratedValues.has(name) || colorProperties.has(name) && hasBrowserOnlyColors(keyframes)) && (name !== "transform" || !transformTemplate) && !onUpdate && !repeatDelay && repeatType !== "mirror" && damping !== 0 && type !== "inertia"; } /** * Maximum time allowed between an animation being created and it being * resolved for us to use the latter as the start time. * * This is to ensure that while we prefer to "start" an animation as soon * as it's triggered, we also want to avoid a visual jump if there's a big delay * between these two moments. */ var MAX_RESOLVE_DELAY = 40; var AsyncMotionValueAnimation = class extends WithPromise { constructor({ autoplay = true, delay = 0, type = "keyframes", repeat = 0, repeatDelay = 0, repeatType = "loop", keyframes, name, motionValue, element, ...options }) { super(); /** * Bound to support return animation.stop pattern */ this.stop = () => { if (this._animation) { this._animation.stop(); this.stopTimeline?.(); } this.keyframeResolver?.cancel(); }; this.createdAt = time.now(); const optionsWithDefaults = { autoplay, delay, type, repeat, repeatDelay, repeatType, name, motionValue, element, ...options }; const KeyframeResolver$1 = element?.KeyframeResolver || KeyframeResolver; this.keyframeResolver = new KeyframeResolver$1(keyframes, (resolvedKeyframes, finalKeyframe, forced) => this.onKeyframesResolved(resolvedKeyframes, finalKeyframe, optionsWithDefaults, !forced), name, motionValue, element); this.keyframeResolver?.scheduleResolve(); } onKeyframesResolved(keyframes, finalKeyframe, options, sync) { this.keyframeResolver = void 0; const { name, type, velocity, delay, isHandoff, onUpdate } = options; this.resolvedAt = time.now(); /** * If we can't animate this value with the resolved keyframes * then we should complete it immediately. */ let canAnimateValue = true; if (!canAnimate(keyframes, name, type, velocity)) { canAnimateValue = false; if (MotionGlobalConfig.instantAnimations || !delay) onUpdate?.(getFinalKeyframe(keyframes, options, finalKeyframe)); keyframes[0] = keyframes[keyframes.length - 1]; makeAnimationInstant(options); options.repeat = 0; } const resolvedOptions = { startTime: sync ? !this.resolvedAt ? this.createdAt : this.resolvedAt - this.createdAt > MAX_RESOLVE_DELAY ? this.resolvedAt : this.createdAt : void 0, finalKeyframe, ...options, keyframes }; /** * Animate via WAAPI if possible. If this is a handoff animation, the optimised animation will be running via * WAAPI. Therefore, this animation must be JS to ensure it runs "under" the * optimised animation. * * Also skip WAAPI when keyframes aren't animatable, as the resolved * values may not be valid CSS and would trigger browser warnings. */ const useWaapi = canAnimateValue && !isHandoff && supportsBrowserAnimation(resolvedOptions); const element = resolvedOptions.motionValue?.owner?.current; let animation; if (useWaapi) try { animation = new NativeAnimationExtended({ ...resolvedOptions, element }); } catch { animation = new JSAnimation(resolvedOptions); } else animation = new JSAnimation(resolvedOptions); animation.finished.then(() => { this.notifyFinished(); }).catch(noop$4); if (this.pendingTimeline) { this.stopTimeline = animation.attachTimeline(this.pendingTimeline); this.pendingTimeline = void 0; } this._animation = animation; } get finished() { if (!this._animation) return this._finished; else return this.animation.finished; } then(onResolve, _onReject) { return this.finished.finally(onResolve).then(() => {}); } get animation() { if (!this._animation) { this.keyframeResolver?.resume(); flushKeyframeResolvers(); } return this._animation; } get duration() { return this.animation.duration; } get iterationDuration() { return this.animation.iterationDuration; } get time() { return this.animation.time; } set time(newTime) { this.animation.time = newTime; } get speed() { return this.animation.speed; } get state() { return this.animation.state; } set speed(newSpeed) { this.animation.speed = newSpeed; } get startTime() { return this.animation.startTime; } attachTimeline(timeline) { if (this._animation) this.stopTimeline = this.animation.attachTimeline(timeline); else this.pendingTimeline = timeline; return () => this.stop(); } play() { this.animation.play(); } pause() { this.animation.pause(); } complete() { this.animation.complete(); } cancel() { if (this._animation) this.animation.cancel(); this.keyframeResolver?.cancel(); } }; var GroupAnimation = class { constructor(animations) { this.stop = () => this.runAll("stop"); this.animations = animations.filter(Boolean); } get finished() { return Promise.all(this.animations.map((animation) => animation.finished)); } /** * TODO: Filter out cancelled or stopped animations before returning */ getAll(propName) { return this.animations[0][propName]; } setAll(propName, newValue) { for (let i = 0; i < this.animations.length; i++) this.animations[i][propName] = newValue; } attachTimeline(timeline) { const subscriptions = this.animations.map((animation) => animation.attachTimeline(timeline)); return () => { subscriptions.forEach((cancel, i) => { cancel && cancel(); this.animations[i].stop(); }); }; } get time() { return this.getAll("time"); } set time(time) { this.setAll("time", time); } get speed() { return this.getAll("speed"); } set speed(speed) { this.setAll("speed", speed); } get state() { return this.getAll("state"); } get startTime() { return this.getAll("startTime"); } get duration() { return getMax(this.animations, "duration"); } get iterationDuration() { return getMax(this.animations, "iterationDuration"); } runAll(methodName) { this.animations.forEach((controls) => controls[methodName]()); } play() { this.runAll("play"); } pause() { this.runAll("pause"); } cancel() { this.runAll("cancel"); } complete() { this.runAll("complete"); } }; function getMax(animations, propName) { let max = 0; for (let i = 0; i < animations.length; i++) { const value = animations[i][propName]; if (value !== null && value > max) max = value; } return max; } var GroupAnimationWithThen = class extends GroupAnimation { then(onResolve, _onReject) { return this.finished.finally(onResolve).then(() => {}); } }; /** * Maximum time between the value of two frames, beyond which we * assume the velocity has since been 0. */ var MAX_VELOCITY_DELTA = 30; var isFloat = (value) => { return !isNaN(parseFloat(value)); }; /** * `MotionValue` is used to track the state and velocity of motion values. * * @public */ var MotionValue = class { /** * @param init - The initiating value * @param config - Optional configuration options * * - `transformer`: A function to transform incoming values with. */ constructor(init, options = {}) { /** * Tracks whether this value can output a velocity. Currently this is only true * if the value is numerical, but we might be able to widen the scope here and support * other value types. * * @internal */ this.canTrackVelocity = null; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; this.updateAndNotify = (v) => { const currentTime = time.now(); /** * If we're updating the value during another frame or eventloop * than the previous frame, then the we set the previous frame value * to current. */ if (this.updatedAt !== currentTime) this.setPrevFrameValue(); this.prev = this.current; this.setCurrent(v); if (this.current !== this.prev) { this.events.change?.notify(this.current); if (this.dependents) for (const dependent of this.dependents) dependent.dirty(); } }; this.hasAnimated = false; this.setCurrent(init); this.owner = options.owner; } setCurrent(current) { this.current = current; this.updatedAt = time.now(); if (this.canTrackVelocity === null && current !== void 0) this.canTrackVelocity = isFloat(this.current); } setPrevFrameValue(prevFrameValue = this.current) { this.prevFrameValue = prevFrameValue; this.prevUpdatedAt = this.updatedAt; } /** * Adds a function that will be notified when the `MotionValue` is updated. * * It returns a function that, when called, will cancel the subscription. * * When calling `onChange` inside a React component, it should be wrapped with the * `useEffect` hook. As it returns an unsubscribe function, this should be returned * from the `useEffect` function to ensure you don't add duplicate subscribers.. * * ```jsx * export const MyComponent = () => { * const x = useMotionValue(0) * const y = useMotionValue(0) * const opacity = useMotionValue(1) * * useEffect(() => { * function updateOpacity() { * const maxXY = Math.max(x.get(), y.get()) * const newOpacity = transform(maxXY, [0, 100], [1, 0]) * opacity.set(newOpacity) * } * * const unsubscribeX = x.on("change", updateOpacity) * const unsubscribeY = y.on("change", updateOpacity) * * return () => { * unsubscribeX() * unsubscribeY() * } * }, []) * * return * } * ``` * * @param subscriber - A function that receives the latest value. * @returns A function that, when called, will cancel this subscription. * * @deprecated */ onChange(subscription) { return this.on("change", subscription); } on(eventName, callback) { if (!this.events[eventName]) this.events[eventName] = new SubscriptionManager(); const unsubscribe = this.events[eventName].add(callback); if (eventName === "change") return () => { unsubscribe(); /** * If we have no more change listeners by the start * of the next frame, stop active animations. */ frame.read(() => { if (!this.events.change.getSize()) this.stop(); }); }; return unsubscribe; } clearListeners() { for (const eventManagers in this.events) this.events[eventManagers].clear(); } /** * Attaches a passive effect to the `MotionValue`. */ attach(passiveEffect, stopPassiveEffect) { this.passiveEffect = passiveEffect; this.stopPassiveEffect = stopPassiveEffect; } /** * Sets the state of the `MotionValue`. * * @remarks * * ```jsx * const x = useMotionValue(0) * x.set(10) * ``` * * @param latest - Latest value to set. * @param render - Whether to notify render subscribers. Defaults to `true` * * @public */ set(v) { if (!this.passiveEffect) this.updateAndNotify(v); else this.passiveEffect(v, this.updateAndNotify); } setWithVelocity(prev, current, delta) { this.set(current); this.prev = void 0; this.prevFrameValue = prev; this.prevUpdatedAt = this.updatedAt - delta; } /** * Set the state of the `MotionValue`, stopping any active animations, * effects, and resets velocity to `0`. */ jump(v, endAnimation = true) { this.updateAndNotify(v); this.prev = v; this.prevUpdatedAt = this.prevFrameValue = void 0; endAnimation && this.stop(); if (this.stopPassiveEffect) this.stopPassiveEffect(); } dirty() { this.events.change?.notify(this.current); } addDependent(dependent) { if (!this.dependents) this.dependents = /* @__PURE__ */ new Set(); this.dependents.add(dependent); } removeDependent(dependent) { if (this.dependents) this.dependents.delete(dependent); } /** * Returns the latest state of `MotionValue` * * @returns - The latest state of `MotionValue` * * @public */ get() { return this.current; } /** * @public */ getPrevious() { return this.prev; } /** * Returns the latest velocity of `MotionValue` * * @returns - The latest velocity of `MotionValue`. Returns `0` if the state is non-numerical. * * @public */ getVelocity() { const currentTime = time.now(); if (!this.canTrackVelocity || this.prevFrameValue === void 0 || currentTime - this.updatedAt > MAX_VELOCITY_DELTA) return 0; const delta = Math.min(this.updatedAt - this.prevUpdatedAt, MAX_VELOCITY_DELTA); return /* @__PURE__ */ velocityPerSecond(parseFloat(this.current) - parseFloat(this.prevFrameValue), delta); } /** * Registers a new animation to control this `MotionValue`. Only one * animation can drive a `MotionValue` at one time. * * ```jsx * value.start() * ``` * * @param animation - A function that starts the provided animation */ start(startAnimation) { this.stop(); return new Promise((resolve) => { this.hasAnimated = true; this.animation = startAnimation(resolve); if (this.events.animationStart) this.events.animationStart.notify(); }).then(() => { if (this.events.animationComplete) this.events.animationComplete.notify(); this.clearAnimation(); }); } /** * Stop the currently active animation. * * @public */ stop() { if (this.animation) { this.animation.stop(); if (this.events.animationCancel) this.events.animationCancel.notify(); } this.clearAnimation(); } /** * Returns `true` if this value is currently animating. * * @public */ isAnimating() { return !!this.animation; } clearAnimation() { delete this.animation; } /** * Destroy and clean up subscribers to this `MotionValue`. * * The `MotionValue` hooks like `useMotionValue` and `useTransform` automatically * handle the lifecycle of the returned `MotionValue`, so this method is only necessary if you've manually * created a `MotionValue` via the `motionValue` function. * * @public */ destroy() { this.dependents?.clear(); this.events.destroy?.notify(); this.clearListeners(); this.stop(); if (this.stopPassiveEffect) this.stopPassiveEffect(); } }; function motionValue(init, options) { return new MotionValue(init, options); } /** * If `transition` has `inherit: true`, shallow-merge it with * `parentTransition` (child keys win) and strip the `inherit` key. * Otherwise return `transition` unchanged. */ function resolveTransition(transition, parentTransition) { if (transition?.inherit && parentTransition) { const { inherit: _, ...rest } = transition; return { ...parentTransition, ...rest }; } return transition; } function getValueTransition$1(transition, key) { const valueTransition = transition?.[key] ?? transition?.["default"] ?? transition; if (valueTransition !== transition) return resolveTransition(valueTransition, transition); return valueTransition; } var underDampedSpring = { type: "spring", stiffness: 500, damping: 25, restSpeed: 10 }; var criticallyDampedSpring = (target) => ({ type: "spring", stiffness: 550, damping: target === 0 ? 2 * Math.sqrt(550) : 30, restSpeed: 10 }); var keyframesTransition = { type: "keyframes", duration: .8 }; /** * Default easing curve is a slightly shallower version of * the default browser easing curve. */ var ease = { type: "keyframes", ease: [ .25, .1, .35, 1 ], duration: .3 }; var getDefaultTransition = (valueKey, { keyframes }) => { if (keyframes.length > 2) return keyframesTransition; else if (transformProps.has(valueKey)) return valueKey.startsWith("scale") ? criticallyDampedSpring(keyframes[1]) : underDampedSpring; return ease; }; var orchestrationKeys = /* @__PURE__ */ new Set([ "when", "delay", "delayChildren", "staggerChildren", "staggerDirection", "repeat", "repeatType", "repeatDelay", "from", "elapsed" ]); /** * Decide whether a transition is defined on a given Transition. * This filters out orchestration options and returns true * if any options are left. */ function isTransitionDefined(transition) { for (const key in transition) if (!orchestrationKeys.has(key)) return true; return false; } var animateMotionValue = (name, value, target, transition = {}, element, isHandoff) => (onComplete) => { const valueTransition = getValueTransition$1(transition, name) || {}; /** * Most transition values are currently completely overwritten by value-specific * transitions. In the future it'd be nicer to blend these transitions. But for now * delay actually does inherit from the root transition if not value-specific. */ const delay = valueTransition.delay || transition.delay || 0; /** * Elapsed isn't a public transition option but can be passed through from * optimized appear effects in milliseconds. */ let { elapsed = 0 } = transition; elapsed = elapsed - /* @__PURE__ */ secondsToMilliseconds(delay); const options = { keyframes: Array.isArray(target) ? target : [null, target], ease: "easeOut", velocity: value.getVelocity(), ...valueTransition, delay: -elapsed, onUpdate: (v) => { value.set(v); valueTransition.onUpdate && valueTransition.onUpdate(v); }, onComplete: () => { onComplete(); valueTransition.onComplete && valueTransition.onComplete(); }, name, motionValue: value, element: isHandoff ? void 0 : element }; /** * If there's no transition defined for this value, we can generate * unique transition settings for this value. */ if (!isTransitionDefined(valueTransition)) Object.assign(options, getDefaultTransition(name, options)); /** * Both WAAPI and our internal animation functions use durations * as defined by milliseconds, while our external API defines them * as seconds. */ options.duration && (options.duration = /* @__PURE__ */ secondsToMilliseconds(options.duration)); options.repeatDelay && (options.repeatDelay = /* @__PURE__ */ secondsToMilliseconds(options.repeatDelay)); /** * Support deprecated way to set initial value. Prefer keyframe syntax. */ if (options.from !== void 0) options.keyframes[0] = options.from; let shouldSkip = false; if (options.type === false || options.duration === 0 && !options.repeatDelay) { makeAnimationInstant(options); if (options.delay === 0) shouldSkip = true; } if (MotionGlobalConfig.instantAnimations || MotionGlobalConfig.skipAnimations || element?.shouldSkipAnimations || valueTransition.skipAnimations) { shouldSkip = true; makeAnimationInstant(options); options.delay = 0; } /** * If the transition type or easing has been explicitly set by the user * then we don't want to allow flattening the animation. */ options.allowFlatten = !valueTransition.type && !valueTransition.ease; /** * If we can or must skip creating the animation, and apply only * the final keyframe, do so. We also check once keyframes are resolved but * this early check prevents the need to create an animation at all. */ if (shouldSkip && !isHandoff && value.get() !== void 0) { const finalKeyframe = getFinalKeyframe(options.keyframes, valueTransition); if (finalKeyframe !== void 0) { frame.update(() => { options.onUpdate(finalKeyframe); options.onComplete(); }); return; } } return valueTransition.isSync ? new JSAnimation(options) : new AsyncMotionValueAnimation(options); }; /** * Parse Framer's special CSS variable format into a CSS token and a fallback. * * ``` * `var(--foo, #fff)` => [`--foo`, '#fff'] * ``` * * @param current */ var splitCSSVariableRegex = /^var\(--(?:([\w-]+)|([\w-]+), ?([a-zA-Z\d ()%#.,-]+))\)/u; function parseCSSVariable(current) { const match = splitCSSVariableRegex.exec(current); if (!match) return [,]; const [, token1, token2, fallback] = match; return [`--${token1 ?? token2}`, fallback]; } function getVariableValue(current, element, depth = 1) { const [token, fallback] = parseCSSVariable(current); if (!token) return; const resolved = window.getComputedStyle(element).getPropertyValue(token); if (resolved) { const trimmed = resolved.trim(); return isNumericalString(trimmed) ? parseFloat(trimmed) : trimmed; } return isCSSVariableToken(fallback) ? getVariableValue(fallback, element, depth + 1) : fallback; } function getValueState(visualElement) { const state = [{}, {}]; visualElement?.values.forEach((value, key) => { state[0][key] = value.get(); state[1][key] = value.getVelocity(); }); return state; } function resolveVariantFromProps(props, definition, custom, visualElement) { /** * If the variant definition is a function, resolve. */ if (typeof definition === "function") { const [current, velocity] = getValueState(visualElement); definition = definition(custom !== void 0 ? custom : props.custom, current, velocity); } /** * If the variant definition is a variant label, or * the function returned a variant label, resolve. */ if (typeof definition === "string") definition = props.variants && props.variants[definition]; /** * At this point we've resolved both functions and variant labels, * but the resolved variant label might itself have been a function. * If so, resolve. This can only have returned a valid target object. */ if (typeof definition === "function") { const [current, velocity] = getValueState(visualElement); definition = definition(custom !== void 0 ? custom : props.custom, current, velocity); } return definition; } function resolveVariant(visualElement, definition, custom) { const props = visualElement.getProps(); return resolveVariantFromProps(props, definition, props.custom, visualElement); } var positionalKeys = /* @__PURE__ */ new Set([ "width", "height", "top", "left", "right", "bottom", ...transformPropOrder ]); var isKeyframesTarget = (v) => { return Array.isArray(v); }; /** * Set VisualElement's MotionValue, creating a new MotionValue for it if * it doesn't exist. */ function setMotionValue(visualElement, key, value) { if (visualElement.hasValue(key)) visualElement.getValue(key).set(value); else visualElement.addValue(key, motionValue(value)); } function resolveFinalValueInKeyframes(v) { return isKeyframesTarget(v) ? v[v.length - 1] || 0 : v; } function setTarget(visualElement, definition) { let { transitionEnd = {}, transition = {}, ...target } = resolveVariant(visualElement, definition) || {}; target = { ...target, ...transitionEnd }; for (const key in target) setMotionValue(visualElement, key, resolveFinalValueInKeyframes(target[key])); } var isMotionValue = (value) => Boolean(value && value.getVelocity); function isWillChangeMotionValue(value) { return Boolean(isMotionValue(value) && value.add); } function addValueToWillChange(visualElement, key) { const willChange = visualElement.getValue("willChange"); /** * It could be that a user has set willChange to a regular MotionValue, * in which case we can't add the value to it. */ if (isWillChangeMotionValue(willChange)) return willChange.add(key); else if (!willChange && MotionGlobalConfig.WillChange) { const newWillChange = new MotionGlobalConfig.WillChange("auto"); visualElement.addValue("willChange", newWillChange); newWillChange.add(key); } } function camelToDash(str) { return str.replace(/([A-Z])/g, (match) => `-${match.toLowerCase()}`); } var optimizedAppearDataAttribute = "data-" + camelToDash("framerAppearId"); function getOptimisedAppearId(visualElement) { return visualElement.props[optimizedAppearDataAttribute]; } /** * Decide whether we should block this animation. Previously, we achieved this * just by checking whether the key was listed in protectedKeys, but this * posed problems if an animation was triggered by afterChildren and protectedKeys * had been set to true in the meantime. */ function shouldBlockAnimation({ protectedKeys, needsAnimating }, key) { const shouldBlock = protectedKeys.hasOwnProperty(key) && needsAnimating[key] !== true; needsAnimating[key] = false; return shouldBlock; } function animateTarget(visualElement, targetAndTransition, { delay = 0, transitionOverride, type } = {}) { let { transition, transitionEnd, ...target } = targetAndTransition; const defaultTransition = visualElement.getDefaultTransition(); transition = transition ? resolveTransition(transition, defaultTransition) : defaultTransition; const reduceMotion = transition?.reduceMotion; const skipAnimations = transition?.skipAnimations; if (transitionOverride) transition = transitionOverride; const animations = []; const animationTypeState = type && visualElement.animationState && visualElement.animationState.getState()[type]; const path = transition?.path; if (path) path.animateVisualElement(visualElement, target, transition, delay, animations); for (const key in target) { const value = visualElement.getValue(key, visualElement.latestValues[key] ?? null); const valueTarget = target[key]; if (valueTarget === void 0 || animationTypeState && shouldBlockAnimation(animationTypeState, key)) continue; const valueTransition = { delay, ...getValueTransition$1(transition || {}, key) }; if (skipAnimations) valueTransition.skipAnimations = true; /** * If the value is already at the defined target, skip the animation. * We still re-assert the value via frame.update to take precedence * over any stale transitionEnd callbacks from previous animations. */ const currentValue = value.get(); if (currentValue !== void 0 && !value.isAnimating() && !Array.isArray(valueTarget) && valueTarget === currentValue && !valueTransition.velocity) { frame.update(() => value.set(valueTarget)); continue; } /** * If this is the first time a value is being animated, check * to see if we're handling off from an existing animation. */ let isHandoff = false; if (window.MotionHandoffAnimation) { const appearId = getOptimisedAppearId(visualElement); if (appearId) { const startTime = window.MotionHandoffAnimation(appearId, key, frame); if (startTime !== null) { valueTransition.startTime = startTime; isHandoff = true; } } } addValueToWillChange(visualElement, key); const shouldReduceMotion = reduceMotion ?? visualElement.shouldReduceMotion; value.start(animateMotionValue(key, value, valueTarget, shouldReduceMotion && positionalKeys.has(key) ? { type: false } : valueTransition, visualElement, isHandoff)); const animation = value.animation; if (animation) animations.push(animation); } if (transitionEnd) { const applyTransitionEnd = () => frame.update(() => { transitionEnd && setTarget(visualElement, transitionEnd); }); if (animations.length) Promise.all(animations).then(applyTransitionEnd); else applyTransitionEnd(); } return animations; } /** * ValueType for "auto" */ var auto = { test: (v) => v === "auto", parse: (v) => v }; /** * Tests a provided value against a ValueType */ var testValueType = (v) => (type) => type.test(v); /** * A list of value types commonly used for dimensions */ var dimensionValueTypes = [ number, px, percent, degrees, vw, vh, auto ]; /** * Tests a dimensional value against the list of dimension ValueTypes */ var findDimensionValueType = (v) => dimensionValueTypes.find(testValueType(v)); function isNone(value) { if (typeof value === "number") return value === 0; else if (value !== null) return value === "none" || value === "0" || isZeroValueString(value); else return true; } /** * Properties that should default to 1 or 100% */ var maxDefaults = /* @__PURE__ */ new Set([ "brightness", "contrast", "saturate", "opacity" ]); function applyDefaultFilter(v) { const [name, value] = v.slice(0, -1).split("("); if (name === "drop-shadow") return v; const [number] = value.match(floatRegex) || []; if (!number) return v; const unit = value.replace(number, ""); let defaultValue = maxDefaults.has(name) ? 1 : 0; if (number !== value) defaultValue *= 100; return name + "(" + defaultValue + unit + ")"; } var functionRegex = /\b([a-z-]*)\(.*?\)/gu; var filter = { ...complex, getAnimatableNone: (v) => { const functions = v.match(functionRegex); return functions ? functions.map(applyDefaultFilter).join(" ") : v; } }; var mask = { ...complex, getAnimatableNone: (v) => { const parsed = complex.parse(v); return complex.createTransformer(v)(parsed.map((v) => typeof v === "number" ? 0 : typeof v === "object" ? { ...v, alpha: 1 } : v)); } }; var int = { ...number, transform: Math.round }; var numberValueTypes = { borderWidth: px, borderTopWidth: px, borderRightWidth: px, borderBottomWidth: px, borderLeftWidth: px, borderRadius: px, borderTopLeftRadius: px, borderTopRightRadius: px, borderBottomRightRadius: px, borderBottomLeftRadius: px, width: px, maxWidth: px, height: px, maxHeight: px, top: px, right: px, bottom: px, left: px, inset: px, insetBlock: px, insetBlockStart: px, insetBlockEnd: px, insetInline: px, insetInlineStart: px, insetInlineEnd: px, padding: px, paddingTop: px, paddingRight: px, paddingBottom: px, paddingLeft: px, paddingBlock: px, paddingBlockStart: px, paddingBlockEnd: px, paddingInline: px, paddingInlineStart: px, paddingInlineEnd: px, margin: px, marginTop: px, marginRight: px, marginBottom: px, marginLeft: px, marginBlock: px, marginBlockStart: px, marginBlockEnd: px, marginInline: px, marginInlineStart: px, marginInlineEnd: px, fontSize: px, backgroundPositionX: px, backgroundPositionY: px, rotate: degrees, /** * Internal channel for `transition.path` orientToPath. Composed onto * `rotate` at the transform-build sites so the user's `rotate` is * never read or overwritten. Not part of `transformPropOrder`. */ pathRotation: degrees, rotateX: degrees, rotateY: degrees, rotateZ: degrees, scale, scaleX: scale, scaleY: scale, scaleZ: scale, skew: degrees, skewX: degrees, skewY: degrees, distance: px, translateX: px, translateY: px, translateZ: px, x: px, y: px, z: px, perspective: px, transformPerspective: px, opacity: alpha, originX: progressPercentage, originY: progressPercentage, originZ: px, zIndex: int, fillOpacity: alpha, strokeOpacity: alpha, numOctaves: int }; /** * A map of default value types for common values */ var defaultValueTypes = { ...numberValueTypes, color, backgroundColor: color, outlineColor: color, fill: color, stroke: color, borderColor: color, borderTopColor: color, borderRightColor: color, borderBottomColor: color, borderLeftColor: color, filter, WebkitFilter: filter, mask, WebkitMask: mask }; /** * Gets the default ValueType for the provided value key */ var getDefaultValueType = (key) => defaultValueTypes[key]; var customTypes = /*@__PURE__*/ new Set([filter, mask]); function getAnimatableNone(key, value) { let defaultValueType = getDefaultValueType(key); if (!customTypes.has(defaultValueType)) defaultValueType = complex; return defaultValueType.getAnimatableNone ? defaultValueType.getAnimatableNone(value) : void 0; } /** * If we encounter keyframes like "none" or "0" and we also have keyframes like * "#fff" or "200px 200px" we want to find a keyframe to serve as a template for * the "none" keyframes. In this case "#fff" or "200px 200px" - then these get turned into * zero equivalents, i.e. "#fff0" or "0px 0px". */ var invalidTemplates = /* @__PURE__ */ new Set([ "auto", "none", "0" ]); function makeNoneKeyframesAnimatable(unresolvedKeyframes, noneKeyframeIndexes, name) { let i = 0; let animatableTemplate = void 0; while (i < unresolvedKeyframes.length && !animatableTemplate) { const keyframe = unresolvedKeyframes[i]; if (typeof keyframe === "string" && !invalidTemplates.has(keyframe) && analyseComplexValue(keyframe).values.length) animatableTemplate = unresolvedKeyframes[i]; i++; } if (animatableTemplate && name) for (const noneIndex of noneKeyframeIndexes) unresolvedKeyframes[noneIndex] = getAnimatableNone(name, animatableTemplate); } var DOMKeyframesResolver = class extends KeyframeResolver { constructor(unresolvedKeyframes, onComplete, name, motionValue, element) { super(unresolvedKeyframes, onComplete, name, motionValue, element, true); } readKeyframes() { const { unresolvedKeyframes, element, name } = this; if (!element || !element.current) return; super.readKeyframes(); /** * If any keyframe is a CSS variable, we need to find its value by sampling the element */ for (let i = 0; i < unresolvedKeyframes.length; i++) { let keyframe = unresolvedKeyframes[i]; if (typeof keyframe === "string") { keyframe = keyframe.trim(); if (isCSSVariableToken(keyframe)) { const resolved = getVariableValue(keyframe, element.current); if (resolved !== void 0) unresolvedKeyframes[i] = resolved; if (i === unresolvedKeyframes.length - 1) this.finalKeyframe = keyframe; } } } /** * Resolve "none" values. We do this potentially twice - once before and once after measuring keyframes. * This could be seen as inefficient but it's a trade-off to avoid measurements in more situations, which * have a far bigger performance impact. */ this.resolveNoneKeyframes(); /** * Check to see if unit type has changed. If so schedule jobs that will * temporarily set styles to the destination keyframes. * Skip if we have more than two keyframes or this isn't a positional value. * TODO: We can throw if there are multiple keyframes and the value type changes. */ if (!positionalKeys.has(name) || unresolvedKeyframes.length !== 2) return; const [origin, target] = unresolvedKeyframes; const originType = findDimensionValueType(origin); const targetType = findDimensionValueType(target); if (containsCSSVariable(origin) !== containsCSSVariable(target) && positionalValues[name]) { this.needsMeasurement = true; return; } /** * Either we don't recognise these value types or we can animate between them. */ if (originType === targetType) return; /** * If both values are numbers or pixels, we can animate between them by * converting them to numbers. */ if (isNumOrPxType(originType) && isNumOrPxType(targetType)) for (let i = 0; i < unresolvedKeyframes.length; i++) { const value = unresolvedKeyframes[i]; if (typeof value === "string") unresolvedKeyframes[i] = parseFloat(value); } else if (positionalValues[name]) /** * Else, the only way to resolve this is by measuring the element. */ this.needsMeasurement = true; } resolveNoneKeyframes() { const { unresolvedKeyframes, name } = this; const noneKeyframeIndexes = []; for (let i = 0; i < unresolvedKeyframes.length; i++) if (unresolvedKeyframes[i] === null || isNone(unresolvedKeyframes[i])) noneKeyframeIndexes.push(i); if (noneKeyframeIndexes.length) makeNoneKeyframesAnimatable(unresolvedKeyframes, noneKeyframeIndexes, name); } measureInitialState() { const { element, unresolvedKeyframes, name } = this; if (!element || !element.current) return; if (name === "height") this.suspendedScrollY = window.pageYOffset; this.measuredOrigin = positionalValues[name](element.measureViewportBox(), window.getComputedStyle(element.current)); unresolvedKeyframes[0] = this.measuredOrigin; const measureKeyframe = unresolvedKeyframes[unresolvedKeyframes.length - 1]; if (measureKeyframe !== void 0) element.getValue(name, measureKeyframe).jump(measureKeyframe, false); } measureEndState() { const { element, name, unresolvedKeyframes } = this; if (!element || !element.current) return; const value = element.getValue(name); value && value.jump(this.measuredOrigin, false); const finalKeyframeIndex = unresolvedKeyframes.length - 1; const finalKeyframe = unresolvedKeyframes[finalKeyframeIndex]; unresolvedKeyframes[finalKeyframeIndex] = positionalValues[name](element.measureViewportBox(), window.getComputedStyle(element.current)); if (finalKeyframe !== null && this.finalKeyframe === void 0) this.finalKeyframe = finalKeyframe; if (this.removedTransforms?.length) this.removedTransforms.forEach(([unsetTransformName, unsetTransformValue]) => { element.getValue(unsetTransformName).set(unsetTransformValue); }); this.resolveNoneKeyframes(); } }; /** * The four corner-radius longhands. Shared so the projection mixer, scale * corrector, WAAPI px-value set and view-transition crop pass don't each carry * their own copy. Order is irrelevant - every consumer mixes/corrects/animates * each corner independently. */ var cornerRadiusProps = [ "borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius" ]; function resolveElements(elementOrSelector, scope, selectorCache) { if (elementOrSelector == null) return []; if (elementOrSelector instanceof EventTarget) return [elementOrSelector]; else if (typeof elementOrSelector === "string") { let root = document; if (scope) root = scope.current; const elements = selectorCache?.[elementOrSelector] ?? root.querySelectorAll(elementOrSelector); return elements ? Array.from(elements) : []; } return Array.from(elementOrSelector).filter((element) => element != null); } /** * Provided a value and a ValueType, returns the value as that value type. */ var getValueAsType = (value, type) => { return type && typeof value === "number" ? type.transform(value) : value; }; var { schedule: microtask} = /* @__PURE__ */ createRenderBatcher(queueMicrotask, false); /** * Checks if an element is an SVG element in a way * that works across iframes */ function isSVGElement(element) { return isObject(element) && "ownerSVGElement" in element; } /** * Checks if an element is specifically an SVGSVGElement (the root SVG element) * in a way that works across iframes */ function isSVGSVGElement(element) { return isSVGElement(element) && element.tagName === "svg"; } /** * A list of all ValueTypes */ var valueTypes = [ ...dimensionValueTypes, color, complex ]; /** * Tests a value against the list of ValueTypes */ var findValueType = (v) => valueTypes.find(testValueType(v)); var createAxis = () => ({ min: 0, max: 0 }); var createBox = () => ({ x: createAxis(), y: createAxis() }); var visualElementStore = /* @__PURE__ */ new WeakMap(); function isAnimationControls(v) { return v !== null && typeof v === "object" && typeof v.start === "function"; } /** * Decides if the supplied variable is variant label */ function isVariantLabel(v) { return typeof v === "string" || Array.isArray(v); } var variantProps = ["initial", ...[ "animate", "whileInView", "whileFocus", "whileHover", "whileTap", "whileDrag", "exit" ]]; function isControllingVariants(props) { return isAnimationControls(props.animate) || variantProps.some((name) => isVariantLabel(props[name])); } function isVariantNode(props) { return Boolean(isControllingVariants(props) || props.variants); } /** * Updates motion values from props changes. * Uses `any` type for element to avoid circular dependencies with VisualElement. */ function updateMotionValuesFromProps(element, next, prev) { for (const key in next) { const nextValue = next[key]; const prevValue = prev[key]; if (isMotionValue(nextValue)) /** * If this is a motion value found in props or style, we want to add it * to our visual element's motion value map. */ element.addValue(key, nextValue); else if (isMotionValue(prevValue)) /** * If we're swapping from a motion value to a static value, * create a new motion value from that */ element.addValue(key, motionValue(nextValue, { owner: element })); else if (prevValue !== nextValue) /** * If this is a flat value that has changed, update the motion value * or create one if it doesn't exist. We only want to do this if we're * not handling the value with our animation state. */ if (element.hasValue(key)) { const existingValue = element.getValue(key); if (existingValue.liveStyle === true) existingValue.jump(nextValue); else if (!existingValue.hasAnimated) existingValue.set(nextValue); } else { const latestValue = element.getStaticValue(key); element.addValue(key, motionValue(latestValue !== void 0 ? latestValue : nextValue, { owner: element })); } } for (const key in prev) if (next[key] === void 0) element.removeValue(key); return next; } var prefersReducedMotion$1 = { current: null }; var hasReducedMotionListener = { current: false }; var isBrowser = typeof window !== "undefined"; function initPrefersReducedMotion() { hasReducedMotionListener.current = true; if (!isBrowser) return; if (window.matchMedia) { const motionMediaQuery = window.matchMedia("(prefers-reduced-motion)"); const setReducedMotionPreferences = () => prefersReducedMotion$1.current = motionMediaQuery.matches; motionMediaQuery.addEventListener("change", setReducedMotionPreferences); setReducedMotionPreferences(); } else prefersReducedMotion$1.current = false; } var propEventHandlers = [ "AnimationStart", "AnimationComplete", "Update", "BeforeLayoutMeasure", "LayoutMeasure", "LayoutAnimationStart", "LayoutAnimationComplete" ]; /** * Static feature definitions - can be injected by framework layer */ var featureDefinitions = {}; /** * A VisualElement is an imperative abstraction around UI elements such as * HTMLElement, SVGElement, Three.Object3D etc. */ var VisualElement = class { /** * This method takes React props and returns found MotionValues. For example, HTML * MotionValues will be found within the style prop, whereas for Three.js within attribute arrays. * * This isn't an abstract method as it needs calling in the constructor, but it is * intended to be one. */ scrapeMotionValuesFromProps(_props, _prevProps, _visualElement) { return {}; } constructor({ parent, props, presenceContext, reducedMotionConfig, skipAnimations, blockInitialAnimation, visualState }, options = {}) { /** * A reference to the current underlying Instance, e.g. a HTMLElement * or Three.Mesh etc. */ this.current = null; /** * A set containing references to this VisualElement's children. */ this.children = /* @__PURE__ */ new Set(); /** * Determine what role this visual element should take in the variant tree. */ this.isVariantNode = false; this.isControllingVariants = false; /** * Decides whether this VisualElement should animate in reduced motion * mode. * * TODO: This is currently set on every individual VisualElement but feels * like it could be set globally. */ this.shouldReduceMotion = null; /** * Decides whether animations should be skipped for this VisualElement. * Useful for E2E tests and visual regression testing. */ this.shouldSkipAnimations = false; /** * A map of all motion values attached to this visual element. Motion * values are source of truth for any given animated value. A motion * value might be provided externally by the component via props. */ this.values = /* @__PURE__ */ new Map(); this.KeyframeResolver = KeyframeResolver; /** * Cleanup functions for active features (hover/tap/exit etc) */ this.features = {}; /** * A map of every subscription that binds the provided or generated * motion values onChange listeners to this visual element. */ this.valueSubscriptions = /* @__PURE__ */ new Map(); /** * A reference to the previously-provided motion values as returned * from scrapeMotionValuesFromProps. We use the keys in here to determine * if any motion values need to be removed after props are updated. */ this.prevMotionValues = {}; /** * Track whether this element has been mounted before, to detect * remounts after Suspense unmount/remount cycles. */ this.hasBeenMounted = false; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; /** * An object containing an unsubscribe function for each prop event subscription. * For example, every "Update" event can have multiple subscribers via * VisualElement.on(), but only one of those can be defined via the onUpdate prop. */ this.propEventSubscriptions = {}; this.notifyUpdate = () => this.notify("Update", this.latestValues); this.render = () => { if (!this.current) return; this.triggerBuild(); this.renderInstance(this.current, this.renderState, this.props.style, this.projection); }; this.renderScheduledAt = 0; this.scheduleRender = () => { const now = time.now(); if (this.renderScheduledAt < now) { this.renderScheduledAt = now; frame.render(this.render, false, true); } }; const { latestValues, renderState } = visualState; this.latestValues = latestValues; this.baseTarget = { ...latestValues }; this.initialValues = props.initial ? { ...latestValues } : {}; this.renderState = renderState; this.parent = parent; this.props = props; this.presenceContext = presenceContext; this.depth = parent ? parent.depth + 1 : 0; this.reducedMotionConfig = reducedMotionConfig; this.skipAnimationsConfig = skipAnimations; this.options = options; this.blockInitialAnimation = Boolean(blockInitialAnimation); this.isControllingVariants = isControllingVariants(props); this.isVariantNode = isVariantNode(props); if (this.isVariantNode) this.variantChildren = /* @__PURE__ */ new Set(); this.manuallyAnimateOnMount = Boolean(parent && parent.current); /** * Any motion values that are provided to the element when created * aren't yet bound to the element, as this would technically be impure. * However, we iterate through the motion values and set them to the * initial values for this component. * * TODO: This is impure and we should look at changing this to run on mount. * Doing so will break some tests but this isn't necessarily a breaking change, * more a reflection of the test. */ const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}, this); for (const key in initialMotionValues) { const value = initialMotionValues[key]; if (latestValues[key] !== void 0 && isMotionValue(value)) value.set(latestValues[key]); } } mount(instance) { /** * If this element has been mounted before (e.g. after a Suspense * unmount/remount), reset motion values to their initial state * so animations replay correctly from initial → animate. */ if (this.hasBeenMounted) for (const key in this.initialValues) { this.values.get(key)?.jump(this.initialValues[key]); this.latestValues[key] = this.initialValues[key]; } this.current = instance; visualElementStore.set(instance, this); if (this.projection && !this.projection.instance) this.projection.mount(instance); if (this.parent && this.isVariantNode && !this.isControllingVariants) this.removeFromVariantTree = this.parent.addVariantChild(this); this.values.forEach((value, key) => this.bindToMotionValue(key, value)); /** * Determine reduced motion preference. Only initialize the matchMedia * listener if we actually need the dynamic value (i.e., when config * is neither "never" nor "always"). */ if (this.reducedMotionConfig === "never") this.shouldReduceMotion = false; else if (this.reducedMotionConfig === "always") this.shouldReduceMotion = true; else { if (!hasReducedMotionListener.current) initPrefersReducedMotion(); this.shouldReduceMotion = prefersReducedMotion$1.current; } /** * Set whether animations should be skipped based on the config. */ this.shouldSkipAnimations = this.skipAnimationsConfig ?? false; this.parent?.addChild(this); this.update(this.props, this.presenceContext); this.hasBeenMounted = true; } unmount() { this.projection && this.projection.unmount(); cancelFrame(this.notifyUpdate); cancelFrame(this.render); this.valueSubscriptions.forEach((remove) => remove()); this.valueSubscriptions.clear(); this.removeFromVariantTree && this.removeFromVariantTree(); this.parent?.removeChild(this); for (const key in this.events) this.events[key].clear(); for (const key in this.features) { const feature = this.features[key]; if (feature) { feature.unmount(); feature.isMounted = false; } } this.current = null; } addChild(child) { this.children.add(child); this.enteringChildren ?? (this.enteringChildren = /* @__PURE__ */ new Set()); this.enteringChildren.add(child); } removeChild(child) { this.children.delete(child); this.enteringChildren && this.enteringChildren.delete(child); } bindToMotionValue(key, value) { if (this.valueSubscriptions.has(key)) this.valueSubscriptions.get(key)(); if (value.accelerate && acceleratedValues.has(key) && this.current instanceof HTMLElement) { const { factory, keyframes, times, ease, duration } = value.accelerate; const animation = new NativeAnimation({ element: this.current, name: key, keyframes, times, ease, duration: /* @__PURE__ */ secondsToMilliseconds(duration) }); const cleanup = factory(animation); this.valueSubscriptions.set(key, () => { cleanup(); animation.cancel(); }); return; } const valueIsTransform = transformProps.has(key); if (valueIsTransform && this.onBindTransform) this.onBindTransform(); const removeOnChange = value.on("change", (latestValue) => { this.latestValues[key] = latestValue; this.props.onUpdate && frame.preRender(this.notifyUpdate); if (valueIsTransform && this.projection) this.projection.isTransformDirty = true; this.scheduleRender(); }); let removeSyncCheck; if (typeof window !== "undefined" && window.MotionCheckAppearSync) removeSyncCheck = window.MotionCheckAppearSync(this, key, value); this.valueSubscriptions.set(key, () => { removeOnChange(); if (removeSyncCheck) removeSyncCheck(); }); } sortNodePosition(other) { /** * If these nodes aren't even of the same type we can't compare their depth. */ if (!this.current || !this.sortInstanceNodePosition || this.type !== other.type) return 0; return this.sortInstanceNodePosition(this.current, other.current); } updateFeatures() { let key = "animation"; for (key in featureDefinitions) { const featureDefinition = featureDefinitions[key]; if (!featureDefinition) continue; const { isEnabled, Feature: FeatureConstructor } = featureDefinition; /** * If this feature is enabled but not active, make a new instance. */ if (!this.features[key] && FeatureConstructor && isEnabled(this.props)) this.features[key] = new FeatureConstructor(this); /** * If we have a feature, mount or update it. */ if (this.features[key]) { const feature = this.features[key]; if (feature.isMounted) feature.update(); else { feature.mount(); feature.isMounted = true; } } } } triggerBuild() { this.build(this.renderState, this.latestValues, this.props); } /** * Measure the current viewport box with or without transforms. * Only measures axis-aligned boxes, rotate and skew must be manually * removed with a re-render to work. */ measureViewportBox() { return this.current ? this.measureInstanceViewportBox(this.current, this.props) : createBox(); } getStaticValue(key) { return this.latestValues[key]; } setStaticValue(key, value) { this.latestValues[key] = value; } /** * Update the provided props. Ensure any newly-added motion values are * added to our map, old ones removed, and listeners updated. */ update(props, presenceContext) { if (props.transformTemplate || this.props.transformTemplate) this.scheduleRender(); this.prevProps = this.props; this.props = props; this.prevPresenceContext = this.presenceContext; this.presenceContext = presenceContext; /** * Update prop event handlers ie onAnimationStart, onAnimationComplete */ for (let i = 0; i < propEventHandlers.length; i++) { const key = propEventHandlers[i]; if (this.propEventSubscriptions[key]) { this.propEventSubscriptions[key](); delete this.propEventSubscriptions[key]; } const listener = props["on" + key]; if (listener) this.propEventSubscriptions[key] = this.on(key, listener); } this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps || {}, this), this.prevMotionValues); if (this.handleChildMotionValue) this.handleChildMotionValue(); } getProps() { return this.props; } /** * Returns the variant definition with a given name. */ getVariant(name) { return this.props.variants ? this.props.variants[name] : void 0; } /** * Returns the defined default transition on this component. */ getDefaultTransition() { return this.props.transition; } getTransformPagePoint() { return this.props.transformPagePoint; } getClosestVariantNode() { return this.isVariantNode ? this : this.parent ? this.parent.getClosestVariantNode() : void 0; } /** * Add a child visual element to our set of children. */ addVariantChild(child) { const closestVariantNode = this.getClosestVariantNode(); if (closestVariantNode) { closestVariantNode.variantChildren && closestVariantNode.variantChildren.add(child); return () => closestVariantNode.variantChildren.delete(child); } } /** * Add a motion value and bind it to this visual element. */ addValue(key, value) { const existingValue = this.values.get(key); if (value !== existingValue) { if (existingValue) this.removeValue(key); this.bindToMotionValue(key, value); this.values.set(key, value); this.latestValues[key] = value.get(); } } /** * Remove a motion value and unbind any active subscriptions. */ removeValue(key) { this.values.delete(key); const unsubscribe = this.valueSubscriptions.get(key); if (unsubscribe) { unsubscribe(); this.valueSubscriptions.delete(key); } delete this.latestValues[key]; this.removeValueFromRenderState(key, this.renderState); } /** * Check whether we have a motion value for this key */ hasValue(key) { return this.values.has(key); } getValue(key, defaultValue) { if (this.props.values && this.props.values[key]) return this.props.values[key]; let value = this.values.get(key); if (value === void 0 && defaultValue !== void 0) { value = motionValue(defaultValue === null ? void 0 : defaultValue, { owner: this }); this.addValue(key, value); } return value; } /** * If we're trying to animate to a previously unencountered value, * we need to check for it in our state and as a last resort read it * directly from the instance (which might have performance implications). */ readValue(key, target) { let value = this.latestValues[key] !== void 0 || !this.current ? this.latestValues[key] : this.getBaseTargetFromProps(this.props, key) ?? this.readValueFromInstance(this.current, key, this.options); if (value !== void 0 && value !== null) { if (typeof value === "string" && (isNumericalString(value) || isZeroValueString(value))) value = parseFloat(value); else if (!findValueType(value) && complex.test(target)) value = getAnimatableNone(key, target); this.setBaseTarget(key, isMotionValue(value) ? value.get() : value); } return isMotionValue(value) ? value.get() : value; } /** * Set the base target to later animate back to. This is currently * only hydrated on creation and when we first read a value. */ setBaseTarget(key, value) { this.baseTarget[key] = value; } /** * Find the base target for a value thats been removed from all animation * props. */ getBaseTarget(key) { const { initial } = this.props; let valueFromInitial; if (typeof initial === "string" || typeof initial === "object") { const variant = resolveVariantFromProps(this.props, initial, this.presenceContext?.custom); if (variant) valueFromInitial = variant[key]; } /** * If this value still exists in the current initial variant, read that. */ if (initial && valueFromInitial !== void 0) return valueFromInitial; /** * Alternatively, if this VisualElement config has defined a getBaseTarget * so we can read the value from an alternative source, try that. */ const target = this.getBaseTargetFromProps(this.props, key); if (target !== void 0 && !isMotionValue(target)) return target; /** * If the value was initially defined on initial, but it doesn't any more, * return undefined. Otherwise return the value as initially read from the DOM. */ return this.initialValues[key] !== void 0 && valueFromInitial === void 0 ? void 0 : this.baseTarget[key]; } on(eventName, callback) { if (!this.events[eventName]) this.events[eventName] = new SubscriptionManager(); return this.events[eventName].add(callback); } notify(eventName, ...args) { if (this.events[eventName]) this.events[eventName].notify(...args); } scheduleRenderMicrotask() { microtask.render(this.render); } }; var DOMVisualElement = class extends VisualElement { constructor() { super(...arguments); this.KeyframeResolver = DOMKeyframesResolver; } sortInstanceNodePosition(a, b) { /** * compareDocumentPosition returns a bitmask, by using the bitwise & * we're returning true if 2 in that bitmask is set to true. 2 is set * to true if b preceeds a. */ return a.compareDocumentPosition(b) & 2 ? 1 : -1; } getBaseTargetFromProps(props, key) { const style = props.style; return style ? style[key] : void 0; } removeValueFromRenderState(key, { vars, style }) { delete vars[key]; delete style[key]; } handleChildMotionValue() { if (this.childSubscription) { this.childSubscription(); delete this.childSubscription; } const { children } = this.props; if (isMotionValue(children)) this.childSubscription = children.on("change", (latest) => { if (this.current) this.current.textContent = `${latest}`; }); } }; /** * Bounding boxes tend to be defined as top, left, right, bottom. For various operations * it's easier to consider each axis individually. This function returns a bounding box * as a map of single-axis min/max values. */ function convertBoundingBoxToBox({ top, left, right, bottom }) { return { x: { min: left, max: right }, y: { min: top, max: bottom } }; } /** * Applies a TransformPoint function to a bounding box. TransformPoint is usually a function * provided by Framer to allow measured points to be corrected for device scaling. This is used * when measuring DOM elements and DOM event points. */ function transformBoxPoints(point, transformPoint) { if (!transformPoint) return point; const topLeft = transformPoint({ x: point.left, y: point.top }); const bottomRight = transformPoint({ x: point.right, y: point.bottom }); return { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x }; } function measureViewportBox(instance, transformPoint) { return convertBoundingBoxToBox(transformBoxPoints(instance.getBoundingClientRect(), transformPoint)); } var translateAlias = { x: "translateX", y: "translateY", z: "translateZ", transformPerspective: "perspective" }; var numTransforms = transformPropOrder.length; /** * Build a CSS transform style from individual x/y/scale etc properties. * * This outputs with a default order of transforms/scales/rotations, this can be customised by * providing a transformTemplate function. */ function buildTransform(latestValues, transform, transformTemplate) { let transformString = ""; let transformIsDefault = true; /** * Loop over all possible transforms in order, adding the ones that * are present to the transform string. */ for (let i = 0; i < numTransforms; i++) { const key = transformPropOrder[i]; const value = latestValues[key]; if (value === void 0) continue; let valueIsDefault = true; if (typeof value === "number") valueIsDefault = value === (key.startsWith("scale") ? 1 : 0); else { const parsed = parseFloat(value); valueIsDefault = key.startsWith("scale") ? parsed === 1 : parsed === 0; } if (!valueIsDefault || transformTemplate) { const valueAsType = getValueAsType(value, numberValueTypes[key]); if (!valueIsDefault) { transformIsDefault = false; const transformName = translateAlias[key] || key; transformString += `${transformName}(${valueAsType}) `; } if (transformTemplate) transform[key] = valueAsType; } } const pathRotation = latestValues.pathRotation; if (pathRotation) { transformIsDefault = false; transformString += `rotate(${getValueAsType(pathRotation, numberValueTypes.pathRotation)}) `; } transformString = transformString.trim(); if (transformTemplate) transformString = transformTemplate(transform, transformIsDefault ? "" : transformString); else if (transformIsDefault) transformString = "none"; return transformString; } function buildHTMLStyles(state, latestValues, transformTemplate) { const { style, vars, transformOrigin } = state; let hasTransform = false; let hasTransformOrigin = false; /** * Loop over all our latest animated values and decide whether to handle them * as a style or CSS variable. * * Transforms and transform origins are kept separately for further processing. */ for (const key in latestValues) { const value = latestValues[key]; if (transformProps.has(key)) { hasTransform = true; continue; } else if (isCSSVariableName(key)) { vars[key] = value; continue; } else { const valueAsType = getValueAsType(value, numberValueTypes[key]); if (key.startsWith("origin")) { hasTransformOrigin = true; transformOrigin[key] = valueAsType; } else style[key] = valueAsType; } } if (!latestValues.transform) { if (hasTransform || transformTemplate) style.transform = buildTransform(latestValues, state.transform, transformTemplate); else if (style.transform) /** * If we have previously created a transform but currently don't have any, * reset transform style to none. */ style.transform = "none"; } /** * Build a transformOrigin style. Uses the same defaults as the browser for * undefined origins. */ if (hasTransformOrigin) { const { originX = "50%", originY = "50%", originZ = 0 } = transformOrigin; style.transformOrigin = `${originX} ${originY} ${originZ}`; } } function renderHTML(element, { style, vars }, styleProp, projection) { const elementStyle = element.style; let key; for (key in style) elementStyle[key] = style[key]; projection?.applyProjectionStyles(elementStyle, styleProp); for (key in vars) elementStyle.setProperty(key, vars[key]); } function pixelsToPercent(pixels, axis) { if (axis.max === axis.min) return 0; return pixels / (axis.max - axis.min) * 100; } /** * We always correct borderRadius as a percentage rather than pixels to reduce paints. * For example, if you are projecting a box that is 100px wide with a 10px borderRadius * into a box that is 200px wide with a 20px borderRadius, that is actually a 10% * borderRadius in both states. If we animate between the two in pixels that will trigger * a paint each time. If we animate between the two in percentage we'll avoid a paint. */ var correctBorderRadius = { correct: (latest, node) => { if (!node.target) return latest; /** * If latest is a string, if it's a percentage we can return immediately as it's * going to be stretched appropriately. Otherwise, if it's a pixel, convert it to a number. */ if (typeof latest === "string") if (px.test(latest)) latest = parseFloat(latest); else return latest; return `${pixelsToPercent(latest, node.target.x)}% ${pixelsToPercent(latest, node.target.y)}%`; } }; var correctBoxShadow = { correct: (latest, { treeScale, projectionDelta }) => { const original = latest; const shadow = complex.parse(latest); if (shadow.length > 5) return original; const template = complex.createTransformer(latest); const offset = typeof shadow[0] !== "number" ? 1 : 0; const xScale = projectionDelta.x.scale * treeScale.x; const yScale = projectionDelta.y.scale * treeScale.y; shadow[0 + offset] /= xScale; shadow[1 + offset] /= yScale; /** * Ideally we'd correct x and y scales individually, but because blur and * spread apply to both we have to take a scale average and apply that instead. * We could potentially improve the outcome of this by incorporating the ratio between * the two scales. */ const averageScale = mixNumber$1(xScale, yScale, .5); if (typeof shadow[2 + offset] === "number") shadow[2 + offset] /= averageScale; if (typeof shadow[3 + offset] === "number") shadow[3 + offset] /= averageScale; return template(shadow); } }; var scaleCorrectors = { borderRadius: { ...correctBorderRadius, applyTo: [...cornerRadiusProps] }, borderTopLeftRadius: correctBorderRadius, borderTopRightRadius: correctBorderRadius, borderBottomLeftRadius: correctBorderRadius, borderBottomRightRadius: correctBorderRadius, boxShadow: correctBoxShadow }; function isForcedMotionValue(key, { layout, layoutId }) { return transformProps.has(key) || key.startsWith("origin") || (layout || layoutId !== void 0) && (!!scaleCorrectors[key] || key === "opacity"); } function scrapeMotionValuesFromProps$1(props, prevProps, visualElement) { const style = props.style; const prevStyle = prevProps?.style; const newValues = {}; if (!style) return newValues; for (const key in style) if (isMotionValue(style[key]) || prevStyle && isMotionValue(prevStyle[key]) || isForcedMotionValue(key, props) || visualElement?.getValue(key)?.liveStyle !== void 0) newValues[key] = style[key]; return newValues; } function getComputedStyle$1(element) { return window.getComputedStyle(element); } var HTMLVisualElement = class extends DOMVisualElement { constructor() { super(...arguments); this.type = "html"; this.renderInstance = renderHTML; } readValueFromInstance(instance, key) { if (transformProps.has(key)) return this.projection?.isProjecting ? defaultTransformValue(key) : readTransformValue(instance, key); else { const computedStyle = getComputedStyle$1(instance); const value = (isCSSVariableName(key) ? computedStyle.getPropertyValue(key) : computedStyle[key]) || 0; return typeof value === "string" ? value.trim() : value; } } measureInstanceViewportBox(instance, { transformPagePoint }) { return measureViewportBox(instance, transformPagePoint); } build(renderState, latestValues, props) { buildHTMLStyles(renderState, latestValues, props.transformTemplate); } scrapeMotionValuesFromProps(props, prevProps, visualElement) { return scrapeMotionValuesFromProps$1(props, prevProps, visualElement); } }; function isObjectKey(key, object) { return key in object; } var ObjectVisualElement = class extends VisualElement { constructor() { super(...arguments); this.type = "object"; } readValueFromInstance(instance, key) { if (isObjectKey(key, instance)) { const value = instance[key]; if (typeof value === "string" || typeof value === "number") return value; } } getBaseTargetFromProps() {} removeValueFromRenderState(key, renderState) { delete renderState.output[key]; } measureInstanceViewportBox() { return createBox(); } build(renderState, latestValues) { Object.assign(renderState.output, latestValues); } renderInstance(instance, { output }) { Object.assign(instance, output); } sortInstanceNodePosition() { return 0; } }; var dashKeys = { offset: "stroke-dashoffset", array: "stroke-dasharray" }; var camelKeys = { offset: "strokeDashoffset", array: "strokeDasharray" }; /** * Build SVG path properties. Uses the path's measured length to convert * our custom pathLength, pathSpacing and pathOffset into stroke-dashoffset * and stroke-dasharray attributes. * * This function is mutative to reduce per-frame GC. * * Note: We use unitless values for stroke-dasharray and stroke-dashoffset * because Safari incorrectly scales px values when the page is zoomed. */ function buildSVGPath(attrs, length, spacing = 1, offset = 0, useDashCase = true) { attrs.pathLength = 1; const keys = useDashCase ? dashKeys : camelKeys; attrs[keys.offset] = `${-offset}`; attrs[keys.array] = `${length} ${spacing}`; } /** * CSS Motion Path properties that should remain as CSS styles on SVG elements. */ var cssMotionPathProperties = [ "offsetDistance", "offsetPath", "offsetRotate", "offsetAnchor" ]; /** * Build SVG visual attributes, like cx and style.transform */ function buildSVGAttrs(state, { attrX, attrY, attrScale, pathLength, pathSpacing = 1, pathOffset = 0, ...latest }, isSVGTag, transformTemplate, styleProp) { buildHTMLStyles(state, latest, transformTemplate); /** * For svg tags we just want to make sure viewBox is animatable and treat all the styles * as normal HTML tags. */ if (isSVGTag) { if (state.style.viewBox) state.attrs.viewBox = state.style.viewBox; return; } state.attrs = state.style; state.style = {}; const { attrs, style } = state; /** * However, we apply transforms as CSS transforms. * So if we detect a transform, transformOrigin we take it from attrs and copy it into style. */ if (attrs.transform) { style.transform = attrs.transform; delete attrs.transform; } if (style.transform || attrs.transformOrigin) { style.transformOrigin = attrs.transformOrigin ?? "50% 50%"; delete attrs.transformOrigin; } if (style.transform) { /** * SVG's element transform-origin uses its own median as a reference. * Therefore, transformBox becomes a fill-box */ style.transformBox = styleProp?.transformBox ?? "fill-box"; delete attrs.transformBox; } for (const key of cssMotionPathProperties) if (attrs[key] !== void 0) { style[key] = attrs[key]; delete attrs[key]; } if (attrX !== void 0) attrs.x = attrX; if (attrY !== void 0) attrs.y = attrY; if (attrScale !== void 0) attrs.scale = attrScale; if (pathLength !== void 0) buildSVGPath(attrs, pathLength, pathSpacing, pathOffset, false); } /** * A set of attribute names that are always read/written as camel case. */ var camelCaseAttributes = /* @__PURE__ */ new Set([ "baseFrequency", "diffuseConstant", "kernelMatrix", "kernelUnitLength", "keySplines", "keyTimes", "limitingConeAngle", "markerHeight", "markerWidth", "numOctaves", "targetX", "targetY", "surfaceScale", "specularConstant", "specularExponent", "stdDeviation", "tableValues", "viewBox", "gradientTransform", "pathLength", "startOffset", "textLength", "lengthAdjust" ]); var isSVGTag = (tag) => typeof tag === "string" && tag.toLowerCase() === "svg"; function renderSVG(element, renderState, _styleProp, projection) { renderHTML(element, renderState, void 0, projection); for (const key in renderState.attrs) element.setAttribute(!camelCaseAttributes.has(key) ? camelToDash(key) : key, renderState.attrs[key]); } function scrapeMotionValuesFromProps(props, prevProps, visualElement) { const newValues = scrapeMotionValuesFromProps$1(props, prevProps, visualElement); for (const key in props) if (isMotionValue(props[key]) || isMotionValue(prevProps[key])) { const targetKey = transformPropOrder.indexOf(key) !== -1 ? "attr" + key.charAt(0).toUpperCase() + key.substring(1) : key; newValues[targetKey] = props[key]; } return newValues; } var SVGVisualElement = class extends DOMVisualElement { constructor() { super(...arguments); this.type = "svg"; this.isSVGTag = false; this.measureInstanceViewportBox = createBox; } getBaseTargetFromProps(props, key) { return props[key]; } readValueFromInstance(instance, key) { if (transformProps.has(key)) { const defaultType = getDefaultValueType(key); return defaultType ? defaultType.default || 0 : 0; } key = !camelCaseAttributes.has(key) ? camelToDash(key) : key; return instance.getAttribute(key); } scrapeMotionValuesFromProps(props, prevProps, visualElement) { return scrapeMotionValuesFromProps(props, prevProps, visualElement); } build(renderState, latestValues, props) { buildSVGAttrs(renderState, latestValues, this.isSVGTag, props.transformTemplate, props.style); } renderInstance(instance, renderState, styleProp, projection) { renderSVG(instance, renderState, styleProp, projection); } mount(instance) { this.isSVGTag = isSVGTag(instance.tagName); super.mount(instance); } }; function animateSingleValue(value, keyframes, options) { const motionValue$1 = isMotionValue(value) ? value : motionValue(value); motionValue$1.start(animateMotionValue("", motionValue$1, keyframes, options)); return motionValue$1.animation; } function isDOMKeyframes(keyframes) { return typeof keyframes === "object" && !Array.isArray(keyframes); } function resolveSubjects(subject, keyframes, scope, selectorCache) { if (subject == null) return []; if (typeof subject === "string" && isDOMKeyframes(keyframes)) return resolveElements(subject, scope, selectorCache); else if (subject instanceof NodeList) return Array.from(subject); else if (Array.isArray(subject)) return subject.filter((s) => s != null); else return [subject]; } function calculateRepeatDuration(duration, repeat, repeatDelay) { return duration * (repeat + 1) + repeatDelay * repeat; } /** * Given a absolute or relative time definition and current/prev time state of the sequence, * calculate an absolute time for the next keyframes. */ function calcNextTime(current, next, prev, labels) { if (typeof next === "number") return next; else if (next.startsWith("-") || next.startsWith("+")) return Math.max(0, current + parseFloat(next)); else if (next === "<") return prev; else if (next.startsWith("<")) return Math.max(0, prev + parseFloat(next.slice(1))); else return labels.get(next) ?? current; } function eraseKeyframes(sequence, startTime, endTime) { for (let i = 0; i < sequence.length; i++) { const keyframe = sequence[i]; if (keyframe.at > startTime && keyframe.at < endTime) { removeItem(sequence, keyframe); i--; } } } function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) { /** * Erase every existing value between currentTime and targetTime, * this will essentially splice this timeline into any currently * defined ones. */ eraseKeyframes(sequence, startTime, endTime); for (let i = 0; i < keyframes.length; i++) sequence.push({ value: keyframes[i], at: mixNumber$1(startTime, endTime, offset[i]), easing: /* @__PURE__ */ getEasingForSegment(easing, i) }); } /** * Take an array of times that represent repeated keyframes. For instance * if we have original times of [0, 0.5, 1] then our repeated times will * be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back * down to a 0-1 scale. * * `repeatDelayUnits` is the repeatDelay expressed in units of a single * iteration's duration, so the total span equals `(repeat + 1) + repeat * repeatDelayUnits`. */ function normalizeTimes(times, repeat, repeatDelayUnits = 0) { const totalUnits = repeat + 1 + repeat * repeatDelayUnits; for (let i = 0; i < times.length; i++) times[i] = times[i] / totalUnits; } function compareByTime(a, b) { if (a.at === b.at) { if (a.value === null) return 1; if (b.value === null) return -1; return 0; } else return a.at - b.at; } var defaultSegmentEasing = "easeInOut"; var MAX_REPEAT = 20; function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) { const defaultDuration = defaultTransition.duration || .3; const animationDefinitions = /* @__PURE__ */ new Map(); const sequences = /* @__PURE__ */ new Map(); const elementCache = {}; const timeLabels = /* @__PURE__ */ new Map(); let prevTime = 0; let currentTime = 0; let totalDuration = 0; /** * Build the timeline by mapping over the sequence array and converting * the definitions into keyframes and offsets with absolute time values. * These will later get converted into relative offsets in a second pass. */ for (let i = 0; i < sequence.length; i++) { const segment = sequence[i]; /** * If this is a timeline label, mark it and skip the rest of this iteration. */ if (typeof segment === "string") { timeLabels.set(segment, currentTime); continue; } else if (!Array.isArray(segment)) { timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels)); continue; } let [subject, keyframes, transition = {}] = segment; /** * If a relative or absolute time value has been specified we need to resolve * it in relation to the currentTime. */ if (transition.at !== void 0) currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels); /** * Keep track of the maximum duration in this definition. This will be * applied to currentTime once the definition has been parsed. */ let maxDuration = 0; const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => { const valueKeyframesAsList = keyframesAsList(valueKeyframes); const { delay = 0, times = defaultOffset(valueKeyframesAsList), type = defaultTransition.type || "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition; let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition; /** * Resolve stagger() if defined. */ const calculatedDelay = typeof delay === "function" ? delay(elementIndex, numSubjects) : delay; /** * If this animation should and can use a spring, generate a spring easing function. */ const numKeyframes = valueKeyframesAsList.length; const createGenerator = isGenerator(type) ? type : generators?.[type || "keyframes"]; if (numKeyframes <= 2 && createGenerator) { /** * As we're creating an easing function from a spring, * ideally we want to generate it using the real distance * between the two keyframes. However this isn't always * possible - in these situations we use 0-100. */ let absoluteDelta = 100; if (numKeyframes === 2 && isNumberKeyframesArray(valueKeyframesAsList)) { const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0]; absoluteDelta = Math.abs(delta); } const springTransition = { ...defaultTransition, ...remainingTransition }; if (duration !== void 0) springTransition.duration = /* @__PURE__ */ secondsToMilliseconds(duration); const springEasing = createGeneratorEasing(springTransition, absoluteDelta, createGenerator); ease = springEasing.ease; duration = springEasing.duration; } duration ?? (duration = defaultDuration); const startTime = currentTime + calculatedDelay; /** * If there's only one time offset of 0, fill in a second with length 1 */ if (times.length === 1 && times[0] === 0) times[1] = 1; /** * Fill out if offset if fewer offsets than keyframes */ const remainder = times.length - valueKeyframesAsList.length; remainder > 0 && fillOffset(times, remainder); /** * If only one value has been set, ie [1], push a null to the start of * the keyframe array. This will let us mark a keyframe at this point * that will later be hydrated with the previous value. */ valueKeyframesAsList.length === 1 && valueKeyframesAsList.unshift(null); if (repeat && repeat < MAX_REPEAT) { /** * Express repeatDelay in units of a single iteration's duration * so it can be added to the per-iteration time offsets below * before they're normalized to 0-1. */ const repeatDelayUnits = duration > 0 ? repeatDelay / duration : 0; duration = calculateRepeatDuration(duration, repeat, repeatDelay); const originalKeyframes = [...valueKeyframesAsList]; const originalTimes = [...times]; ease = Array.isArray(ease) ? [...ease] : [ease]; const originalEase = [...ease]; /** * For reverse/mirror, alternate iterations play the segment * backwards. mirror matches JSAnimation's mirroredGenerator: * reversed keyframes, easings unchanged. reverse matches * JSAnimation's iterationProgress = 1 - p: reversed * keyframes, easing array reversed AND each function easing * mapped through reverseEasing (string easings unchanged — * they're resolved later by the keyframes engine). */ const isFlipping = repeatType === "reverse" || repeatType === "mirror"; let flippedKeyframes = originalKeyframes; let flippedEases = originalEase; if (isFlipping) { flippedKeyframes = [...originalKeyframes].reverse(); if (repeatType === "reverse") flippedEases = [...originalEase].reverse().map((e) => typeof e === "function" ? /* @__PURE__ */ reverseEasing(e) : e); } for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) { const isFlipped = isFlipping && repeatIndex % 2 === 0; const iterKeyframes = isFlipped ? flippedKeyframes : originalKeyframes; const iterEase = isFlipped ? flippedEases : originalEase; const iterStartOffset = (repeatIndex + 1) * (1 + repeatDelayUnits); /** * If repeatDelay is set, hold the previous iteration's * final value through the delay by inserting a keyframe * at the moment the next iteration begins. */ if (repeatDelayUnits > 0) { valueKeyframesAsList.push(valueKeyframesAsList[valueKeyframesAsList.length - 1]); times.push(iterStartOffset); ease.push("linear"); } valueKeyframesAsList.push(...iterKeyframes); for (let keyframeIndex = 0; keyframeIndex < iterKeyframes.length; keyframeIndex++) { times.push(originalTimes[keyframeIndex] + iterStartOffset); ease.push(keyframeIndex === 0 ? "linear" : /* @__PURE__ */ getEasingForSegment(iterEase, keyframeIndex - 1)); } } normalizeTimes(times, repeat, repeatDelayUnits); } const targetTime = startTime + duration; /** * Add keyframes, mapping offsets to absolute time. */ addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime); maxDuration = Math.max(calculatedDelay + duration, maxDuration); totalDuration = Math.max(targetTime, totalDuration); }; if (isMotionValue(subject)) { const subjectSequence = getSubjectSequence(subject, sequences); resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence)); } else { const subjects = resolveSubjects(subject, keyframes, scope, elementCache); const numSubjects = subjects.length; /** * For every element in this segment, process the defined values. */ for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) { /** * Cast necessary, but we know these are of this type */ keyframes = keyframes; transition = transition; const thisSubject = subjects[subjectIndex]; const subjectSequence = getSubjectSequence(thisSubject, sequences); for (const key in keyframes) resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects); } } prevTime = currentTime; currentTime += maxDuration; } /** * For every element and value combination create a new animation. */ sequences.forEach((valueSequences, element) => { for (const key in valueSequences) { const valueSequence = valueSequences[key]; /** * Arrange all the keyframes in ascending time order. */ valueSequence.sort(compareByTime); const keyframes = []; const valueOffset = []; const valueEasing = []; /** * For each keyframe, translate absolute times into * relative offsets based on the total duration of the timeline. */ for (let i = 0; i < valueSequence.length; i++) { const { at, value, easing } = valueSequence[i]; keyframes.push(value); valueOffset.push(/* @__PURE__ */ progress(0, totalDuration, at)); valueEasing.push(easing || "easeOut"); } /** * If the first keyframe doesn't land on offset: 0 * provide one by duplicating the initial keyframe. This ensures * it snaps to the first keyframe when the animation starts. */ if (valueOffset[0] !== 0) { valueOffset.unshift(0); keyframes.unshift(keyframes[0]); valueEasing.unshift(defaultSegmentEasing); } /** * If the last keyframe doesn't land on offset: 1 * provide one with a null wildcard value. This will ensure it * stays static until the end of the animation. */ if (valueOffset[valueOffset.length - 1] !== 1) { valueOffset.push(1); keyframes.push(null); } if (!animationDefinitions.has(element)) animationDefinitions.set(element, { keyframes: {}, transition: {} }); const definition = animationDefinitions.get(element); definition.keyframes[key] = keyframes; /** * Exclude `type` from defaultTransition since springs have been * converted to duration-based easing functions in resolveValueSequence. * Including `type: "spring"` would cause JSAnimation to error when * the merged keyframes array has more than 2 keyframes. */ const { type: _type, ...remainingDefaultTransition } = defaultTransition; definition.transition[key] = { ...remainingDefaultTransition, duration: totalDuration, ease: valueEasing, times: valueOffset, ...sequenceTransition }; } }); return animationDefinitions; } function getSubjectSequence(subject, sequences) { !sequences.has(subject) && sequences.set(subject, {}); return sequences.get(subject); } function getValueSequence(name, sequences) { if (!sequences[name]) sequences[name] = []; return sequences[name]; } function keyframesAsList(keyframes) { return Array.isArray(keyframes) ? keyframes : [keyframes]; } function getValueTransition(transition, key) { return transition && transition[key] ? { ...transition, ...transition[key] } : { ...transition }; } var isNumber = (keyframe) => typeof keyframe === "number"; var isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber); function createDOMVisualElement(element) { const options = { presenceContext: null, props: {}, visualState: { renderState: { transform: {}, transformOrigin: {}, style: {}, vars: {}, attrs: {} }, latestValues: {} } }; const node = isSVGElement(element) && !isSVGSVGElement(element) ? new SVGVisualElement(options) : new HTMLVisualElement(options); node.mount(element); visualElementStore.set(element, node); } function createObjectVisualElement(subject) { const node = new ObjectVisualElement({ presenceContext: null, props: {}, visualState: { renderState: { output: {} }, latestValues: {} } }); node.mount(subject); visualElementStore.set(subject, node); } function isSingleValue(subject, keyframes) { return isMotionValue(subject) || typeof subject === "number" || typeof subject === "string" && !isDOMKeyframes(keyframes); } /** * Implementation */ function animateSubject(subject, keyframes, options, scope) { const animations = []; if (isSingleValue(subject, keyframes)) animations.push(animateSingleValue(subject, isDOMKeyframes(keyframes) ? keyframes.default || keyframes : keyframes, options ? options.default || options : options)); else { if (subject == null) return animations; const subjects = resolveSubjects(subject, keyframes, scope); const numSubjects = subjects.length; for (let i = 0; i < numSubjects; i++) { const thisSubject = subjects[i]; const createVisualElement = thisSubject instanceof Element ? createDOMVisualElement : createObjectVisualElement; if (!visualElementStore.has(thisSubject)) createVisualElement(thisSubject); const visualElement = visualElementStore.get(thisSubject); const transition = { ...options }; /** * Resolve stagger function if provided. */ if ("delay" in transition && typeof transition.delay === "function") transition.delay = transition.delay(i, numSubjects); animations.push(...animateTarget(visualElement, { ...keyframes, transition }, {})); } } return animations; } function animateSequence(sequence, options, scope) { const animations = []; createAnimationsFromSequence(sequence.map((segment) => { if (Array.isArray(segment) && typeof segment[0] === "function") { const callback = segment[0]; const mv = motionValue(0); mv.on("change", callback); if (segment.length === 1) return [mv, [0, 1]]; else if (segment.length === 2) return [ mv, [0, 1], segment[1] ]; else return [ mv, segment[1], segment[2] ]; } return segment; }), options, scope, { spring }).forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)); }); return animations; } function isSequence(value) { return Array.isArray(value) && value.some(Array.isArray); } /** * Creates an animation function that is optionally scoped * to a specific element. */ function createScopedAnimate(options = {}) { const { scope, reduceMotion, skipAnimations } = options; /** * Implementation */ function scopedAnimate(subjectOrSequence, optionsOrKeyframes, options) { let animations = []; let animationOnComplete; const inherited = {}; if (reduceMotion !== void 0) inherited.reduceMotion = reduceMotion; if (skipAnimations !== void 0) inherited.skipAnimations = skipAnimations; if (isSequence(subjectOrSequence)) { const { onComplete, ...sequenceOptions } = optionsOrKeyframes || {}; if (typeof onComplete === "function") animationOnComplete = onComplete; animations = animateSequence(subjectOrSequence, { ...inherited, ...sequenceOptions }, scope); } else { const { onComplete, ...rest } = options || {}; if (typeof onComplete === "function") animationOnComplete = onComplete; animations = animateSubject(subjectOrSequence, optionsOrKeyframes, { ...inherited, ...rest }, scope); } const animation = new GroupAnimationWithThen(animations); if (animationOnComplete) animation.finished.then(animationOnComplete); if (scope) { scope.animations.push(animation); animation.finished.then(() => { removeItem(scope.animations, animation); }); } return animation; } return scopedAnimate; } var animate = createScopedAnimate(); var springConfigs = { contentEntrance: { damping: 28, stiffness: 300, type: "spring" }, modalBackdrop: { bounce: 0, duration: .4, type: "spring" }, modalSurface: { bounce: 0, duration: .35, type: "spring" }, ratingEntrance: { bounce: 0, duration: .3, type: "spring" }, reviewBodyEntrance: { bounce: 0, duration: .3, type: "spring" }, stickyNav: { bounce: 0, duration: .3, type: "spring" }, summaryEntrance: { bounce: 0, duration: .3, type: "spring" }, swipeDismissExit: { bounce: .2, duration: .4, type: "spring" }, swipeSettleBack: { damping: 15, stiffness: 180, type: "spring" } }; var animateWithReducedMotion = (element, options) => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return animate(element, options.reducedMotionProperties ?? options.properties, { duration: .2 }); return animate(element, options.properties, options.springConfig ?? springConfigs.contentEntrance); }; /** Standard one-shot entrance animation: fade in + slide up 4px. */ var playEntrance = (element, springConfig) => animateWithReducedMotion(element, { properties: { opacity: [0, 1], transform: ["translateY(4px)", "translateY(0)"] }, reducedMotionProperties: { opacity: [0, 1] }, springConfig }); var ENTERING_SURFACE_TRANSFORM = "scale(0.92) translateY(8px)"; var EXITING_SURFACE_TRANSFORM = "scale(0.92) translateY(100dvh)"; var reducedMotionQuery = "(prefers-reduced-motion: reduce)"; var prefersReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; var finishClosingAnimation = async (animationId, animations, animationIdRef, finishClose) => { try { await Promise.all(animations.map((animation) => animation.finished)); if (animationId === animationIdRef.current) finishClose(); } catch {} }; var ModalCloseContext = X(null); var useModalClose = () => { const close = x(ModalCloseContext); if (!close) throw new Error("useModalClose must be used inside a ModalShell"); return close; }; var focusableSelector = [ "button:not([disabled])", "iframe", "a[href]", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])" ].join(", "); var focusableElements = (root) => [...root.querySelectorAll(focusableSelector)].filter((node) => !node.hasAttribute("disabled") && node.getAttribute("aria-hidden") !== "true"); var trapFocus = (event, root) => { if (event.key !== "Tab") return; const focusable = focusableElements(root); if (!focusable.length) { event.preventDefault(); root.focus(); return; } const [first] = focusable; if (!first) return; const last = focusable.at(-1); const active = document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); return; } if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } }; var useModalAccessibility = ({ onClose, overlayRef, surfaceRef }) => { h(() => { const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKeydown = (event) => { if (event.key === "Escape") { onClose(); return; } const surface = surfaceRef.current; if (surface) trapFocus(event, surface); }; document.addEventListener("keydown", onKeydown); requestAnimationFrame(() => surfaceRef.current?.focus()); return () => { document.removeEventListener("keydown", onKeydown); document.body.style.overflow = previousOverflow; }; }, [onClose, surfaceRef]); h(() => { const overlay = overlayRef.current; if (!overlay) return; const onClick = (event) => { if (event.target === overlay) onClose(); }; overlay.addEventListener("click", onClick); return () => overlay.removeEventListener("click", onClick); }, [onClose, overlayRef]); }; var readTranslateY = (element) => { const computedTransform = getComputedStyle(element).transform; const transform = element.style.transform || computedTransform; const translateMatch = transform.match(/translateY\((?-?[\d.]+)px\)/u); if (translateMatch?.groups?.translateY) return Number(translateMatch.groups.translateY); const matrixMatch = transform.match(/^matrix\([^,]+,[^,]+,[^,]+,[^,]+,[^,]+,\s*(?-?[\d.]+)\)$/u); return matrixMatch?.groups?.translateY ? Number(matrixMatch.groups.translateY) : 0; }; var rubberBand = (distance) => -Math.min(distance * .35, 96); var useSwipeToDismiss = (options) => { const optionsRef = A(options); h(() => { optionsRef.current = options; }, [options]); const startYRef = A(0); const baseTranslationRef = A(0); const currentDeltaRef = A(0); const currentPositionRef = A(0); const activePointerIdRef = A(null); const pointerHistoryRef = A([]); const animationRef = A(null); const animationGenerationRef = A(0); const elementRef = A(null); const stopAnimation = q(() => { animationGenerationRef.current += 1; animationRef.current?.stop(); animationRef.current = null; }, []); const handlePointerDown = q((event) => { if (activePointerIdRef.current !== null) return; stopAnimation(); const element = event.currentTarget; elementRef.current = element; startYRef.current = event.clientY; baseTranslationRef.current = readTranslateY(element); currentDeltaRef.current = 0; currentPositionRef.current = baseTranslationRef.current; pointerHistoryRef.current = [{ time: performance.now(), y: event.clientY }]; activePointerIdRef.current = event.pointerId; element.setPointerCapture(event.pointerId); }, [stopAnimation]); const handlePointerMove = q((event) => { const element = elementRef.current; if (!element) return; if (event.pointerId !== activePointerIdRef.current) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const position = baseTranslationRef.current + event.clientY - startYRef.current; currentPositionRef.current = position < 0 ? rubberBand(-position) : position; currentDeltaRef.current = Math.max(0, position); const history = pointerHistoryRef.current; history.push({ time: performance.now(), y: event.clientY }); if (history.length > 5) history.shift(); element.style.transform = `translateY(${currentPositionRef.current}px)`; }, []); const settlePointer = q((element) => { const delta = currentDeltaRef.current; const position = currentPositionRef.current; const history = pointerHistoryRef.current; currentDeltaRef.current = 0; currentPositionRef.current = 0; baseTranslationRef.current = 0; const { dismissThreshold = 80, velocityThreshold = 300, onDismiss } = optionsRef.current; let velocity = 0; if (history.length >= 2) { const [first] = history; const last = history.at(-1); if (first && last) { const dt = (last.time - first.time) / 1e3; if (dt > 0) velocity = (last.y - first.y) / dt; } } if (delta > dismissThreshold || velocity > velocityThreshold) { onDismiss({ position, velocity }); return; } if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) element.style.transform = ""; else { const animationGeneration = animationGenerationRef.current + 1; animationGenerationRef.current = animationGeneration; const anim = animate(element, { transform: "translateY(0)" }, springConfigs.swipeSettleBack); animationRef.current = anim; (async () => { try { await anim.finished; if (animationGenerationRef.current !== animationGeneration || animationRef.current !== anim) return; animationRef.current = null; element.style.transform = ""; } catch {} })(); } }, []); const handlePointerEnd = q((event) => { const element = elementRef.current; if (element && event.pointerId === activePointerIdRef.current) { activePointerIdRef.current = null; settlePointer(element); } }, [settlePointer]); return { ref: q((el) => { stopAnimation(); const prevEl = elementRef.current; if (prevEl) { prevEl.removeEventListener("pointerdown", handlePointerDown); prevEl.removeEventListener("pointermove", handlePointerMove); prevEl.removeEventListener("pointerup", handlePointerEnd); prevEl.removeEventListener("pointercancel", handlePointerEnd); } if (el) { el.addEventListener("pointerdown", handlePointerDown); el.addEventListener("pointermove", handlePointerMove); el.addEventListener("pointerup", handlePointerEnd); el.addEventListener("pointercancel", handlePointerEnd); elementRef.current = el; } else elementRef.current = null; }, [ handlePointerDown, handlePointerEnd, handlePointerMove, stopAnimation ]) }; }; var ModalShell = ({ ariaDescribedBy, ariaLabel, ariaLabelledBy, children, className, dismissable = false, id, onClose, surfaceClassName }) => { const overlayRef = A(null); const surfaceRef = A(null); const closeSourceRef = A("standard"); const swipeDismissRef = A(null); const realOnCloseRef = A(onClose); const [phase, setPhase] = d("open"); const [reducedMotion, setReducedMotion] = d(prefersReducedMotion); const didFinishCloseRef = A(false); const animationIdRef = A(0); const animationsRef = A([]); const session = useModalSession(); const previousSessionRef = A(session); h(() => { realOnCloseRef.current = onClose; }, [onClose]); h(() => { const mediaQuery = window.matchMedia?.(reducedMotionQuery); if (!mediaQuery) return; const updateReducedMotion = () => setReducedMotion(mediaQuery.matches); mediaQuery.addEventListener("change", updateReducedMotion); return () => mediaQuery.removeEventListener("change", updateReducedMotion); }, []); const finishClose = q(() => { if (didFinishCloseRef.current) return; didFinishCloseRef.current = true; realOnCloseRef.current(); }, []); const animateModal = q((state) => { const overlay = overlayRef.current; const surface = surfaceRef.current; if (!overlay || !surface) return; for (const animation of animationsRef.current) animation.stop(); const animationId = animationIdRef.current + 1; animationIdRef.current = animationId; const opening = state === "open"; const backdropAnimation = animateWithReducedMotion(overlay, { properties: { opacity: opening ? 1 : 0 }, reducedMotionProperties: { opacity: opening ? 1 : 0 }, springConfig: springConfigs.modalBackdrop }); const surfaceExitTransform = closeSourceRef.current === "swipe" ? "translateY(100dvh)" : EXITING_SURFACE_TRANSFORM; const surfaceSpring = closeSourceRef.current === "swipe" ? { ...springConfigs.swipeDismissExit, velocity: swipeDismissRef.current?.velocity ?? 0 } : springConfigs.modalSurface; if (!opening) { closeSourceRef.current = "standard"; swipeDismissRef.current = null; } const surfaceAnimation = animateWithReducedMotion(surface, { properties: { opacity: opening ? 1 : 0, transform: opening ? "scale(1) translateY(0)" : surfaceExitTransform }, reducedMotionProperties: { opacity: opening ? 1 : 0 }, springConfig: surfaceSpring }); animationsRef.current = [backdropAnimation, surfaceAnimation]; if (!opening) finishClosingAnimation(animationId, [backdropAnimation, surfaceAnimation], animationIdRef, finishClose); }, [finishClose]); _(() => { if (previousSessionRef.current === session) return; previousSessionRef.current = session; didFinishCloseRef.current = false; setPhase("open"); animateModal("open"); }, [animateModal, session]); const handleClose = q(() => { if (didFinishCloseRef.current) return; didFinishCloseRef.current = false; setPhase("closing"); animateModal("closed"); }, [animateModal]); const handleSwipeDismiss = q((details) => { closeSourceRef.current = "swipe"; swipeDismissRef.current = details; handleClose(); }, [handleClose]); const swipe = useSwipeToDismiss({ onDismiss: dismissable ? handleSwipeDismiss : (_details) => {} }); const surfaceRefCallback = q((el) => { surfaceRef.current = el; if (dismissable) swipe.ref(el); }, [dismissable, swipe]); h(() => { const frame = requestAnimationFrame(() => { if (!overlayRef.current) return; animateModal("open"); }); return () => { cancelAnimationFrame(frame); for (const animation of animationsRef.current) animation.stop(); }; }, [animateModal]); useModalAccessibility({ onClose: handleClose, overlayRef, surfaceRef }); return /* @__PURE__ */ u(ModalCloseContext.Provider, { value: handleClose, children: /* @__PURE__ */ u("dialog", { "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-modal": "true", class: className, id, open: true, ref: overlayRef, style: { opacity: 0, pointerEvents: phase === "open" ? "auto" : "none" }, children: /* @__PURE__ */ u("div", { class: surfaceClassName, onClick: (event) => event.stopPropagation(), ref: dismissable ? surfaceRefCallback : surfaceRef, role: "none", style: { opacity: 0, transform: reducedMotion ? "none" : ENTERING_SURFACE_TRANSFORM, ...dismissable ? { touchAction: "pan-x" } : {} }, tabindex: -1, children }) }) }); }; var MODAL_ID$1 = "atv-poster-modal"; var PosterModalContent = ({ alt, previewSrc, src }) => { const close = useModalClose(); const hasPreview = Boolean(previewSrc && previewSrc !== src); const [imageState, setImageState] = d(hasPreview ? "loading" : "loaded"); return /* @__PURE__ */ u("div", { class: `atv-image-modal-frame is-${imageState}`, children: [ /* @__PURE__ */ u(ModalCloseButton, { ariaLabel: "关闭图片预览", className: "atv-image-modal-close", onClick: close }), hasPreview ? /* @__PURE__ */ u("img", { alt: "", "aria-hidden": "true", class: "atv-modal-preview", src: previewSrc }) : null, /* @__PURE__ */ u("img", { alt: alt || "", class: "atv-modal-img", onError: () => { if (hasPreview) setImageState("preview"); }, onLoad: () => setImageState("loaded"), src }), imageState === "loading" ? /* @__PURE__ */ u("output", { "aria-live": "polite", class: "atv-image-modal-loading", children: [/* @__PURE__ */ u("span", { "aria-hidden": "true", class: "atv-spinner" }), /* @__PURE__ */ u("span", { children: "加载图片中…" })] }) : null ] }); }; var PosterModal = ({ alt, onClose, previewSrc, src }) => /* @__PURE__ */ u(ModalShell, { ariaLabel: "海报预览", className: "atv-modal-overlay", id: MODAL_ID$1, onClose, surfaceClassName: "atv-modal-surface", children: /* @__PURE__ */ u(PosterModalContent, { alt, ...previewSrc ? { previewSrc } : {}, src }, src) }); var useModalRequest = () => { const [active, setActive] = d(null); return { active, handleClose: q(() => setActive(null), []), handleOpen: q((value) => { setActive({ value }); }, []) }; }; var REVEAL_THRESHOLD = 0; /** * Reveals a section (toggles `is-revealed`) the first time it scrolls into * the viewport. The visual fade/slide lives in CSS (`.atv-section-reveal`); * this hook only flips the class so the work stays on the compositor and the * page never ships a permanently-hidden section. * * Reduced-motion and IntersectionObserver-less environments mark the section * revealed immediately — the CSS reduced-motion branch keeps it visible * without any movement. */ var useSectionReveal = (ref) => { h(() => { const element = ref.current; if (!element) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || typeof IntersectionObserver === "undefined") { element.classList.add("is-revealed"); return; } const observer = new IntersectionObserver((entries) => { for (const entry of entries) if (entry.isIntersecting) { element.classList.add("is-revealed"); observer.unobserve(element); } }, { threshold: [REVEAL_THRESHOLD] }); observer.observe(element); return () => { observer.disconnect(); }; }, [ref]); }; var Section = ({ children, id, moreLink, title }) => { const ref = A(null); useSectionReveal(ref); return /* @__PURE__ */ u("section", { class: "atv-section atv-section-reveal", "data-atv-section-reveal": true, id, ref, children: [moreLink ? /* @__PURE__ */ u("div", { class: "atv-section-h-row", children: [/* @__PURE__ */ u("h2", { class: "atv-section-h", children: title }), /* @__PURE__ */ u("a", { class: "atv-section-more", href: moreLink.href, rel: "noopener", target: "_blank", children: moreLink.text })] }) : /* @__PURE__ */ u("h2", { class: "atv-section-h", children: title }), children] }); }; var AwardLink = ({ children, href }) => href ? /* @__PURE__ */ u("a", { href, rel: "noreferrer", target: "_blank", children }) : /* @__PURE__ */ u("span", { children }); var PersonageAwardsSection = ({ awards }) => { if (!awards?.awards.length) return null; return /* @__PURE__ */ u(Section, { id: "atv-personage-awards", ...awards.allAwardsHref ? { moreLink: { href: awards.allAwardsHref, text: "查看全部 →" } } : {}, title: "荣誉", children: /* @__PURE__ */ u("ul", { class: "atv-personage-awards-list", children: awards.awards.map((award) => /* @__PURE__ */ u("li", { children: [/* @__PURE__ */ u("time", { class: "atv-personage-award-year", children: award.year }), /* @__PURE__ */ u("div", { class: "atv-personage-award-detail", children: [ /* @__PURE__ */ u("span", { class: "atv-personage-award-ceremony", children: /* @__PURE__ */ u(AwardLink, { href: award.ceremonyHref, children: award.ceremony }) }), /* @__PURE__ */ u("p", { class: "atv-personage-award-name", children: award.award }), award.work ? /* @__PURE__ */ u("p", { class: "atv-personage-award-work", children: /* @__PURE__ */ u(AwardLink, { href: award.workHref, children: award.work }) }) : null ] })] }, `${award.year}-${award.ceremony}-${award.award}`)) }) }); }; /** Gets the raw aspect-ratio string for CSS (e.g. "4/3" or "1.5") */ var formatAspectRatio = (ratio) => { const fraction = ratio.toFixed(4); if (fraction.endsWith("6667")) return "2 / 3"; if (fraction.endsWith("7500")) return "3 / 4"; if (fraction.endsWith("3333")) return "4 / 3"; if (fraction.endsWith("5000")) return "3 / 2"; if (fraction.endsWith("0000")) return `${Math.round(ratio)}`; return String(ratio); }; var SafeImage = ({ src, alt, className, loading = "lazy", aspectRatio, staggerIndex, onLoad, fallback }) => { const [failed, setFailed] = d(false); if (!src) return fallback ?? /* @__PURE__ */ u("div", { "aria-hidden": "true", class: "atv-safe-image-fallback" }); if (failed) return fallback ?? /* @__PURE__ */ u("div", { "aria-hidden": "true", class: "atv-safe-image-fallback" }); const img = /* @__PURE__ */ u("img", { alt, class: className, loading, onError: () => setFailed(true), onLoad: (event) => { const image = event.currentTarget; if (image.naturalWidth && image.naturalHeight) onLoad?.({ height: image.naturalHeight, width: image.naturalWidth }); }, src }); if (aspectRatio !== void 0 || staggerIndex !== void 0) { const vars = {}; if (aspectRatio !== void 0) vars.aspectRatio = formatAspectRatio(aspectRatio); if (staggerIndex !== void 0) vars["--stagger-index"] = String(staggerIndex); return /* @__PURE__ */ u("div", { class: "atv-safe-image-container", style: vars, children: img }); } return img; }; var PersonageCollaborators = ({ collaborators }) => { if (!collaborators?.collaborators.length) return null; const { allCollaboratorsHref, collaborators: entries } = collaborators; return /* @__PURE__ */ u(Section, { id: "atv-personage-collaborators", title: "合作", ...allCollaboratorsHref ? { moreLink: { href: allCollaboratorsHref, text: "查看全部 →" } } : {}, children: /* @__PURE__ */ u("ol", { class: "atv-personage-collaborators", children: entries.map((collaborator, index) => /* @__PURE__ */ u("li", { class: "atv-personage-collaborator", "data-leading": index === 0 ? "true" : void 0, style: { "--stagger-index": String(index) }, children: [ /* @__PURE__ */ u("span", { "aria-hidden": "true", class: "atv-personage-collaborator-rank", children: index + 1 }), collaborator.avatar ? /* @__PURE__ */ u(SafeImage, { alt: "", className: "atv-personage-collaborator-avatar", fallback: /* @__PURE__ */ u("span", { "aria-hidden": "true", class: "atv-personage-collaborator-avatar is-fallback", children: collaborator.name.slice(0, 1) }), src: collaborator.avatar }) : /* @__PURE__ */ u("span", { "aria-hidden": "true", class: "atv-personage-collaborator-avatar is-fallback", children: collaborator.name.slice(0, 1) }), /* @__PURE__ */ u("div", { class: "atv-personage-collaborator-info", children: [/* @__PURE__ */ u("a", { class: "atv-personage-collaborator-name", href: collaborator.href, rel: "noopener", target: "_blank", children: collaborator.name }), /* @__PURE__ */ u("span", { class: "atv-personage-collaborator-count", children: [collaborator.sharedWorkCount, " 次合作"] })] }), collaborator.sharedWorksHref ? /* @__PURE__ */ u("a", { class: "atv-personage-collaborator-action", href: collaborator.sharedWorksHref, rel: "noopener", target: "_blank", children: ["查看共同作品", /* @__PURE__ */ u("span", { class: "atv-personage-collaborator-arrow", "aria-hidden": "true", children: [" ", "↗"] })] }) : null ] }, collaborator.href)) }) }); }; var noop$3 = () => void 0; var PersonageGalleryImageTile = ({ alt, largeSrc, onOpenImage, src, staggerIndex }) => { const [aspectRatio, setAspectRatio] = d(null); return /* @__PURE__ */ u("li", { style: { "--stagger-index": String(staggerIndex), ...aspectRatio ? { "--atv-personage-gallery-aspect-ratio": String(aspectRatio) } : {} }, children: /* @__PURE__ */ u("button", { "aria-label": `查看${alt}`, class: "atv-image-preview-trigger", onClick: () => onOpenImage({ alt, previewSrc: src, src: largeSrc }), type: "button", children: /* @__PURE__ */ u(SafeImage, { alt, loading: "lazy", onLoad: ({ width, height }) => setAspectRatio(width / height), src }) }) }); }; var PersonageGallerySection = ({ gallery, name, onOpenImage = noop$3 }) => { if (!gallery?.images.length) return null; return /* @__PURE__ */ u(Section, { id: "atv-personage-gallery", ...gallery.allImagesHref ? { moreLink: { href: gallery.allImagesHref, text: "查看全部 →" } } : {}, title: "图集", children: /* @__PURE__ */ u("ul", { "aria-label": `${name}的图片`, class: "atv-carousel atv-personage-gallery-rail", children: gallery.images.map((image, index) => { return /* @__PURE__ */ u(PersonageGalleryImageTile, { alt: image.alt || `${name}的图片 ${index + 1}`, largeSrc: image.largeSrc, onOpenImage, src: image.src, staggerIndex: index }, image.src); }) }) }); }; var splitPersonageName = (name) => { const originalNameStart = name.search(/\p{Script=Latin}/u); if (originalNameStart <= 0) return { originalName: null, primaryName: name }; return { originalName: name.slice(originalNameStart).trim(), primaryName: name.slice(0, originalNameStart).trim() }; }; var biographyPreviewLineCount = 3; var useBiographyDisclosure = (biography) => { const contentRef = A(null); const biographyKey = biography?.join("\n") ?? ""; const previousBiographyKeyRef = A(biographyKey); const [canExpand, setCanExpand] = d(false); const [isExpanded, setIsExpanded] = d(false); _(() => { if (previousBiographyKeyRef.current !== biographyKey) { previousBiographyKeyRef.current = biographyKey; setCanExpand(false); setIsExpanded(false); } }, [biographyKey]); _(() => { const content = contentRef.current; if (!content || !biographyKey) { setCanExpand(false); setIsExpanded(false); return; } if (canExpand) return; const measureOverflow = () => { const lineHeight = Number(window.getComputedStyle(content).lineHeight.replace("px", "")); const fullHeight = content.scrollHeight; const hasHiddenContent = Number.isFinite(lineHeight) && fullHeight > lineHeight * biographyPreviewLineCount + 1; setCanExpand(hasHiddenContent); if (!hasHiddenContent) setIsExpanded(false); }; measureOverflow(); const frame = window.requestAnimationFrame(measureOverflow); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureOverflow); observer?.observe(content); return () => { window.cancelAnimationFrame(frame); observer?.disconnect(); }; }, [biographyKey, canExpand]); return { canExpand, contentRef, isExpanded, setIsExpanded }; }; var computeStatsKicker = (profile) => { const totalWorks = profile.recentWorks?.totalCount ?? profile.representativeWorks?.totalCount ?? 0; const totalAwards = profile.awards?.totalCount ?? profile.awards?.awards.length ?? 0; if (totalWorks === 0 && totalAwards === 0) return null; const parts = []; if (totalWorks > 0) parts.push(`${totalWorks} 部作品`); if (totalAwards > 0) parts.push(`${totalAwards} 项荣誉`); return parts.join(" · "); }; var PersonageHeroPortrait = ({ portrait, primaryName, onOpenPortrait }) => { const handleClick = () => { onOpenPortrait(portrait, `${primaryName}的头像`); }; const handleKeyDown = (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleClick(); } }; return /* @__PURE__ */ u("button", { "aria-label": `查看${primaryName}的头像`, class: "atv-personage-portrait-trigger atv-image-preview-trigger", onClick: handleClick, onKeyDown: handleKeyDown, type: "button", children: /* @__PURE__ */ u(SafeImage, { alt: "", className: "atv-personage-portrait", fallback: /* @__PURE__ */ u("div", { "aria-hidden": "true", class: "atv-personage-portrait is-empty" }), loading: "eager", src: portrait }) }); }; var PersonageBiography = ({ biography, canExpand, contentRef, isExpanded, setIsExpanded }) => /* @__PURE__ */ u("div", { class: "atv-personage-biography", children: [/* @__PURE__ */ u("div", { class: `atv-personage-biography-content ${canExpand && !isExpanded ? "is-clamped" : "is-expanded"}`, ref: contentRef, children: biography.map((paragraph, index) => /* @__PURE__ */ u("p", { children: paragraph }, `${index}-${paragraph}`)) }), canExpand ? /* @__PURE__ */ u("button", { "aria-expanded": isExpanded, onClick: () => setIsExpanded((expanded) => !expanded), type: "button", children: isExpanded ? "收起" : "展开" }) : null] }); var PersonageHero = ({ profile, onOpenPortrait }) => { const { canExpand, contentRef, isExpanded, setIsExpanded } = useBiographyDisclosure(profile.biography); const { originalName, primaryName } = splitPersonageName(profile.name); const [isRevealed, setIsRevealed] = d(false); h(() => { const frame = requestAnimationFrame(() => { setIsRevealed(true); }); return () => cancelAnimationFrame(frame); }, []); const statsKicker = computeStatsKicker(profile); return /* @__PURE__ */ u("section", { class: `atv-personage-hero${isRevealed ? " is-revealed" : ""}`, children: /* @__PURE__ */ u("div", { class: "atv-personage-hero-inner", children: [profile.portrait ? /* @__PURE__ */ u(PersonageHeroPortrait, { portrait: profile.portrait, primaryName, onOpenPortrait }) : /* @__PURE__ */ u("div", { "aria-hidden": "true", class: "atv-personage-portrait is-empty" }), /* @__PURE__ */ u("div", { class: "atv-personage-identity", children: [ statsKicker ? /* @__PURE__ */ u("p", { class: "atv-personage-kicker", children: statsKicker }) : null, /* @__PURE__ */ u("h1", { children: primaryName }), originalName ? /* @__PURE__ */ u("p", { class: "atv-personage-original-name", children: originalName }) : null, profile.facts.length > 0 ? /* @__PURE__ */ u("dl", { class: "atv-personage-facts", children: profile.facts.map((fact, index) => /* @__PURE__ */ u("div", { style: { "--fact-index": String(index) }, children: [/* @__PURE__ */ u("dt", { children: fact.label }), /* @__PURE__ */ u("dd", { children: fact.value })] }, fact.label)) }) : null, profile.biography?.length ? /* @__PURE__ */ u(PersonageBiography, { biography: profile.biography, canExpand, contentRef, isExpanded, setIsExpanded }) : null ] })] }) }); }; var PosterPlaceholder = () => /* @__PURE__ */ u("div", { class: "atv-poster-placeholder", children: /* @__PURE__ */ u(IconFilmPlaceholder, {}) }); var PosterImage = ({ alt, className, poster }) => /* @__PURE__ */ u(SafeImage, { alt, ...className ? { className } : {}, fallback: /* @__PURE__ */ u(PosterPlaceholder, {}), loading: "lazy", src: poster }); /** Group works by year, descending. Works without a year go under "其他". */ var groupByYear = (works) => { const map = /* @__PURE__ */ new Map(); for (const work of works) { const key = work.year ?? "其他"; let group = map.get(key); if (!group) { group = []; map.set(key, group); } group.push(work); } return [...map.entries()].toSorted(([a], [b]) => { if (a === "其他") return 1; if (b === "其他") return -1; return Number(b) - Number(a); }); }; var WorkCard = ({ index, work }) => /* @__PURE__ */ u("a", { class: "atv-timeline-card", href: work.href, rel: "noopener", style: { "--stagger-index": String(index) }, target: "_blank", children: [/* @__PURE__ */ u("div", { class: "atv-timeline-card-poster", children: [/* @__PURE__ */ u(PosterImage, { alt: work.title, poster: work.poster }), work.rating ? /* @__PURE__ */ u("span", { class: "atv-timeline-card-rating", children: work.rating }) : null] }), /* @__PURE__ */ u("span", { class: "atv-timeline-card-title", children: work.title })] }); var PersonageTimeline = ({ id, rail, title }) => { if (!rail?.works.length) return null; const groups = groupByYear(rail.works); return /* @__PURE__ */ u(Section, { id, ...rail.allWorksHref ? { moreLink: { href: rail.allWorksHref, text: "查看全部 →" } } : {}, title, children: /* @__PURE__ */ u("div", { class: "atv-timeline", style: { "--group-count": String(groups.length) }, children: groups.map(([year, works], gi) => /* @__PURE__ */ u("div", { class: "atv-timeline-year-group", style: { "--group-index": String(gi) }, children: [/* @__PURE__ */ u("span", { class: "atv-timeline-year-num", children: year }), /* @__PURE__ */ u("div", { class: "atv-timeline-row", children: works.map((work, wi) => /* @__PURE__ */ u(WorkCard, { index: wi, work }, work.href)) })] }, year)) }) }); }; var PersonageWorkRail = ({ id, rail, title }) => { if (!rail?.works.length) return null; return /* @__PURE__ */ u(Section, { id, ...rail.allWorksHref ? { moreLink: { href: rail.allWorksHref, text: "查看全部 →" } } : {}, title, children: /* @__PURE__ */ u("div", { class: "atv-carousel atv-personage-work-rail", children: rail.works.map((work, index) => /* @__PURE__ */ u("a", { class: "atv-personage-work-card", href: work.href, rel: "noopener", target: "_blank", children: [ /* @__PURE__ */ u("div", { class: "atv-personage-work-poster", children: [ /* @__PURE__ */ u("span", { class: "atv-work-index", children: index + 1 }), work.rating ? /* @__PURE__ */ u("span", { class: "atv-work-rating", children: work.rating }) : null, /* @__PURE__ */ u(PosterImage, { alt: work.title, poster: work.poster }) ] }), /* @__PURE__ */ u("span", { class: "atv-personage-work-title", children: work.title }), work.year ? /* @__PURE__ */ u("span", { class: "atv-personage-work-year", children: work.year }) : null ] }, work.href)) }) }); }; var extractPrimaryName = (name) => { const originalNameStart = name.search(/\p{Script=Latin}/u); if (originalNameStart <= 0) return name; return name.slice(0, originalNameStart).trim(); }; var PersonagePage = ({ navigation, profile }) => { const activeImage = useModalRequest(); const primaryName = extractPrimaryName(profile.name); const handleOpenPortrait = (src, alt) => { activeImage.handleOpen({ alt, src }); }; return /* @__PURE__ */ u(S, { children: [ navigation ? /* @__PURE__ */ u(StickyNav, { ...navigation, title: primaryName }) : null, /* @__PURE__ */ u("main", { class: "atv-personage", children: [ /* @__PURE__ */ u(PersonageHero, { profile, onOpenPortrait: handleOpenPortrait }), /* @__PURE__ */ u(PersonageAwardsSection, { awards: profile.awards }), /* @__PURE__ */ u(PersonageTimeline, { id: "atv-personage-recent-works", rail: profile.recentWorks, title: "近作" }), /* @__PURE__ */ u(PersonageWorkRail, { id: "atv-personage-representative-works", rail: profile.representativeWorks, title: "作品选" }), /* @__PURE__ */ u(PersonageCollaborators, { collaborators: profile.collaborators }), /* @__PURE__ */ u(PersonageGallerySection, { gallery: profile.gallery, name: primaryName, onOpenImage: activeImage.handleOpen }) ] }), activeImage.active ? /* @__PURE__ */ u(ModalSession, { request: activeImage.active, children: /* @__PURE__ */ u(PosterModal, { alt: activeImage.active.value.alt, onClose: activeImage.handleClose, ...activeImage.active.value.previewSrc ? { previewSrc: activeImage.active.value.previewSrc } : {}, src: activeImage.active.value.src }) }) : null ] }); }; var PERSONAGE_SECTIONS = [ { id: "atv-personage-awards", navLabel: () => "荣誉", visible: (p) => (p.awards?.awards.length ?? 0) > 0 }, { id: "atv-personage-recent-works", navLabel: () => "近作", visible: (p) => (p.recentWorks?.works.length ?? 0) > 0 }, { id: "atv-personage-representative-works", navLabel: () => "作品选", visible: (p) => (p.representativeWorks?.works.length ?? 0) > 0 }, { id: "atv-personage-collaborators", navLabel: () => "合作", visible: (p) => (p.collaborators?.collaborators.length ?? 0) > 0 }, { id: "atv-personage-gallery", navLabel: () => "图集", visible: (p) => (p.gallery?.images.length ?? 0) > 0 } ]; var useStickyNavigation = (doc, sections) => { const [activeSectionId, setActiveSectionId] = d(""); const [visible, setVisible] = d(false); const [scrolling, setScrolling] = d(false); const lastVisibleRef = A(false); const navRef = A(null); h(() => { const view = doc.defaultView ?? window; let scrollTimer; const handleScroll = () => { const isVisible = view.scrollY > 300; if (isVisible !== lastVisibleRef.current) { lastVisibleRef.current = isVisible; setVisible(isVisible); } setScrolling(true); view.clearTimeout(scrollTimer); scrollTimer = view.setTimeout(() => setScrolling(false), 150); }; view.addEventListener("scroll", handleScroll, { passive: true }); handleScroll(); return () => { view.removeEventListener("scroll", handleScroll); view.clearTimeout(scrollTimer); }; }, [doc]); h(() => { const nav = navRef.current; if (!nav) return; animateWithReducedMotion(nav, { properties: visible ? { opacity: 1, transform: "translateY(0)" } : { opacity: 0, transform: "translateY(-100%)" }, reducedMotionProperties: { opacity: visible ? 1 : 0 }, springConfig: springConfigs.stickyNav }); }, [visible]); h(() => { const view = doc.defaultView ?? window; const elements = /* @__PURE__ */ new Map(); for (const section of sections) { const element = doc.querySelector(`#${section.id}`); if (element) elements.set(section.id, element); } let pending = false; const pick = () => { let activeId = ""; let bestScore = -Infinity; for (const section of sections) { const element = elements.get(section.id); if (!element) continue; const rect = element.getBoundingClientRect(); const visibleTop = Math.max(rect.top, 56); const visibleBottom = Math.min(rect.bottom, view.innerHeight * .55); const score = Math.max(0, visibleBottom - visibleTop); if (score > bestScore) { activeId = section.id; bestScore = score; } } setActiveSectionId(activeId); pending = false; }; const observer = new view.IntersectionObserver(() => { if (pending) return; pending = true; view.requestAnimationFrame(pick); }, { threshold: [ 0, .25, .5 ] }); for (const element of elements.values()) observer.observe(element); pick(); return () => observer.disconnect(); }, [doc, sections]); return { activeSectionId, navRef, onJump: q((sectionId) => { const view = doc.defaultView ?? window; const target = doc.querySelector(`#${sectionId}`); if (!target) return; const prefersReducedMotion = view.matchMedia("(prefers-reduced-motion: reduce)").matches; view.history.pushState(null, "", `#${sectionId}`); target.tabIndex = -1; target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "start" }); target.focus({ preventScroll: true }); }, [doc]), scrolling, sections, visible }; }; var biographyExpansionRetryDelay = 50; var biographyExpansionMaxAttempts = 40; var isBiographyExpansionPending = (doc) => [...doc.querySelectorAll(".subject-intro .fold-switch")].some((element) => element.textContent?.includes("展开")); var computePersonageNavSections = (profile) => PERSONAGE_SECTIONS.filter((entry) => entry.visible(profile)).map((entry) => ({ id: entry.id, label: entry.navLabel(profile) })); var PersonageProfileAdoption = ({ doc, profile: initialProfile }) => { const [profile, setProfile] = d(initialProfile); const biographyKey = profile.biography?.join("\n") ?? ""; const sections = T(() => computePersonageNavSections(profile), [profile]); const navigation = useStickyNavigation(doc, sections); _(() => { const view = doc.defaultView; if (!view) return; let retryTimer; let attempts = 0; const refreshProfile = () => { const nextProfile = extractPersonageProfile(doc); if (nextProfile) setProfile(nextProfile); }; const tryExpand = () => { const foldSwitch = [...doc.querySelectorAll(".subject-intro .fold-switch")].find((element) => element.textContent?.includes("展开")); if (!foldSwitch) return; foldSwitch.click(); if (!isBiographyExpansionPending(doc)) { refreshProfile(); return; } attempts += 1; if (attempts < biographyExpansionMaxAttempts) retryTimer = view.setTimeout(tryExpand, biographyExpansionRetryDelay); }; const frame = view.requestAnimationFrame(tryExpand); return () => { view.cancelAnimationFrame(frame); clearTimeout(retryTimer); }; }, [biographyKey, doc]); _(() => { let timer; const refreshProfile = () => { const nextProfile = extractPersonageProfile(doc); if (nextProfile) setProfile(nextProfile); }; const observer = new MutationObserver((mutations) => { if (!mutations.some((mutation) => !(mutation.target instanceof Element && mutation.target.closest("#atv-douban-root")))) return; clearTimeout(timer); timer = setTimeout(refreshProfile, 200); }); observer.observe(doc.body, { childList: true, subtree: true }); return () => { observer.disconnect(); clearTimeout(timer); }; }, [doc]); return /* @__PURE__ */ u(PersonagePage, { navigation: sections.length > 0 ? navigation : void 0, profile }); }; var isPersonageHomepage = (location) => location.hostname === "www.douban.com" && /^\/personage\/\d+\/?$/u.test(location.pathname); var mountPersonage = (doc = document) => { if (doc.querySelector("#atv-douban-root")) return; const profile = extractPersonageProfile(doc); if (!profile) return; if (installEnhancedRoot(doc, (root) => R(/* @__PURE__ */ u(PersonageProfileAdoption, { doc, profile }), root))) doc.title = `${profile.name}`; }; var personagePage = { matches: isPersonageHomepage, mount: mountPersonage }; /** * Extract awards from `ul.award` elements in the info section. * * Each award is a `ul.award` with 3 `li` children: * 0: organization name (may contain ``) * 1: award name (plain text) * 2: person name (may contain ``) */ var extractAwards = (doc) => $$("ul.award", doc).map((ul) => { const lis = $$("li", ul); const orgEl = lis[0] ? $("a", lis[0]) : null; const org = lis[0] ? safeText(lis[0]) : ""; const name = lis[1] ? safeText(lis[1]) : ""; const personEl = lis[2] ? $("a", lis[2]) : null; const person = lis[2] ? safeText(lis[2]) : ""; return { name, org, orgLink: orgEl ? orgEl.href : "", person, personLink: personEl ? personEl.href : "" }; }).filter((a) => a.org); var RE_SLASH_SEP = /\s*\/\s*/gu; var RE_WS_GLOBAL = /\s+/gu; var RE_COLON_WS = /[::\s]/gu; var RE_YEAR_TRAIL = /\(\d{4}\)\s*$/u; var RE_H1_YEAR = /\((?\d{4})\)\s*$/u; var RE_WS = /\s+/u; var RE_YEAR = /(?\d{4})/u; var RE_NON_DIGIT = /\D/gu; var RE_HSPACE = /[ \t]+/gu; var RE_NL_MULTI = /\n{3,}/gu; var RE_IMDB_ID = /(?tt\d+)/u; var RE_BG_URL = /url\(["']?(?[^"')]+)["']?\)/u; var RE_SUBJECT_ID = /subject\/(?\d+)/u; var RE_ALLSTAR = /allstar(?\d{2})/u; var RE_HTTP = /^https?:\/\//u; var RE_ONLINE_VIDEO = /online-video/u; var RE_IMDB_LINK = /^tt\d+$/u; var RE_SEASON_SUFFIX = /\d$/u; var RE_SEASON_EP = /^第[一二三四五六七八九十百\d]+[季集]\s*/u; var RE_PLAY_SOURCES = /sources\[(?\d+)\]\s*=\s*\[\s*\{play_link:\s*"(?[^"]+)"/gu; var RE_SOURCES_SCRIPT = /\bsources\s*\[/u; /** * Upgrade poster CDN URL from standard to HD. * Replaces "s_ratio_poster" with "l_ratio_poster". */ var upgradePoster = (url) => { if (!url) return null; return encodeURI(url.replace("/s_ratio_poster/", "/l_ratio_poster/").replace("s_ratio_poster", "l_ratio_poster")); }; /** * Upgrade photo CDN URL from standard to HD. * Replaces "/sqxs/" → "/large/" and "/m/" → "/l/". */ var upgradePhoto = (url) => { if (!url) return null; return encodeURI(url.replace("/sqxs/", "/large/").replace("/m/", "/l/")); }; /** * Replace Douban's square crop with a proportion-preserving rail thumbnail. */ var thumbnailPhoto = (url) => { if (!url) return null; return encodeURI(url.replace("/sqxs/", "/photo/")); }; /** * Extract full / primary / original title from the page heading. */ var extractTitle = (doc) => { const h1 = $("#content h1", doc); const full = safeText($("span[property=\"v:itemreviewed\"]", h1 ?? doc)) || safeText(h1).replace(RE_YEAR_TRAIL, "").trim(); let primary = full; let original = ""; const idx = full.search(RE_WS); if (idx > 0) { primary = full.slice(0, idx).trim(); original = full.slice(idx).trim().replace(RE_SEASON_EP, ""); } const seasonLabel = full.match(/第[一二三四五六七八九十百\d]+[季集]/u)?.[0] ?? ""; return { full, original, primary, seasonLabel }; }; /** * Extract year string (e.g. "1994") from the .year element. */ var extractYear = (doc) => { return safeText($("#content h1 .year", doc)).match(RE_YEAR)?.[1] ?? ""; }; /** * Extract poster image URL and upgrade to HD. */ var extractPoster = (doc) => { const img = $("#mainpic img", doc) || $("a.nbgnbg img", doc); if (!img) return null; return upgradePoster(img.src || img.dataset.src || ""); }; /** * Extract subject ID from the current URL path. */ var extractSubjectId = (doc) => { return (doc.defaultView?.location.pathname ?? "").match(RE_SUBJECT_ID)?.[1] ?? ""; }; var MAX_DISCUSSION_TOPICS = 10; var DOUBAN_ORIGIN = "https://www.douban.com/"; var TOPIC_PATH = /^\/(?:group\/topic\/\d+|subject\/\d+\/discussion\/\d+)\/?$/u; var DISCUSSION_COLLECTION_PATH = /^\/subject\/\d+\/discussion\/?$/u; var COLLECTION_PATH = /^\/(?:group\/\d+|subject\/\d+\/discussion)\/?$/u; var START_DISCUSSION_PATH = /^\/(?:group\/\d+|subject\/\d+\/discussion\/create|register)\/?$/u; var AUTHOR_PROFILE_PATH = /^\/people\/[^/]+\/?$/u; var DISCUSSION_TIMESTAMP = /^(?\d{4}-\d{2}-\d{2})\s+(?